diff --git a/research-wiki/plans/2026-08-16-issue10-error-body-retention.md b/research-wiki/plans/2026-08-16-issue10-error-body-retention.md new file mode 100644 index 0000000..20827b7 --- /dev/null +++ b/research-wiki/plans/2026-08-16-issue10-error-body-retention.md @@ -0,0 +1,242 @@ +# 实现计划: 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 +_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 +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`(按上文"关键接口"逐字实现,含中文模块/函数 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` 非流式)签名不变,**不改动**。 +- **不改** `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=)`。 +- `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 的提交说明须附"修复前失败、修复后通过"的实际输出。