1489aab95d
Codex review: the code blocks reference httpx and PolyGatewayError, but neither module imports them today. A zero-context implementer copying them verbatim would stall on F821.
260 lines
15 KiB
Markdown
260 lines
15 KiB
Markdown
# 实现计划: 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 的提交说明须附"修复前失败、修复后通过"的实际输出。
|