stall 误判: timeout_s >= stall_window_s 时重试预算静默失效,单次超时即判 scope 级死亡 #8
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
现象
全套件跑(
pytest tests/)每次都有一个 e2e 测试以AllSourcesExhausted: llm 网关暂时不可用: stalled失败,失败的是哪一个每次都不同,单独跑则全部通过。三次跑分别失败于test_call_site_shape_runs_governed、test_call_site_shape_with_cache_salt+test_stream_chat、test_non_stream_fast_path。一开始判为环境 flaky,
--durations拿到确凿证据后否掉了这个判断:301 秒——正好是源
TIMEOUT_S=300加判定开销。随机性只来自"真实网关这次挂住的是哪个请求",失效机制本身是确定的。失效链条
TIMEOUT_S=300,transport 抛TransientErrornow - entered_at = 301 > stall_window_s(300)✓middleware/retry.py:216progress_age_s() > 300:新建 client 从未成功过,两个后端此时都返回float("inf")→ 恒真backends/memory/limiter.py:170-173、backends/redis/limiter.py:300-307AllSourcesExhausted(reason="stalled")middleware/retry.py:217后果:
LLM_MAX_RETRIES=3一次都没用上——第二次尝试尚未发出即被判死。不重试、不换源,直接抛 scope 级不可用让下游延期重投。为什么这是库的问题而不是配置的问题
retry.py:302-303的注释写明设计意图是「本地累计等待与全局无进展同时超窗才判死」,双条件是保护。但progress_age_s()在"从未进展"时返回inf,使第二条件在冷启动窗口内恒真——保护恰好在最需要它的时刻(全新 client、还没有任何成功样本)不存在,双条件退化为单条件。而
config.py:243已经有一条同源的装配期校验:它拦住了
stall_window < ttft_timeout,却漏了更关键的stall_window <= timeout。后者一旦成立,单次超时即耗尽 stall 窗口,重试预算在超时场景下静默失效——没有任何报错或 warning,配置方以为自己配了 3 次重试。本机
.env恰好TIMEOUT_S=300与stall_window_s默认值 300 相等,正撞在边界上。影响面
任何下游只要配出
timeout_s >= stall_window_s(含"只配 timeout、不配 stall 用默认 300"这种最常见的情形),就会得到:网关慢一次 → 整个 scope 判死 → 任务延期重投,而不是它以为的换源重试。而stall_window_s默认 300 恰好是个很容易被 timeout 追平的值。建议方向(待定,未实施)
stall_window_s须 >max(timeout_s),与既有 ttft 校验同源同风格。这一条能机械挡住配置层面的踩坑。inf语义: 考虑让"从未进展"不参与 stall 判死——冷启动的"还没开始"与运行期的"卡住了"是两回事。需要单独讨论,因为它改变治理行为。环境
polygateway 1.0.6,分支
feat/issue-7-governance-backend-error。证据来自本机全套件跑391.67s (0:06:31),1 failed, 748 passed, 21 skipped, 32 deselected。已修复(分支
feat/issue-8-stall-budget)根因比 issue 描述的更深一层
retry.py那处判定的注释自述它是「429 免预算后的兜底,防饱和期无限循环」——它治理的对象是非生产性循环。但条件 A 度量的是墙钟总耗时,无法区分两类性质相反的时间:timeout_s的超时)max_attempts(重试预算)fails)→ 正是 stall 的职责缺陷即两个预算重叠计费。stall 预算(300s)远小于重试预算(3×300s),必然先耗尽,于是重试预算在超时场景下永远用不上。
.env里TIMEOUT_S与默认值相等只是把它暴露得最快。修法:两个预算正交
StallClock让 stall 只累计非生产性等待。划分依据是"谁消耗重试预算",不是"是否发出了请求":烧max_attempts的时间不烧stall_window_s,不烧max_attempts的时间归 stall 治理。stall_window_s与timeout_s自此无耦合,不必按timeout × retries放大。本机.env里那个 1200 的临时缓解可以回退默认值。issue 里两个建议方向都没采纳
stall_window > max(timeout): 治标——把缺陷固化成配置契约而非消除它。且约束值须为timeout × max_attempts(本机 900s),会让 stall 兜底迟钝到近乎失效,修好一个洞挖开另一个;即便配到 1200s,累计超窗后条件 B 的inf仍恒真,坑只是被推远。新口径下这条校验没有存在的理由。inf语义: 新口径下inf已从"有害恒真"回归为"正确的保守默认"——"非生产性排队耗满窗口且 scope 从未出餐"判死本就正当。单独改它反而会制造冷启动兜底真空(429 免预算无其他兜底),并反转既有测试。一次改动解决问题,优于两次改动互相牵制。顺带两条佐证
inf恒真是遗漏而非设计:_PROGRESS_TTL_S = 3600的注释写着「远大于任何 stall_window,防进度键过期造成假停滞」——"无 progress 记录 ≠ 停滞"早已是共识,只是冷启动这一路径被漏掉了;而test_both_windows_exceeded_raises_stalled正是全靠inf恒真才能触发判死。issue 未记录的两处同构缺陷
embedding.py与ocr.py有同款判定和同款墙钟,经"先超时一次、再遇到无可用源"触发同样的误判。三条路径已一并修复,各带一条回归用例(改前分别红于retry.py:218/embedding.py:250/ocr.py:275)。独立验证抓到一个我引入的回归
初稿按"是否发出请求"划分,使 429 尝试两个预算都不烧——429 免重试预算,其耗时又算生产性,掉进缝隙。排队型网关(持满
timeout_s才回 429)下实测:即初稿把一个 bug 换成了更严重的 bug。订正后划分依据改为"谁消耗重试预算",缝隙闭合。
下游须知
stall_window_s抬升到约max_attempts × timeout_s——这是重试预算恢复生效的正确表现,但若上游有调用超时请据此复核。429 路径不会突破这个量级。inf语义、429 免预算、退避与 jitter 公式、fail_fast分支、AllSourcesExhausted字段与reason取值全部未动。验证:
make lint通过(含依赖契约),711 passed / 6 skipped / 0 failed。已在 1.1.1(2026-08-06)修复,现结案。
修复方案与本 issue 建议方向不同: 没有加装配期校验,而是消除根因——两个预算重叠计费。新增
StallClock(src/polygateway/middleware/retry.py:91),stall 判定的本地超窗条件只累计非生产性等待(429 退避、配额 wait 轮询、熔断冷却),消耗max_attempts的真实尝试耗时不再计入。stall_window_s与timeout_s自此完全解耦。覆盖面: chat / embedding / ocr 三条治理循环口径一致。embedding 与 ocr 存在同一缺陷,本 issue 只记录了 chat 路径。
两条建议方向的定夺:
stall_window_s > max(timeout_s)未采纳——根因消除后两参数已无耦合,该约束会误拒合理配置。原有 ttft 校验保留为保守冗余(理由记在config.py:_validate_stalldocstring)。progress_age_s()的inf语义未动,双条件判死结构未动,错误面零变更。回归测试:
tests/unit/test_backpressure.py::test_single_timeout_does_not_exhaust_stall_budget即本 issue 场景(timeout_s == stall_window_s,断言第二次尝试真实发出);另有 429 退还、遥测计生产性、per-call 时钟隔离等用例。本次复核 28 passed。相关提交: 02c3d06(chat)、6d0f3c9(embedding)、0477d95(ocr)、a0a5cf7(429 退还)。详见 CHANGELOG 1.1.1。