fix: return 429 attempt time to the stall budget
Independent verification found the first cut had swapped one bug for a worse one. The budgets were split by "did we send a request", so a 429 attempt counted as productive — but 429 is exempt from the retry budget, so its time burned neither budget. Against a queueing gateway that holds the request for the full timeout before answering 429, a call could hang for 301 attempts / 25.2 hours, measured, versus 301 seconds before the change. The split is now by which budget the time consumes: time that burns max_attempts is excluded from stall, time that does not (429 attempts included) belongs to stall. Measured again: back to one attempt / 301s. Only the chat loop needs this — embedding and ocr count 429 against max_attempts unconditionally, so the gap never existed there. The stall verdict moved into _stalled(), which both call sites had duplicated, to keep __call__ under the complexity gate.
This commit is contained in:
@@ -54,8 +54,10 @@
|
||||
|
||||
| 花在哪 | 烧哪个预算 |
|
||||
|---|---|
|
||||
| 真实尝试(`_attempt` 内) | 重试预算 `max_attempts` |
|
||||
| 其余一切等待 | stall 预算 `stall_window_s` |
|
||||
| 真实尝试(`_attempt` 内),**429 除外** | 重试预算 `max_attempts` |
|
||||
| 其余一切等待,**含 429 尝试本身** | stall 预算 `stall_window_s` |
|
||||
|
||||
> **划分依据是"谁消耗重试预算",不是"是否发出了请求"**(2026-08-06 实施期订正,见 §3.6)。初稿按后者划分,使 429 尝试两个预算都不烧。
|
||||
|
||||
**"生产性"的边界即 `_attempt` 的边界**——包含该次尝试的记账(`record_success`/`mark_progress`)与遥测收尾,而不止于"等响应"。这是有意的:这些收尾是"尝试已有结论"之后的动作,不是"在等待重试机会"的停滞;把它们计入 stall 会让遥测抖动参与判死,与「遥测写失败降级不冒泡」所守的"遥测不得影响主路径判决"同精神。其耗时本也在毫秒量级。
|
||||
|
||||
@@ -129,9 +131,27 @@ async with clock.attempting(): # 包裹真实尝试
|
||||
|
||||
### 3.5 429 饱和场景下兜底仍然有效(正确性验证)
|
||||
|
||||
修改后必须确认 stall 兜底没有被削弱:429 往返本身是生产性时间,不再计入 stall。
|
||||
修改后必须确认 stall 兜底没有被削弱。429 免预算使 `fails` 恒为 0,`retry.py` 的 `max(fails, 1)` 令退避恒定在 `backoff_base_s` 档(或取 `Retry-After` 提示的较大值),不随轮次增长。每轮构成为「一次 429 往返」+「一段恒定退避 sleep」,后者非生产性且每轮累加,`stalled_s` 单调逼近 `stall_window_s`,兜底有效。
|
||||
|
||||
注意退避时长在纯 429 场景下**不随轮次增长**:429 免预算使 `fails` 恒为 0,`retry.py:243` 的 `max(fails, 1)` 令退避恒定在 `backoff_base_s` 档(或取 `Retry-After` 提示的较大值)。但这不影响结论——每轮的构成是「一次**快速失败**的 429 往返(网关立即拒绝,不耗 `timeout_s`,毫秒至秒级)」+「一段恒定退避 sleep(`backoff_base_s` 量级)」,后者是非生产性且**每轮都在累加**。故饱和期内非生产性时间仍占绝对多数,`stalled_s` 单调逼近 `stall_window_s`,兜底有效;触发时刻仅比修改前晚了"累计 429 往返耗时"的量级,可忽略。
|
||||
**但这个论证在初稿里依赖一个未加保护的假设**:「429 往返是快速失败,毫秒至秒级」。§3.6 处理它不成立的情形。
|
||||
|
||||
### 3.6 订正:429 尝试必须退还给 stall 账(2026-08-06 实施期,独立验证发现)
|
||||
|
||||
**缺陷**:初稿按"是否发出请求"划分两个预算,于是 429 尝试的耗时算生产性。但 429 **不消耗重试预算**——它于是**两个预算都不烧**,掉进缝隙。§3.1 初稿声称的"无缝覆盖调用的全部时间"因此不成立。
|
||||
|
||||
**后果实测**(排队型网关:持满 `timeout_s` 才回 429,`timeout=300 / stall=300 / backoff_base=2 / rng=0`):
|
||||
|
||||
| | 尝试次数 | 墙钟 |
|
||||
|---|---|---|
|
||||
| 修复前(main) | 1 | 301s |
|
||||
| 初稿口径 | **301** | **90,601s ≈ 25.2 小时** |
|
||||
| 订正后 | 1 | 301s |
|
||||
|
||||
即初稿把一个 bug 换成了一个更严重的 bug——25 小时的挂起。
|
||||
|
||||
**订正**:划分依据改为**"谁消耗重试预算"**。429 免重试预算 → 429 尝试的耗时归 stall 治理,由 `StallClock.attempting()` yield 的句柄 `refund()` 退还。缝隙就此闭合,且这条规则比初稿更本质:两个预算按"由谁治理"划分,而非按"是否发出请求"这个表象。
|
||||
|
||||
**影响范围仅 chat**:embedding/ocr 无 429 免预算(无条件 `fails += 1`),429 照常烧重试预算,不存在缝隙,无需改动(与 §5.4 的分析一致)。
|
||||
|
||||
## 4. 旧版行为审计(stall 子系统逐条)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user