# HTTP 错误响应体留存设计(Issue #10)

- **日期**: 2026-08-16
- **来源**: Gitea Issue #10(下游 1050 张医学影像批处理,1 张收到 400 被判确定性失败;事后无从查证原因。基于 1.1.2 源码核查)
- **状态**: **已批准(2026-08-16)**,待 `writing-plans`
- **触发档位**: 强制(`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 = 2048    # 字符(非字节),含省略标记在内的最终总长上限
_HEAD_CHARS     = 1400
_TAIL_CHARS     = 600

折叠空白后 len ≤ 2048  →  原样返回
否则                   →  s[:1400] + f"…(略 {len(s) - 2000} 字)…" + s[-600:]
```

> 规则必须写成算术而非叙述:"截断至 cap 并补标记"能同时被读成总长 2048 与 2049,两者会让测试断言与遥测长度承诺对不上(Codex 审查 2026-08-16 提出)。

**头尾保留而非头部硬切**(2026-08-16 调研决策)。截断的对象是**结构化 JSON 错误体**,信息分布头重尾也重:人话(`message`)在前,机器可判的 `type` / `code` / `param` / `request_id` 在后。Issue 给出的真实样本即 `"code":"invalid_parameter_error"` 收尾——头部硬切正好切掉向网关方追查时唯一有用的那部分。省略标记记下**被省略的字符数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。

按**字符**而非字节切:多字节字符不会被切成半个(Sentry 曾为按字节切开 issue #1691),且 `error TEXT` 列无定长约束,无需字节口径。

### 3.4.1 取值依据:同场景开源实践

| 项目 | 场景 | 上限 | 保留策略 |
|---|---|---|---|
| **Kubernetes client-go** `rest/request.go` | **读 HTTP 错误体生成错误信息**(与本设计同构) | `maxUnstructuredResponseTextBytes = 2048` | 头部硬切 |
| OpenAI Python SDK `_exceptions.py` | 异常对象持有 body | **不截断**(内存对象,不落库) | — |
| Sentry Python `strip_string` | 事件写入前 trim | `max_value_length`,2.34.0 前默认 1024 | 头部 + `...`,另用 metadata 记原长 |
| Elastic APM | 长字段 | keyword 1024 / long field 10000 | 截断带省略号 |
| Python 标准库 `reprlib` | 给人读的长字符串 | `maxstring` | **头 + 尾,中间省略** |

**2048 对齐 k8s client-go**——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。初稿的 500 仅以 issue 的单个样本(约 160 字符)为据,是拿一个样本定上限,已废弃。头部硬切在 k8s/Sentry 成立是因为它们截的是任意文本;本设计截的是结构化 JSON,故取 `reprlib` 的头尾策略。

遥测代价:纯 ASCII 约 2KB/条,纯中文最多约 6KB/条;5xx 重试 3 次即一次调用最多约 18KB。批处理场景(1050 次调用、5% 失败)约 300KB,`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) |
| `README.md:34` | 安装 pin `==1.1.*` → `>=1.2,<2`(§5.3,发布前置,漏改则下游拿不到本修复) |

三个调用点(`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` 对自身输出再调用一次是幂等的:输出总长恒为 `2000 + len(标记) ≤ 2048`(标记形如 `…(略 N 字)…`,8 + N 的位数,现实中远不足 48),且不含需折叠的空白,故第二次调用走"原样返回"分支,不会出现标记被反复嵌套。

### 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;2026-08-16 人类定夺)。

**发布时必须同步改 README 的安装 pin**:`README.md:34` 现为 `"polygateway[redis,postgres,structured]==1.1.*"`,发 1.2.0 后照此命令安装的下游会**静默停在 1.1.2**——无报错、无警告,与 CLAUDE.md §4.4.1 点名的"极易漏改"完全同款(registry 长期停在 1.0.5 即此类事故)。本次改为 **`>=1.2,<2`**,把"每发一个 minor 就要通知三个下游改 pin"这一反复出现的麻烦一次性消除。此项列入实现计划的发布前置步骤,不是发布日的临时动作。

### 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 | 超长体 → 前 1400 字符与原文头部逐字相同、**末 600 字符与原文尾部逐字相同**、中段为 `…(略 N 字)…` 且 N 等于实际省略数;`body_text` 与 message 中的摘要逐字相同 | G3 |
| 3b | 长度恰为 2048 / 2049 的体 → 前者原样无标记,后者走头尾保留(边界) | §3.4 |
| 3c | **尾部关键字段可见**:以 issue 的真实样本尾部 `"code":"invalid_parameter_error"}}` 构造超长体 → 断言该串出现在摘要中 | §3.4 头尾决策的验收 |
| 3d | 摘要对自身幂等(再摘要一次不嵌套标记) | §4.3 |
| 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. 人类定夺记录(2026-08-16)

| 议题 | 定夺 |
|---|---|
| 摘要上限与保留策略 | 初稿 500 + 头部硬切被否:上限提至 **2048**(对齐 k8s client-go 同场景先例),策略改为**头 1400 + 尾 600 + 省略字数标记**——人类指出"有用的信息可能只在后半部分",经调研证实 JSON 错误体的 `code`/`request_id` 确实收尾(§3.4、§3.4.1) |
| 429 是否设例外 | **不设**,一律拼摘要(§3.3) |
| 版本 | **1.2.0**,并同步把 README pin 由 `==1.1.*` 改为 `>=1.2,<2`(§5.3) |
