docs: document the optional call deadline and cancellation settlement

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.
This commit is contained in:
2026-09-10 04:33:55 -04:00
parent da77b123ec
commit b1bc06e2b1
4 changed files with 154 additions and 1 deletions
+5
View File
@@ -69,6 +69,11 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
# ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样
# ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ──
# LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait
# LLM__CALL_DEADLINE_S= # 一次逻辑调用的墙钟硬边界(秒);缺省不设 = 不启用
# ── 治理对象是"等待"(退避/配额轮询/熔断冷却/结构化重问/embedding 分批共享一份),
# ── 不是单次 HTTP 超时(那是 TIMEOUT_S)。清理仍在 finally 跑完: 返回时刻 = 期限 + 清理耗时,
# ── 且到期 ≠ 未产出、≠ 未计费。到期抛 CallDeadlineExceeded(不属四分类、
# ── 不属 GatewayUnavailableError 族、无 retry_after_s);非法值(0/负/nan/inf)装配期报错 ──
# ══ 装配选择(PGW_*)══
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
+38
View File
@@ -1,5 +1,43 @@
# Changelog
## 未发布
给一次逻辑调用加了一条**可选**墙钟硬边界(issue #22),并修好取消路径的 TPM 结算与 `Retry-After` 非有限值防御。
### 关于调用期限,请先读这三句
| # | 承诺 | 展开 |
| --- | --- | --- |
| 1 | **期限治理的是「等待」,不是「返回时刻」** | 到期后取消在飞的尝试,但清理(遥测写入、限流结算、缓存收尾)仍在 `finally` 里跑完,**允许超出期限**。实测构造(两次慢遥测写入)里返回时刻达期限的 **57 倍**;库只对「等待被切断」给承诺,对「多久返回」不给上界 |
| 2 | **到期 ≠ 未产出、≠ 未计费** | 上游可能已经算完并计费,只是结果在返回路上被丢弃(缓存写入慢于期限就是一例)。把 `CallDeadlineExceeded` 当成「这次没花钱」会低估成本 |
| 3 | **不配置就是 1.3.5 语义,逐字不变** | `call_deadline_s` 缺省 `None` 时根本不进 `asyncio.timeout` 上下文。故 1.3.5 的两条长等仍在: 纯 429 序列(429 不消耗重试预算)仍可能长时间等待;**有限大的 `Retry-After`(如 3600s)仍照睡**——库有意不用 `backoff_max_s` 去夹它,唯一制约手段就是本版这条期限 |
### 公共面新增(三项,全为纯新增)
| # | 位置 | 内容 |
| --- | --- | --- |
| 1 | `polygateway.CallDeadlineExceeded` | 新异常;带 `scope` / `deadline_s`,**无 `retry_after_s`**(到期不含「何时可再试」,给 `0.0` 会指示下游立刻重打饱和渠道) |
| 2 | 配置键 `{SCOPE}__CALL_DEADLINE_S` | 缺省不设 = 不启用;非法值(0/负/`nan`/`inf`/非数)在**装配期**当场 `ValueError` |
| 3 | 三个 client 构造参数 + 四个公开方法的 keyword-only 参数 | `GatewayClient` / `EmbeddingClient` / `OcrClient``call_deadline_s`;`chat` / `embed` / `recognize_text` / `parse_layout` 可 per-call 覆盖(`None` = 继承装配值,**不提供「本次关闭」**)。一次 `embed` 的 N 个批次共享同一份期限,不随批数放大 |
> [!WARNING]
> **`except GatewayUnavailableError` 接不住 `CallDeadlineExceeded`。** 新异常直接继承 `PolyGatewayError`,既不属四分类,也不在 `GatewayUnavailableError` 族内——期限到期是**调用方自己设的边界**,不是网关不可用。只有显式配了期限的调用方才会遇到它,需要处理就单列一条 `except`。遥测侧无需改动: 三个边界既有的 `except PolyGatewayError` 会接住它并照常写一条 `terminal_failure` 行(`error_type='CallDeadlineExceeded'`),**零新增列**。
### 行为变更:取消路径的 TPM 结算口径
| 情形 | 1.3.5 | 本版 |
| --- | --- | --- |
| 取消发生在**端口已开始、结算尚未确定**时 | `settle(0)`,入场预扣整笔退还 | 按 `est` **保留预扣**(方向是宁多扣不空退: 上游可能已计费) |
| 结算已确定(含真实 usage 恰为 0 的成功、已判 `SourceDead``0`) | 按已算出的值 | **一字不变**,取消不覆写 |
| 未被四分类接住的异常逃逸(`RuntimeError` 等) | `0` | **仍按 `0`**,本版不扩大语义(已登记为残留) |
启用期限后库自身会常规性触发取消路径,故这条记账修复与期限同版交付。OCR 的 `settle(0)` 不变——无 token 是事实而非「未知」。非取消路径的最终结算值与 1.3.5 逐字相同,只是算得更早(失败分支的结算决定前移到其第一个 `await` 之前)。
### 其他
- `Retry-After: inf` / `1e999`(`float()` 会把它舍成 `inf`)此前会原样进入退避并让该次尝试睡到天荒地老;现按「无提示」处理,退回纯指数退避,并发**一条**带源名与判据词的 warning(不回显原始头,429 风暴下会淹掉真信号)。`nan`、空串、负数、HTTP-date 的既有值语义一字未动。
- 限流 Lua、`Permit` 端口签名、遥测 schema、缓存 key 公式、重试预算与退避算法、熔断语义**均未改动**。
## 1.3.5(2026-09-09)
把治理单位从「一次尝试」补齐到「一次逻辑调用」(issue #19#23)。此前重试、换源、结构化重问、embedding 分批都各自独立可见,而「这一次调用总共打了几次、总共花了多久、最后为什么失败」在库外拼不出来;结构化耗尽、embedding/OCR 的无源与准入拒绝更是**一条遥测行都没有**。
+11 -1
View File
@@ -16,6 +16,7 @@
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 调用期限 | 一次逻辑调用可选一条**墙钟硬边界**(`{SCOPE}__CALL_DEADLINE_S``chat(call_deadline_s=...)`,缺省不启用):治理的是**等待**——重试退避、配额轮询、熔断冷却、结构化重问与 embedding 分批共享同一份期限。三条须知:①**返回时刻 = 期限 + 清理耗时**(遥测/结算/缓存收尾在 `finally` 里跑完,允许超期;实测构造达期限的 5–7 倍),库只承诺切断等待、不给返回上界;②**到期 ≠ 未产出、≠ 未计费**,上游可能已算完并计费;③**不配置即逐字保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。到期抛 `CallDeadlineExceeded`,**不属四分类、不属 `GatewayUnavailableError` 族** |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
@@ -185,13 +186,17 @@ vectors = (await embed.embed(["文本 a", "文本 b"])).vectors
### 4. 业务侧异常处理
```python
from polygateway import GatewayUnavailableError, RequestRejectedError
from polygateway import CallDeadlineExceeded, GatewayUnavailableError, RequestRejectedError
try:
resp = await client.chat(messages)
except GatewayUnavailableError as exc:
# 整个 scope 暂时无源可用: 延期重投,不消耗业务失败预算
schedule_retry(after_s=exc.retry_after_s) # exc.reason / exc.per_source_reasons 供诊断
except CallDeadlineExceeded as exc:
# 只在自己配了调用期限时出现: 不在 GatewayUnavailableError 族内,上一条接不住;
# 且无 retry_after_s(到期不含"何时可再试"),重投时机由业务侧定
schedule_retry(after_s=None) # exc.scope / exc.deadline_s 供诊断
except RequestRejectedError:
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
```
@@ -463,6 +468,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
**网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。
**有限大的 `Retry-After` 仍照睡**: 能力表那条「尊重 `Retry-After`」是字面意思——库**有意不用 `backoff_max_s` 去夹服务端给的提示**(夹住就是提前重打已明确说「还没好」的网关),服务端给 3600s 就真睡 3600s;**唯一的制约手段是 1.3.6 的调用期限**(上表「调用期限」行)。自 1.3.6 起,`inf` / `-inf` / `1e999` 这类**非有限**取值按「无提示」处理(退回纯指数退避 + 一条带源名的 warning),不再造成无限等待;`nan`、空串、负数、HTTP-date 的既有语义不变。
### 哪些异常会到达调用方
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
@@ -473,9 +480,12 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
| `ResultInvalidError` | |
| `SourceNotConfiguredError` | |
| `CallDeadlineExceeded`(1.3.6 起) | |
**`GovernanceBackendError` 属于第一列**: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 `GatewayUnavailableError`,所以 §4 那段 `except GatewayUnavailableError` 一条即覆盖完整,无需为它单列分支。`retry_after_s` 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。
**`CallDeadlineExceeded` 既不属四分类、也不属 `GatewayUnavailableError` 族**: 它直接继承 `PolyGatewayError`,表达的是「调用方自己设的墙钟边界到了」而非网关不可用,故**只有显式配了 `call_deadline_s` / `{SCOPE}__CALL_DEADLINE_S` 的调用方才可能遇到它**,不配就永远不会出现。它**无 `retry_after_s`**,且 §4 那段 `except GatewayUnavailableError` **接不住**它——要处理就得单列一条分支。
**`SourceNotConfiguredError` 有意不在第一列的族内**: 源名不在限流后端的配置字典中是**装配缺陷**而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。
## 配置参考
@@ -0,0 +1,100 @@
---
type: finding
node_id: finding:2026-09-10-136-call-deadline-validation
title: "1.3.6 可选调用期限与取消结算验证记录"
date: 2026-09-10
---
# 1.3.6 可选调用期限与取消结算验证记录
> 范围:分支 `feature/1.3.6-call-budgets` 上的 T1T3 三个行为提交(`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` 实测)。所有 pytestlint 命令均**不接管道**,退出码直取。
> 版本号未 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` | T1T4 复跑 | `8 passed``exit=0` |
| `make lint`ruff + import-linter | T2T2cT3 收尾 | `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——全部留给发布清单。