33 KiB
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。 基线 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
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 新类(唯一定义点)
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-onlycall_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-onlycall_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 逃逸),属未批准范围。改用局部阶段变量:
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
- 先红:按 §5 批次 C 写 S3/S7 用例(
test_retry.py、test_embedding.py),确认失败信息是tpm_used == 0 != 400(不是构造错误);同批写 S5-dead 与 S8 两条防越界用例(实现前应已绿,作回归锁)。 - 改
middleware/retry.py::_attempt与embedding.py::_attempt(§3.5:初值保 0 + 局部settlement_known,失败分支结算决定上移到两个 await 之前),ocr.py只补注释不改值;不新增任何公开参数、不改未分类异常路径。 - 后绿:新用例通过;
pytest tests/unit -q全绿(S1/S4/S5-dead/S6/S8 回归断言在批次 C 内一并落地)。 - 真实 Redis:
tests/integration/test_redis_cross_connection.py新增取消结算用例(§5 批次 F),跑pytest tests/integration/test_redis_cross_connection.py -q。 - 暂存路径:
src/polygateway/middleware/retry.py、src/polygateway/embedding.py、src/polygateway/ocr.py、三个测试文件。
T2 → 提交 2 feat: add an optional per-call wall-clock deadline
- 新建
deadline.py(§3.1)、errors.py新类(§3.2)、__init__.py导出、pyproject.tomllayers 一行。 config.py四处(字段/loader/守卫/from_env)、三个 client 的构造参数 + 公开方法参数 + 包裹点 +from_settings透传(§3.3/§3.4)。- 先红后绿顺序:批次 A(
test_deadline.py值域)→ 批次 B(五形态)→ 批次 D(三链路命中与覆盖面)→ 批次 E(配置四条路)。 - 回归门:
pytest tests/unit tests/contracts -q全绿且未改一行既有断言;make lint(含 import-linter 新层)通过。 - 暂存路径:
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
- 先红:
tests/unit/test_openai_compat.py加inf/-inf/1e999/nan/空/负/HTTP-date 七例,并加一例漏传source_name的TypeError(批次 G)。 - 改
_parse_retry_after:加必填私有 kwsource_name、加math.isinf判据与一条 warning,同步唯一调用处_translate_429(:140);跑该文件与tests/unit -q。 - 暂存:
src/polygateway/transports/openai_compat.py、tests/unit/test_openai_compat.py。
T4 → 提交 4 docs: document the optional call deadline and cancellation settlement
CHANGELOG.md未发布段:三句强制措辞——期限治理的是等待、返回时刻 = 期限 + 清理耗时(实测 5–7 倍);到期不等于未产出、未计费;不配置即保持 1.3.5 语义(纯 429 序列仍可能长等、有限大Retry-After仍照睡)。另记取消结算口径变化:仅当取消发生在“端口已开始、结算尚未确定”时按est保留预扣(方向为宁多扣不空退);已知结算(含真实 usage 恰为 0、已判SourceDead的0)不被覆写,未分类异常仍按0。另列"except GatewayUnavailableError接不住新异常"。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=(缺省不启用,说明其治理对象是等待)。research-wiki/findings/2026-09-10-136-call-deadline-validation.md:红绿证据、命令与退出码、豁免索引。- 独立验证(全新上下文 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 需复述 |