docs: correct how a wait-mode call actually dies on a dead source

Branch review caught the docs claiming something the code does not do.
CHANGELOG, README and the design's behaviour matrix all said a
force-opened source under circuit_open=wait waits out the full stall
window. It does not: the probe let through after each cooldown is a
real attempt, so it burns a max_attempts slot like any other, and a
401 source usually runs out of retry budget first -- reason is
retry_exhausted, not stalled. Which budget wins depends on
max_attempts against the cooldowns and the stall window.

The behaviour is right; only the prose was wrong. Charging the probe
to the retry budget is exactly the split issue #8 settled: the
question is who spends max_attempts, and a probe does send a real
request. A test now pins it so the claim cannot drift again.

Also drops the planned "woke up" log line. Each wait round already
logs on entry with its duration, and a still-blocked wake-up logs the
next round immediately, so a second line would only double the volume.
This commit is contained in:
2026-08-20 01:00:47 -04:00
parent 5a025b6e5d
commit c5b2b3fade
5 changed files with 45 additions and 8 deletions
@@ -74,10 +74,11 @@ async def settle_and_release(permit: Permit, actual: int) -> None:
def _nap(self, hint: float, clock: StallClock) -> float:
jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
return max(self._bp.poll_interval_s, min(hint + jitter if hint > 0 else jitter, budget))
wait = hint + jitter if hint > 0 else jitter
return max(jitter, min(wait, budget))
```
`hint == 0` 时该式退化为 `jitter`,即现有 quota-wait 行为逐字不变(`tests/unit/test_backpressure.py` 已钉 `[0.5p, 1.0p]`)。`budget` 加一个 `poll_interval_s` 是因为 `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`),恰好夹到窗口不会判死。
`hint == 0` 时该式退化为 `jitter`,即现有 quota-wait 行为逐字不变(`tests/unit/test_backpressure.py` 已钉 `[0.5p, 1.0p]`)。**下界取 `jitter` 而非 `poll_interval_s`(实施期修正)**: 后者会把 `rng → 0` 那半边从 `0.5p` 抬到 `1.0p`,既有的 `test_poll_jitter_bounds` 当场变红;`jitter` 同样能在预算为负时兜住不返回负数、不忙循环。`budget` 加一个 `poll_interval_s` 是因为 `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`),恰好夹到窗口不会判死。
**调用约束**: `_nap` 必须在 `stalled()` 判定**之后**调用。若已 stall 超窗才进来,`budget` 为负,外层 `max(poll_interval_s, ...)` 会兜成一个 poll 间隔(不会返回负数),但那意味着本该判死却又睡了一轮——顺序由 `on_no_runnable` 保证(两条路汇合后统一判 `stalled()` 再 sleep)。验算示例: `hint=60, stall_window=300, 已 stall 290, poll=0.05``jitter∈[0.025,0.05]``budget=10.05` → 返回 `10.05`,醒来累计约 `300.05` > 300,下一轮判死。
@@ -231,7 +232,7 @@ conda run -n PolyGateway python -m pytest tests/unit/test_config.py tests/unit/t
**必须避免的坑**: 若只把第一分支改成"wait 时不抛"而不做分派,控制流会掉进 `quota_full` 分支——`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`
**可观测性**: `wait` 档进入等待时 `logger.info` 一条(scope、`per_source_reasons`、本次预计睡眠秒数),退出等待时一条。**不新增遥测列**(等待期不发请求,无 attempt 行可记;调用级总耗时下游可自测)。
**可观测性**: `wait`每轮进入等待时 `logger.info` 一条(scope、`per_source_reasons`、本次睡眠秒数)。**只此一条,不打"醒来"那条**(实施期决定): 每一轮等待各自留痕,时间线已可完整还原,而醒来后若仍被拒会立刻打下一条——补一条"醒来"只会让日志量翻倍且信息重复。**不新增遥测列**(等待期不发请求,无 attempt 行可记;调用级总耗时下游可自测)。
**计时归属**: 睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3"熔断冷却属非生产性等待"一致——**无需改 `StallClock`**。
@@ -240,7 +241,8 @@ conda run -n PolyGateway python -m pytest tests/unit/test_config.py tests/unit/t
**测试要求**(先失败后通过,注入时钟/睡眠/rng 保持确定性):
- `circuit_open=wait` + 全源开路 → **不**抛 `CircuitOpenError`,而是按 `retry_after` 睡;冷却结束后拿到探针并成功返回
- `circuit_open=wait` + `quota_full=fail_fast` + 全源开路 → **不**抛 `quota_exhausted`(这是上面那个坑的钉子)
- `circuit_open=wait` + 源持续 `force_open`最终`AllSourcesExhausted(reason="stalled")`,`per_source_reasons``circuit_open`,累计墙钟 ≤ `stall_window_s + poll_interval_s`
- `circuit_open=wait` + 冷却比 stall 预算还长 → 抛 `AllSourcesExhausted(reason="stalled")`,`per_source_reasons``circuit_open`,累计墙钟 ≤ `stall_window_s + poll_interval_s`
- `circuit_open=wait` + 源持续 `force_open`**`retry_exhausted` 而非 `stalled`**(整分支审查发现,原稿写错): 冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`,故两个预算里先耗尽的那个决定 reason
- 混合原因(部分 `circuit_open` + 部分 `rate_limited`)→ 走 quota 分支,`per_source_reasons` 如实混合
- `hint == 0` 时睡眠落在 `[0.5p, 1.0p]`(现有 quota-wait 行为逐字不变)
- `wait` 档等待中收到 `CancelledError` → 逐字穿透,in-flight permit 已释放