Files
PolyGateway/research-wiki/plans/2026-08-16-issue10-error-body-retention.md
iomgaa 1489aab95d docs: add the missing imports to the plan's key interfaces
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.
2026-08-16 05:57:07 -04:00

15 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_429ctx
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
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 ""
# 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
# 既有 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_429insufficient_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 / GovernanceBackendErrorbody_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>) → 返回 "" 且不抛(构造法见下)

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

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 追加后缀,ctxbody_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(...)):135SELECT 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.21.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 的提交说明须附"修复前失败、修复后通过"的实际输出。