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

37 KiB
Raw Blame History

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 = 继承装配值;CallDeadlineExceededPolyGatewayError 直接子类;合作式清理会超出期限、到期可能丢弃已计费的成功;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.mddesigns/2026-09-09-135-call-observability-design.md

1. 目标与非目标

内容
目标 1 让调用方能对一次逻辑调用设墙钟上限;不配置时,库行为逐字保持 1.3.5
目标 2 Retry-After 非有限值防御缺口(inf/1e999sleep(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:319ocr.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:381embedding.py:192ocr.py:272 期限起点与 total_latency_ms 同口径,校验时间不计入
洋葱与两条循环全在同一 await 树下 client.py:398embedding.py:209ocr.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-58telemetry/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)

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() 作为第二道守卫保留。

探针证据(/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 秒)、非数值类型、NaNinf0、负数 → 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 新错误类型

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_msStallClock、退避 不读、不改;测试替换它不会影响期限判定,这一点必须在测试里明确

代价写实: 两者不同源,故 total_latency_msdeadline_s 之间存在微小偏差(注入钟被伪造时可任意大)。这是有意的——统一它们要么强迫调用方注入 loop 钟,要么自建定时器,两者都比这点偏差贵。

5.2 四种"到期周边形态"必须分开

现象 判据 结果
本层期限到期 except TimeoutErrorcm.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:341admission.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_setcall_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 finallyadmission.py:46-60 settle_and_release(permit, actual);settle 算的是 delta = actual - est(backends/memory/limiter.py:48-55backends/redis/limiter.py:136-158),故 actual=0 = 把入场预扣的 TPM 整笔退还。取消发生在 transport 在途时,上游可能已经计费——退款就是把已消耗的额度退回闸里。启用期限后库自己会常规性触发该路径,故先修后启用。

精确结算矩阵(以“这一刻库到底知道什么”为唯一判据;estsource.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 接住的异常(RuntimeErrorKeyError 等未分类逃逸) 库对用量一无所知,且不在本版批准范围 0(与 1.3.5 逐字一致) 否——本版把"端口开始 = 可能已计费"推广到未分类异常

实现形态(实施时不得变形;人类只批准了"取消路径"这一条,故语义扩大必须被限制在取消分支内):

actual 初值保持 0 不动;另设一个局部阶段变量 settlement_known: bool = False(纯函数内局部,不是新公共面、不进任何签名、不进配置)。置位规则只有两条——成功路径拿到用量(真实 usage 或 usage_source == "unavailable"est)后置 True;三个已处理领域失败分支在进入分支后的第一条语句算出结算值并置 True。取消分支只在 settlement_known 仍为 False 时才赋 actual = est

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.pyembedding.py_attempt(§6.3 结算矩阵:只改 actual 取值 + 1 个局部阶段变量 settlement_known)、transports/openai_compat.py(F1 一行判据 + warning + source_name 必填私有 kw 及其唯一调用处)、pyproject.toml(import-linter layers 新增 polygateway.deadline 一层)
直接复用 _CallContextemit_terminal_onceclaim_terminalasyncio.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 CallDeadlineExceededPolyGatewayError 直接子类、无 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.pytest_backpressure.pytest_embedding.pytest_ocr_client.pytest_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 取消落在 SourceDeadrecord_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-Afterinf/-inf/1e999 → 按无提示处理且各记一条 warning(断言日志含源名、不含原始字符串);nan/空/负/HTTP-date → 静默 None无 warning;_parse_retry_aftersource_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 活性),给一个"经验公式"就是把两个预算再次绑死