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

33 KiB
Raw Permalink Blame History

type, node_id, title, date
type node_id title date
plan plan:2026-09-10-136-call-deadline 1.3.6 可选调用期限与取消结算修复实施计划 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。 基线 HEADd2455e8(分支 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);成功、RequestRejectedResultInvalidSourceDead 四条既有路径与未分类异常逃逸路径(S8,仍 0的结算值逐字不变;实现只能用函数内局部阶段变量,不得新增公开参数;限流 Lua、Permit 端口签名、幂等语义一律不动
依赖铁律 新模块 deadline.py 只 import stdlib + errors.pymiddleware/ 仍只依赖端口与内核;import-linter 契约新增一层执法
取消 CancelledError 永不吞没;不引入 shield、不开后台任务;清理仍在 finally,允许超出期限
降级方向 限流/熔断后端仍 fail-closed;遥测/缓存仍 warning 降级;非法期限值 → 当场 ValueError(装配错误不属降级面)
证据与秘密 不打印 .env、token、Authorization;不提交 .pi/tests/outputs/;命令输出只记路径、状态与退出码
证据复用 复用既有 FakeClock / 假 transport / settle_and_release 出口 / 限流契约套件 / 真实 Redis 跨连接用例;不重跑模型能力矩阵,本版零付费调用

Skill 纪律:T0 已执行 writing-plansT1T3 行为变更执行 test-driven-development(先失败后通过的证据须落在本会话工具输出里);每次提交执行 commit(英文祈使标题、无 AI 签名、显式路径暂存);T4 前执行 requesting-code-reviewverification-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.5actual 初值仍 0 + 局部 settlement_known);__call__ 循环、backoff_delayStallClock 一字不动
修改 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+ FakeTransportentered 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.mdREADME.md.env.example 新键、新异常、对外承诺三句话(§4 C4)
新建 research-wiki/findings/2026-09-10-136-call-deadline-validation.md 红绿、命令、豁免索引,≤300 行

不改ports.pyPermit.settle 签名与语义不动)、middleware/admission.pysettle_and_release 逐字不动)、middleware/ratelimit.pymiddleware/breaker.pymiddleware/structured.pymiddleware/cache.pymiddleware/telemetry.pytelemetry/schema.py(零新增列)、backends/**(含全部 Lua)、sources.pystreaming.pytypes.pytransports/monkey_ocr.py:58 不解析 Retry-After)、tests/contracts/**(复用现有 settle 契约,只跑不改)。若实施时发现必须突破本清单,先说明最小原因交父会话核定。

3. 跨任务接口(可执行定义,禁止占位)

3.1 src/polygateway/deadline.py

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.timeoutTimeoutError__cause__ 也是 CancelledError)。本机制不新增公共配置、不开后台任务、不改异常对象

3.2 errors.py 新类(唯一定义点)

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_settingsclient.py:449)传 settings.call_deadline_sEmbeddingClient.from_settingsembedding.py:584)与 OcrClient.from_settingsocr.py:631)传 gw.call_deadline_s;三个 from_env 签名不变
不耦合 不校验 call_deadline_stimeout_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_ladderstructured.py:67-98)与 _embed_batchembedding.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 = NoneNone = 继承装配值,正数 = 本次覆盖,不提供"本次关闭"。取值一行: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-217texts == [] 早返回在 try 之前,天然落在期限之外——保持原样,测试显式记一笔。

3.5 取消结算矩阵落到代码(设计 §6.3 的唯一实现形态)

middleware/retry.py::_attempt(对照现读行号)。人类只批准了“取消路径的结算口径”这一条,故 actual 初值不得改成 est——那会把语义泛化到一切未分类异常(RuntimeErrorKeyError 逃逸),属未批准范围。改用局部阶段变量

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 = TrueS4真实 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.CancelledErrorexcept (SourceDeadError, TransientError)同级分支,落在后者块内 await 上的取消不会被前者接住,直接穿到 finally——那一刻 actual 是什么就结什么,标志位没有机会被读到。故失败分支必须在其第一个 await 之前就把 actual 定死。本版不把 S5 泛化成“一切失败按 est 结算”est 只是“取消且结算未定”这一格的兜底值。

embedding.py::_attempt 同构::345 保持 actual = 0 并新增 settlement_known = False:358/:360 值不变、其后置 True:377RequestRejected/ResultInvalid 合并分支)首句 actual = 0; settlement_known = True:408 失败分支在 dead 之后、record_failure:414)之前插入 actual = 0 if dead else est; settlement_known = True 并删掉 :414-415if not dead: 赋值;:392 取消分支首句加同款条件赋值;:429-430 finally 不动。

ocr.py:449 settle_and_release(permit, 0) 保持 0OCR 无 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-322ocr.py:410-411embedding.py:393-394)、mark_progress 的调用点、次数与顺序全部逐字保留;不新增任何公开参数

3.6 F1Retry-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 / 1e999float() 成功且 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_delaymax(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.pytest_embedding.py),确认失败信息是 tpm_used == 0 != 400(不是构造错误);同批写 S5-dead 与 S8 两条防越界用例(实现前应已绿,作回归锁)。
  2. middleware/retry.py::_attemptembedding.py::_attempt(§3.5:初值保 0 + 局部 settlement_known,失败分支结算决定上移到两个 await 之前),ocr.py 只补注释不改值;不新增任何公开参数、不改未分类异常路径
  3. 后绿:新用例通过;pytest tests/unit -q 全绿(S1/S4/S5-dead/S6/S8 回归断言在批次 C 内一并落地)。
  4. 真实 Redistests/integration/test_redis_cross_connection.py 新增取消结算用例(§5 批次 F),跑 pytest tests/integration/test_redis_cross_connection.py -q
  5. 暂存路径:src/polygateway/middleware/retry.pysrc/polygateway/embedding.pysrc/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. 先红后绿顺序:批次 Atest_deadline.py 值域)→ 批次 B(五形态)→ 批次 D(三链路命中与覆盖面)→ 批次 E(配置四条路)。
  4. 回归门:pytest tests/unit tests/contracts -q 全绿且未改一行既有断言make lint(含 import-linter 新层)通过。
  5. 暂存路径:src/polygateway/deadline.pyerrors.py__init__.pyconfig.pyclient.pyembedding.pyocr.pypyproject.tomltests/unit/test_deadline.py 及四个改动测试文件。

T3 → 提交 3 fix: ignore non-finite Retry-After hints

  1. 先红:tests/unit/test_openai_compat.pyinf/-inf/1e999/nan/空/负/HTTP-date 七例,并加一例漏传 source_nameTypeError(批次 G)。
  2. _parse_retry_after:加必填私有 kw source_name、加 math.isinf 判据与一条 warning,同步唯一调用处 _translate_429:140);跑该文件与 tests/unit -q
  3. 暂存:src/polygateway/transports/openai_compat.pytests/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、已判 SourceDead0)不被覆写,未分类异常仍按 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.exampleLLM__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:35tests/unit/test_embedding.py:176tests.contracts.conftest import FakeClock 现读 改这一份即影响契约与两个单测文件
tests/unit/test_ocr_client.py:344 自带一份同名 FakeClock 现读 OCR 用例只吃这一份;给 OCR 加期限用例时不得误改 contracts 那份并以为生效
tests/unit/test_embedding.py:177tests.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:474test_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"/Truebool 不得当 1 秒)/object()ValueError 且消息含 origin(→T2 tests/unit/test_deadline.py
B 形态区分 ①到期 → CallDeadlineExceededscope/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.3s4–10× 余量,不标 slow
C 取消结算 S3tpm=1000, est_tokens=400、transport hangentered 后取消 → tpm_used == 400红→绿核心)且 inflight == 0S4:假 gate 在 record_successset() 后挂起 → 取消 → tpm_used == 15(真实 usage 未被覆盖);S1:熔断开路使 picksettle_and_release(permit, 0)tpm_used == 0S5-dead:假 gate 在 SourceDeadrecord_failure 处挂起 → 取消 → tpm_used == 0不得est);S5-transient:同位置但瞬时失败 → tpm_used == estS6OCR 源 tpm=600、transport hang → 取消 → tpm_used == 0S7embedding 同 S3S8 防越界:假 transport 抛 RuntimeErrortpm_used == 0 且异常原样上抛;真实 usage 恰为 0 的成功 → tpm_used == 0;四条既有路径(成功/SourceDead/RequestRejected/ResultInvalid)结算值逐字不变(→T1 test_retry.pytest_embedding.pytest_ocr_client.py
D 覆盖面 期限分别落在 ①退避 sleep(注入真 asyncio.sleep)②准入排队(配额满轮询)③结构化重问 ④embedding 多批 → 均抛 CallDeadlineExceededembedding 断言 N 批共享一份期限(总时长不随批数放大);texts == [] 早返回不受期限影响(→T2 test_client.pytest_embedding.pytest_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__ 直传} 各一例 → ValueErrorper-call 非法值抛错且无 "coroutine was never awaited" 警告pytest.warns 反向断言 / -W error::RuntimeWarning);call_deadline_s < timeout_s 合法不报错;注入钟跳变 10^6 秒触发期限,而期限触发时 total_latency_ms 仍取自注入钟(→T2 test_config.pytest_client.py
F 真实 Redis 复用 tests/integration/test_redis_cross_connection.pyclients/_limiter/_client/ScriptedTransport(hang=True):源 tpm=1000, est_tokens=400inflight 出现后取消 → source_stats.tpm_used == 400inflight == 0不改 Lua、不改 tests/contracts/;另跑既有 pytest tests/contracts/test_limiter_contract.py -qmemory+redis 双参数)证明后端算术未被触碰(→T1) tests/integration/test_redis_cross_connection.py
G F1 inf/-inf/1e999None + 各一条 warning(断言日志含源名、不含原始字符串);nan/空/负/HTTP-date → None warningnan 仍走既有 seconds > 0 值语义,不新增分支);漏传 source_nameTypeError(钉住必填 kw);有限正数仍参与 max(delay, retry_after)insufficient_quota 仍归 SourceDead(→T3 test_openai_compat.py
H 未启用回归 call_deadline_s=Nonetests/unittests/contracts 全绿且未改一行既有断言(→T2 门) 全套件

命令(全部 conda run -n PolyGateway,禁止接管道以免退出码失真):pytest tests/unit -qpytest tests/contracts -qpytest tests/integration/test_redis_cross_connection.py -qmake 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/339embedding.py:345/349/358/360/377/392/408/414/429ocr.py:449client.py:398embedding.py:209ocr.py:274config.py:186/349/380-384openai_compat.py:111-119/140__init__.py:61-62
是否复用而非重造 是——settle_and_releaseemit_terminal_onceclaim_terminalasyncio.timeout 范式、FakeClock/BoundedSleep/ScriptedTransport、限流契约套件与真实 Redis 用例全部复用;新增仅 1 文件 + 1 异常类 + 1 配置键
是否有先失败后通过的证据点 是——T1 的 S3/S7、T2 的 A/B/D、T3 的 G 均先红
取消与降级铁律 未新增 except ExceptionCancelledError 无新捕获点;期限未启用时不进任何上下文
反 gold-plating ResultInvalid/RequestRejected 退全款、#24、shield、遥测列、Lua 一律不碰
残余诚实标注 S3 是保守选择而非"已计费"的证明;未分类异常(S8)仍按 0 退全款,属已知残留、本版不动;清理期自抛 TimeoutError 时无终态行;跨 Python 版本仅 3.12.13 有探针实证——四条均已写进设计 §12,findings 需复述