diff --git a/research-wiki/designs/2026-08-16-issue10-error-body-retention-design.md b/research-wiki/designs/2026-08-16-issue10-error-body-retention-design.md index 28801db..4bce72d 100644 --- a/research-wiki/designs/2026-08-16-issue10-error-body-retention-design.md +++ b/research-wiki/designs/2026-08-16-issue10-error-body-retention-design.md @@ -90,11 +90,36 @@ message 形态:`"{源名} {标签}: {状态码} | {摘要}"`;**摘要为空时 ### 3.4 常量取值 -**机械规则(实现与测试逐字照此)**:`_ERROR_BODY_CAP = 500` 是**最终摘要的总长度上限**,含截断标记。折叠空白后长度 ≤ 500 则原样返回;超出则取前 **499** 字符并追加 `…`,总长恰为 500。 +**机械规则(实现与测试逐字照此)**: -> 这条必须写成算术而非叙述:`text[:500] + "…"`(501)与 `text[:499] + "…"`(500)都能被"截断至 cap 并补 `…`"这句话涵盖,而两者会让测试断言与遥测长度承诺对不上(Codex 审查 2026-08-16 提出)。 +``` +_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限 +_HEAD_CHARS = 1400 +_TAIL_CHARS = 600 -取值理由:Issue 给出的真实样本约 160 字符;既有遥测文本截断常量是 200(`embedding.py:72`、`ocr.py:72`),对错误体偏紧(会切掉稍长的 `error.message`)。500 能容下绝大多数网关错误体,最坏情形(5xx 重试 3 次)每次调用向遥测多写约 1.5KB,`TEXT` 列可承受。 +折叠空白后 len ≤ 2048 → 原样返回 +否则 → s[:1400] + f"…(略 {len(s) - 2000} 字)…" + s[-600:] +``` + +> 规则必须写成算术而非叙述:"截断至 cap 并补标记"能同时被读成总长 2048 与 2049,两者会让测试断言与遥测长度承诺对不上(Codex 审查 2026-08-16 提出)。 + +**头尾保留而非头部硬切**(2026-08-16 调研决策)。截断的对象是**结构化 JSON 错误体**,信息分布头重尾也重:人话(`message`)在前,机器可判的 `type` / `code` / `param` / `request_id` 在后。Issue 给出的真实样本即 `"code":"invalid_parameter_error"` 收尾——头部硬切正好切掉向网关方追查时唯一有用的那部分。省略标记记下**被省略的字符数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。 + +按**字符**而非字节切:多字节字符不会被切成半个(Sentry 曾为按字节切开 issue #1691),且 `error TEXT` 列无定长约束,无需字节口径。 + +### 3.4.1 取值依据:同场景开源实践 + +| 项目 | 场景 | 上限 | 保留策略 | +|---|---|---|---| +| **Kubernetes client-go** `rest/request.go` | **读 HTTP 错误体生成错误信息**(与本设计同构) | `maxUnstructuredResponseTextBytes = 2048` | 头部硬切 | +| OpenAI Python SDK `_exceptions.py` | 异常对象持有 body | **不截断**(内存对象,不落库) | — | +| Sentry Python `strip_string` | 事件写入前 trim | `max_value_length`,2.34.0 前默认 1024 | 头部 + `...`,另用 metadata 记原长 | +| Elastic APM | 长字段 | keyword 1024 / long field 10000 | 截断带省略号 | +| Python 标准库 `reprlib` | 给人读的长字符串 | `maxstring` | **头 + 尾,中间省略** | + +**2048 对齐 k8s client-go**——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。初稿的 500 仅以 issue 的单个样本(约 160 字符)为据,是拿一个样本定上限,已废弃。头部硬切在 k8s/Sentry 成立是因为它们截的是任意文本;本设计截的是结构化 JSON,故取 `reprlib` 的头尾策略。 + +遥测代价:纯 ASCII 约 2KB/条,纯中文最多约 6KB/条;5xx 重试 3 次即一次调用最多约 18KB。批处理场景(1050 次调用、5% 失败)约 300KB,`TEXT` 列可忽略。 message 与 `body_text` **共用同一变量**,不设两个长度:两份不同长度会让"遥测里看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。 @@ -107,6 +132,7 @@ message 与 `body_text` **共用同一变量**,不设两个长度:两份不同 | `transports/openai_compat.py` | `_status_to_error` 表驱动重写;`_translate_429` 收 `ctx` | | `transports/monkey_ocr.py` | `_classify_status` 带摘要 | | `errors.py` docstring + `ARCHITECTURE.md` §6.2 | 中转拓扑下 400 的提醒(§5.2) | +| `README.md:34` | 安装 pin `==1.1.*` → `>=1.2,<2`(§5.3,发布前置,漏改则下游拿不到本修复) | 三个调用点(`openai_compat.py:402` embed、`:417` stream、`:509` 非流式)**签名不变**,无需改动。 @@ -122,7 +148,7 @@ message 与 `body_text` **共用同一变量**,不设两个长度:两份不同 ### 4.3 幂等与重复 -纯函数,同输入同输出。`summarize_body` 对自身输出再调用一次是幂等的:输出总长恒 ≤ 500(§3.4)且不含需折叠的空白,第二次调用走"原样返回"分支,不会出现 `…` 被反复追加。 +纯函数,同输入同输出。`summarize_body` 对自身输出再调用一次是幂等的:输出总长恒为 `2000 + len(标记) ≤ 2048`(标记形如 `…(略 N 字)…`,8 + N 的位数,现实中远不足 48),且不含需折叠的空白,故第二次调用走"原样返回"分支,不会出现标记被反复嵌套。 ### 4.4 持久化与原子性 @@ -151,7 +177,9 @@ Issue 报告了一个有说服力的观察:同字节 15 次重发全部成功、 `LLMResponse` 一族的"字段只增不删不改名"约束(ARCHITECTURE §5.1)同样适用于异常。本次是**纯新增关键字参数且带默认值**:既有构造点、既有 `except` 写法、既有 `str(exc)` 消费方全部不受影响。message 文本变化不构成破坏——现有测试对这些 message 无格式依赖(仅 `test_openai_compat.py:558` match 源名)。 -版本建议 **1.2.0**(公共类型新增字段属 minor)。 +版本 **1.2.0**(公共类型新增字段属 minor;2026-08-16 人类定夺)。 + +**发布时必须同步改 README 的安装 pin**:`README.md:34` 现为 `"polygateway[redis,postgres,structured]==1.1.*"`,发 1.2.0 后照此命令安装的下游会**静默停在 1.1.2**——无报错、无警告,与 CLAUDE.md §4.4.1 点名的"极易漏改"完全同款(registry 长期停在 1.0.5 即此类事故)。本次改为 **`>=1.2,<2`**,把"每发一个 minor 就要通知三个下游改 pin"这一反复出现的麻烦一次性消除。此项列入实现计划的发布前置步骤,不是发布日的临时动作。 ### 5.4 有意不夹带的两项(建议单开 issue) @@ -188,8 +216,10 @@ Issue 的中转 400 场景确实指向这个方向,但当前只有一个使用 |---|---|---| | 1 | **端到端遥测**:mock transport 返回 400 + 真实样本体 → 断言 recorder 收到的 `error` 含摘要 | G1 | | 2 | 参数化状态码(400 / 401 / 404 兜底 / 429 普通 / 429 `insufficient_quota` / 500)→ 断言 message 含摘要且 `exc.body_text` 非空,**分类与既有断言逐一不变** | G2, G4 | -| 3 | 超长体 → `len(summary) == 500` 且以 `…` 结尾,前 499 字符与原文前 499 字符逐字相同;`body_text` 与 message 中的摘要逐字相同 | G3 | -| 3b | 长度恰为 500 / 501 的体 → 前者原样不带 `…`,后者截为 500 带 `…`(边界) | §3.4 | +| 3 | 超长体 → 前 1400 字符与原文头部逐字相同、**末 600 字符与原文尾部逐字相同**、中段为 `…(略 N 字)…` 且 N 等于实际省略数;`body_text` 与 message 中的摘要逐字相同 | G3 | +| 3b | 长度恰为 2048 / 2049 的体 → 前者原样无标记,后者走头尾保留(边界) | §3.4 | +| 3c | **尾部关键字段可见**:以 issue 的真实样本尾部 `"code":"invalid_parameter_error"}}` 构造超长体 → 断言该串出现在摘要中 | §3.4 头尾决策的验收 | +| 3d | 摘要对自身幂等(再摘要一次不嵌套标记) | §4.3 | | 4 | 多行缩进 JSON → 折叠为单行 | G3 | | 5 | 空体 / 纯空白体 → 不拼悬空分隔符,`body_text == ""` | §3.3 | | 6 | 非 JSON 体、非 UTF-8 字节 → 不抛异常,分类不变 | §4.2 | @@ -199,8 +229,10 @@ Issue 的中转 400 场景确实指向这个方向,但当前只有一个使用 `tests/unit/test_errors.py:29` 现有的"四类构造形态"参数化用例需扩展 `body_text` 默认值断言(默认 `""`、可传入、`GatewayUnavailableError` 一族恒空)。 -## 8. 待人类确认的点 +## 8. 人类定夺记录(2026-08-16) -1. `_ERROR_BODY_CAP = 500` 是否合适(§3.4)——它直接决定遥测表增量。 -2. 429 不设例外、一律拼摘要(§3.3)——高频限速场景下遥测 `error` 列会变长。 -3. 版本定 1.2.0(§5.3)。 +| 议题 | 定夺 | +|---|---| +| 摘要上限与保留策略 | 初稿 500 + 头部硬切被否:上限提至 **2048**(对齐 k8s client-go 同场景先例),策略改为**头 1400 + 尾 600 + 省略字数标记**——人类指出"有用的信息可能只在后半部分",经调研证实 JSON 错误体的 `code`/`request_id` 确实收尾(§3.4、§3.4.1) | +| 429 是否设例外 | **不设**,一律拼摘要(§3.3) | +| 版本 | **1.2.0**,并同步把 README pin 由 `==1.1.*` 改为 `>=1.2,<2`(§5.3) | diff --git a/research-wiki/designs/issue10-error-body-retention.md b/research-wiki/designs/issue10-error-body-retention.md index f8325fe..c05b294 100644 --- a/research-wiki/designs/issue10-error-body-retention.md +++ b/research-wiki/designs/issue10-error-body-retention.md @@ -29,7 +29,7 @@ Issue 建议"给异常加 `body_text` 字段,下游就能记进遥测"——** | 与 `ResultInvalidError.raw_text` 的界限写进 docstring | `body_text` = 非 2xx 的拒绝理由;`raw_text` = 2xx 但不可解析的模型输出。两个"原文字段"不钉死必被混用 | | 新建 `transports/_http_errors.py` 共用摘要口径 | 两 transport 各有分类逻辑(OCR 无 429 细分,有意保留),但摘要必须同一份,否则就是下一个"只修一半" | | `_status_to_error` 改表驱动 | 五分支各拼各的 message,加摘要即五处重复;查表 + 单点拼装后代码更短 | -| 摘要 = 折叠空白 + 总长 ≤ 500(超出取前 499 + `…`) | 折叠是因错误体常是缩进 JSON,拼进 message 会炸成多行;规则写成算术是 Codex 审查所提——"截断至 cap 并补标记"能被读成 500 或 501 | +| 摘要 = 折叠空白 + 总长 ≤ 2048,超出则**保留头 1400 + 尾 600**,中段记省略字数 | 折叠是因错误体常是缩进 JSON,拼进 message 会炸成多行。2048 对齐 k8s client-go 的 `maxUnstructuredResponseTextBytes`(唯一同场景先例);**头尾保留取自 `reprlib`**——JSON 错误体的 `code`/`request_id` 收尾,头部硬切正好切掉向网关追查唯一有用的部分(人类质疑 + 2026-08-16 调研,初稿的 500 + 头部硬切已废) | | 429 不设例外 | 例外就是下一个复发点;`insufficient_quota` 那支的配额细节全在 body 里 | | 400 治理语义不动,只补文档 | 见下 | @@ -57,4 +57,6 @@ Issue 给出有力证据(同字节 15 次重发全成功、`prompt_tokens=0`、2 ## 验收主张 -一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要——这条端到端断言是本设计成立与否的唯一硬判据,其余用例为覆盖性(状态码参数化、截断边界 500/501、空白折叠、空体不拼悬空分隔符、非 UTF-8 不炸、流式路径、OCR 路径含 `ResponseNotRead` 降级)。 +一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要——这条端到端断言是本设计成立与否的唯一硬判据,其余用例为覆盖性(状态码参数化、截断边界 2048/2049、**尾部关键字段可见**、空白折叠、空体不拼悬空分隔符、非 UTF-8 不炸、流式路径、OCR 路径含 `ResponseNotRead` 降级)。 + +**发布约束**:版本 1.2.0,且 README 安装 pin 必须由 `==1.1.*` 改为 `>=1.2,<2`——否则照 README 安装的下游静默停在 1.1.2,拿不到本修复。