14 KiB
实现计划: 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 版本号 |
关键接口(跨任务消费,必须逐字一致)
# 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 ""
# 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:
# 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>) → 返回 "" 且不抛(构造法见下) |
未读响应的构造(已实测可用):
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),只在末尾追加| {摘要}。 - 三个调用点(
:402embed、:417stream、: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=<issue #10 真实样本体>)。 client.chat(...)抛RequestRejectedError(400 不重试不换源,行为不变)。recorder.close()后查SELECT error FROM llm_calls:该行error串含样本体里的InvalidParameter与结尾的invalid_parameter_error。
真实样本体(取自 issue #10 原文,一字不改):
{"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: 合并前全量门
make lint(ruff + import-linter)→ 零违规,重点确认新建transports/_http_errors.py未触发洋葱分层契约。make test全套件 → 全 PASS,覆盖率不低于既有水平。- 派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 Phase 2 硬门):逐条核对 Task 1-6 验收项与本会话工具输出。
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 的提交说明须附"修复前失败、修复后通过"的实际输出。