Issue #10: the 400 body dies in _status_to_error, and telemetry only writes str(exc), so adding a field alone would not make the refusal queryable after the fact. Design keeps the summary in both the message and a new base-class body_text, across every non-2xx branch and both transports.
15 KiB
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
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 |
折叠空白 → 截断至 _ERROR_BODY_CAP → 被截时补 … |
空/空白入参返回 "" |
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。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 对已摘要文本再摘要是幂等的(长度已 ≤ cap)。
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 | 超长体 → 截断至 cap 且以 … 结尾;body_text 与 message 中的摘要逐字相同 |
G3 |
| 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. 待人类确认的点
_ERROR_BODY_CAP = 500是否合适(§3.4)——它直接决定遥测表增量。- 429 不设例外、一律拼摘要(§3.3)——高频限速场景下遥测
error列会变长。 - 版本定 1.2.0(§5.3)。