stall 误判: timeout_s >= stall_window_s 时重试预算静默失效,单次超时即判 scope 级死亡 #8

Closed
opened 2026-08-06 15:20:52 +08:00 by iomgaa · 2 comments
Owner

现象

全套件跑(pytest tests/)每次都有一个 e2e 测试以 AllSourcesExhausted: llm 网关暂时不可用: stalled 失败,失败的是哪一个每次都不同,单独跑则全部通过。三次跑分别失败于 test_call_site_shape_runs_governedtest_call_site_shape_with_cache_salt + test_stream_chattest_non_stream_fast_path

一开始判为环境 flaky,--durations 拿到确凿证据后否掉了这个判断:

301.18s call  tests/e2e/test_smoke_gateway.py::TestRealGatewaySmoke::test_non_stream_fast_path
retry.py:217: raise AllSourcesExhausted

301 秒——正好是源 TIMEOUT_S=300 加判定开销。随机性只来自"真实网关这次挂住的是哪个请求",失效机制本身是确定的。

失效链条

发生了什么 出处
1 网关偶发挂起,请求耗满 TIMEOUT_S=300,transport 抛 TransientError
2 RetryMW 回循环开头查 stall:now - entered_at = 301 > stall_window_s(300) middleware/retry.py:216
3 第二条件 progress_age_s() > 300:新建 client 从未成功过,两个后端此时都返回 float("inf")恒真 backends/memory/limiter.py:170-173backends/redis/limiter.py:300-307
4 双条件同时成立 → 抛 AllSourcesExhausted(reason="stalled") middleware/retry.py:217

后果: LLM_MAX_RETRIES=3 一次都没用上——第二次尝试尚未发出即被判死。不重试、不换源,直接抛 scope 级不可用让下游延期重投。

为什么这是库的问题而不是配置的问题

retry.py:302-303 的注释写明设计意图是「本地累计等待与全局无进展同时超窗才判死」,双条件是保护。但 progress_age_s() 在"从未进展"时返回 inf,使第二条件在冷启动窗口内恒真——保护恰好在最需要它的时刻(全新 client、还没有任何成功样本)不存在,双条件退化为单条件。

config.py:243 已经有一条同源的装配期校验:

if ttfts and self.backpressure.stall_window_s < max(ttfts):
    raise ...(f"stall_window_s 须 ≥ 最大源 ttft_timeout_s")

它拦住了 stall_window < ttft_timeout,却漏了更关键的 stall_window <= timeout。后者一旦成立,单次超时即耗尽 stall 窗口,重试预算在超时场景下静默失效——没有任何报错或 warning,配置方以为自己配了 3 次重试。

本机 .env 恰好 TIMEOUT_S=300stall_window_s 默认值 300 相等,正撞在边界上。

影响面

任何下游只要配出 timeout_s >= stall_window_s(含"只配 timeout、不配 stall 用默认 300"这种最常见的情形),就会得到:网关慢一次 → 整个 scope 判死 → 任务延期重投,而不是它以为的换源重试。而 stall_window_s 默认 300 恰好是个很容易被 timeout 追平的值。

建议方向(待定,未实施)

  1. 装配期校验: stall_window_s 须 > max(timeout_s),与既有 ttft 校验同源同风格。这一条能机械挡住配置层面的踩坑。
  2. 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

## 现象 全套件跑(`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.18s call tests/e2e/test_smoke_gateway.py::TestRealGatewaySmoke::test_non_stream_fast_path retry.py:217: raise AllSourcesExhausted ``` 301 秒——正好是源 `TIMEOUT_S=300` 加判定开销。随机性只来自"真实网关这次挂住的是哪个请求",失效机制本身是确定的。 ## 失效链条 | 步 | 发生了什么 | 出处 | |---|---|---| | 1 | 网关偶发挂起,请求耗满 `TIMEOUT_S=300`,transport 抛 `TransientError` | — | | 2 | RetryMW 回循环开头查 stall:`now - entered_at = 301 > stall_window_s(300)` ✓ | `middleware/retry.py:216` | | 3 | 第二条件 `progress_age_s() > 300`:新建 client **从未成功过**,两个后端此时都返回 `float("inf")` → **恒真** | `backends/memory/limiter.py:170-173`、`backends/redis/limiter.py:300-307` | | 4 | 双条件同时成立 → 抛 `AllSourcesExhausted(reason="stalled")` | `middleware/retry.py:217` | **后果: `LLM_MAX_RETRIES=3` 一次都没用上**——第二次尝试尚未发出即被判死。不重试、不换源,直接抛 scope 级不可用让下游延期重投。 ## 为什么这是库的问题而不是配置的问题 `retry.py:302-303` 的注释写明设计意图是「本地累计等待与全局无进展**同时**超窗才判死」,双条件是**保护**。但 `progress_age_s()` 在"从未进展"时返回 `inf`,使第二条件在**冷启动窗口内恒真**——保护恰好在最需要它的时刻(全新 client、还没有任何成功样本)不存在,双条件退化为单条件。 而 `config.py:243` 已经有一条同源的装配期校验: ```python if ttfts and self.backpressure.stall_window_s < max(ttfts): raise ...(f"stall_window_s 须 ≥ 最大源 ttft_timeout_s") ``` 它拦住了 `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 追平的值。 ## 建议方向(待定,未实施) 1. **装配期校验**: `stall_window_s` 须 > `max(timeout_s)`,与既有 ttft 校验同源同风格。这一条能机械挡住配置层面的踩坑。 2. **`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`。
Author
Owner

已修复(分支 feat/issue-8-stall-budget)

根因比 issue 描述的更深一层

retry.py 那处判定的注释自述它是「429 免预算后的兜底,防饱和期无限循环」——它治理的对象是非生产性循环。但条件 A 度量的是墙钟总耗时,无法区分两类性质相反的时间:

时间性质 应由谁治理
真实尝试(含耗满 timeout_s 的超时) max_attempts(重试预算)
429 退避、配额轮询、熔断冷却 无人治理(429 不计 fails)→ 正是 stall 的职责

缺陷即两个预算重叠计费。stall 预算(300s)远小于重试预算(3×300s),必然先耗尽,于是重试预算在超时场景下永远用不上。.envTIMEOUT_S 与默认值相等只是把它暴露得最快。

修法:两个预算正交

StallClock 让 stall 只累计非生产性等待。划分依据是"谁消耗重试预算",不是"是否发出了请求":烧 max_attempts 的时间不烧 stall_window_s,不烧 max_attempts 的时间归 stall 治理。

stall_window_stimeout_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.pyocr.py 有同款判定和同款墙钟,经"先超时一次、再遇到无可用源"触发同样的误判。三条路径已一并修复,各带一条回归用例(改前分别红于 retry.py:218 / embedding.py:250 / ocr.py:275)。

独立验证抓到一个我引入的回归

初稿按"是否发出请求"划分,使 429 尝试两个预算都不烧——429 免重试预算,其耗时又算生产性,掉进缝隙。排队型网关(持满 timeout_s 才回 429)下实测:

尝试次数 墙钟
修复前 1 301s
初稿口径 301 90,601s ≈ 25.2 小时
订正后 1 301s

即初稿把一个 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。

## 已修复(分支 `feat/issue-8-stall-budget`) ### 根因比 issue 描述的更深一层 `retry.py` 那处判定的注释自述它是「429 免预算后的兜底,防饱和期无限循环」——它治理的对象是**非生产性循环**。但条件 A 度量的是**墙钟总耗时**,无法区分两类性质相反的时间: | 时间性质 | 应由谁治理 | |---|---| | 真实尝试(含耗满 `timeout_s` 的超时) | `max_attempts`(重试预算) | | 429 退避、配额轮询、熔断冷却 | **无人治理**(429 不计 `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)下实测: | | 尝试次数 | 墙钟 | |---|---|---| | 修复前 | 1 | 301s | | 初稿口径 | 301 | **90,601s ≈ 25.2 小时** | | 订正后 | 1 | 301s | 即初稿把一个 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。
Author
Owner

已在 1.1.1(2026-08-06)修复,现结案。

修复方案与本 issue 建议方向不同: 没有加装配期校验,而是消除根因——两个预算重叠计费。新增 StallClock(src/polygateway/middleware/retry.py:91),stall 判定的本地超窗条件只累计非生产性等待(429 退避、配额 wait 轮询、熔断冷却),消耗 max_attempts 的真实尝试耗时不再计入。stall_window_stimeout_s 自此完全解耦。

覆盖面: chat / embedding / ocr 三条治理循环口径一致。embedding 与 ocr 存在同一缺陷,本 issue 只记录了 chat 路径。

两条建议方向的定夺:

  1. 装配期校验 stall_window_s > max(timeout_s) 未采纳——根因消除后两参数已无耦合,该约束会误拒合理配置。原有 ttft 校验保留为保守冗余(理由记在 config.py:_validate_stall docstring)。
  2. 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。

已在 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 路径。 **两条建议方向的定夺**: 1. 装配期校验 `stall_window_s > max(timeout_s)` 未采纳——根因消除后两参数已无耦合,该约束会误拒合理配置。原有 ttft 校验保留为保守冗余(理由记在 `config.py:_validate_stall` docstring)。 2. `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。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#8