# 实现计划: HTTP 错误响应体留存(Issue #10)

- **设计**: `research-wiki/designs/2026-08-16-issue10-error-body-retention-design.md`(**已批准 2026-08-16**)
- **分支**: `feat/issue-10-error-body-retention`
- **目标**: 网关拒绝一次调用时,它说的话必须能在库自己的遥测表里被事后查到。
- **方案概述**: transport 翻译层把 HTTP 错误响应体折叠空白并按头尾策略摘要,**同一份串**同时拼进异常 message(经既有 `error` 列落遥测)与新增的基类字段 `body_text`(供下游结构化留存)。覆盖两个 transport 的全部非 2xx 分支。不改任何状态码→分类的映射。
- **涉及技术**: Python 3.11 / httpx / pytest。无新增依赖。
- **保真校验**: 本计划**不涉及** `reference/` 参考实现迁移——错误分类映射逐条不变,保真体现为"既有分类断言全部保留、无一条被改写"(Task 3/4 验收项)。

## 文件结构

| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/errors.py` | 改 | 基类 `PolyGatewayError` 新增 `body_text` 字段;与 `raw_text` 的界限 docstring;`RequestRejectedError` 补中转拓扑提醒 |
| `src/polygateway/transports/_http_errors.py` | **新建** | 摘要口径单一实现:`summarize_body` / `compose_message` / `response_body` + 三个常量 |
| `src/polygateway/transports/openai_compat.py` | 改 | `_status_to_error` 表驱动重写;`_translate_429` 收 `ctx` |
| `src/polygateway/transports/monkey_ocr.py` | 改 | `_classify_status` 带摘要 |
| `tests/unit/test_http_error_body.py` | **新建** | 摘要单元的纯函数用例(截断边界、头尾保留、折叠、幂等) |
| `tests/unit/test_openai_compat.py` | 改 | 状态码参数化断言 message + 字段;超长 `insufficient_quota` 回归 |
| `tests/unit/test_monkey_ocr.py` | 改 | OCR 分支同款 + `ResponseNotRead` 降级 |
| `tests/unit/test_errors.py` | 改 | `body_text` 默认值与可传性 |
| `tests/integration/test_governance_stack.py` | 改 | **端到端验收**:400 调用后 SQLite `error` 列含摘要 |
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` / `research-wiki/ARCHITECTURE.md` | 改 | 文档与 1.2.0 版本号 |

## 关键接口(跨任务消费,必须逐字一致)

```python
# src/polygateway/transports/_http_errors.py
from __future__ import annotations

import httpx                      # response_body 的类型与 ResponseNotRead 都来自它

_ERROR_BODY_CAP = 2048   # 字符(非字节),含省略标记在内的最终总长上限
_HEAD_CHARS = 1400
_TAIL_CHARS = 600


def summarize_body(text: str) -> str:
    """折叠空白后按头尾策略摘要;空/空白入参返回空串。"""
    collapsed = " ".join(text.split())
    if len(collapsed) <= _ERROR_BODY_CAP:
        return collapsed
    omitted = len(collapsed) - _HEAD_CHARS - _TAIL_CHARS
    return f"{collapsed[:_HEAD_CHARS]}…(略 {omitted} 字)…{collapsed[-_TAIL_CHARS:]}"


def compose_message(message: str, summary: str) -> str:
    """摘要非空才拼后缀,避免悬空分隔符。"""
    return f"{message} | {summary}" if summary else message


def response_body(response: httpx.Response) -> str:
    """取已缓冲的响应文本;未读缓冲一律降级空串,绝不触发网络读。"""
    try:
        return response.text
    except httpx.ResponseNotRead:
        return ""
```

```python
# src/polygateway/errors.py
class PolyGatewayError(Exception):
    def __init__(
        self,
        message: str,
        *,
        source_name: str | None = None,
        status_code: int | None = None,
        operation: str | None = None,
        body_text: str = "",
    ) -> None:
```

```python
# src/polygateway/transports/openai_compat.py
# 既有 errors 导入(:18-23)须补入 PolyGatewayError —— 当前只导了四个子类,
# 直接写 _classify 的返回注解会让 ruff 报 F821 未定义名。
from polygateway.errors import (
    PolyGatewayError,          # ← 新增
    RequestRejectedError,
    ResultInvalidError,
    SourceDeadError,
    TransientError,
)
from polygateway.transports._http_errors import compose_message, summarize_body


def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
    """状态码 → (错误类, message 标签);映射与 1.1.2 逐条相同。"""
    if status in (401, 403):
        return SourceDeadError, "凭据失效/欠费"
    if status == 400:
        return RequestRejectedError, "请求被拒"
    if status >= 500:
        return TransientError, "瞬时错误"
    return RequestRejectedError, "客户端错误"


def _status_to_error(
    source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
) -> Exception:
    summary = summarize_body(body_text)          # 全函数只算一次
    ctx: dict[str, Any] = {
        "source_name": source.name,
        "status_code": status,
        "operation": "chat",
        "body_text": summary,
    }
    if status == 429:
        return _translate_429(source, body_text, headers, ctx)   # 传**原文**,见下
    cls, label = _classify(status)
    return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
```

> **实现红线**:`_translate_429` 判 `insufficient_quota` 必须解析**未截断的原文** `body_text`,不得改用 `summary`。摘要会破坏 JSON 结构,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`——那是把一个诊断改进变成治理 bug。Task 3 有专门的回归用例钉死这条。

## 任务清单

### - [ ] Task 1: 内核新增 `body_text` 字段

**改**: `src/polygateway/errors.py`

- `PolyGatewayError.__init__` 按上文签名新增 `body_text: str = ""`,存为实例属性。
- 类 docstring 增补与 `ResultInvalidError.raw_text` 的界限:`body_text` = 非 2xx 的 HTTP 错误响应体摘要(对方拒绝的理由);`raw_text` = 2xx 但内容不可解析时的模型输出。并写明"可能包含请求回显,已截断"。
- **不动** `TransientError` / `SourceDeadError` / `RequestRejectedError` / `ResultInvalidError` / `GatewayUnavailableError` 的任何既有签名与行为。

**测试**(`tests/unit/test_errors.py`,扩展 `:29` 的四类构造形态参数化):
- 四个 transport 错误类默认 `body_text == ""`;显式传入后可读回。
- `GatewayUnavailableError` / `CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` 的 `body_text` 恒为 `""`(它们不经 HTTP 响应翻译)。

**验收**: 新增字段不改变任何既有异常的 `str()` 输出。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_errors.py -v` → 全 PASS。

### - [ ] Task 2: 共享摘要单元

**新建**: `src/polygateway/transports/_http_errors.py`(按上文"关键接口"逐字实现,**含其中的 `import httpx`**,加中文模块/函数 docstring 解释**为什么**折叠空白、为什么头尾保留、为什么 `response_body` 必须降级)

**新建测试**: `tests/unit/test_http_error_body.py`

| 用例 | 断言 |
|---|---|
| 短体原样 | `summarize_body('{"a":1}') == '{"a":1}'` |
| 空白折叠 | 多行缩进 JSON → 单行,无连续空格 |
| 空 / 纯空白入参 | 返回 `""` |
| 长度恰 2048 | 原样返回,无标记 |
| 长度 2049 | 走头尾策略 |
| 超长体头尾 | 前 1400 字符 == 原文前 1400;**末 600 字符 == 原文末 600**;中段标记内 N == `len(原文) - 2000` |
| **尾部关键字段可见**(设计 §7 用例 3c) | 以 issue 真实样本尾部 `"code":"invalid_parameter_error"}}` 收尾构造超长体 → 断言该串出现在摘要中 |
| 幂等 | `summarize_body(summarize_body(x)) == summarize_body(x)`(标记不嵌套) |
| `compose_message` | 摘要为空时返回原 message 不变;非空时以竖线分隔符拼接 |
| `response_body` 降级 | `httpx.Response(400, stream=<未读 SyncByteStream>)` → 返回 `""` 且不抛(构造法见下) |

未读响应的构造(已实测可用):

```python
class _Unread(httpx.SyncByteStream):
    def __iter__(self):
        yield b"body"

resp = httpx.Response(400, stream=_Unread())   # 未 read → .text 抛 ResponseNotRead
```

**验收**: 摘要总长恒 ≤ `2000 + len(标记)`;头尾各自与原文逐字对应。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_http_error_body.py -v` → 全 PASS。

### - [ ] Task 3: openai_compat 翻译层收口

**改**: `src/polygateway/transports/openai_compat.py`

- 新增 `_classify`,`_status_to_error` 按上文骨架重写(五分支各拼各的 message → 查表 + 单点拼装)。
- `_translate_429` 签名改为 `(source, body_text, headers, ctx)`,两支 message 各自追加 `compose_message` 后缀,构造改用 `**ctx`;**`json.loads` 仍读原文 `body_text`**。
- message 主体逐字保持 1.1.2 原样(`凭据失效/欠费: {status}` / `请求被拒: 400` / `瞬时错误: {status}` / `客户端错误: {status}` / `配额耗尽(insufficient_quota)` / `限速: 429`),只在末尾追加 ` | {摘要}`。
- 三个调用点(`:402` embed、`:417` stream、`:509` 非流式)签名不变,**不改动**。
- **补 import**:`PolyGatewayError`(errors)与 `compose_message` / `summarize_body`(`._http_errors`),见上文关键接口——漏补则 `make lint` 报 F821(Codex 审查 2026-08-16 提出)。
- **不改** `operation` 硬编码 `"chat"`(设计 §5.4 有意留给独立 issue)。

**测试**(`tests/unit/test_openai_compat.py`,沿用既有 `_transport_for(handler)` + `httpx.MockTransport`):

| # | 用例 | 断言 |
|---|---|---|
| 3.1 | 状态码参数化 400 / 401 / 403 / 404 / 500 / 503,handler 返回带真实样本体 | 异常类型与 1.1.2 **逐条相同**;message 含摘要;`exc.body_text` == 摘要 |
| 3.2 | 429 普通限速(body 无 `insufficient_quota`) | `TransientError`,message 含摘要,`retry_after_s` 解析不受影响 |
| 3.3 | 429 + `insufficient_quota` | `SourceDeadError`,message 含摘要 |
| 3.4 | **回归红线**:429 + `insufficient_quota` 且 body 长度 > 2048(前置大量填充字段) | 仍判 `SourceDeadError`——证明类型判定读的是原文而非摘要 |
| 3.5 | 空 body 的 400 | message 无悬空分隔符,`body_text == ""` |
| 3.6 | 非 JSON body、非 UTF-8 字节 body | 不抛额外异常,分类不变 |
| 3.7 | 流式路径(handler 对 stream 请求返回 400 + body) | 经 `_complete_stream:415-417` 抛出的异常同样带摘要 |
| 3.8 | embedding 路径(`transport.embed(...)` 遇 400) | 同样带摘要 |

**验收**: 既有测试零修改通过(除 3.x 新增外);`test_openai_compat.py:558`(match 源名)仍 PASS。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → 全 PASS。

### - [ ] Task 4: monkey_ocr 同款收口

**改**: `src/polygateway/transports/monkey_ocr.py`

- `_classify_status`:`summary = summarize_body(response_body(exc.response))`,三支 message 统一经 `compose_message` 追加后缀,`ctx` 带 `body_text=summary`。
- message 主体保持 `f"{source_name} OCR {operation} HTTP {status}"` 不变。
- 429/5xx → `TransientError`、401/403 → `SourceDeadError`、其余 → `RequestRejectedError` 的映射**逐条不变**(OCR 无 429 细分是设计有意保留,见模块 docstring `:53-54`)。

**测试**(`tests/unit/test_monkey_ocr.py`):
- 扩展 `:300` 的状态码参数化:各分支 message 含摘要且 `body_text` 非空,分类不变。
- `ResponseNotRead` 降级:`exc.response` 为未读流 → `body_text == ""`,message 无悬空分隔符,**分类仍正确**(不得因取 body 失败而改变错误类型或抛出 httpx 异常)。

**验收**: `:195` 与 `:300` 既有断言不被改写。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_monkey_ocr.py -v` → 全 PASS。

### - [ ] Task 5: 端到端遥测验收(**本计划的硬判据**)

**改**: `tests/integration/test_governance_stack.py`

新增用例,沿用既有 `_full_client(handler, telemetry=SQLiteRecorder(...))` 与 `:135` 的 `SELECT error FROM llm_calls` 断言模式:

- handler 对 chat 请求返回 `httpx.Response(400, content=<issue #10 真实样本体>)`。
- `client.chat(...)` 抛 `RequestRejectedError`(400 不重试不换源,行为不变)。
- `recorder.close()` 后查 `SELECT error FROM llm_calls`:该行 `error` 串**含样本体里的 `InvalidParameter` 与结尾的 `invalid_parameter_error`**。

真实样本体(取自 issue #10 原文,一字不改):

```json
{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal and cannot be opened","type":"invalid_request_error","param":"","code":"invalid_parameter_error"}}
```

**验收**: 这条断言在 Task 1-4 之前**必然失败**(1.1.2 的 `error` 列只有 `"qwen_1 请求被拒: 400"`),之后通过——这就是本 issue 的"先失败后通过"证据主体,执行时须保留失败输出截图/文本进提交说明。
**验证**: `conda run -n PolyGateway pytest tests/integration/test_governance_stack.py -v` → 全 PASS。

### - [ ] Task 6: 文档与版本

**改**:

| 文件 | 内容 |
|---|---|
| `src/polygateway/errors.py` | `RequestRejectedError` docstring 加一句:经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类(设计 §5.2) |
| `research-wiki/ARCHITECTURE.md` §6.2 | 同一提醒 + 注明四分类错误自 1.2.0 起携带 `body_text` |
| `CHANGELOG.md` | 新增 `## 1.2.0(2026-08-16)` 段:行为变更(message 追加摘要 → 遥测 `error` 列变长)、新增字段、不变项(分类映射零变更、错误面零变更) |
| `README.md:34` | 安装 pin `==1.1.*` → **`>=1.2,<2`**(2026-08-16 人类定夺;漏改则下游静默停在 1.1.2) |
| `pyproject.toml` + `src/polygateway/__init__.py` | 版本号 `1.1.2` → `1.2.0`,**两处必须一致** |

**验收**: `grep -rn "1\.1\.\*" README.md` 零命中;两处版本号一致。
**验证**: `conda run -n PolyGateway python -c "import polygateway; print(polygateway.__version__)"` → `1.2.0`。

### - [ ] Task 7: 合并前全量门

1. `make lint`(ruff + import-linter)→ 零违规,**重点确认新建 `transports/_http_errors.py` 未触发洋葱分层契约**。
2. `make test` 全套件 → 全 PASS,覆盖率不低于既有水平。
3. 派**全新上下文** verifier subagent 独立验证(CLAUDE.md §3 Phase 2 硬门):逐条核对 Task 1-6 验收项与本会话工具输出。
4. `finishing-a-development-branch` 合并回 main(`--no-ff`),合并后在 main 上重跑 `make lint` 与全套件。

**发布**(合并后)严格按 CLAUDE.md §4.4.1 九步执行,不在本计划展开;其中步骤 1(更新 README)已在 Task 6 前置完成,**构建前须再次确认 pin 已是 `>=1.2,<2`**。

## 执行顺序与提交点

```
Task 1 ──┐
         ├── Task 3 ──┐
Task 2 ──┴── Task 4 ──┴── Task 5 ── Task 6 ── Task 7
```

Task 1 与 2 可并行(互不依赖);Task 3、4 都依赖 1+2;Task 5 依赖 3;Task 6 独立于代码但须在 Task 7 之前。每个 Task 一次语义化提交(`commit` skill),Task 5 的提交说明须附"修复前失败、修复后通过"的实际输出。
