--- 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`;T1–T3 行为变更执行 `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, "(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, "(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.3s,4–10× 余量,不标 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`;S6:OCR 源 `tpm=600`、transport `hang` → 取消 → `tpm_used == 0`;S7:embedding 同 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` 未配置) | 用例自动 skip;findings 必须显式记"未取得真实 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 需复述 |