Files
PolyGateway/research-wiki/designs/2026-09-09-136-call-budgets-design.md
T

314 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 1.3.6:可选调用期限(issue #22),兼 `Retry-After` 有限值防御
- 状态: **人类已于 2026-09-10 批准**(§9 七项批准项全数获批,H3 取 (a′) 档);实施计划见 `research-wiki/plans/2026-09-10-136-call-deadline.md`
- 基线: main `ab00aa4` / 1.3.5,工作区 HEAD `d2455e8`;分支 `feature/1.3.6-call-budgets`
- 范围收敛(2026-09-09 人类定夺,2026-09-10 追认): **只做方案 B 的后半**——保留 chat 的 429 免次数预算,新增**可选**整体调用期限,缺省不启用;**不做** 429 次数预算(原 D2/D3)、**不做** issue #24(未批,不得捆绑)
- 已批准契约(2026-09-10 逐条定案): `call_deadline_s` 缺省 `None`;单调用参数 `None` = 继承装配值;`CallDeadlineExceeded``PolyGatewayError` **直接**子类;合作式清理会超出期限、**到期可能丢弃已计费的成功**;chat 429 免次数预算保留原样
- 取消结算(TPM)修复**并入本版**: 不再作为「先立独立 issue、修好再启用期限」的前置阻塞,改为本版第一个原子提交(§6.3 矩阵即其精确契约)
- 输入: issue #22 原文;设计审查 `a544b789/design136/review.md`(BL1-BL5)与独立复审 B(B1-B5,含离线 asyncio 探针实测);本轮独立探针 `/tmp/pgw_deadline_probe.py``/tmp/pgw_deadline_probe2.py`(3.12.13 实测,§5.2);1.3.5 源码逐行现读
- 关联: ARCHITECTURE §7.2/§7.3、`designs/2026-08-06-issue8-stall-budget-design.md``designs/2026-09-09-135-call-observability-design.md`
## 1. 目标与非目标
| 项 | 内容 |
| --- | --- |
| 目标 1 | 让调用方能对**一次逻辑调用**设墙钟上限;不配置时,库行为逐字保持 1.3.5 |
| 目标 2 | 修 `Retry-After` **非有限值**防御缺口(`inf`/`1e999``sleep(inf)` 永久挂起) |
| 目标 3 | 期限是**单一硬边界**,覆盖缓存 IO/准入排队/退避 sleep/transport/结构化重问/embedding 分批,不是轮首软检查 |
| 非目标 A | 不新增 429 次数预算、不改 429 退避指数分账、不收紧任何缺省(原 §4.1/§4.2 已删除) |
| 非目标 B | 不改三条循环各自的既有差异(chat 429 免预算 + stall 退还;embed/OCR 无条件计数) |
| 非目标 C | ~~本设计不含取消路径 `settle(0)` 结算修复~~**2026-09-10 改判**: 该修复**已获批并并入本版**,是期限落地前的第一个原子提交(§6.3 给出精确结算矩阵)。它只改**取消路径**的结算取值,不动成功/拒绝/失败三条既有分支的口径 |
| 非目标 D | 不做 issue #24(长尾对冲);不新增遥测列;不新增后台任务/`shield` |
## 2. 1.3.5 现状核实(现读源码,不引用旧报告)
| 事实 | 证据 | 对本设计的意义 |
| --- | --- | --- |
| chat 429 免重试预算并退还 stall 账 | `middleware/retry.py:158-164``:250-257` | **保留不动**;期限是正交的第二层 |
| embed/OCR 无条件 `fails += 1` | `embedding.py:319``ocr.py:339` | **保留不动**;两条循环次数有界、时长无界,由期限兜 |
| 退避与 `Retry-After` 取大且不夹上限 | `retry.py:64-77` `max(delay, retry_after)` | 有限大值**照睡**(§6.2 说明为何不夹) |
| `_parse_retry_after` 放行 `inf`,但 `nan` **已被忽略** | `transports/openai_compat.py:111-120`:`float()` 成功后 `seconds > 0`;`nan > 0` 恒假 → `nan` 现已返回 `None` | 真实缺口只有 `inf`/`1e999` 一族(唯一一处;`monkey_ocr.py:58` 不解析该头) |
| 三个公开边界均在**输入校验之后**建 `_CallContext` | `client.py:381``embedding.py:192``ocr.py:272` | 期限起点与 `total_latency_ms` 同口径,校验时间不计入 |
| 洋葱与两条循环全在同一 `await` 树下 | `client.py:398``embedding.py:209``ocr.py:274` | 一个 `asyncio.timeout` 即可覆盖全部等待,**无须**改 `backoff_delay`/`SourceAdmission._nap` 签名(解 BL3) |
| 终态行唯一出口 + 去重 | `telemetry.py:670-716` `emit_terminal_once` / `types.py:355` `claim_terminal` | 到期终态复用该出口,`error_type` 列自动落新类名,**无新增列** |
| 取消路径 attempt 行写 `error="cancelled"` 后穿透 | `retry.py:320-323` | 到期时 attempt 行仍记 cancelled(保留),**终态行**必须记 deadline |
| 库内已有 `asyncio.timeout` + `cm.expired()` 范式 | `streaming.py:43-58``telemetry/postgres.py:419-423` | 本设计沿用同一范式,不发明新写法 |
| `asyncio.timeout` 的**公共契约**: 到期投递取消并在退出时转 `TimeoutError`,外部取消原样上抛,擦边成功不遗留游离取消 | 标准库文档 + 受支持 Python 矩阵上的行为测试(§10"形态区分"批次;复审 B 在 3.12.13 上以离线探针复核) | 外部取消优先与"擦边成功"竞态由运行时判定,库不自判(§5.2);**不依据 CPython 私有实现细节立论**,跨版本保证由测试矩阵给 |
## 3. 实现范式的三个备选
| 方案 | 做法 | 判定 |
| --- | --- | --- |
| **A 单一硬边界(推荐)** | 在三个公开边界各用一次 `asyncio.timeout(d)` 包住整条 `await` 树;到期由运行时取消在途 await,边界处转成 `CallDeadlineExceeded` | 覆盖面天然完整(缓存/准入/sleep/transport/重问/分批);零签名扩散;代价是**到期即取消在途尝试**(§6.3) |
| B 轮首软检查 + sleep 夹紧 | 三条循环轮首判剩余预算,退避 `min(delay, 剩余)` | **否决**: 名为期限实为"轮次粒度上限"——一次 300s 的慢尝试或一次准入排队即可整体越限,且要改 `backoff_delay``_nap`、三处轮首共 5 个点(BL3 原形态)。用软检查冒充硬期限正是 issue #8 "不要用一个预算冒充另一个预算"的同形错误 |
| C 每层各自超时 | 缓存、准入、transport 各配一份超时 | **否决**: N 个键相加才是总时长,调用方仍拿不到"最多等 N 秒"的承诺;配置面爆炸 |
**推荐 A**,且缺省 `None` = 不启用:opt-in 才能保证存量下游行为逐字不变(1.3.x 内不做默认行为变更)。
## 4. 方案 A 的具体形态
### 4.1 新模块 `src/polygateway/deadline.py`(约 50 行,只依赖 stdlib + `errors.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(...) # bool 先判:不得把 True 当成 1 秒
v = float(value)
if not math.isfinite(v) or v <= 0:
raise ValueError(...) # NaN / inf / 0 / 负
return v
async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T:
"""给一次逻辑调用施加单一硬边界;None = 不进任何上下文,逐字走旧路径。
入参已由调用方在**构造 `aw` 之前**校验(见 §4.3),本函数不再校验。
"""
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 # 内层失败原样上抛,绝不贴 deadline 标签
```
**为何不能只用 `cm.expired()`(本轮独立探针实测,3.12.13)**: `Timeout.expired()``EXPIRING`/`EXPIRED` 两态都返 True(`inspect.getsource` 现读),而计时器触发后的**清理路径**若自抛 `TimeoutError`(第三方端口实现自抛,或其内部另一个 `asyncio.timeout` 到期),该异常会被只看 `expired()` 的写法**改标成 `CallDeadlineExceeded`**(探针 E1/E2 实测均为误标)。`__cause__` 启发式也不够: 内层 `asyncio.timeout` 抛的 `TimeoutError``__cause__` 同样是 `CancelledError`(E1 实测仍误标)。故采用**局部变量身份比较**这一最小辅助机制: 本层 `asyncio.timeout` 转出的是 `raise TimeoutError from exc_val` 新建对象,与体内那个实例必不同一,判据确定、不依赖任何 CPython 私有实现。它**不新增公共配置、不开后台任务、不改异常对象**;`cm.expired()` 作为第二道守卫保留。
```text
探针证据(/tmp/pgw_deadline_probe2.py, python 3.12.13):
D1 到期命中 → CallDeadlineExceeded 耗时 0.050s cancelling=0
D2 清理内层 timeout → TimeoutError(原样) 耗时 0.060s ← 只看 expired() 会误标
D3 清理裸 TimeoutError→ TimeoutError(原样) 耗时 0.050s ← 只看 expired() 会误标
D4 未到期内层自抛 → TimeoutError(原样) 耗时 0.010s
D5 到期窗口内领域异常 → 领域异常(期限让位) 耗时 0.200s
D6 慢清理 → CallDeadlineExceeded 耗时 0.251s(期限 5×)
D7 清理期外部取消 → CancelledError cancelling=1(外部取消优先)
D8 外部取消先到 → CancelledError
D9 未启用(None) → 逐字旧路径
```
写成**接收 awaitable 的函数**而非 `@asynccontextmanager`: 后者要把 `yield` 包进 timeout,取消经 `athrow` 回注生成器,语义正确但绕(`streaming.py:60-70` 的 docstring 已记录同类陷阱);函数形态只有一条直路。`None` 分支**不进** `asyncio.timeout`,故未启用时连"必须在 Task 内运行"这一新约束都不引入。
**两个实现约束(实施时不得变形)**:
1. **校验先于构造 awaitable**: 公开方法入口先跑 `ensure_call_deadline`,通过后才构造 `self._handler(request)` 等协程。若把校验放进 `with_call_deadline`,非法参数抛错时会遗留**未 await 的协程**(RuntimeWarning + 未释放资源)。
2. **分层合法**: `deadline.py` 只 import stdlib 与 `errors.py`,并作为**新一层**进 import-linter 契约(`pyproject.toml` layers 中置于 `polygateway.thinking` 之下、内核行之上)。`config.py` 位在更上层,import 它合法且**不会依赖任何具体实现**(transports/backends/telemetry 一律不引入)。
### 4.2 三处接入点(唯一三处;严禁在循环内层再建 scope)
| 文件:行 | 现状 | 改后 |
| --- | --- | --- |
| `client.py:398` | `response = await self._handler(request)` | `await with_call_deadline(self._handler(request), deadline_s=d, scope=self._scope)` |
| `embedding.py:209` | `return await self._embed_all(...)` | 同款包住 `_embed_all(...)`(**整次调用一份**,分批共享) |
| `ocr.py:274` | `return await self._run(...)` | 同款包住 `_run(...)` |
三处均在既有 `try` 之内、`_CallContext` 之后,故到期路径照走 `except PolyGatewayError → emit_terminal_once`(§7)。`StructuredMW._run_ladder`(`structured.py:67-98`)与 `_embed_batch`(`embedding.py:295`)**不得**新建 scope:同级重试/重问/分批共享同一期限,否则期限被轮数放大 N 倍即等于没有。
### 4.3 装配路径与 per-call 覆盖(实际签名)
| 层 | 签名变化 | 语义 |
| --- | --- | --- |
| 配置键 | `{SCOPE}__CALL_DEADLINE_S`,经 `_first` 读(**不用** `_require`,否则是破坏性配置变更) | 未设 = `None` = 不启用 |
| `GatewaySettings` | 末尾追加 `call_deadline_s: float \| None = None` + `_validate_call_deadline()``__post_init__`(见 `config.py:186-193`) | 有默认值,不扰动既有位置构造;校验覆盖 env/直接构造/`dataclasses.replace` 三条路 |
| 值域校验 | **全装配路径共用** `deadline.ensure_call_deadline`(§4.1):`GatewaySettings.__post_init__`、三个 client `__init__`、四个公开方法各调一次,实现只一份 | 拒收: `bool`(True 不得当 1 秒)、非数值类型、`NaN``inf``0`、负数 → `ValueError`(消息含来源)。**不与 `timeout_s` 耦合**:期限短于单次超时是合法选择 |
| 三个 client `__init__` | 追加 keyword-only `call_deadline_s: float \| None = None`,**入口即校** | 与 `now`/`sleep`/`rng` 同款注入位;全量注入是正式装配路,不得只靠 `GatewaySettings` 守门(否则 `inf` 静默失效、`NaN` 每次调用当场失败) |
| 三条 `from_settings` | 传 `settings.call_deadline_s`(embed/OCR 取 `settings.gateway.call_deadline_s`) | `from_env` 无签名变化(经 settings 透传) |
| 四个公开方法 | `chat`/`embed`/`recognize_text`/`parse_layout` 追加 keyword-only `call_deadline_s: float \| None = None` | `None` = **继承装配值**;正数 = 本次覆盖;**不提供"本次关闭"**(需要不同期限就装配两个 client;三态哨兵不值这个公共面复杂度) |
| 校验时点 | per-call 值在 `_CallContext` 创建**之前**、也在构造被包裹协程之前校验(与既有三项校验同列;OCR 两个入口经 `_call` 两级透传,与 `image` 校验同列) | 输入校验边界保持:非法期限抛裸 `ValueError`,不进统计边界、不写终态行、不遗留未 await 协程 |
### 4.4 新错误类型
```python
class CallDeadlineExceeded(PolyGatewayError):
"""调用方设定的整体期限到期;不是网关不可用、也不是源故障。"""
def __init__(self, *, scope: str, deadline_s: float) -> None:
super().__init__(f"{scope} 调用期限 {deadline_s}s 到期")
self.scope, self.deadline_s = scope, deadline_s
```
| 决策 | 取法 | 理由 |
| --- | --- | --- |
| 父类 | `PolyGatewayError` **直接**子类 | 不用 `TransientError`: 那是"退避后可重试、计熔断"的源级瞬时故障,下游按它无限外层重试只会把同一份期限再等一遍;不用 `GatewayUnavailableError`: 其消息硬编码"{scope} 网关暂时不可用"(`errors.py:156`),把调用方自选的期限报告成 scope 死亡,正是要避免的"用一个时钟冒充另一个"(BL4) |
| `scope` 字段 | **有** | 多 scope 部署时的诊断分组,免得下游从消息串里解析 |
| `retry_after_s` 字段 | **无** | 期限到期不含"何时可再试"的信息;给 `0.0` 会按 `errors.py` 既定语义指示下游**立刻重打仍饱和的渠道** |
| `SCOPE_REASONS` | **不新增值** | 它不是 `GatewayUnavailableError` 家族成员,与 `reason` 无关 |
| 四分类 | 不变 | 它是**调用方策略**的终止信号,不是四分类里的失败;文档须明写 |
| 导出 | 进 `__init__.py``__all__` | 下游要能 `except CallDeadlineExceeded` |
**醒目**: `except GatewayUnavailableError` / `except AllSourcesExhausted` 的存量代码**接不住**本异常——这是有意设计,且只在显式配置期限后才可能出现。CHANGELOG / wiki / README 必须以此措辞列出。
## 5. 时钟与失败模式的边界
### 5.1 两个时钟不混用
| 时钟 | 用途 | 纪律 |
| --- | --- | --- |
| 事件循环时钟(`loop.time()`,`asyncio.timeout` 内部) | 期限的**唯一**计时源 | 只传**相对时长** `deadline_s`;严禁把 `_CallContext._started`(注入 `now`)加上偏移当绝对截止时刻传进去 |
| 注入 `now`(`types.py:335`、三个 client) | `CallStats.total_latency_ms``StallClock`、退避 | 不读、不改;测试替换它不会影响期限判定,这一点必须在测试里明确 |
代价写实: 两者不同源,故 `total_latency_ms``deadline_s` 之间存在微小偏差(注入钟被伪造时可任意大)。这是**有意**的——统一它们要么强迫调用方注入 loop 钟,要么自建定时器,两者都比这点偏差贵。
### 5.2 四种"到期周边形态"必须分开
| 现象 | 判据 | 结果 |
| --- | --- | --- |
| 本层期限到期 | `except TimeoutError``cm.expired()` | `CallDeadlineExceeded`,终态行记 deadline |
| 内层自抛 `TimeoutError`(未到期) | `cm.expired()` 为假 | 原样上抛,不吞不改判(同 `streaming.py:52-58`);探针 D4 |
| **到期后清理路径自抛 `TimeoutError`** | `cm.expired()` 为真但异常对象**就是体内那一个**(身份比较) | 原样上抛该 `TimeoutError`,**不改标成 deadline**(探针 D2/D3;只看 `expired()` 的写法在此会误标)。代价: 该异常不是 `PolyGatewayError`,三个边界的 `except PolyGatewayError` 接不住→**无终态行**(与 1.3.5 已有的裸 `TimeoutError` 穿透行为同口径,非本版新增) |
| 外部取消 | `asyncio.timeout` 契约:不是本层计时器造成的取消 → `CancelledError` 原样上抛 | 走既有 `except asyncio.CancelledError` 分支,终态行记 `"cancelled"`;**不会**同时出现两条终态行(`claim_terminal()` 去重)。探针 D7/D8 实测: 外部取消**无论先于还是晚于到期**(含清理期到达)都胜出 |
| **到期窗口内体内先抛领域异常** | 计时器已触发、取消尚未投递到达时,体内先 `raise AllSourcesExhausted(...)` 等 | **领域异常原样逐层上抛,期限静默让位**;终态行记该领域异常而非 deadline。该窗口在重试循环真实存在(一次尝试刚结束与计时器同刻),复审 B 探针 E2/E8 已实测 |
故本设计只承诺"到期**通常**得 `CallDeadlineExceeded`",**不承诺 100%**;实现与测试均不得写成无条件断言。擦边竞态(计时器已触发但调用体已成功返回)不会遗留游离取消——这是 `asyncio.timeout` 的公共行为,库不自判形态、也不依赖任何 CPython 私有实现;跨版本保证由受支持 Python 矩阵上的行为测试提供(§10)。
### 5.3 期限治理的是"等待",不是返回时刻(必须写进 wiki,不得含糊)
asyncio 是**合作式取消**: 到期只是向任务投递一次取消,真正返回的时刻取决于在途 `await` 何时到达取消点,以及**清理路径**跑多久。已知会在期限之后继续跑的三段:
| 段 | 位置 | 性质 |
| --- | --- | --- |
| permit 结算与释放 | `retry.py:341``admission.py:46-60` | Redis 后端是两次网络往返;不受期限管辖 |
| 取消路径的 attempt 遥测行 | `retry.py:320-323` | 一次落库;丢了就丢了本次现场 |
| 终态遥测行 | 三个边界的 `emit_terminal_once` | **有意留在期限之外**:诊断行如果自己被期限切掉,期限到期这件事就没有台账 |
**量级不是毫秒级**: 复审 B 探针 E4 以上述真实形态(取消分支 0.2s 遥测 + `finally` 0.1s 结算)实测:期限 0.05s → 返回时刻 0.351s(**约 7 倍**);本轮独立探针 D6(清理 0.2s)复现同一形态: 0.251s(**5 倍**)。故对外措辞必须是"**返回时刻 = 期限 + 清理耗时**",而不是"最多 N 秒返回";清理耗时取决于 permit/遥测后端,可远超期限本身。§10 以量化断言把越限量变成可观测、可回归的量。
**反向代价(同等重要)**: 成功之后的旁路 IO **在期限之内**——`middleware/cache.py:199``_safe_set``call_next` 返回之后执行,`middleware/telemetry.py:734` 的缓存命中写同理。期限落在这两步 → 一个已完成、**已计费**的 `LLMResponse` 被丢弃,调用方只拿到 `CallDeadlineExceeded`。故 wiki 必须写明"期限到期**不等于**未产出、未计费";本版**不引入 `shield`** 去抢救它。
取舍是显式的: **宁可超出期限也要留下资源清理与诊断**,而不是引入 `shield`/后台任务去"抢救"(那会把取消语义弄脏,违反"取消可穿透"铁律)。若下游端口实现(自实现 transport / 遥测)在清理里长时间阻塞,期限的超出量就是那段阻塞时长——库不为第三方实现兜底。
## 6. 与既有算法的关系(明确不改的三件事)
### 6.1 F1:`Retry-After` 非有限值(纯 bug 修复)
`_parse_retry_after` 加判据: **解析成功但为无穷**(`inf`/`-inf`/`1e999`,判据 `math.isinf(seconds)`)时显式忽略并记一条 `warning`,按"服务端没给提示"处理,不伪造缺省值。`nan` 维持现状——它被既有的 `seconds > 0` 恒假拦下,**静默 `None` 且不告警**(§2),本版**不给它加告警、不改判据顺序**。
告警要有主语但不得回显不可信输入,故函数签名改为 `_parse_retry_after(raw: str | None, *, source_name: str) -> float | None`:`source_name` 是**必填 keyword-only 私有参数**(带前导下划线的模块内函数,不属公共面,无需 keyword 默认值兜底),唯一调用处 `_translate_429`(`transports/openai_compat.py:140`)传 `source_name=source.name`。warning 只写源名与判据词(如 `retry_after_not_finite`),**不拼接、不截断、不打印原始头字符串**。其余形态(HTTP-date、空串、负数、不可解析)**维持静默返回 `None`**--HTTP-date 是 RFC 7231 合法形态、空/负是常见噪声,逐次 warning 会在 429 风暴时把真缺陷的信号淡掉。不抛 `RequestRejectedError`:一个坏响应头不该把一次可重试的 429 判死。
### 6.2 有限大值的 `Retry-After` 不夹上限
审查 BL1 建议 `min(retry_after, backoff_max_s)`,**本设计不采纳**:夹小的直接后果是提前重打一个明确说了"3600 秒后再来"的饱和渠道,把服务端调度指令改写成库的猜测。有限大值的处置只有一条正路——调用方设期限,由 §4 的硬边界在到期时切断那次 sleep。未配期限即维持 1.3.5 语义(等满 `Retry-After`),这一残余必须在 wiki 明写。
### 6.3 取消路径结算修复(**已获批,本版第一个原子提交**)
现状(现读): `retry.py:281 actual = 0``:320` 取消分支 → `:341 finally``admission.py:46-60 settle_and_release(permit, actual)`;`settle` 算的是 `delta = actual - est`(`backends/memory/limiter.py:48-55``backends/redis/limiter.py:136-158`),故 `actual=0` = **把入场预扣的 TPM 整笔退还**。取消发生在 transport 在途时,上游可能已经计费——退款就是把已消耗的额度退回闸里。启用期限后库自己会常规性触发该路径,故先修后启用。
**精确结算矩阵**(以“这一刻库到底知道什么”为唯一判据;`est``source.effective_est_tokens()`):
| # | 取消落点(精确位置) | 库此时知道的事实 | `settle()` 取值 | 本版是否改变 |
| --- | --- | --- | --- | --- |
| S1 | 准入阶段(`admission.pick`:`try_enter` 异常、开路分支) | **确定未调用 transport** | `0`(全额退) | 否(既有行为即此) |
| S2 | 退避 `sleep` / 配额轮询 / 熝断等待 | 本轮未持 permit(上一轮已在 `finally` 结清) | 无 permit 可结 | 否 |
| S3 | `_attempt` 内、transport 在途(`retry.py:288` 的 await 未返回) | **端口已开始但用量未知** | `est`(delta==0,保留预扣) | **是**——原为 `0` |
| S4 | transport 已返回、`actual` 已算出后的任一 await(记账写回/逐次遥测) | **完整 usage 已知**(measured/estimated) | 保留已算出的真实 `actual`,**不得被取消分支覆盖回 `est`** | 否(现行为已正确,修复不得弄坏) |
| S5 | **已处理领域失败**分支内的 await(`record_failure`/`_emit`)中途取消 | 已知失败类型,结算决定已算出 | 该分支的决定值:瞬时 = `est`;`SourceDead` = **`0`**;`RequestRejected`/`ResultInvalid` = `0` | 是——决定移到该分支同步处理之后、紧贴首个 await 之前,故 `SourceDead` 的既有 `0` 在取消下**被保住**而不再退化成 `est` |
| S6 | OCR 任何位置(`ocr.py:449 settle_and_release(permit, 0)`) | **OCR 无 token 是事实**,不是“未知” | `0` | 否(事实即 0,不得改成 `est`) |
| S7 | embedding 与 chat 同构两处(`embedding.py:392-407` 取消分支) | 同 S3/S4 | 同 S3/S4 | **是**(与 chat 同口径同时改) |
| S8 | **未被任何 except 接住**的异常(`RuntimeError`、`KeyError` 等未分类逃逸) | 库对用量一无所知,且**不在本版批准范围** | `0`(与 1.3.5 逐字一致) | 否——本版**不**把"端口开始 = 可能已计费"推广到未分类异常 |
**实现形态**(实施时不得变形;人类只批准了"取消路径"这一条,故语义扩大**必须**被限制在取消分支内):
`actual` 初值**保持 `0` 不动**;另设一个**局部阶段变量** `settlement_known: bool = False`(纯函数内局部,不是新公共面、不进任何签名、不进配置)。置位规则只有两条——成功路径拿到用量(真实 usage 或 `usage_source == "unavailable"``est`)后置 `True`;三个**已处理领域失败**分支在**进入分支后的第一条语句**算出结算值并置 `True`。取消分支只在 `settlement_known` 仍为 `False` 时才赋 `actual = est`
```python
actual = 0
settlement_known = False # 局部阶段变量: 该刻库是否已算出确定结算
# 成功: actual = 真实 usage 或 est → settlement_known = True
# RequestRejected / ResultInvalid: actual = 0; settlement_known = True(分支首句)
# SourceDead / Transient: actual = 0 if dead else est; settlement_known = True(分支首句)
except asyncio.CancelledError:
if not settlement_known: # 端口已开始、结算未定 → 保守保留预扣
actual = source.effective_est_tokens()
...
```
三条不变量(同级 `except CancelledError` 接不住其他 `except` 块内的取消,故必须在其首个 await 前先确定结算,而非只靠标志位):① **已确定的值一律不覆写**,包括真实 usage 恰为 `0`(源真返回 0 token 是事实,不是"未知");② "确定结算"的界桩按**当前代码里 failure 记账 await 的前后位置**划定——失败分支的结算决定被前移到 `record_failure` / `_emit` **之前**,故取消无论落在这两个 await 的哪一侧,拿到的都是该失败类型本来的值;③ 未被任何 `except` 接住的异常不经上述任一分支,`actual` 保持 `0` 原样传播(S8)。
**明确收窄**: 本版**不再**把 S5 泛化成"一切失败按 `est` 结算"。1.3.5 的 `SourceDead` 退全款(`0`)是**有意**的语义(源已判死,不该继续占额度),取消恰好落在它之后时必须保留那个 `0`;"端口开始 = 可能已计费"只是**取消且结算未定**这一格的兜底取值,不是全局通则。
这样只有取消路径的取值发生改变,其余四条既有路径与未分类异常路径逐字不变(验收矩阵 §10 "结算"批次逐条钉,并新增一条**防越界回归**)。
**保守结算的诚实边界**(不得写成“修对了”): `await transport.complete(...)` 返回前被取消,只能证明**端口协程已被进入**,不能证明 HTTP 字节已发出、更不能证明上游已计费。故 S3 是一个**保守选择**(宁可多扣不可凭空退款),不是事实性计量;它与既有的“瞬时失败按 `est` 结算”(`retry.py:337`)同一口径、同一理由。库**不新增任何 wire 事件协议**(如“transport 上报字节已发出”)来缩小这个不确定区——那是新公共端口面,且 httpx 层面也给不出可靠信号;不确定性写进文档而不是藏起来。
**不改且不被本版解决的相邻缺口**(列出以免被读成已修): ① `ResultInvalidError` 路径(如 `embedding.py:350-355` 维度不符、`transports/openai_compat.py:280-308` 响应形态异常)——响应真实返回过(已计费)但仍按 `0` 退全款;② `RequestRejectedError` 同理。两者与取消无关,属另一族记账语义变更,**未获批准即不动**,另行立 issue。
**共享状态不变**: `permit.release()``pacer.leave()``breaker.release_probe()`(探针归还)、`mark_progress` 全部保持原样且仍在 `finally`;本修复**只改 `settle()` 的入参取值**,不动限流 Lua、不动端口签名、不动幂等语义。
## 7. 遥测(无新增列,无新增 DDL)
| 行 | 到期时的取值 |
| --- | --- |
| attempt 行 | 被取消的那次仍记 `error="cancelled"`(`retry.py:320-323`)——它描述的是**那次尝试**的真实结局,保留 |
| 终态行 | 经既有 `emit_terminal_once` 写出;到期**通常** `error_type='CallDeadlineExceeded'`(例外见 §5.2 第 4 行:体内先抛领域异常时记该异常)、`error` 为其消息串(`telemetry.py:274-305` 既有取值逻辑,零改动),`logical_call_id`/`attempts`/`total_latency_ms` 照旧 |
| 计数 | 每逻辑调用至多一条终态行,由 `claim_terminal()` 保证;**不会**同时出现 cancelled 与 deadline 两条 |
## 8. 变更点清单(反 gold-plating)
| 类别 | 内容 |
| --- | --- |
| 新增文件 | `src/polygateway/deadline.py`(1 个校验函数 + 1 个包裹函数) |
| 改动文件 | `errors.py`(1 个类)、`__init__.py`(1 个导出)、`config.py`(1 个键 + 1 个 loader + 1 个守卫 + 1 个字段,守卫直调 `deadline.ensure_call_deadline`)、`client.py`/`embedding.py`/`ocr.py`(各 1 处包裹 + 构造参数 + 公开方法参数 + 入口校验 + `from_settings` 透传)、`middleware/retry.py``embedding.py``_attempt`(§6.3 结算矩阵:只改 `actual` 取值 + 1 个**局部**阶段变量 `settlement_known`)、`transports/openai_compat.py`(F1 一行判据 + warning + `source_name` 必填私有 kw 及其唯一调用处)、`pyproject.toml`(import-linter layers 新增 `polygateway.deadline` 一层) |
| 直接复用 | `_CallContext``emit_terminal_once``claim_terminal``asyncio.timeout` + `cm.expired()` 范式、`settle_and_release` 单一出口、既有 FakeClock/假 transport 测试设施、限流契约套件(Lua 不改) |
| **明确不做** | 不改 `backoff_delay`/`SourceAdmission._nap`/`StallClock`/429 分账/限流 Lua/端口签名;不改 `ResultInvalid`/`RequestRejected` 的结算口径;不加遥测列;不加 `shield`/后台任务;不加 429 次数键;不做 #24 |
## 9. 集中人类批准项(2026-09-10 全数获批)
| # | 决策 | 结果 | 不采纳的代价 |
| --- | --- | --- | --- |
| H1 | 采纳 §3 方案 A(单一 `asyncio.timeout` 硬边界),缺省 `None` | **已批** | B/C 只能给轮次粒度或多键相加的"伪期限" |
| H2 | `CallDeadlineExceeded``PolyGatewayError` 直接子类、无 `retry_after_s`、不进 `SCOPE_REASONS` | **已批** | 挂进 `GatewayUnavailableError` 会把调用方的期限报告成网关不可用 |
| H3 | 取消结算修复的位置 | **已批 (a)**: 不再另立前置 issue,改为 **1.3.6 内的第一个原子提交**,口径按 §6.3 矩阵(S1-S8,含 S8 未分类异常维持 `0` 的收窄)逐格定死 | 选 (b) = 把已知记账缺口变成常规路径,正是本项目反复吃过的亏 |
| H4 | per-call 覆盖形态: §4.3 的"`None` 继承、无单次关闭" | **已批** | 三态哨兵扩大公共面;完全不给 per-call 则长短调用必须装两个 client |
| H5 | §6.2 不夹有限大 `Retry-After`(与审查 BL1 建议相反) | **已批**,残余写进 wiki | 夹小即提前重打饱和渠道 |
| H6 | §5.3/§11 的对外承诺形态: 期限治理**等待**,返回时刻 = 期限 + 清理耗时(实测可达 5-7 倍),且到期可能丢弃已计费成功 | **已批**,不引入 `shield` | 写成"最多 N 秒返回"会让下游上层超时被整片击穿 |
| H7 | issue #22 关闭判据 = "调用方**能配置**上限";#24 保持 open、**本版不实现** | **已批** | 见 §11 |
实施边界不得再扩: 本表之外的任何公共面变化(新配置键、新端口方法、新遥测列、其它记账口径变更)均属**未批准**,需停下来报。
## 10. 离线验收矩阵(实施时须先失败后通过;全部不触网、不付费)
| 批次 | 断言 |
| --- | --- |
| 未启用回归 | `call_deadline_s=None` 时三条链路行为逐字不变:现有 `tests/unit/test_retry.py``test_backpressure.py``test_embedding.py``test_ocr_client.py``test_client.py` 全绿(不改一行断言) |
| 期限命中 | 假 transport 真实 `await asyncio.sleep(0.3)``call_deadline_s=0.05` → 抛 `CallDeadlineExceeded`,`scope`/`deadline_s` 正确;计时范式沿用 `tests/unit/test_streaming.py` 的真实 loop 时钟 + 4-10× 余量(已验证稳定,不标 slow) |
| 覆盖面 | 分别令 ①退避 sleep(注入真 `asyncio.sleep`)②准入排队(配额满轮询)③结构化重问 ④embedding 多批 各自超期 → 均抛 `CallDeadlineExceeded`;embedding 断言 N 批**共享一份**期限(总时长不随批数放大) |
| 形态区分 | ①内层自抛 `TimeoutError`(假 transport 直抛)且未到期 → 原样上抛,不变成 deadline;②外部 `task.cancel()` → 仍抛 `CancelledError`,终态行 `error="cancelled"`;③擦边成功(transport 耗时略小于期限)→ 正常返回,无游离取消;④**到期窗口内体内先抛领域异常**(自旋构造确定性窗口)→ 上抛该领域异常、终态行记它,**不断言必为 deadline**;⑤**到期后清理自抛 `TimeoutError`**(假端口在取消分支里 `raise TimeoutError`)→ 原样上抛该 `TimeoutError`,**断言不是 `CallDeadlineExceeded`**(钉住身份比较机制;只看 `cm.expired()` 的写法在此变红) |
| 结算(§6.3 矩阵逐格) | S1 准入取消 → `tpm_used` 回到 0;S3 transport 在途取消 → `tpm_used == est`(**先失败后通过**的核心红绿);S4 取消落在 usage 已知之后 → `tpm_used == 真实 usage`(不被 `est` 覆盖);S6 OCR 取消 → `tpm_used == 0`;S5 取消落在 `SourceDead``record_failure` await 中途 → `tpm_used == 0`(**不得**变成 `est`);**S8 防越界回归**: 假 transport 抛 `RuntimeError`(未分类)→ `tpm_used == 0` 且异常原样上抛;成功/`SourceDead`/`RequestRejected`/`ResultInvalid` 四条既有路径结算值逐字不变;真实 usage 恰为 0 的成功 → `tpm_used == 0`;permit `release`/`pacer.leave`/`release_probe` 调用次数不变 |
| 终态遥测 | 到期恰好一条 `event_kind='terminal_failure'` 行,`error_type='CallDeadlineExceeded'`;被取消的 attempt 行仍为 `cancelled`;两者 `logical_call_id` 一致;列数不变 |
| 清理不可越过(含量化) | 到期时 permit 的 `settle`/`release` 与 pacer `leave` 仍被调用(假 permit 记账断言);`CancelledError` 未被吞;**量化断言**: 注入已知 sleep 的假 permit/假 emitter → 返回时刻 ≈ 期限 + 已知清理时长(可远大于期限本身) |
| 成功旁路被切 | 假缓存后端 `set` 慢于期限 → 抛 `CallDeadlineExceeded` 且断言上游 transport **已成功调用一次**(已计费成功被丢弃,§5.3),把该行为钉住而非留作偶然 |
| 注入钟无关 | 伪造注入 `now`(跳变 10^6 秒)不触发期限;反之期限触发时 `total_latency_ms` 仍来自注入钟 |
| 配置(全装配路径) | 键未设 → `None`;`0`/负/`nan`/`inf`/非数/`True`(bool 不得当 1 秒)→ `ValueError` 且消息含来源——**四条路各测一遍**: env、`GatewaySettings` 直接构造、`dataclasses.replace`、**三个 client `__init__` 直传**;per-call 非法值在构造协程前抛错且**无 "coroutine was never awaited" 警告**;`call_deadline_s < timeout_s` **不报错**(允许) |
| F1 | `Retry-After``inf`/`-inf`/`1e999` → 按无提示处理且各记一条 warning(**断言日志含源名、不含原始字符串**);`nan`/空/负/HTTP-date → 静默 `None` 且**无 warning**;`_parse_retry_after``source_name` 为必填 kw(漏传即 `TypeError`);有限正数仍取大;`insufficient_quota` 仍归 `SourceDead` |
## 11. issue 闭环判据
| issue | 判据 | 措辞纪律 |
| --- | --- | --- |
| #22 | **可关闭**: 调用方通过 `{SCOPE}__CALL_DEADLINE_S` 或 per-call 参数即可给一次调用设上限 | 措辞必须是"**期限治理的是等待,不是返回时刻**;返回时刻 = 期限 + 清理耗时(取决于 permit/遥测后端,实测可达数倍)",不得写成"最多等 N 秒"的硬保证;同时写明"到期不等于未产出、未计费"(§5.3)与"**不配置就保持 1.3.5 旧语义**"(纯 429 序列仍可能长时间等待、有限大 `Retry-After` 仍照睡)——三句均需出现在 CHANGELOG、wiki 与 issue 关闭说明的显要位置 |
| #24 | **保持 open、本版不实现**(未获批准),另行设计 | 期限只让长尾**更早失败**,与"更快成功"是两件事;任何文档不得把前者写成后者,也不得因本版而声称 #24 缓解 |
## 12. 待补证据与残余风险(诚实标注)
| 项 | 状态 |
| --- | --- |
| 取消是否让上游停止生成与计费 | 无一手证据 → §6.3 的 S3 只能是**保守选择**而非事实计量;不做任何"取消即省钱"或"已修准"的表述 |
| 保守结算的反向偏差 | S3 在“transport 确实未发出字节”时会**多扣** `est`(直到窗口滞后自然过期);与“凭空退款击穿网关”相比这是有意选定的方向(降级方向铁律),但必须写进 CHANGELOG |
| 未分类异常的结算 | `RuntimeError` 等未被四分类接住的异常仍按 `0` 退全款(S8)——人类只批准了取消路径,本版**不**将保守口径扩到它们;它们理论上同样可能发生在 transport 在途之后,属已知残留,需时另立 issue |
| `ResultInvalid`/`RequestRejected` 的退全款 | 本版**不改**(§6.3 末段);它们与取消无关,属未批准的另一族记账语义变更 |
| 跨 Python 版本的取消/超时语义 | 本设计只依赖 `asyncio.timeout` 的**公共行为**与异常对象身份比较,不引 CPython 私有实现作保证;保证由 §10"形态区分"在受支持 Python 矩阵上的测试提供(本轮探针仅覆盖 3.12.13) |
| 清理自抛 `TimeoutError` 时无终态行 | 该异常不是 `PolyGatewayError`,三个边界的 `except` 接不住(§5.2);与 1.3.5 已有的裸 `TimeoutError` 穿透同口径,本版不扩大也不修补 |
| #22 现场 46.7s/20min 数字 | 未复跑;本设计不依赖其数值,只依赖路径成立性(已由源码证明) |
| 第三方端口实现的清理耗时 | 不可控,直接构成期限超出量(§5.3) |
| 期限与 `stall_window_s` 的联合调参建议 | 本版不给推荐值:两者治理对象不同(调用方意志 vs scope 活性),给一个"经验公式"就是把两个预算再次绑死 |