Files
PolyGateway/research-wiki/designs/issue10-error-body-retention.md
T
iomgaa 7462cad166 docs: widen the body cap to 2048 and keep the tail
The 500-char head-only rule came from a single sample. k8s client-go
caps the same thing at 2048; reprlib keeps head and tail because the
text is meant to be read. Gateway error bodies are JSON whose code and
request_id sit at the very end, so a head-only cut drops exactly what
you need to chase the provider. Version pinned at 1.2.0, which forces
the README install pin off ==1.1.*.
2026-08-16 05:24:30 -04:00

5.5 KiB

type, node_id, title, date
type node_id title date
design design:issue10-error-body-retention HTTP 错误响应体留存(Issue #10) 2026-08-16

HTTP 错误响应体留存(Issue #10)

来源: Gitea issue #10(CHSAnalyzer3 现场,1050 张影像批处理中 1 张 400 被判确定性失败、事后无从查证)|范围: errors.py + 两个 transport|全文: designs/2026-08-16-issue10-error-body-retention-design.md|相关: [design:issue8-stall-budget]

问题

网关拒绝一次调用时,它说的话在 transport 翻译层被丢弃,进程中不再有任何副本:该模块无 logger、异常类无承载字段、库遥测只写 message。三条留存通道同时为空,故"永久查不到"。

根因(Issue 前提的关键修正)

Issue 建议"给异常加 body_text 字段,下游就能记进遥测"——只做这一半解决不了它自己陈述的痛点。库的逐次遥测写的是 error=str(exc)(retry.py:552telemetry.pysqlite.pyerror TEXT 列),即异常 message;新增字段不进库的遥测表。下游说的"写进遥测表"是他们自己的埋点。

缺陷范围也大于 issue 所述:实为 6 处同构——_status_to_error 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,_translate_429 两支(读了 body 判类型却不带),以及 monkey_ocr._classify_status 全部分支(message 只有 HTTP {status})。Issue 场景"读表格"极可能正落在 OCR 路径。

选定方案

摘要在翻译层算一次,同一份串同时进 message 与新增的基类字段——前者解决"事后可查"(走既有遥测列,零 DDL),后者解决下游结构化留存。

决策 理由
字段加在 PolyGatewayError 基类,非 RequestRejectedError 这些错误全由同一个 HTTP 响应翻译而来,"对方说了什么"与"属于哪一类"正交;只加子类,下次给 SourceDeadError 加又是一次公共 API 变更 + 人类门
ResultInvalidError.raw_text 的界限写进 docstring body_text = 非 2xx 的拒绝理由;raw_text = 2xx 但不可解析的模型输出。两个"原文字段"不钉死必被混用
新建 transports/_http_errors.py 共用摘要口径 两 transport 各有分类逻辑(OCR 无 429 细分,有意保留),但摘要必须同一份,否则就是下一个"只修一半"
_status_to_error 改表驱动 五分支各拼各的 message,加摘要即五处重复;查表 + 单点拼装后代码更短
摘要 = 折叠空白 + 总长 ≤ 2048,超出则保留头 1400 + 尾 600,中段记省略字数 折叠是因错误体常是缩进 JSON,拼进 message 会炸成多行。2048 对齐 k8s client-go 的 maxUnstructuredResponseTextBytes(唯一同场景先例);头尾保留取自 reprlib——JSON 错误体的 code/request_id 收尾,头部硬切正好切掉向网关追查唯一有用的部分(人类质疑 + 2026-08-16 调研,初稿的 500 + 头部硬切已废)
429 不设例外 例外就是下一个复发点;insufficient_quota 那支的配额细节全在 body 里
400 治理语义不动,只补文档 见下

400 语义:不改行为,本次修复本身就是答复

Issue 给出有力证据(同字节 15 次重发全成功、prompt_tokens=0、2996ms 远低于同批成功最快的 7366ms),说明那次 400 来自中转服务抖动而非坏输入。仍不改分类:400 重试对直连供应商是纯浪费,而"中转也回 400"是部署拓扑引入的信息损失,库从状态码无从分辨;默认改可重试 = 让所有直连用户为一种部署形态买单。

但 body 留存后下游能自己区分——中转抖动体与供应商 invalid_request_error 体形态不同。库不替下游判断,把判断所需的信息交出去。配套在 docstring 与 ARCHITECTURE §6.2 加一句中转拓扑提醒。

被否决的备选

备选 否决理由
遥测端口加一列(22 → 23 字段) 端口签名变更 + 双后端 DDL + 下游 ALTER + 列序契约全线改动,为一个诊断串付出跨三项目迁移;复用既有 error 列可达成同样可查证性
只打一条 WARNING 日志(issue 方向二) 日志轮转后仍查不到,而痛点恰是"事后";且 4xx/5xx 在批处理下可能极高频
截断放进异常构造器 下游自建异常的文本被悄悄改写(违反 P4),且 message 侧仍需单独算一次,反出现两条规范化路径
错误分类映射可插拔 Issue 场景确实指向它,但当前只有一个使用方且已用自己的兜底分类解决;ProviderProfile 无此扩展点,加它是子系统级设计。YAGNI

有意不夹带(留独立 issue)

  • _status_to_erroroperation 硬编码 "chat",而 embed() 也调它 → embedding 的 HTTP 错误在遥测里被标成 chat。
  • _complete_streamaread() 对错误响应体无大小上限,超大错误体可打爆内存(既有风险,留存后更显眼)。

两项都在本次触及的函数附近,但均不服务本 issue 目标,且各需独立行为讨论。

验收主张

一次 400 调用后,注入的 recorder 收到的 error 串含网关响应体摘要——这条端到端断言是本设计成立与否的唯一硬判据,其余用例为覆盖性(状态码参数化、截断边界 2048/2049、尾部关键字段可见、空白折叠、空体不拼悬空分隔符、非 UTF-8 不炸、流式路径、OCR 路径含 ResponseNotRead 降级)。

发布约束:版本 1.2.0,且 README 安装 pin 必须由 ==1.1.* 改为 >=1.2,<2——否则照 README 安装的下游静默停在 1.1.2,拿不到本修复。