The deadline governs waiting, not the moment a call returns: cleanup still runs in finally, so the return time is the deadline plus the cleanup cost (5-7x the deadline in the measured fixture). An expiry therefore does not mean nothing was produced or nothing was billed. Leaving the key unset keeps 1.3.5 semantics verbatim, which also keeps its two long waits: a pure 429 sequence can still wait for a long time, and a large finite Retry-After is still slept in full because the library deliberately does not clamp the hint with backoff_max_s. - CHANGELOG unreleased section states those three sentences, records the cancellation settlement change (an attempt cancelled after the port started but before the settlement is known keeps its reservation at the source estimate: over-charge rather than refund something the upstream may already have billed; known settlements and unclassified escapes are untouched) and warns that except GatewayUnavailableError does not catch CallDeadlineExceeded - README in four places: the capability table, the exception handling example, the "which exceptions reach the caller" table and the error model section, which now spells out the remaining large-but-finite Retry-After wait - .env.example documents LLM__CALL_DEADLINE_S as commented out - new findings file indexes the red/green evidence, the commands and their exit codes, what was not run and who covers it, and repeats the three residual risks No version bump, no tag, no release: those belong to the release checklist.
9.6 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| finding | finding:2026-09-10-136-call-deadline-validation | 1.3.6 可选调用期限与取消结算验证记录 | 2026-09-10 |
1.3.6 可选调用期限与取消结算验证记录
范围:分支
feature/1.3.6-call-budgets上的 T1–T3 三个行为提交(1ff83bb/9474c76/da77b12)与 T4 文档提交。设计research-wiki/designs/2026-09-09-136-call-budgets-design.md(人类已批准),计划research-wiki/plans/2026-09-10-136-call-deadline.md。 本文件是证据索引:原始输出在tests/outputs/136/(按纪律不提交),此处只记路径、命令、退出码与结论。 环境:conda 环境PolyGateway,Python 3.12.13(conda run -n PolyGateway python -V实测)。所有 pytest/lint 命令均不接管道,退出码直取。 版本号未 bump、未 tag、未发布——发布清单(CLAUDE.md §4.4.1)不在本轮范围。
1. 红绿证据索引
| 任务 | 阶段 | 证据文件 | 结果 |
|---|---|---|---|
| T1 取消结算 | 红/绿 | 未落盘(见 §1.1 诚实说明) | 见 §1.1 |
| T1 真实 Redis | 绿(本轮在 HEAD da77b12 上复跑) |
tests/outputs/136/t4/redis_cross_connection.txt |
8 passed,exit=0 |
| T2 期限 | 红(值域+形态) | tests/outputs/136/t2/red_deadline.txt |
收集期 1 error(deadline.py 缺席),exit=2 |
| T2 期限 | 绿(test_deadline.py) |
tests/outputs/136/t2/green_deadline.txt |
22 passed,exit=0 |
| T2 接线中途 | 绿 | tests/outputs/136/t2/unit_contracts_midway.txt、unit_contracts_after_wiring.txt |
各 1560 passed, 17 skipped |
| T2 入口冒烟 | 绿 | tests/outputs/136/t2/entry_smoke.txt |
四个方法签名含 call_deadline_s;到期异常 has retry_after_s: False,exit=0 |
| T2 回归门 | 绿 | tests/outputs/136/t2/final_unit_contracts.txt |
1572 passed, 17 skipped,pytest_exit=0 |
| T2 lint | 红→绿 | tests/outputs/136/t2/lint.txt(Found 3 errors,lint_exit=2)→ lint_final.txt(All checks passed! + Contracts: 1 kept, 0 broken.,lint_exit=0) |
修后绿 |
| T2c 补测 | 红 pass1 | tests/outputs/136/t2c/red_pass1_import_absent.txt |
3 errors in 0.29s(三个模块收集期 ImportError) |
| T2c 补测 | 红 pass2 | tests/outputs/136/t2c/red_pass2_real_reasons.txt + red_method_and_reasons.txt |
32 failed;分布见 §1.2 |
| T2c 补测 | 绿 | green_unit_after_hardening.txt(1530 passed)、green_client_recheck.txt(110 passed,EXIT=0)、green_recheck_client_embedding.txt(165 passed)、green_unit_contracts.txt(1592 passed, 17 skipped)、final_unit_contracts.txt(1606 passed, 17 skipped) |
全绿 |
| T2c lint | 绿 | tests/outputs/136/t2c/lint.txt |
All checks passed! + Contracts: 1 kept, 0 broken. |
T3 Retry-After |
红 | tests/outputs/136/t3/red.txt |
6 failed, 8 passed, 129 deselected |
T3 Retry-After |
绿 | green_file.txt(143 passed)、green_unit_contracts.txt(1606 passed, 17 skipped) |
全绿 |
| T3 lint | 绿 | tests/outputs/136/t3/lint.txt |
All checks passed! + Contracts: 1 kept, 0 broken. |
tests/outputs/136/t2/lsp_noise_refutation.txt 与 t2c/lsp_noise_refutation.txt 记录编辑器 LSP 报的 import/属性告警属环境噪声(pydantic 在 conda 环境可解析),不是代码缺陷。
1.1 T1 的证据形态(诚实说明)
T1(1ff83bb)的先红后通过证据产生于当时的会话工具输出,未落盘为 tests/outputs/136/t1/ 文件。本文件不追认那次输出,只登记两项当下可复核的替代证据:
| 替代证据 | 内容 |
|---|---|
提交 1ff83bb 的 diff |
三个源文件 + 四个测试文件共 213 插入;测试侧含 S3/S7(取消结算按 est)、S5-dead(仍 0)、S8(RuntimeError 逃逸仍 0)、真实 usage 恰为 0 的成功仍 0 四组断言 |
本轮在 HEAD da77b12 上重跑真实 Redis |
pytest tests/integration/test_redis_cross_connection.py -q → 8 passed,exit=0(tests/outputs/136/t4/redis_cross_connection.txt) |
结论口径:T1 的“红”只有会话内证据、无归档文件;T1 的“绿”在当前 HEAD 上已被真实 Redis 复现证实。
1.2 T2c 两趟红证据的方法说明(诚实说明)
T2c 的红证据是在**基线 1ff83bb(T2 之前)**的 git worktree --detach 检出上取的,用 PYTHONPATH=<worktree>/src 覆盖 editable .pth(已实测 polygateway 加载自 worktree 且 deadline.py 缺席):
| 趟次 | 做法 | 结果 |
|---|---|---|
| pass1 | 用例原样跑 | 三个测试模块收集期 ImportError(CallDeadlineExceeded 不存在)→ 3 errors。只证明符号缺席,没有执行到函数体 |
| pass2 | 仅把缺失的导入符号替换成本地占位异常类(shim),让函数体真正跑起来 | 32 failed:27 条“参数/属性不存在”(chat() 9、embed() 4、GatewaySettings.__init__() 3、GatewaySettings.call_deadline_s 属性 3、recognize_text() 2、GatewayClient.__init__() 2、parse_layout()/OcrClient.__init__()/EmbeddingClient.__init__()/GatewayClient._call_deadline_s 各 1)+ 5 条 Failed: DID NOT RAISE ValueError |
该 shim 是一次性本地脚手架,未提交、不在任何分支上;它只替换导入符号,不改被测源码。故 pass2 的红是针对预 T2 源码的真实失败原因分布,而非构造错误——但读者需知这份红无法从仓库检出复现,只能从上表与 red_method_and_reasons.txt 复核。
2. 命令与退出码
命令(前缀均为 conda run -n PolyGateway python -m,lint 为 make lint) |
何时 | 结果 |
|---|---|---|
pytest tests/unit/test_deadline.py -q |
T2 红 | exit=2(collection error,符合预期) |
pytest tests/unit/test_deadline.py -q |
T2 绿 | 22 passed,exit=0 |
pytest tests/unit tests/contracts -q |
T2 门 | 1572 passed, 17 skipped,exit=0 |
pytest tests/unit/test_client.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_config.py -q -rf -k "…deadline…" |
T2c 红 | 32 failed(基线 worktree,见 §1.2) |
pytest tests/unit tests/contracts -q |
T2c 门 | 1606 passed, 17 skipped,exit=0 |
pytest tests/unit/test_openai_compat.py -q -rf -k RetryAfterNonFinite |
T3 红 | 6 failed, 8 passed, 129 deselected |
pytest tests/unit/test_openai_compat.py -q |
T3 绿 | 143 passed |
pytest tests/unit tests/contracts -q |
T3 门 | 1606 passed, 17 skipped,exit=0 |
pytest tests/integration/test_redis_cross_connection.py -q |
T1/T4 复跑 | 8 passed,exit=0 |
make lint(ruff + import-linter) |
T2/T2c/T3 收尾 | All checks passed!;Contracts: 1 kept, 0 broken.(新 polygateway.deadline 层在内) |
T4(本次文档提交)不含行为变更,故未跑测试;仅新增上表最后一行的真实 Redis 复跑作为 §1.1 的替代证据。
3. 豁免索引(哪些门没跑,为什么,谁来兜)
| 未执行项 | 原因 | 兜底责任 |
|---|---|---|
pytest -m slow(真实网关 e2e、Redis 时间语义变体) |
成败取决于外部服务当下状态,默认被 addopts = "-m 'not slow'" 排除;计划 §5 明确本轮不跑 |
发布清单(CLAUDE.md §4.4.1)第 4 步,合并 main 后统一执行 |
tests/e2e/ 四个文件 |
同上,本版零付费调用 | 同上 |
| 真实网关的期限行为实测 | 期限用例用真实事件循环时钟+假 transport 构造,余量 4–10 倍,不依赖网关 | 发布清单第 4 步的 e2e 顺带覆盖;本轮无真实网关证据 |
| 模型能力矩阵复验 | 本版未触碰推理/能力表 | 不适用 |
| 跨 Python 版本验证 | 见 §4 残余三 | 未兜底,登记为残余 |
真实 Redis 不在豁免之列:T1 已证、本轮在 HEAD 上复跑(8 passed),未以 memory 后端冒充。
4. 残余风险(三条,逐条复述设计 §12)
| # | 残余 | 诚实口径 |
|---|---|---|
| 1 | 取消结算按 est 保留预扣 |
这是保守选择,不是“上游已计费”的证明。库无法知道端口已开始的那次调用是否真的产生了计费用量;方向定为宁多扣不空退(多扣只损失本窗口一点额度,空退会让已计费用量绕过闸门)。真实 usage 已知(含恰为 0)与已判 SourceDead 的 0 不被覆写;未分类异常逃逸仍按 0,属已知残留,本版不动 |
| 2 | 清理期自抛 TimeoutError 时无终态遥测行 |
与 1.3.5 的裸 TimeoutError 穿透同一口径(这条路径一直存在、一直没有终态行),本版没有让它变坏;但期限把这条路径常态化了——启用期限后触发清理的频率上升,其可达性随之上升。with_call_deadline 的局部变量身份比较保证这种 TimeoutError 不会被误标成 CallDeadlineExceeded(test_deadline.py 有断言钉住) |
| 3 | 跨 Python 版本仅 3.12.13 有实证 | asyncio.timeout 的 cm.expired()/uncancel() 行为与清理期异常传播是探针在 3.12.13 单一版本上实测的;3.13+的行为未验证。库声明 3.12+,故这是真实的验证缺口,不是理论担忧 |
5. 未在本轮做的事
- 不实现 issue #24(长尾对冲):未获批准,代码与文档均无 hedge 机制,本版不缓解 #24。
- 不修
ResultInvalid/RequestRejected已计费坏结果仍退全款:属另一族记账语义,未批准,登记待立 issue。 - 不 bump 版本号、不打 tag、不构建、不上传 registry、不同步 wiki——全部留给发布清单。