单源 + 上游 429 无 Retry-After 时,一次调用挂满整个 stall window(生产配置下 >20 分钟) #22

Closed
opened 2026-09-05 22:37:30 +08:00 by iomgaa · 1 comment
Owner

一句话

单源场景下,上游回 429 且不带 Retry-After 时,一次调用会挂满整个 stall window——生产配置 LLM__BACKPRESSURE__STALL_WINDOW_S=1200 下,每轮阻塞超过 20 分钟才报 stalled。

怎么发现的

2026-09-05 跑 T10 能力表实测(经 new-api 对 26 个模型逐个打真实请求)时撞到: claude 系三个型号的 key 撞上「7 天限额已用完」、gpt-5.4 全账号限流,上游一律回 429 且不带 Retry-After。探测用例因此每个型号卡住约 20 分钟,只能把 stall window 临时压到 60s 绕开。

压到 45s 时实测 46.7s 报 stalled,路径已确认。

为什么值得单独修

  • 与推理档位无关,是既有的背压/重试行为,任何走单源 + 上游持续 429 的下游都会撞上;
  • 生产默认配置(1200s)正是为「重试预算不被 stall 判死吃掉」而设的(见 issue #8),两者在这个场景下互相拉扯: 窗口调小会让正常重试被误判 stalled,窗口调大则 429 时长时间不返回;
  • 429 是上游明确表态「现在别打了」,与「网络卡住、一个字节都没回来」在语义上完全不同,却共用同一条 stall 判定路径。

复现

单源(LLM__X__1__*,不配第二个源),让上游稳定回 429 且响应头无 Retry-After,发一次 chat()。观察: 直到 stall window 耗尽才返回。

可能的方向(未定)

  1. 429 且无 Retry-After 时用退避上限(LLM_RETRY_MAX_DELAY)而不是 stall window 兜底;
  2. 让 TransientError.retry_after_s 在缺失时取一个保守默认,而不是留空让上层等满;
  3. 单源场景专门处理: 无备源可换时,重试预算耗尽即返回,不再等 stall window。

选型需评估与 issue #8(stall window 必须 > TIMEOUT_S × MAX_RETRIES)的相互作用。

现场数据

  • 生产窗口 1200s: 每轮阻塞 > 20 分钟
  • 窗口 45s: 46.7s 报 stalled
  • 触发型号: claude-opus-5 / claude-sonnet-5 / claude-haiku-5(7 天限额)、gpt-5.4(全账号限流)
## 一句话 单源场景下,上游回 429 且不带 `Retry-After` 时,一次调用会挂满整个 stall window——生产配置 `LLM__BACKPRESSURE__STALL_WINDOW_S=1200` 下,每轮阻塞超过 20 分钟才报 `stalled`。 ## 怎么发现的 2026-09-05 跑 T10 能力表实测(经 new-api 对 26 个模型逐个打真实请求)时撞到: claude 系三个型号的 key 撞上「7 天限额已用完」、gpt-5.4 全账号限流,上游一律回 429 且不带 `Retry-After`。探测用例因此每个型号卡住约 20 分钟,只能把 stall window 临时压到 60s 绕开。 压到 45s 时实测 46.7s 报 `stalled`,路径已确认。 ## 为什么值得单独修 - **与推理档位无关**,是既有的背压/重试行为,任何走单源 + 上游持续 429 的下游都会撞上; - 生产默认配置(1200s)正是为「重试预算不被 stall 判死吃掉」而设的(见 issue #8),两者在这个场景下互相拉扯: 窗口调小会让正常重试被误判 stalled,窗口调大则 429 时长时间不返回; - 429 是**上游明确表态**「现在别打了」,与「网络卡住、一个字节都没回来」在语义上完全不同,却共用同一条 stall 判定路径。 ## 复现 单源(`LLM__X__1__*`,不配第二个源),让上游稳定回 429 且响应头无 `Retry-After`,发一次 `chat()`。观察: 直到 stall window 耗尽才返回。 ## 可能的方向(未定) 1. 429 且无 `Retry-After` 时用退避上限(`LLM_RETRY_MAX_DELAY`)而不是 stall window 兜底; 2. 让 `TransientError.retry_after_s` 在缺失时取一个保守默认,而不是留空让上层等满; 3. 单源场景专门处理: 无备源可换时,重试预算耗尽即返回,不再等 stall window。 选型需评估与 issue #8(stall window 必须 > TIMEOUT_S × MAX_RETRIES)的相互作用。 ## 现场数据 - 生产窗口 1200s: 每轮阻塞 > 20 分钟 - 窗口 45s: 46.7s 报 `stalled` - 触发型号: claude-opus-5 / claude-sonnet-5 / claude-haiku-5(7 天限额)、gpt-5.4(全账号限流)
Author
Owner

已在 1.3.6 解决( opt-in ):

  • 新增可选整体调用期限 {SCOPE}__CALL_DEADLINE_S 或 per-call call_deadline_s=(缺省 None 不启用,不配置即逐字保持 1.3.5 语义)。期限覆盖缓存读、准入排队、退避 sleep、transport、结构化重问与 embedding 分批(共享同一份)。
  • 到期抛 CallDeadlineExceeded(不属四分类、不属 GatewayUnavailableError 族,捕获需单列分支)。
  • 诚实边界:期限治理的是等待,返回时刻 = 期限 + 清理耗时(遥测/结算/缓存收尾允许超期);到期 ≠ 未产出、≠ 未计费;有限大 Retry-After 仍照睡(唯一的制约就是本期限);inf/1e999 等非有限 Retry-After 自本版起按无提示处理。
  • 顺带修复:调用在端口已开始、结算未定时被取消,TPM 预扣不再按 0 空退(按 est 保守保留;已知真实用量与已判死源的 0 不覆写)。

发布: https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.3.6

已在 1.3.6 解决( opt-in ): - 新增可选整体调用期限 `{SCOPE}__CALL_DEADLINE_S` 或 per-call `call_deadline_s=`(缺省 `None` 不启用,不配置即逐字保持 1.3.5 语义)。期限覆盖缓存读、准入排队、退避 sleep、transport、结构化重问与 embedding 分批(共享同一份)。 - 到期抛 `CallDeadlineExceeded`(不属四分类、不属 `GatewayUnavailableError` 族,捕获需单列分支)。 - **诚实边界**:期限治理的是等待,返回时刻 = 期限 + 清理耗时(遥测/结算/缓存收尾允许超期);到期 ≠ 未产出、≠ 未计费;有限大 `Retry-After` 仍照睡(唯一的制约就是本期限);`inf`/`1e999` 等非有限 Retry-After 自本版起按无提示处理。 - 顺带修复:调用在端口已开始、结算未定时被取消,TPM 预扣不再按 0 空退(按 est 保守保留;已知真实用量与已判死源的 0 不覆写)。 发布: https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.3.6
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#22