Files
PolyGateway/research-wiki/plans/2026-09-10-136-call-deadline.md
T

271 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: plan
node_id: plan:2026-09-10-136-call-deadline
title: "1.3.6 可选调用期限与取消结算修复实施计划"
date: 2026-09-10
---
# 1.3.6 可选调用期限与取消结算修复实施计划
> 设计:`research-wiki/designs/2026-09-09-136-call-budgets-design.md`**人类于 2026-09-10 正式批准**(§9 七项批准项全数获批,H3 取 (a′):取消结算修复并入本版而非另立前置 issue)。
> 计划审核门:Claude 自审 + 独立模型审查;plan 无人类门,审毕直接执行。
> 目标:① 修好取消路径的 TPM 结算(§6.3 矩阵 S1–S7);② 给一次逻辑调用一条**可选**墙钟硬边界(issue #22),缺省 `None` 时行为逐字等于 1.3.5;③ 修 `Retry-After` 非有限值防御缺口。
> 方案:设计 §3 方案 A——三个公开边界各一次 `asyncio.timeout`,配**局部变量身份比较**判据(探针实证:只看 `cm.expired()` 会把清理期自抛的 `TimeoutError` 误标成 deadline)。
> 技术:Python 3.12+、asyncio、frozen dataclass、pytest + FakeClock + 真实 `asyncio.Event`、真实实验室 Redis、ruff、import-linter。
> 基线 HEAD`d2455e8`(分支 `feature/1.3.6-call-budgets`;工作区仅 `CLAUDE.md` 既有 markdown 差异、未跟踪 `.pi/` 与本轮两份文档,前两者一律不动、不暂存)。
**不实现 issue #24(长尾对冲)**:未获批准,任何提交、测试与文档均不得出现 hedge/对冲机制,也不得声称本版缓解 #24
## 1. 边界、授权与执行纪律
| 项目 | 固定边界 |
| --- | --- |
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.X 合并/发布授权沿用;跨到 1.4、新公共面变化或验证豁免须停下确认 |
| 公共面 | 只做设计 §9 已批准四项:新错误类 `CallDeadlineExceeded`、新配置键 `{SCOPE}__CALL_DEADLINE_S`、三个 client 构造参数 + 四个公开方法 keyword-only 参数、取消路径结算口径。**不新增其它键/端口方法/遥测列** |
| 记账边界 | 只改**取消路径**的 `settle()` 入参取值(设计 §6.3 S3/S5/S7);成功、`RequestRejected``ResultInvalid``SourceDead` 四条既有路径与**未分类异常逃逸路径(S8,仍 `0`)**的结算值逐字不变;实现只能用**函数内局部阶段变量**,不得新增公开参数;限流 Lua、`Permit` 端口签名、幂等语义一律不动 |
| 依赖铁律 | 新模块 `deadline.py` 只 import stdlib + `errors.py``middleware/` 仍只依赖端口与内核;import-linter 契约新增一层执法 |
| 取消 | `CancelledError` 永不吞没;不引入 `shield`、不开后台任务;清理仍在 `finally`,允许超出期限 |
| 降级方向 | 限流/熔断后端仍 fail-closed;遥测/缓存仍 warning 降级;非法期限值 → **当场 `ValueError`**(装配错误不属降级面) |
| 证据与秘密 | 不打印 `.env`、token、Authorization;不提交 `.pi/``tests/outputs/`;命令输出只记路径、状态与退出码 |
| 证据复用 | 复用既有 FakeClock / 假 transport / `settle_and_release` 出口 / 限流契约套件 / 真实 Redis 跨连接用例;**不重跑模型能力矩阵**,本版零付费调用 |
Skill 纪律:T0 已执行 `writing-plans`T1T3 行为变更执行 `test-driven-development`(先失败后通过的证据须落在本会话工具输出里);每次提交执行 `commit`(英文祈使标题、无 AI 签名、显式路径暂存);T4 前执行 `requesting-code-review``verification-before-completion`;异常先 `systematic-debugging` 定根因。
## 2. 文件职责与不变接缝
| 动作 | 精确路径 | 职责 |
| --- | --- | --- |
| 新建 | `src/polygateway/deadline.py` | `ensure_call_deadline()` 值域校验 + `with_call_deadline()` 单一硬边界(§3.1 |
| 修改 | `src/polygateway/errors.py` | 追加 `CallDeadlineExceeded(PolyGatewayError)`(§3.2);`SCOPE_REASONS`/四分类**不动** |
| 修改 | `src/polygateway/__init__.py` | `from polygateway.errors import ... CallDeadlineExceeded``__all__` 插在 `"CallStats"` 之后、`"CircuitOpenError"` 之前(现读 `:61-62`;该列表并非全字母序,头部 `DEFAULT_PROFILES`/`EFFORT_ORDER`/`Effort` 是既有例外,**不得顺手重排**) |
| 修改 | `src/polygateway/config.py` | `GatewaySettings` 末尾追加 `call_deadline_s: float \| None = None``_load_call_deadline()``_validate_call_deadline()``__post_init__`(§3.3 |
| 修改 | `src/polygateway/client.py` | `__init__` 追加 `call_deadline_s`(入口即校);`chat()` 追加 per-call 参数;`:398` 包裹;`from_settings` 透传 |
| 修改 | `src/polygateway/embedding.py` | 同上三处(`:209` 包裹整次 `_embed_all`);`_attempt` 结算矩阵(§3.5 |
| 修改 | `src/polygateway/ocr.py` | `__init__`/两个公开方法/`_call` 两级透传;`:274` 包裹 `_run``:449 settle_and_release(permit, 0)` **保持 0** |
| 修改 | `src/polygateway/middleware/retry.py` | `_attempt` 结算矩阵(§3.5`actual` 初值仍 `0` + 局部 `settlement_known`);`__call__` 循环、`backoff_delay``StallClock` 一字不动 |
| 修改 | `src/polygateway/transports/openai_compat.py` | `_parse_retry_after``:111-119`)增 `math.isinf` 判据 + 一条 warning + 必填私有 kw `source_name`;同步唯一调用处 `_translate_429``:140`)(§3.6 |
| 修改 | `pyproject.toml` | import-linter layers 在 `"polygateway.thinking"``"polygateway.providers : polygateway.sources"` 之间插入 `"polygateway.deadline"` 一行 |
| 新建 | `tests/unit/test_deadline.py` | `deadline.py` 的值域与五种形态区分(§5 批次 A/B) |
| 修改 | `tests/unit/test_retry.py` | 取消结算红绿(S1/S3/S4/S5+ `FakeTransport``entered` Event |
| 修改 | `tests/unit/test_embedding.py` | 取消结算(S7)、多批共享一份期限 |
| 修改 | `tests/unit/test_ocr_client.py` | 取消结算恒 0(S6)、两个入口的期限与 per-call 校验 |
| 修改 | `tests/unit/test_client.py` | chat 期限命中、终态遥测行、已计费成功被丢弃、注入钟无关 |
| 修改 | `tests/unit/test_config.py` | 键未设/非法值 × env / 直接构造 / `dataclasses.replace` / 三个 `__init__` 直传 |
| 修改 | `tests/unit/test_openai_compat.py` | F1 四类取值 |
| 修改 | `tests/integration/test_redis_cross_connection.py` | 真实 Redis 上的取消结算契约(复用既有 `clients`/`_limiter`/`_client`/`ScriptedTransport`,**不改 Lua、不改契约套件**) |
| 修改 | `CHANGELOG.md``README.md``.env.example` | 新键、新异常、对外承诺三句话(§4 C4) |
| 新建 | `research-wiki/findings/2026-09-10-136-call-deadline-validation.md` | 红绿、命令、豁免索引,≤300 行 |
**不改**`ports.py``Permit.settle` 签名与语义不动)、`middleware/admission.py``settle_and_release` 逐字不动)、`middleware/ratelimit.py``middleware/breaker.py``middleware/structured.py``middleware/cache.py``middleware/telemetry.py``telemetry/schema.py`(零新增列)、`backends/**`(含全部 Lua)、`sources.py``streaming.py``types.py``transports/monkey_ocr.py``:58` 不解析 `Retry-After`)、`tests/contracts/**`(复用现有 settle 契约,只跑不改)。若实施时发现必须突破本清单,先说明最小原因交父会话核定。
## 3. 跨任务接口(可执行定义,禁止占位)
### 3.1 `src/polygateway/deadline.py`
```python
def ensure_call_deadline(value: object, origin: str) -> float | None:
"""全装配路径共用的值域校验: None 或有限正数, 否则 ValueError(消息含 origin)。"""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{origin} 必须是 None 或有限正数秒: {value!r}") # bool 先判
v = float(value)
if not math.isfinite(v) or v <= 0:
raise ValueError(f"{origin} 必须是有限正数秒: {value!r}") # NaN/inf/0/负
return v
async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T:
if deadline_s is None:
return await aw # 未启用: 不进上下文, 逐字旧路径
inner_timeout: BaseException | None = None
try:
async with asyncio.timeout(deadline_s) as cm:
try:
return await aw
except TimeoutError as exc:
inner_timeout = exc # 体内(含清理路径)自抛, 非本层期限
raise
except TimeoutError as exc:
if cm.expired() and exc is not inner_timeout:
raise CallDeadlineExceeded(scope=scope, deadline_s=deadline_s) from None
raise
```
三条实现红线:① **校验先于构造 awaitable**(否则非法值抛错时遗留未 await 协程 → `RuntimeWarning` + 资源不释放);② 只传**相对时长**,绝不把注入 `now` 加偏移换算成绝对截止时刻;③ 身份比较**不可退化**为只看 `cm.expired()`——探针 `/tmp/pgw_deadline_probe.py` E1/E2 实测:清理路径自抛的 `TimeoutError` 会被只看 `expired()` 的写法改标成 `CallDeadlineExceeded``__cause__` 启发式同样失效(内层 `asyncio.timeout``TimeoutError``__cause__` 也是 `CancelledError`)。本机制**不新增公共配置、不开后台任务、不改异常对象**。
### 3.2 `errors.py` 新类(唯一定义点)
```python
class CallDeadlineExceeded(PolyGatewayError):
"""调用方设定的整体期限到期; 不是网关不可用、也不是源故障。"""
def __init__(self, *, scope: str, deadline_s: float) -> None:
super().__init__(f"{scope} 调用期限 {deadline_s}s 到期")
self.scope = scope
self.deadline_s = deadline_s
```
`retry_after_s`(期限到期不含"何时可再试",给 `0.0` 会按既定语义指示下游立刻重打饱和渠道);不进 `SCOPE_REASONS`;不属四分类。`__init__.py` 导出后,三个边界既有的 `except PolyGatewayError` 自动接住并写终态行——**遥测零改动**。
### 3.3 配置(`config.py`
| 项 | 精确定义 |
| --- | --- |
| 键名 | `{SCOPE}__CALL_DEADLINE_S`(两段式,`"LLM__CALL_DEADLINE_S".split("__")` 长度 **2** ≠ 4,故 `_load_sources`(函数定义 `:380`,判据行 `:384`)天然跳过,**不必**加进 `_RESERVED_SEGMENTS` |
| loader | `_load_call_deadline(scope, env)``found = _first(env, f"{scope}__CALL_DEADLINE_S")``None → None`;否则 `ensure_call_deadline(_cast(found[1], "float", found[0]), found[0])`。**用 `_first` 不用 `_require`**`_require` 会把未设当成配置缺失报错 = 破坏性变更);**origin 传实际命中的 env 键名 `found[0]`**,不传 `"GatewaySettings.call_deadline_s"`——否则 env 里写 `LLM__CALL_DEADLINE_S=0` 的人会拿到一条指向字段名的错误,在多 scope 部署里无法定位是哪个键(`_cast` 只能接住“不是数字”,`0`/负/`inf` 会穿过它) |
| 字段 | `GatewaySettings` **末尾**追加 `call_deadline_s: float \| None = None`(有默认值,不扰动既有位置构造;`EmbeddingSettings.gateway` / `OcrSettings.gateway` 自动继承) |
| 守卫 | `_validate_call_deadline()` 加入 `__post_init__``:186-194`)末位,实现体是 `object.__setattr__(self, "call_deadline_s", ensure_call_deadline(self.call_deadline_s, "GatewaySettings.call_deadline_s"))`——盖住直接构造与 `dataclasses.replace` 两条路;env 路已在 loader 里拿真键名报过错,此处重跑对合法值是幂等空操作 |
| 装配透传 | `GatewaySettings.from_env``:349`)返回字典加 `call_deadline_s=_load_call_deadline(scope_u, env)``GatewayClient.from_settings``client.py:449`)传 `settings.call_deadline_s``EmbeddingClient.from_settings``embedding.py:584`)与 `OcrClient.from_settings``ocr.py:631`)传 `gw.call_deadline_s`;三个 `from_env` 签名不变 |
| 不耦合 | **不校验** `call_deadline_s``timeout_s`/`stall_window_s` 的大小关系:期限短于单次超时是调用方的合法选择 |
### 3.4 三个接入点与 per-call 透传(唯一三处)
| 文件:行 | 改后 |
| --- | --- |
| `client.py:398` | `response = await with_call_deadline(self._handler(request), deadline_s=deadline, scope=self._scope)` |
| `embedding.py:209` | 同款包住 `self._embed_all(...)`(**整次调用一份**,N 批共享) |
| `ocr.py:274` | 同款包住 `self._run(...)` |
- 三处均在既有 `try` 之内、`_CallContext` 创建之后 → 到期照走 `except PolyGatewayError → emit_terminal_once`
- `StructuredMW._run_ladder``structured.py:67-98`)与 `_embed_batch``embedding.py:295`)**严禁**新建 scope:同级重问/分批共享同一份期限,否则期限被轮数放大 N 倍。
- 三个 `__init__` 追加 keyword-only `call_deadline_s: float | None = None`,函数体首行即 `self._call_deadline_s = ensure_call_deadline(call_deadline_s, "<Class>(call_deadline_s=...)")``client.py` 需自存 `self._scope = scope`(现只传给 RetryMW,未自存)。
- 四个公开方法(`chat`/`embed`/`recognize_text`/`parse_layout`)追加 keyword-only `call_deadline_s: float | None = None``None` = 继承装配值,正数 = 本次覆盖,**不提供"本次关闭"**。取值一行:`deadline = self._call_deadline_s if call_deadline_s is None else ensure_call_deadline(call_deadline_s, "<method>(call_deadline_s=...)")`,位置在既有输入校验之列、`_CallContext` 创建**之前**。
- OCR 两个入口经 `recognize_text`/`parse_layout``_call` 形参透传;`_call` 内的校验须与 `image` 校验同列(`ocr.py:266-269`),即仍在 `_CallContext``:272`)之前。
- `embedding.py:205-217``texts == []` 早返回在 `try` 之前,天然落在期限之外——保持原样,测试显式记一笔。
### 3.5 取消结算矩阵落到代码(设计 §6.3 的唯一实现形态)
`middleware/retry.py::_attempt`(对照现读行号)。人类只批准了“**取消路径**的结算口径”这一条,故 `actual` 初值**不得**改成 `est`——那会把语义泛化到一切未分类异常(`RuntimeError``KeyError` 逃逸),属未批准范围。改用**局部阶段变量**:
```python
actual = 0 # 初值不动:未分类异常逃逸时仍逐字走 1.3.5 语义
settlement_known = False # 局部变量:该刻库是否已算出确定结算(不进任何签名)
```
| 现状行 | 现状 | 改后 |
| --- | --- | --- |
| `:281` | `actual = 0` | **保持 `0`**,紧随一行新增 `settlement_known = False`(局部阶段变量,带注释:只服务于取消分支的兜底取值,不进任何签名) |
| `:288` | `await self._transport.complete(...)` | 不动(S3 的“端口已开始”窗口就是它未返回的那段) |
| `:301` | `actual = source.effective_est_tokens()`(usage 不可得) | 值不变,其后置 `settlement_known = True` |
| `:303` | `actual = result.prompt_tokens + result.completion_tokens` | 值不变,其后置 `settlement_known = True`S4**真实 usage 恰为 0 也算已知**,取消不得覆写) |
| `:311` `RequestRejectedError` | 隐式 0 | 分支**首句**`actual = 0; settlement_known = True`(逐字保住 1.3.5,且取消落在本分支 await 中途仍得 0) |
| `:315` `ResultInvalidError` | 隐式 0 | 同上(已计费的坏结果仍退全款属另一族缺口,本版不动) |
| `:320` `CancelledError` | 不动 `actual` | **唯一**新增赋值点,且在本分支首句:`if not settlement_known: actual = source.effective_est_tokens()``is_probe` / `_emit` / `raise` 三行原样 |
| `:325` 失败分支入口 | `dead = isinstance(exc, SourceDeadError)` | 完成该分支原有同步分类/选源反馈后,紧贴第一个 `await record_failure` 之前插入 `actual = 0 if dead else source.effective_est_tokens(); settlement_known = True`;不得前移到同步分类之前改变其异常结算 |
| `:335-336` | `if not dead: actual = est` | **删除**(已上移);非取消路径的最终值与 1.3.5 逐字相同,只是算得更早 |
| `:339-341` | `finally: pacer.leave(); settle_and_release(permit, actual)` | 一字不动 |
**“确定结算”的界桩就是上表的 await 位置**:失败分支的结算决定前移到两个记账 await 之前,故**取消发生在已知 `SourceDead` 之后时保留那个既有的 `0`**(源已判死就不该继续占额度)。**为何必须靠位置而不能只靠标志位**:`except asyncio.CancelledError``except (SourceDeadError, TransientError)` 是**同级**分支,落在后者块内 await 上的取消**不会**被前者接住,直接穿到 `finally`——那一刻 `actual` 是什么就结什么,标志位没有机会被读到。故失败分支必须在其**第一个 await 之前**就把 `actual` 定死。本版**不把 S5 泛化成“一切失败按 `est` 结算”**`est` 只是“取消且结算未定”这一格的兜底值。
`embedding.py::_attempt` 同构:`:345` 保持 `actual = 0` 并新增 `settlement_known = False``:358`/`:360` 值不变、其后置 `True``:377``RequestRejected`/`ResultInvalid` 合并分支)首句 `actual = 0; settlement_known = True``:408` 失败分支在 `dead` 之后、`record_failure``:414`)之前插入 `actual = 0 if dead else est; settlement_known = True` 并删掉 `:414-415``if not dead:` 赋值;`:392` 取消分支首句加同款条件赋值;`:429-430 finally` 不动。
`ocr.py:449 settle_and_release(permit, 0)` **保持 0**OCR 无 token 是事实而非"未知"`ocr.py:9` 既有声明),不得改成 est,也不引入 `settlement_known`
**防越界回归(必带)**:假 transport 抛 `RuntimeError`(不属四分类、无 except 接住)→ `tpm_used == 0` 且异常原样上抛;真实 usage 恰为 0 的成功 → `tpm_used == 0`。两条把“不得扩到未批准语义”钉成可回归的断言。
共享状态与探针一律不变:`pacer.leave()``permit.release()``breaker.release_probe()``retry.py:321-322``ocr.py:410-411``embedding.py:393-394`)、`mark_progress` 的调用点、次数与顺序全部逐字保留;**不新增任何公开参数**。
### 3.6 F1`Retry-After` 非有限值(`transports/openai_compat.py:111-119`
签名改为 `_parse_retry_after(raw: str | None, *, source_name: str) -> float | None`——`source_name` 是**必填 keyword-only 参数**(私有模块内函数,不属公共面,故不给默认值;漏传即 `TypeError`);**唯一调用处**是 `_translate_429``:140`),改传 `source_name=source.name`(该函数已持有 `source`,不需新参数)。
判据与告警(按已批设计 §6.1 原文精确定义,不得自行扩大):
| 输入形态 | 返回 | 日志 |
| --- | --- | --- |
| `inf` / `-inf` / `1e999``float()` 成功且 `math.isinf(seconds)` | `None` | **一条 `logger.warning`**,只写源名与判据词(如 `retry_after_not_finite`),**不拼接、不截断、不打印原始头字符串** |
| `nan` | `None` | **无告警**:沿用既有 `seconds > 0` 恒假的值语义,本版**不为它新增分支、不改判据顺序** |
| HTTP-date / 空串 / 负数 / 不可解析 | `None` | 无告警(429 风暴下逐次告警会淹掉真信号) |
| 有限正数 | 该值 | 无 |
实现上只在 `float()` 成功后、`seconds > 0` 之前插一段 `if math.isinf(seconds): warning; return None``_translate_429` 的分类、`backoff_delay``max(delay, retry_after)` 取大逻辑一字不动(设计 §6.2:不夹 `backoff_max_s`)。
## 4. 任务与提交点(4 个原子提交)
### T0:设计批准状态与本计划(本任务,无代码)
产出:设计文档状态改批准 + §6.3 结算矩阵 + §5.2 五形态;本计划。不提交代码、不动测试。
### T1 → 提交 1 `fix: settle cancelled attempts against the source estimate`
1. **先红**:按 §5 批次 C 写 S3/S7 用例(`test_retry.py``test_embedding.py`),确认失败信息是 `tpm_used == 0 != 400`(不是构造错误);同批写 S5-dead 与 S8 两条**防越界**用例(实现前应已绿,作回归锁)。
2.`middleware/retry.py::_attempt``embedding.py::_attempt`(§3.5:初值保 0 + 局部 `settlement_known`,失败分支结算决定上移到两个 await 之前),`ocr.py` 只补注释不改值;**不新增任何公开参数、不改未分类异常路径**。
3. **后绿**:新用例通过;`pytest tests/unit -q` 全绿(S1/S4/S5-dead/S6/S8 回归断言在批次 C 内一并落地)。
4. 真实 Redis`tests/integration/test_redis_cross_connection.py` 新增取消结算用例(§5 批次 F),跑 `pytest tests/integration/test_redis_cross_connection.py -q`
5. 暂存路径:`src/polygateway/middleware/retry.py``src/polygateway/embedding.py``src/polygateway/ocr.py`、三个测试文件。
### T2 → 提交 2 `feat: add an optional per-call wall-clock deadline`
1. 新建 `deadline.py`(§3.1)、`errors.py` 新类(§3.2)、`__init__.py` 导出、`pyproject.toml` layers 一行。
2. `config.py` 四处(字段/loader/守卫/`from_env`)、三个 client 的构造参数 + 公开方法参数 + 包裹点 + `from_settings` 透传(§3.3/§3.4)。
3. 先红后绿顺序:批次 A`test_deadline.py` 值域)→ 批次 B(五形态)→ 批次 D(三链路命中与覆盖面)→ 批次 E(配置四条路)。
4. 回归门:`pytest tests/unit tests/contracts -q` 全绿且**未改一行既有断言**`make lint`(含 import-linter 新层)通过。
5. 暂存路径:`src/polygateway/deadline.py``errors.py``__init__.py``config.py``client.py``embedding.py``ocr.py``pyproject.toml``tests/unit/test_deadline.py` 及四个改动测试文件。
### T3 → 提交 3 `fix: ignore non-finite Retry-After hints`
1. 先红:`tests/unit/test_openai_compat.py``inf`/`-inf`/`1e999`/`nan`/空/负/HTTP-date 七例,并加一例漏传 `source_name``TypeError`(批次 G)。
2.`_parse_retry_after`:加必填私有 kw `source_name`、加 `math.isinf` 判据与一条 warning,同步唯一调用处 `_translate_429``:140`);跑该文件与 `tests/unit -q`
3. 暂存:`src/polygateway/transports/openai_compat.py``tests/unit/test_openai_compat.py`
### T4 → 提交 4 `docs: document the optional call deadline and cancellation settlement`
1. `CHANGELOG.md` 未发布段:三句强制措辞——**期限治理的是等待、返回时刻 = 期限 + 清理耗时(实测 5–7 倍)**;**到期不等于未产出、未计费**;**不配置即保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。另记取消结算口径变化:**仅当取消发生在“端口已开始、结算尚未确定”时**按 `est` 保留预扣(方向为宁多扣不空退);已知结算(含真实 usage 恰为 0、已判 `SourceDead``0`)不被覆写,**未分类异常仍按 `0`**。另列"`except GatewayUnavailableError` 接不住新异常"。
2. `README.md` **四处同步(缺一不可,按行号定位)**:① 能力表(`:10-22` 区间)新增一行“调用期限”,措辞用 §5.3 三句;② “### 4. 业务侧异常处理”示例(`:185-195`)——该段 `except GatewayUnavailableError` **接不住** `CallDeadlineExceeded`,必须加一条 `except CallDeadlineExceeded` 分支并注明它无 `retry_after_s`;③ “哪些异常会到达调用方”表(`:466-476`)左列新增 `CallDeadlineExceeded` 行,并写明它**不属四分类、不属 `GatewayUnavailableError` 族**,只在显式配期限后才可能出现;④ 错误模型段补一句**有限大 `Retry-After` 残留**(能力表 `:15` 写的“尊重 Retry-After”仍成立:库不夹 `backoff_max_s`,服务端给 3600s 就睡 3600s,唯一制约手段是本版的调用期限;`inf`/`1e999` 自 1.3.6 起按无提示处理)。另:`.env.example``LLM__CIRCUIT_OPEN``:71`)之后加注释行 `# LLM__CALL_DEADLINE_S=`(缺省不启用,说明其治理对象是等待)。
3. `research-wiki/findings/2026-09-10-136-call-deadline-validation.md`:红绿证据、命令与退出码、豁免索引。
4. 独立验证(全新上下文 verifier)与整分支审查在本提交前完成;版本号与 wiki 同步留给发布清单(本计划不 bump、不发布)。
## 5. 测试矩阵 → 任务映射
**测试设施复用与"哪一份副本"的硬性核对**(历史坑,动手前必须核对):
| 事实 | 证据 | 纪律 |
| --- | --- | --- |
| `tests/unit/test_retry.py:35``tests/unit/test_embedding.py:176``tests.contracts.conftest` import `FakeClock` | 现读 | 改这一份即影响契约与两个单测文件 |
| `tests/unit/test_ocr_client.py:344` **自带一份同名 `FakeClock`** | 现读 | OCR 用例只吃这一份;给 OCR 加期限用例时不得误改 contracts 那份并以为生效 |
| `tests/unit/test_embedding.py:177``tests.unit.test_backpressure` import `BoundedSleep` | 现读 | 复用它做"轮询次数有界"断言,不新造 |
| 无 `tests/conftest.py` / `tests/unit/conftest.py` | `ls` 实测 | 新 fixture 只能进各文件本地,或复用 `tests/contracts/conftest.py`(已被 unit 直接 import |
**取消白箱的确定性纪律**:既有取消用例用 `await asyncio.sleep(0.05)` 撞窗口(`test_retry.py:474``test_ocr_client.py:365`)——新用例**不得**沿用。做法:给 `test_retry.py::FakeTransport``"hang"` 分支加 `self.entered.set()`(构造期 `self.entered = asyncio.Event()`3.10+ 不绑定 loop),用例 `await transport.entered.wait()` 后再 `task.cancel()`embedding/OCR 的 `ScriptedEmbedTransport`/`ScriptedOcrTransport` 同款加一个 `entered`。既有用例不动。
| 批次 | 断言(→ 任务) | 落点 |
| --- | --- | --- |
| A 值域 | `None` 通过;`0`/负/`nan`/`inf`/`"1"`/`True`bool 不得当 1 秒)/`object()``ValueError` 且消息含 origin(→T2 | `tests/unit/test_deadline.py` |
| B 形态区分 | ①到期 → `CallDeadlineExceeded``scope`/`deadline_s` 正确);②未到期内层自抛 `TimeoutError` → 原样上抛;③**到期后清理自抛 `TimeoutError`** → 原样上抛且**断言不是** `CallDeadlineExceeded`(钉住身份比较);④外部 `task.cancel()`(先于/晚于到期各一例)→ `CancelledError`;⑤擦边成功 → 正常返回且 `task.cancelling() == 0`;⑥到期窗口内体内先抛领域异常(同步自旋构造)→ 上抛该异常,**不断言必为 deadline**;⑦`deadline_s=None` → 逐字旧路径(→T2 | `tests/unit/test_deadline.py`(真实 loop 时钟,期限 0.05s、体 0.3s410× 余量,不标 slow |
| C 取消结算 | S3`tpm=1000, est_tokens=400`、transport `hang``entered` 后取消 → `tpm_used == 400`**红→绿核心**)且 `inflight == 0`S4:假 gate 在 `record_success``set()` 后挂起 → 取消 → `tpm_used == 15`(真实 usage 未被覆盖);S1:熔断开路使 `pick``settle_and_release(permit, 0)``tpm_used == 0`S5-dead:假 gate 在 `SourceDead``record_failure` 处挂起 → 取消 → `tpm_used == 0`**不得**变 `est`);S5-transient:同位置但瞬时失败 → `tpm_used == est`S6OCR 源 `tpm=600`、transport `hang` → 取消 → `tpm_used == 0`S7embedding 同 S3**S8 防越界**:假 transport 抛 `RuntimeError``tpm_used == 0` 且异常原样上抛;真实 usage 恰为 0 的成功 → `tpm_used == 0`;四条既有路径(成功/`SourceDead`/`RequestRejected`/`ResultInvalid`)结算值逐字不变(→T1 | `test_retry.py``test_embedding.py``test_ocr_client.py` |
| D 覆盖面 | 期限分别落在 ①退避 `sleep`(注入真 `asyncio.sleep`)②准入排队(配额满轮询)③结构化重问 ④embedding 多批 → 均抛 `CallDeadlineExceeded`embedding 断言 **N 批共享一份**期限(总时长不随批数放大);`texts == []` 早返回不受期限影响(→T2 | `test_client.py``test_embedding.py``test_ocr_client.py` |
| D2 到期代价 | ①终态遥测:到期恰好一条 `event_kind='terminal_failure'``error_type='CallDeadlineExceeded'`,被取消的 attempt 行仍 `cancelled`,两行 `logical_call_id` 一致,列数不变;②清理不可越过 + 量化:假 permit/假 emitter 各注入已知 sleep → 返回时刻 ≈ 期限 + 已知清理时长(断言 > 期限的若干倍,不断言上界);③已计费成功被丢弃:假缓存后端 `set` 慢于期限 → 抛 deadline 且断言 transport **已成功调用一次**(→T2 | `test_client.py` |
| E 配置四条路 | 键未设 → `None`;非法值 × {env、`GatewaySettings(...)` 直接构造、`dataclasses.replace`、三个 client `__init__` 直传} 各一例 → `ValueError`per-call 非法值抛错且**无 "coroutine was never awaited" 警告**`pytest.warns` 反向断言 / `-W error::RuntimeWarning`);`call_deadline_s < timeout_s` 合法不报错;注入钟跳变 10^6 秒**不**触发期限,而期限触发时 `total_latency_ms` 仍取自注入钟(→T2 | `test_config.py``test_client.py` |
| F 真实 Redis | 复用 `tests/integration/test_redis_cross_connection.py``clients`/`_limiter`/`_client`/`ScriptedTransport(hang=True)`:源 `tpm=1000, est_tokens=400``inflight` 出现后取消 → `source_stats.tpm_used == 400``inflight == 0`。**不改 Lua、不改 `tests/contracts/`**;另跑既有 `pytest tests/contracts/test_limiter_contract.py -q`memory+redis 双参数)证明后端算术未被触碰(→T1) | `tests/integration/test_redis_cross_connection.py` |
| G F1 | `inf`/`-inf`/`1e999``None` + 各一条 warning(断言日志**含源名、不含**原始字符串);`nan`/空/负/HTTP-date → `None` 且**无** warning`nan` 仍走既有 `seconds > 0` 值语义,不新增分支);漏传 `source_name``TypeError`(钉住必填 kw);有限正数仍参与 `max(delay, retry_after)``insufficient_quota` 仍归 `SourceDead`(→T3 | `test_openai_compat.py` |
| H 未启用回归 | `call_deadline_s=None``tests/unit``tests/contracts` 全绿且**未改一行既有断言**(→T2 门) | 全套件 |
命令(全部 `conda run -n PolyGateway`,禁止接管道以免退出码失真):`pytest tests/unit -q``pytest tests/contracts -q``pytest tests/integration/test_redis_cross_connection.py -q``make lint`。真实网关 e2e 与 `-m slow` 变体本计划**不跑**,由发布清单第 4 步统一负责。
## 6. 阻塞矩阵与交接
| 触发条件 | 处置 |
| --- | --- |
| 需要新增本计划外的公共键/端口方法/遥测列 | **停下上报**(设计 §9 边界之外即未批准) |
| 批次 B③(清理期自抛 `TimeoutError`)在实现里无法确定性构造 | 改用假端口在 `except CancelledError` 内直接 `raise TimeoutError`(探针 D3 已证可复现);仍不可得则记入 findings 的豁免索引,不得删断言 |
| 批次 D2② 的量化断言在 CI 机器上抖动 | 只断言下界(返回时刻 > 期限 × 2),不断言上界;不得改成 `sleep` 猜测 |
| 真实 Redis 不可用(`REDIS_URL` 未配置) | 用例自动 skipfindings 必须显式记"未取得真实 Redis 证据",不得以 memory 结果冒充 |
| 发现 `ResultInvalid`/`RequestRejected` 退全款想顺手修 | **不修**(设计 §6.3 末段:未批准的另一族记账语义),登记为新 issue 交父会话 |
| 失败分支结算决定上移后发现某条既有用例变红 | 先 `systematic-debugging` 定根因;如确为语义变化(非取消路径的最终值应与 1.3.5 逐字相同)则**停下上报**——说明本项只改算得更早、不改算出什么 |
| 想把保守口径扩到未分类异常(`RuntimeError` 等) | **不扩**(人类仅批准取消路径);S8 回归用例就是这道锁,需要就另立 issue |
| issue #24 相关想法 | 一律不实现、不写进代码与文档 |
交接物:4 个提交、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
## 7. 自审
| 检查 | 结论 |
| --- | --- |
| 路径/行号/签名是否可执行无 TBD | 是——所有接入点均现读行号(`retry.py:281/288/301/303/311/315/320/325/335/339``embedding.py:345/349/358/360/377/392/408/414/429``ocr.py:449``client.py:398``embedding.py:209``ocr.py:274``config.py:186/349/380-384``openai_compat.py:111-119/140``__init__.py:61-62` |
| 是否复用而非重造 | 是——`settle_and_release``emit_terminal_once``claim_terminal``asyncio.timeout` 范式、FakeClock/BoundedSleep/ScriptedTransport、限流契约套件与真实 Redis 用例全部复用;新增仅 1 文件 + 1 异常类 + 1 配置键 |
| 是否有先失败后通过的证据点 | 是——T1 的 S3/S7、T2 的 A/B/D、T3 的 G 均先红 |
| 取消与降级铁律 | 未新增 `except Exception``CancelledError` 无新捕获点;期限未启用时不进任何上下文 |
| 反 gold-plating | `ResultInvalid`/`RequestRejected` 退全款、#24`shield`、遥测列、Lua 一律不碰 |
| 残余诚实标注 | S3 是保守选择而非"已计费"的证明;**未分类异常(S8)仍按 `0` 退全款,属已知残留、本版不动**;清理期自抛 `TimeoutError` 时无终态行;跨 Python 版本仅 3.12.13 有探针实证——四条均已写进设计 §12,findings 需复述 |