# HTTP 错误响应体留存设计(Issue #10) - **日期**: 2026-08-16 - **来源**: Gitea Issue #10(下游 1050 张医学影像批处理,1 张收到 400 被判确定性失败;事后无从查证原因。基于 1.1.2 源码核查) - **状态**: 待人类审批 - **触发档位**: 强制(`errors.py` 属最内层内核,新增公共字段即变更库对下游的承诺) - **方案范围**: 人类明确要求单一方案(2026-08-16),故本文不列平行备选,仅在 §6 记录被否决路线及否决理由(体例沿用 Issue #7/#8 设计) ## 1. 目标与非目标 | | 内容 | |---|---| | **G1** | 网关拒绝一次调用时,**它说了什么必须可事后查证**——库自己的遥测表里就能查到,不依赖下游额外埋点 | | **G2** | 留存口径覆盖 transport 层**全部**非 2xx 分支与**全部** transport(chat / embedding / stream / OCR),杜绝"只修 400 → 下次 401 复发" | | **G3** | 摘要文本单点规范化(折叠空白 + 截断 + 截断标记),message 与结构化字段**取同一份串**,两处永不打架 | | **G4** | 不改变任何状态码 → 错误分类的映射(ARCHITECTURE §6.2 表原封不动),下游 `except` 写法零影响 | | **非目标** | 不改 400 的治理语义(不重试不换源,见 §5.2);不新增遥测列(见 §6.1);不新增配置项;不做错误分类可插拔(见 §6.4);不顺手修 `_status_to_error` 的 `operation` 硬编码缺陷(见 §5.4) | ### 1.1 Issue 前提的两处修正(按 1.1.2 源码核实) | Issue 原文 | 实际情况 | |---|---| | 建议方向一「让异常带上截断后的响应体……就能让下游把它记进日志和遥测」 | **只做这一半解决不了 Issue 自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`middleware/retry.py:558` → `middleware/telemetry.py:89` → `telemetry/sqlite.py:43` 的 `error TEXT` 列),即**异常 message**。新增字段不会进库的遥测表;下游说的"写进遥测表"是他们自己的埋点。故本设计**两件都做,且以 message 为主**(§3.3) | | 缺陷范围 = 400 分支 + 4xx 兜底 | 实为 **6 处同构**:`openai_compat._status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 的两支(读了 body 判 `insufficient_quota`,但 message 仍不带),以及 `monkey_ocr._classify_status:74-88` 的**全部**分支(message 只有 `HTTP {status}`)。Issue 场景是"读表格",极可能正落在 OCR 路径 | ## 2. 根因:诊断信息在翻译层被丢弃,而遥测只看 message `_status_to_error`(`transports/openai_compat.py:131-143`)手上握着 `body_text`,却只把它用于 429 的类型细分,翻出的异常与 message 都不携带它。响应体在这一层之后**不再存在于进程任何位置**:该模块无 logger(grep `logger|loguru` 零命中),异常类无字段,遥测只写 message。 三条留存通道同时为空,是"永久查不到"的完整解释: | 通道 | 现状 | 本设计后 | |---|---|---| | 日志 | 模块无 logger | 仍无(§6.2:不加日志) | | 异常字段 | 无承载处 | `body_text`(§3.1) | | 库遥测 `error` 列 | 只有 `"{源名} 请求被拒: 400"` | message 携带摘要(§3.3) | ## 3. 选定方案 ### 3.1 内核:`PolyGatewayError` 基类新增 `body_text` ```python class PolyGatewayError(Exception): def __init__(self, message, *, source_name=None, status_code=None, operation=None, body_text: str = "") -> None: ``` **加在基类而非 `RequestRejectedError`**:这些错误全部由同一个 HTTP 响应翻译而来,"对方说了什么"与"它属于哪一类"正交。只给一个子类加,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 一次人类门。 与既有 `ResultInvalidError.raw_text`(`errors.py:77`)的界限必须在 docstring 钉死,否则两个"原文字段"必然被混用: | 字段 | 语义 | 来源 | |---|---|---| | `body_text` | **非 2xx** 的 HTTP 错误响应体摘要——对方**拒绝**你的理由 | transport 翻译层 | | `raw_text` | **2xx** 但内容不可解析时的模型输出原文 | 结构化解析层 | `GatewayUnavailableError` 一族继承到一个恒空的 `body_text` 不是噪音:scope 级失败本就"没有单一响应体可言",空串是对这件事的如实表达。 ### 3.2 共享单元:`transports/_http_errors.py`(新建,~40 行) 两个 transport 各有自己的状态码分类逻辑(OCR 无 429 细分,有意保留,见 `monkey_ocr.py:53-54`),但**摘要口径必须同一份**,否则就是下一个"只修一半"。两函数: | 函数 | 职责 | 关键防御 | |---|---|---| | `summarize_body(text) -> str` | 折叠空白 → 按 §3.4 的机械规则截断 | 空/空白入参返回 `""` | | `response_body(response) -> str` | 从 `httpx.Response` 取已缓冲文本 | `ResponseNotRead` → 返回 `""`,**绝不触发网络读** | - **折叠空白不是洁癖**:错误体常是缩进 JSON,直接拼进 message 会让一行日志炸成多行、遥测列不可读。 - **截断必须留标记**:不标记,读的人分不清"网关只说了这么多"和"库切的"。 - **`response_body` 的防御是硬要求**:`monkey_ocr._classify_status` 只拿得到 `httpx.HTTPStatusError`,若某天 OCR 走 stream 请求,`.text` 会抛 `ResponseNotRead`,把一次可分类的 4xx 变成泄漏的 httpx 异常——**违反"一切失败必须落入四分类"铁律**。诊断信息缺失绝不能升级为崩溃(降级方向,§4.2)。 放在 `transports/` 私有模块而非 `errors.py`:职责是"HTTP 响应 → 领域错误"的工具,放内核会稀释 `errors.py` 的单一职责(P3)。两个 transport 同 import 一个私有模块,不构成 transport 之间的互相依赖,import-linter 的 layers 契约(同层 `|` 独立性)不受影响。 ### 3.3 翻译层:表驱动收口,message 与字段共用一份摘要 `_status_to_error` 现在是五个分支各拼各的 message,新增摘要意味着五处重复。改为**分类表 + 单点拼装**,代码反而变短: ``` summary = summarize_body(body_text) # 全函数只算一次 ctx = {..., "body_text": summary} # 字段 429 → _translate_429(source, body_text, headers, ctx) # 需原文判 type,单列 其余 → cls, label = _STATUS_MAP 查表 → cls(_compose(source, label, status, summary), **ctx) ``` message 形态:`"{源名} {标签}: {状态码} | {摘要}"`;**摘要为空时不拼后缀**,避免出现悬空的 ` | `。分隔符取 ` | ` 而非既有的 `: `,让"库的话"与"网关的话"一眼可分。 **429 也拼,不设例外**:例外就是下一个复发点。`insufficient_quota` 那支尤其需要(配额细节全在 body 里);普通限速 body 通常很短。代价是高频限速场景遥测 `error` 列变长,由 `_ERROR_BODY_CAP` 兜住。 `monkey_ocr._classify_status` 同款处理:`summary = summarize_body(response_body(exc.response))`,message 追加同一后缀,`ctx` 带上字段。 ### 3.4 常量取值 **机械规则(实现与测试逐字照此)**:`_ERROR_BODY_CAP = 500` 是**最终摘要的总长度上限**,含截断标记。折叠空白后长度 ≤ 500 则原样返回;超出则取前 **499** 字符并追加 `…`,总长恰为 500。 > 这条必须写成算术而非叙述:`text[:500] + "…"`(501)与 `text[:499] + "…"`(500)都能被"截断至 cap 并补 `…`"这句话涵盖,而两者会让测试断言与遥测长度承诺对不上(Codex 审查 2026-08-16 提出)。 取值理由:Issue 给出的真实样本约 160 字符;既有遥测文本截断常量是 200(`embedding.py:72`、`ocr.py:72`),对错误体偏紧(会切掉稍长的 `error.message`)。500 能容下绝大多数网关错误体,最坏情形(5xx 重试 3 次)每次调用向遥测多写约 1.5KB,`TEXT` 列可承受。 message 与 `body_text` **共用同一变量**,不设两个长度:两份不同长度会让"遥测里看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。 ### 3.5 改动清单 | 文件 | 改动 | |---|---| | `errors.py` | 基类新增 `body_text` 字段 + 与 `raw_text` 的界限 docstring | | `transports/_http_errors.py` | **新建**:`summarize_body` / `response_body` / `_ERROR_BODY_CAP` | | `transports/openai_compat.py` | `_status_to_error` 表驱动重写;`_translate_429` 收 `ctx` | | `transports/monkey_ocr.py` | `_classify_status` 带摘要 | | `errors.py` docstring + `ARCHITECTURE.md` §6.2 | 中转拓扑下 400 的提醒(§5.2) | 三个调用点(`openai_compat.py:402` embed、`:417` stream、`:509` 非流式)**签名不变**,无需改动。 ## 4. 非功能维度 ### 4.1 并发与取消 新增全部是纯函数与数据字段,无状态、无锁、无 IO、不引入 `await`。`response_body` 只读已缓冲字节,`ResponseNotRead` 时直接返回空串而**不发起网络读**——否则会在错误路径上凭空插入一次可能挂住的 IO。`CancelledError` 路径逐字不变。 ### 4.2 降级方向 响应体不可得(未读缓冲 / 解码失败 / 空体)→ `body_text=""`,**静默降级,绝不报错**。诊断信息属可观测性,按库铁律与缓存/遥测同档:缺了降级,不得把一次本可正确分类的失败变成不可分类的崩溃。流式路径的 `(await resp.aread()).decode("utf-8", errors="replace")`(`:416`)已是这个口径,保持。 ### 4.3 幂等与重复 纯函数,同输入同输出。`summarize_body` 对自身输出再调用一次是幂等的:输出总长恒 ≤ 500(§3.4)且不含需折叠的空白,第二次调用走"原样返回"分支,不会出现 `…` 被反复追加。 ### 4.4 持久化与原子性 不新增表、不改 DDL、不动遥测端口的 22 字段与列序。摘要经既有 `error TEXT` 列落盘,原子性由既有单行写入保证。 ### 4.5 安全与体积 - **响应体可能回显请求内容**(部分网关的 `error.param` 会带违规字段值)。截断 + 空白折叠是主要止血手段;字段 docstring 须写明"可能包含请求回显,已截断"。库不做内容脱敏——库不知道下游哪些字段敏感,猜测式脱敏只会同时丢掉诊断价值与安全性。 - **本设计不放大既有的读取风险**:`_complete_stream:416` 的 `aread()` 对错误响应体无大小上限(超大错误体可打爆内存),该风险今天已经存在(读完即丢),留存后只是更显眼。**不夹带修复**,见 §5.4。 ## 5. 错误处理、语义与边界 ### 5.1 错误分类 不改任何映射。`body_text` 是**旁路数据**,不参与任何治理判定——不影响重试、换源、熔断计数、AIMD、限流结算。这是本设计能与 ARCHITECTURE §6.1/§6.2 零冲突的根本原因。 ### 5.2 400 语义:不改行为,补文档 Issue 报告了一个有说服力的观察:同字节 15 次重发全部成功、`prompt_tokens=0`、耗时 2996ms 远低于同批 631 次成功调用的最快值 7366ms——说明那次 400 来自中转服务自身抖动,而非"你的输入有问题"。 **仍不改分类**:400 重试对直连供应商是纯浪费(确定性坏输入,重试只烧配额并拖延失败);"中转也回 400"是**部署拓扑**引入的信息损失,库从状态码无从分辨。默认改为可重试 = 让所有直连用户为一种部署形态买单,且推翻已冻结的公共契约。 **但本设计本身就是对这个观察最好的答复**:body 留存后,下游能自己区分——中转抖动的 400 体与供应商 `invalid_request_error` 体形态不同。库不替下游做判断,而是把判断所需的信息交出去。配套文档动作:`RequestRejectedError` docstring 与 ARCHITECTURE §6.2 各加一句"经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类"。 ### 5.3 兼容性 `LLMResponse` 一族的"字段只增不删不改名"约束(ARCHITECTURE §5.1)同样适用于异常。本次是**纯新增关键字参数且带默认值**:既有构造点、既有 `except` 写法、既有 `str(exc)` 消费方全部不受影响。message 文本变化不构成破坏——现有测试对这些 message 无格式依赖(仅 `test_openai_compat.py:558` match 源名)。 版本建议 **1.2.0**(公共类型新增字段属 minor)。 ### 5.4 有意不夹带的两项(建议单开 issue) | 项 | 说明 | |---|---| | `_status_to_error` 的 `operation` 硬编码 `"chat"`(`:134`),而 `embed()` 也调它(`:402`) | embedding 的 HTTP 错误在遥测里被标成 `operation="chat"`,是既有数据正确性缺陷,与本 issue 无关 | | `_complete_stream:416` 的 `aread()` 无大小上限 | 恶意/故障网关的超大错误体可打爆内存,属独立的健壮性问题 | 两项都在本次重构触及的函数附近,但修它们既不服务 G1-G4,也各自需要独立的行为讨论——按反 gold-plating 铁律留给独立 issue。 ## 6. 被否决的路线 ### 6.1 给遥测端口加一列(22 → 23 字段) 最"正统"的结构化留存,但成本极不相称:端口 Protocol 签名变更 + SQLite/Postgres 双后端 DDL 迁移 + 下游已有表的 ALTER + 列序契约测试全线改动——为一个诊断串付出一次跨三项目的迁移。而复用既有 `error TEXT` 列可达成同样的可查证性。 ### 6.2 只在 `_status_to_error` 打一条 WARNING 日志(Issue 方向二) 不采纳为**主**手段:日志与遥测是两套留存,日志轮转后仍然查不到,而 Issue 的痛点恰是"事后"。且库铁律要求库不擅自向下游日志流写入高频内容(4xx/5xx 在批处理下可能极高频)。message 携带摘要已让 loguru 侧的下游在捕获点自然拿到同一份信息,再加一条独立日志属重复留存。 ### 6.3 截断放在异常构造器内 构造器自动规范化更"防遗漏",但会让下游自建异常时传入的文本被悄悄改写,违反 P4;且 message 里的摘要仍需在翻译层单独算一次,反而出现两条规范化路径。选定方案在翻译层算一次、两处共用,更简且更显式。 ### 6.4 错误分类映射可插拔(provider profile 注入 classifier) Issue 的中转 400 场景确实指向这个方向,但当前只有一个使用方且他们已用自己的兜底分类解决。`ProviderProfile`(`providers.py:17-45`)目前也没有这个扩展点,加它是新子系统级的设计。YAGNI:等第二个使用方提出。 ## 7. 测试策略(先失败后通过) **验收主张**:一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要。这条端到端断言直接对应 Issue 的痛点,是本设计成立与否的唯一硬判据;其余为覆盖性用例。 | # | 用例 | 覆盖 | |---|---|---| | 1 | **端到端遥测**:mock transport 返回 400 + 真实样本体 → 断言 recorder 收到的 `error` 含摘要 | G1 | | 2 | 参数化状态码(400 / 401 / 404 兜底 / 429 普通 / 429 `insufficient_quota` / 500)→ 断言 message 含摘要且 `exc.body_text` 非空,**分类与既有断言逐一不变** | G2, G4 | | 3 | 超长体 → `len(summary) == 500` 且以 `…` 结尾,前 499 字符与原文前 499 字符逐字相同;`body_text` 与 message 中的摘要逐字相同 | G3 | | 3b | 长度恰为 500 / 501 的体 → 前者原样不带 `…`,后者截为 500 带 `…`(边界) | §3.4 | | 4 | 多行缩进 JSON → 折叠为单行 | G3 | | 5 | 空体 / 纯空白体 → 不拼悬空分隔符,`body_text == ""` | §3.3 | | 6 | 非 JSON 体、非 UTF-8 字节 → 不抛异常,分类不变 | §4.2 | | 7 | 流式错误路径(`_complete_stream` 415-417)同样带摘要 | G2 | | 8 | `monkey_ocr._classify_status` 同款(含 `ResponseNotRead` 时降级为空串而非抛出) | G2, §4.2 | | 9 | embedding 路径(`:402`)HTTP 错误带摘要 | G2 | `tests/unit/test_errors.py:29` 现有的"四类构造形态"参数化用例需扩展 `body_text` 默认值断言(默认 `""`、可传入、`GatewayUnavailableError` 一族恒空)。 ## 8. 待人类确认的点 1. `_ERROR_BODY_CAP = 500` 是否合适(§3.4)——它直接决定遥测表增量。 2. 429 不设例外、一律拼摘要(§3.3)——高频限速场景下遥测 `error` 列会变长。 3. 版本定 1.2.0(§5.3)。