Compare commits
17 Commits
v1.1.2
...
17dcff41c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 17dcff41c3 | |||
| fa4a7e220b | |||
| 658086e2c0 | |||
| 9dada0be9d | |||
| a3f4cc323f | |||
| 0edb9d397a | |||
| 484900d300 | |||
| e302247022 | |||
| 1489aab95d | |||
| c2dd4a1cf4 | |||
| 1801289277 | |||
| 7462cad166 | |||
| 3cbe8aab91 | |||
| 707f8f7317 | |||
| 10fbc5441e | |||
| 114fc8b1b3 | |||
| 4f1ab21562 |
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 1.2.0(2026-08-16)
|
||||
|
||||
网关拒绝一次调用时,**它说的话不再丢失**(issue #10)。下游一轮 1050 张医学影像的批处理里,1 张在读表格这一步收到 400、被判确定性失败而放弃;事后想知道"这张图到底哪里不合规",无从查起——响应体在 transport 翻译层之后就不存在于进程任何位置了。
|
||||
|
||||
根因是三条留存通道同时为空: `_status_to_error` 手上握着 `body_text` 却只用于 429 的类型细分,该模块没有任何 logger 调用,异常类也没有承载响应体的字段。而库的逐次遥测写的是 `str(exc)`,即 message——所以**只给异常加字段并不能让它进遥测表**,必须两者都做。
|
||||
|
||||
### 新增
|
||||
|
||||
- **四分类错误新增 `body_text` 字段**(加在 `PolyGatewayError` 基类): 非 2xx 响应体的摘要。与 `ResultInvalidError.raw_text` 分工明确——前者是"对方拒绝的理由"(非 2xx),后者是"2xx 但内容不可解析时的模型输出"。scope 级错误(`GatewayUnavailableError` 一族)恒为空串: 它们没有单一响应体可言。
|
||||
- **同一份摘要同时进入异常 message**,故 SQLite/Postgres 遥测的 `error` 列里直接可查,下游不必为此单独埋点。
|
||||
|
||||
### 行为变更
|
||||
|
||||
- **非 2xx 的 message 末尾追加 ` | {响应体摘要}`**,覆盖两个 transport 的**全部**分支: chat 的 400 / 401·403 / 4xx 兜底 / 5xx / 429 两支(含 `insufficient_quota`),以及 OCR 的全部分支。issue 只报告了 chat 的 400,但 401 会 `force_open` 整个源、OCR 侧 message 原本只有一个状态码,是同一个缺陷的其余分支。
|
||||
- 摘要口径: 先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 **2048 字符**(对齐 Kubernetes client-go 同场景的 `maxUnstructuredResponseTextBytes`)。超长时**保留头 1400 + 尾 600**并记下省略字数——JSON 错误体的 `code` / `request_id` 收在尾部,头部硬切正好会切掉向网关方追查时唯一有用的那部分。
|
||||
- 遥测 `error` 列因此变长: 纯 ASCII 约 2KB/条,最坏(5xx 重试 3 次)一次调用约 6KB。
|
||||
|
||||
### 不变
|
||||
|
||||
- **状态码 → 错误分类的映射逐条未动**(ARCHITECTURE §6.2 表),`retry_after_s` 解析、429 免重试预算、`insufficient_quota` 细分全部保持——429 的类型判定仍解析**未截断的原文**,若改用摘要,超长 body 的配额耗尽会退化成普通限速、该源不再 `force_open`。
|
||||
- 异常类型树、`str(exc)` 之外的字段、遥测 22 字段与列序、DDL 全部未变。**错误面零变更**,下游 `except` 写法不受影响。
|
||||
- 400 仍按确定性失败处理(不重试不换源)。**但请注意**: 经第三方中转部署时,中转自身抖动也会回 400,从状态码上与"你的输入有问题"分不开(下游实测: 同一份字节 sha256 一致、重发 15 次全部成功,失败那次 `prompt_tokens=0` 且耗时远低于任何成功调用)。库不改默认语义——直连供应商时重试只会白烧配额——但 `body_text` 现在给了下游自行区分的判据。
|
||||
|
||||
### 升级提示
|
||||
|
||||
README 的安装 pin 由 `==1.1.*` 改为 `>=1.2,<2`。**仍按 `==1.1.*` 安装的下游会静默停在 1.1.2**,拿不到本次修复且没有任何报错,请同步改自己的依赖约束。
|
||||
|
||||
- 打包元数据补齐: `readme` 与 `[project.urls]`。1.1.2 及之前的包在 registry 页面上**没有任何说明正文**(缺 `readme` 时 twine 只警告不阻塞),也没有仓库链接。代码零变更,自本版生效。
|
||||
|
||||
## 1.1.2(2026-08-07)
|
||||
|
||||
Postgres 遥测撞上建表权限就整体判死的问题(issue #9)。**最小权限部署会静默丢掉全部遥测**: 应用账号有表级 `INSERT`、表也已存在,但没有 schema 的 `CREATE` 权限时,初始化的 `CREATE TABLE IF NOT EXISTS` 被拒 → recorder 永久 no-op,业务调用一切正常,只留一行 warning。下游 CHSAnalyzer3 首次端到端跑的 150+ 次调用耗时/token/成本因此全部丢失,且事后无法补回。
|
||||
|
||||
@@ -96,6 +96,10 @@ make ci # 只读验证(check + test)
|
||||
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
|
||||
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
|
||||
| 8 | **验证已发布** | `pip download --no-deps --index-url .../pypi/simple/ "polygateway==X.Y.Z"`,并解包确认新代码在内。**不验证不算发布完成** |
|
||||
| 9 | **建 Release + 核对包页面** | `POST /api/v1/repos/iomgaa/PolyGateway/releases`(body 取 CHANGELOG 本版段;历史上只打 tag 不建 release,Releases 页长期为空);随后打开包页面确认有正文与仓库链接,**Link to a repository 只能在网页手动做**(该实例的 link API 返 404) |
|
||||
|
||||
> [!CRITICAL]
|
||||
> **发布完成的判据是外部可见结果,不是本地步骤跑通**: 收尾必须以下游视角逐一打开产物页面(registry 包页面正文与仓库链接、仓库 Releases 页、`pip install` 后包内文件),看到什么算什么,缺的当场补进本清单——1.1.2 三步全绿却出现包页面空白(`pyproject` 缺 `readme`)、Releases 页 0 条、包未挂仓库。
|
||||
|
||||
Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI 元数据不含仓库字段,故不会自动挂到 `PolyGateway/packages`,需在包页面手动 Link to a repository。
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
```bash
|
||||
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||
"polygateway[redis,postgres,structured]==1.1.*"
|
||||
"polygateway[redis,postgres,structured]>=1.2,<2"
|
||||
```
|
||||
|
||||
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||
@@ -125,6 +125,8 @@ except RequestRejectedError:
|
||||
|
||||
预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。
|
||||
|
||||
**网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。
|
||||
|
||||
### 哪些异常会到达调用方
|
||||
|
||||
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
|
||||
|
||||
+9
-1
@@ -4,8 +4,11 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "polygateway"
|
||||
version = "1.1.2"
|
||||
version = "1.2.0"
|
||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
||||
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"httpx>=0.27",
|
||||
@@ -31,6 +34,11 @@ dev = [
|
||||
"import-linter>=2.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.iomgaa.online/iomgaa/PolyGateway"
|
||||
Changelog = "https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/CHANGELOG.md"
|
||||
Issues = "https://gitea.iomgaa.online/iomgaa/PolyGateway/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
|
||||
@@ -396,6 +396,10 @@ flowchart TB
|
||||
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) |
|
||||
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
|
||||
|
||||
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
|
||||
|
||||
**400 在中转拓扑下的语义提醒**(同上): 第三方 API 中转服务自身抖动时也会回 400,从状态码上与供应商的"输入非法"无法区分(下游实测: 同一份字节重发 15 次全成功,失败那次 `prompt_tokens=0`、耗时远低于任何成功调用,即请求在推理开始前被挡)。本表**不改** 400 → `RequestRejectedError` 的映射——直连供应商时重试只会白烧配额,且改默认语义等于让所有直连用户为一种部署形态买单;库改为把判据(`body_text`)交给下游自行区分。
|
||||
|
||||
### 6.3 "坏结果 ≠ 坏服务"(ResultInvalidError 语义,继承 CHSAnalyzer)
|
||||
|
||||
由输入内容决定的**确定性失败**(这张图就是解析不出表格、这段输出就是修不成 JSON):服务是健康的,换源重试只会白烧配额。因此熔断器记成功、不换源、异常上抛消耗业务侧的失败预算。出处:`CHSAnalyzer governance.py:237-239`。
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# HTTP 错误响应体留存设计(Issue #10)
|
||||
|
||||
- **日期**: 2026-08-16
|
||||
- **来源**: Gitea Issue #10(下游 1050 张医学影像批处理,1 张收到 400 被判确定性失败;事后无从查证原因。基于 1.1.2 源码核查)
|
||||
- **状态**: **已批准(2026-08-16)**,待 `writing-plans`
|
||||
- **触发档位**: 强制(`errors.py` 属最内层内核,新增公共字段即变更库对下游的承诺)
|
||||
- **方案范围**: 人类明确要求单一方案(2026-08-16),故本文不列平行备选,仅在 §6 记录被否决路线及否决理由(体例沿用 Issue #7/#8 设计)
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
| | 内容 |
|
||||
|---|---|
|
||||
| **G1** | 网关拒绝一次调用时,**它说了什么必须可事后查证**——库自己的遥测表里就能查到,不依赖下游额外埋点 |
|
||||
| **G2** | 留存口径覆盖 transport 层**全部**非 2xx 分支与**全部** transport(chat / embedding / stream / OCR),杜绝"只修 400 → 下次 401 复发" |
|
||||
| **G3** | 摘要文本单点规范化(折叠空白 + 截断 + 截断标记),message 与结构化字段**取同一份串**,两处永不打架 |
|
||||
| **G4** | 不改变任何状态码 → 错误分类的映射(ARCHITECTURE §6.2 表原封不动),下游 `except` 写法零影响 |
|
||||
| **非目标** | 不改 400 的治理语义(不重试不换源,见 §5.2);不新增遥测列(见 §6.1);不新增配置项;不做错误分类可插拔(见 §6.4);不顺手修 `_status_to_error` 的 `operation` 硬编码缺陷(见 §5.4) |
|
||||
|
||||
### 1.1 Issue 前提的两处修正(按 1.1.2 源码核实)
|
||||
|
||||
| Issue 原文 | 实际情况 |
|
||||
|---|---|
|
||||
| 建议方向一「让异常带上截断后的响应体……就能让下游把它记进日志和遥测」 | **只做这一半解决不了 Issue 自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`middleware/retry.py:558` → `middleware/telemetry.py:89` → `telemetry/sqlite.py:43` 的 `error TEXT` 列),即**异常 message**。新增字段不会进库的遥测表;下游说的"写进遥测表"是他们自己的埋点。故本设计**两件都做,且以 message 为主**(§3.3) |
|
||||
| 缺陷范围 = 400 分支 + 4xx 兜底 | 实为 **6 处同构**:`openai_compat._status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 的两支(读了 body 判 `insufficient_quota`,但 message 仍不带),以及 `monkey_ocr._classify_status:74-88` 的**全部**分支(message 只有 `HTTP {status}`)。Issue 场景是"读表格",极可能正落在 OCR 路径 |
|
||||
|
||||
## 2. 根因:诊断信息在翻译层被丢弃,而遥测只看 message
|
||||
|
||||
`_status_to_error`(`transports/openai_compat.py:131-143`)手上握着 `body_text`,却只把它用于 429 的类型细分,翻出的异常与 message 都不携带它。响应体在这一层之后**不再存在于进程任何位置**:该模块无 logger(grep `logger|loguru` 零命中),异常类无字段,遥测只写 message。
|
||||
|
||||
三条留存通道同时为空,是"永久查不到"的完整解释:
|
||||
|
||||
| 通道 | 现状 | 本设计后 |
|
||||
|---|---|---|
|
||||
| 日志 | 模块无 logger | 仍无(§6.2:不加日志) |
|
||||
| 异常字段 | 无承载处 | `body_text`(§3.1) |
|
||||
| 库遥测 `error` 列 | 只有 `"{源名} 请求被拒: 400"` | message 携带摘要(§3.3) |
|
||||
|
||||
## 3. 选定方案
|
||||
|
||||
### 3.1 内核:`PolyGatewayError` 基类新增 `body_text`
|
||||
|
||||
```python
|
||||
class PolyGatewayError(Exception):
|
||||
def __init__(self, message, *, source_name=None, status_code=None,
|
||||
operation=None, body_text: str = "") -> None:
|
||||
```
|
||||
|
||||
**加在基类而非 `RequestRejectedError`**:这些错误全部由同一个 HTTP 响应翻译而来,"对方说了什么"与"它属于哪一类"正交。只给一个子类加,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 一次人类门。
|
||||
|
||||
与既有 `ResultInvalidError.raw_text`(`errors.py:77`)的界限必须在 docstring 钉死,否则两个"原文字段"必然被混用:
|
||||
|
||||
| 字段 | 语义 | 来源 |
|
||||
|---|---|---|
|
||||
| `body_text` | **非 2xx** 的 HTTP 错误响应体摘要——对方**拒绝**你的理由 | transport 翻译层 |
|
||||
| `raw_text` | **2xx** 但内容不可解析时的模型输出原文 | 结构化解析层 |
|
||||
|
||||
`GatewayUnavailableError` 一族继承到一个恒空的 `body_text` 不是噪音:scope 级失败本就"没有单一响应体可言",空串是对这件事的如实表达。
|
||||
|
||||
### 3.2 共享单元:`transports/_http_errors.py`(新建,~40 行)
|
||||
|
||||
两个 transport 各有自己的状态码分类逻辑(OCR 无 429 细分,有意保留,见 `monkey_ocr.py:53-54`),但**摘要口径必须同一份**,否则就是下一个"只修一半"。两函数:
|
||||
|
||||
| 函数 | 职责 | 关键防御 |
|
||||
|---|---|---|
|
||||
| `summarize_body(text) -> str` | 折叠空白 → 按 §3.4 的机械规则截断 | 空/空白入参返回 `""` |
|
||||
| `response_body(response) -> str` | 从 `httpx.Response` 取已缓冲文本 | `ResponseNotRead` → 返回 `""`,**绝不触发网络读** |
|
||||
|
||||
- **折叠空白不是洁癖**:错误体常是缩进 JSON,直接拼进 message 会让一行日志炸成多行、遥测列不可读。
|
||||
- **截断必须留标记**:不标记,读的人分不清"网关只说了这么多"和"库切的"。
|
||||
- **`response_body` 的防御是硬要求**:`monkey_ocr._classify_status` 只拿得到 `httpx.HTTPStatusError`,若某天 OCR 走 stream 请求,`.text` 会抛 `ResponseNotRead`,把一次可分类的 4xx 变成泄漏的 httpx 异常——**违反"一切失败必须落入四分类"铁律**。诊断信息缺失绝不能升级为崩溃(降级方向,§4.2)。
|
||||
|
||||
放在 `transports/` 私有模块而非 `errors.py`:职责是"HTTP 响应 → 领域错误"的工具,放内核会稀释 `errors.py` 的单一职责(P3)。两个 transport 同 import 一个私有模块,不构成 transport 之间的互相依赖,import-linter 的 layers 契约(同层 `|` 独立性)不受影响。
|
||||
|
||||
### 3.3 翻译层:表驱动收口,message 与字段共用一份摘要
|
||||
|
||||
`_status_to_error` 现在是五个分支各拼各的 message,新增摘要意味着五处重复。改为**分类表 + 单点拼装**,代码反而变短:
|
||||
|
||||
```
|
||||
summary = summarize_body(body_text) # 全函数只算一次
|
||||
ctx = {..., "body_text": summary} # 字段
|
||||
429 → _translate_429(source, body_text, headers, ctx) # 需原文判 type,单列
|
||||
其余 → cls, label = _STATUS_MAP 查表 → cls(_compose(source, label, status, summary), **ctx)
|
||||
```
|
||||
|
||||
message 形态:`"{源名} {标签}: {状态码} | {摘要}"`;**摘要为空时不拼后缀**,避免出现悬空的 ` | `。分隔符取 ` | ` 而非既有的 `: `,让"库的话"与"网关的话"一眼可分。
|
||||
|
||||
**429 也拼,不设例外**:例外就是下一个复发点。`insufficient_quota` 那支尤其需要(配额细节全在 body 里);普通限速 body 通常很短。代价是高频限速场景遥测 `error` 列变长,由 `_ERROR_BODY_CAP` 兜住。
|
||||
|
||||
`monkey_ocr._classify_status` 同款处理:`summary = summarize_body(response_body(exc.response))`,message 追加同一后缀,`ctx` 带上字段。
|
||||
|
||||
### 3.4 常量取值
|
||||
|
||||
**机械规则(实现与测试逐字照此)**:
|
||||
|
||||
```
|
||||
_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限
|
||||
_HEAD_CHARS = 1400
|
||||
_TAIL_CHARS = 600
|
||||
|
||||
折叠空白后 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 到的"对不上,排查时反而多一层困惑。
|
||||
|
||||
### 3.5 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `errors.py` | 基类新增 `body_text` 字段 + 与 `raw_text` 的界限 docstring |
|
||||
| `transports/_http_errors.py` | **新建**:`summarize_body` / `response_body` / `_ERROR_BODY_CAP` |
|
||||
| `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` 非流式)**签名不变**,无需改动。
|
||||
|
||||
## 4. 非功能维度
|
||||
|
||||
### 4.1 并发与取消
|
||||
|
||||
新增全部是纯函数与数据字段,无状态、无锁、无 IO、不引入 `await`。`response_body` 只读已缓冲字节,`ResponseNotRead` 时直接返回空串而**不发起网络读**——否则会在错误路径上凭空插入一次可能挂住的 IO。`CancelledError` 路径逐字不变。
|
||||
|
||||
### 4.2 降级方向
|
||||
|
||||
响应体不可得(未读缓冲 / 解码失败 / 空体)→ `body_text=""`,**静默降级,绝不报错**。诊断信息属可观测性,按库铁律与缓存/遥测同档:缺了降级,不得把一次本可正确分类的失败变成不可分类的崩溃。流式路径的 `(await resp.aread()).decode("utf-8", errors="replace")`(`:416`)已是这个口径,保持。
|
||||
|
||||
### 4.3 幂等与重复
|
||||
|
||||
纯函数,同输入同输出。`summarize_body` 对自身输出再调用一次是幂等的:输出总长恒为 `2000 + len(标记) ≤ 2048`(标记形如 `…(略 N 字)…`,8 + N 的位数,现实中远不足 48),且不含需折叠的空白,故第二次调用走"原样返回"分支,不会出现标记被反复嵌套。
|
||||
|
||||
### 4.4 持久化与原子性
|
||||
|
||||
不新增表、不改 DDL、不动遥测端口的 22 字段与列序。摘要经既有 `error TEXT` 列落盘,原子性由既有单行写入保证。
|
||||
|
||||
### 4.5 安全与体积
|
||||
|
||||
- **响应体可能回显请求内容**(部分网关的 `error.param` 会带违规字段值)。截断 + 空白折叠是主要止血手段;字段 docstring 须写明"可能包含请求回显,已截断"。库不做内容脱敏——库不知道下游哪些字段敏感,猜测式脱敏只会同时丢掉诊断价值与安全性。
|
||||
- **本设计不放大既有的读取风险**:`_complete_stream:416` 的 `aread()` 对错误响应体无大小上限(超大错误体可打爆内存),该风险今天已经存在(读完即丢),留存后只是更显眼。**不夹带修复**,见 §5.4。
|
||||
|
||||
## 5. 错误处理、语义与边界
|
||||
|
||||
### 5.1 错误分类
|
||||
|
||||
不改任何映射。`body_text` 是**旁路数据**,不参与任何治理判定——不影响重试、换源、熔断计数、AIMD、限流结算。这是本设计能与 ARCHITECTURE §6.1/§6.2 零冲突的根本原因。
|
||||
|
||||
### 5.2 400 语义:不改行为,补文档
|
||||
|
||||
Issue 报告了一个有说服力的观察:同字节 15 次重发全部成功、`prompt_tokens=0`、耗时 2996ms 远低于同批 631 次成功调用的最快值 7366ms——说明那次 400 来自中转服务自身抖动,而非"你的输入有问题"。
|
||||
|
||||
**仍不改分类**:400 重试对直连供应商是纯浪费(确定性坏输入,重试只烧配额并拖延失败);"中转也回 400"是**部署拓扑**引入的信息损失,库从状态码无从分辨。默认改为可重试 = 让所有直连用户为一种部署形态买单,且推翻已冻结的公共契约。
|
||||
|
||||
**但本设计本身就是对这个观察最好的答复**:body 留存后,下游能自己区分——中转抖动的 400 体与供应商 `invalid_request_error` 体形态不同。库不替下游做判断,而是把判断所需的信息交出去。配套文档动作:`RequestRejectedError` docstring 与 ARCHITECTURE §6.2 各加一句"经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类"。
|
||||
|
||||
### 5.3 兼容性
|
||||
|
||||
`LLMResponse` 一族的"字段只增不删不改名"约束(ARCHITECTURE §5.1)同样适用于异常。本次是**纯新增关键字参数且带默认值**:既有构造点、既有 `except` 写法、既有 `str(exc)` 消费方全部不受影响。message 文本变化不构成破坏——现有测试对这些 message 无格式依赖(仅 `test_openai_compat.py:558` match 源名)。
|
||||
|
||||
版本 **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)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| `_status_to_error` 的 `operation` 硬编码 `"chat"`(`:134`),而 `embed()` 也调它(`:402`) | embedding 的 HTTP 错误在遥测里被标成 `operation="chat"`,是既有数据正确性缺陷,与本 issue 无关 |
|
||||
| `_complete_stream:416` 的 `aread()` 无大小上限 | 恶意/故障网关的超大错误体可打爆内存,属独立的健壮性问题 |
|
||||
|
||||
两项都在本次重构触及的函数附近,但修它们既不服务 G1-G4,也各自需要独立的行为讨论——按反 gold-plating 铁律留给独立 issue。
|
||||
|
||||
## 6. 被否决的路线
|
||||
|
||||
### 6.1 给遥测端口加一列(22 → 23 字段)
|
||||
|
||||
最"正统"的结构化留存,但成本极不相称:端口 Protocol 签名变更 + SQLite/Postgres 双后端 DDL 迁移 + 下游已有表的 ALTER + 列序契约测试全线改动——为一个诊断串付出一次跨三项目的迁移。而复用既有 `error TEXT` 列可达成同样的可查证性。
|
||||
|
||||
### 6.2 只在 `_status_to_error` 打一条 WARNING 日志(Issue 方向二)
|
||||
|
||||
不采纳为**主**手段:日志与遥测是两套留存,日志轮转后仍然查不到,而 Issue 的痛点恰是"事后"。且库铁律要求库不擅自向下游日志流写入高频内容(4xx/5xx 在批处理下可能极高频)。message 携带摘要已让 loguru 侧的下游在捕获点自然拿到同一份信息,再加一条独立日志属重复留存。
|
||||
|
||||
### 6.3 截断放在异常构造器内
|
||||
|
||||
构造器自动规范化更"防遗漏",但会让下游自建异常时传入的文本被悄悄改写,违反 P4;且 message 里的摘要仍需在翻译层单独算一次,反而出现两条规范化路径。选定方案在翻译层算一次、两处共用,更简且更显式。
|
||||
|
||||
### 6.4 错误分类映射可插拔(provider profile 注入 classifier)
|
||||
|
||||
Issue 的中转 400 场景确实指向这个方向,但当前只有一个使用方且他们已用自己的兜底分类解决。`ProviderProfile`(`providers.py:17-45`)目前也没有这个扩展点,加它是新子系统级的设计。YAGNI:等第二个使用方提出。
|
||||
|
||||
## 7. 测试策略(先失败后通过)
|
||||
|
||||
**验收主张**:一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要。这条端到端断言直接对应 Issue 的痛点,是本设计成立与否的唯一硬判据;其余为覆盖性用例。
|
||||
|
||||
| # | 用例 | 覆盖 |
|
||||
|---|---|---|
|
||||
| 1 | **端到端遥测**:mock transport 返回 400 + 真实样本体 → 断言 recorder 收到的 `error` 含摘要 | G1 |
|
||||
| 2 | 参数化状态码(400 / 401 / 404 兜底 / 429 普通 / 429 `insufficient_quota` / 500)→ 断言 message 含摘要且 `exc.body_text` 非空,**分类与既有断言逐一不变** | G2, G4 |
|
||||
| 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 |
|
||||
| 7 | 流式错误路径(`_complete_stream` 415-417)同样带摘要 | G2 |
|
||||
| 8 | `monkey_ocr._classify_status` 同款(含 `ResponseNotRead` 时降级为空串而非抛出) | G2, §4.2 |
|
||||
| 9 | embedding 路径(`:402`)HTTP 错误带摘要 | G2 |
|
||||
|
||||
`tests/unit/test_errors.py:29` 现有的"四类构造形态"参数化用例需扩展 `body_text` 默认值断言(默认 `""`、可传入、`GatewayUnavailableError` 一族恒空)。
|
||||
|
||||
## 8. 人类定夺记录(2026-08-16)
|
||||
|
||||
| 议题 | 定夺 |
|
||||
|---|---|
|
||||
| 摘要上限与保留策略 | 初稿 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) |
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue10-error-body-retention
|
||||
title: "HTTP 错误响应体留存(Issue #10)"
|
||||
date: 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:552` → `telemetry.py` → `sqlite.py` 的 `error 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_error` 的 `operation` 硬编码 `"chat"`,而 `embed()` 也调它 → embedding 的 HTTP 错误在遥测里被标成 chat。
|
||||
- `_complete_stream` 的 `aread()` 对错误响应体无大小上限,超大错误体可打爆内存(既有风险,留存后更显眼)。
|
||||
|
||||
两项都在本次触及的函数附近,但均不服务本 issue 目标,且各需独立行为讨论。
|
||||
|
||||
## 验收主张
|
||||
|
||||
一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要——这条端到端断言是本设计成立与否的唯一硬判据,其余用例为覆盖性(状态码参数化、截断边界 2048/2049、**尾部关键字段可见**、空白折叠、空体不拼悬空分隔符、非 UTF-8 不炸、流式路径、OCR 路径含 `ResponseNotRead` 降级)。
|
||||
|
||||
**发布约束**:版本 1.2.0,且 README 安装 pin 必须由 `==1.1.*` 改为 `>=1.2,<2`——否则照 README 安装的下游静默停在 1.1.2,拿不到本修复。
|
||||
@@ -150,6 +150,16 @@
|
||||
"id": "plan:issue8-stall-budget-plan",
|
||||
"label": "issue #8 实施计划: stall 非生产性等待口径",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:issue10-error-body-retention",
|
||||
"label": "HTTP 错误响应体留存(Issue #10)",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:issue10-error-body-retention-plan",
|
||||
"label": "实现计划: HTTP 错误响应体留存(Issue #10)",
|
||||
"type": "plan"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
@@ -271,6 +281,13 @@
|
||||
"relation": "implements",
|
||||
"evidence": "T1-T6 实施该设计,含 §3.6 订正",
|
||||
"added": "2026-08-06T14:58:01.673693+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:issue10-error-body-retention-plan",
|
||||
"target": "design:issue10-error-body-retention",
|
||||
"relation": "implements",
|
||||
"evidence": "7 任务覆盖设计 G1-G4 与 §7 全部验收用例",
|
||||
"added": "2026-08-16T09:50:57.830855+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
# Research Wiki 索引
|
||||
|
||||
> 自动生成,更新时间:2026-08-07 15:11 UTC
|
||||
> 自动生成,更新时间:2026-08-16 09:50 UTC
|
||||
|
||||
## design (26)
|
||||
## design (28)
|
||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||
@@ -15,9 +15,11 @@
|
||||
- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design`
|
||||
- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design`
|
||||
- [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design`
|
||||
- [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design`
|
||||
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
||||
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||
- [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention`
|
||||
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
||||
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||
@@ -44,7 +46,7 @@
|
||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||
|
||||
## plan (21)
|
||||
## plan (23)
|
||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||
@@ -55,6 +57,7 @@
|
||||
- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params`
|
||||
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
|
||||
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
|
||||
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
|
||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||
@@ -63,6 +66,7 @@
|
||||
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
||||
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
||||
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
||||
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||
|
||||
@@ -97,3 +97,8 @@
|
||||
- [2026-08-07 15:20 UTC] 新增 design: 建表前先探测,判死只认「确定写不进去」 (design:issue9-telemetry-ddl-probe)
|
||||
- [2026-08-07 15:20 UTC] 重建索引: 62 篇页面
|
||||
- [2026-08-07 15:11 UTC] 重建索引: 62 篇页面
|
||||
- [2026-08-16 09:09 UTC] 新增 design: HTTP 错误响应体留存(Issue #10) (design:issue10-error-body-retention)
|
||||
- [2026-08-16 09:12 UTC] 重建索引: 64 篇页面
|
||||
- [2026-08-16 09:50 UTC] 新增 plan: 实现计划: HTTP 错误响应体留存(Issue #10) (plan:issue10-error-body-retention-plan)
|
||||
- [2026-08-16 09:50 UTC] 新增边: plan:issue10-error-body-retention-plan --implements--> design:issue10-error-body-retention
|
||||
- [2026-08-16 09:50 UTC] 重建索引: 66 篇页面
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# 实现计划: 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 的提交说明须附"修复前失败、修复后通过"的实际输出。
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:issue10-error-body-retention-plan
|
||||
title: "实现计划: HTTP 错误响应体留存(Issue #10)"
|
||||
date: 2026-08-16
|
||||
---
|
||||
|
||||
# 实现计划: HTTP 错误响应体留存(Issue #10)
|
||||
|
||||
**全文**: `plans/2026-08-16-issue10-error-body-retention.md`|**实现**: [[design:issue10-error-body-retention]]|**分支**: `feat/issue-10-error-body-retention`
|
||||
|
||||
## 任务分解
|
||||
|
||||
| # | 任务 | 产出 |
|
||||
|---|---|---|
|
||||
| 1 | 内核字段 | `PolyGatewayError.body_text`(默认空串)+ 与 `raw_text` 的界限 docstring |
|
||||
| 2 | 共享摘要单元 | 新建 `transports/_http_errors.py`:`summarize_body` / `compose_message` / `response_body` |
|
||||
| 3 | openai_compat 收口 | `_status_to_error` 表驱动;429 判类型仍读原文 |
|
||||
| 4 | monkey_ocr 收口 | `_classify_status` 同款,含 `ResponseNotRead` 降级 |
|
||||
| 5 | **端到端验收** | 400 调用后 SQLite `error` 列含摘要——本计划的硬判据 |
|
||||
| 6 | 文档与版本 | 1.2.0、README pin `>=1.2,<2`、CHANGELOG、ARCHITECTURE §6.2 |
|
||||
| 7 | 合并前门 | lint + 全套件 + 全新上下文 verifier |
|
||||
|
||||
依赖:1‖2 → 3‖4 → 5 → 6 → 7。
|
||||
|
||||
## 计划期钉死的两条实现红线
|
||||
|
||||
1. **429 判 `insufficient_quota` 必须解析未截断原文**,不得改用摘要——摘要会破坏 JSON,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`,把诊断改进变成治理 bug。Task 3.4 有专门回归用例。
|
||||
2. **message 主体逐字保持 1.1.2 原样**,只在末尾追加摘要后缀;状态码→分类映射逐条不变,验收要求既有分类断言零改写。
|
||||
|
||||
## 保真校验
|
||||
|
||||
不涉及 `reference/` 迁移。错误分类映射不变,保真体现为"既有分类断言全部保留"。
|
||||
|
||||
## 执行期观察
|
||||
|
||||
Task 计划提交时 pre-commit 钩子的全套件跑出现一次 `tests/e2e/test_compat_projects.py::TestGovDocOnboarding::test_call_site_shape_runs_governed` 失败,单独跑与 e2e 全目录跑(7 passed / 23.30s,每例 2-3.5s)均通过,重跑全套件亦通过 → 判为真实网关抖动,非回归。该用例正是 [[design:issue8-stall-budget]] 当年记录的三个漂移用例之一,e2e 打真实网关的固有 flaky 面仍在。
|
||||
@@ -32,7 +32,7 @@ from polygateway.types import (
|
||||
SourceConfig,
|
||||
)
|
||||
|
||||
__version__ = "1.1.2"
|
||||
__version__ = "1.2.0"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROFILES",
|
||||
|
||||
@@ -35,7 +35,26 @@ SOURCE_REASONS = frozenset(
|
||||
|
||||
|
||||
class PolyGatewayError(Exception):
|
||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。"""
|
||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
|
||||
|
||||
`body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。
|
||||
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
|
||||
|
||||
============== ==================================================
|
||||
``body_text`` **非 2xx** 的 HTTP 错误响应体: 对方**拒绝**的理由
|
||||
``raw_text`` **2xx** 但内容不可解析时的模型输出原文
|
||||
============== ==================================================
|
||||
|
||||
加在基类而非某个子类,是因为这些错误全部由同一个 HTTP 响应翻译而来——
|
||||
"对方说了什么"与"它属于哪一类"正交。scope 级错误(`GatewayUnavailableError`
|
||||
一族)继承到的恒空值不是噪音,而是"没有单一响应体可言"的如实表达。
|
||||
|
||||
**内容已由 transport 层截断**(`transports/_http_errors.summarize_body`),
|
||||
且可能包含网关对请求的回显——库不做脱敏: 它不知道下游哪些字段敏感,
|
||||
猜测式脱敏只会同时丢掉诊断价值与安全性。
|
||||
|
||||
本字段是**旁路数据**,不参与任何治理判定(重试/换源/熔断计数/限流结算)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -44,11 +63,13 @@ class PolyGatewayError(Exception):
|
||||
source_name: str | None = None,
|
||||
status_code: int | None = None,
|
||||
operation: str | None = None,
|
||||
body_text: str = "",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.source_name = source_name
|
||||
self.status_code = status_code
|
||||
self.operation = operation
|
||||
self.body_text = body_text
|
||||
|
||||
|
||||
class TransientError(PolyGatewayError):
|
||||
@@ -64,7 +85,16 @@ class SourceDeadError(PolyGatewayError):
|
||||
|
||||
|
||||
class RequestRejectedError(PolyGatewayError):
|
||||
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。"""
|
||||
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。
|
||||
|
||||
**经中转部署时请注意**(issue #10 下游实测): 第三方 API 中转服务自身抖动
|
||||
时也会回 400,从状态码上与供应商说"你的输入有问题"无法区分。下游曾观测到
|
||||
同一份字节(sha256 一致)重发 15 次全部成功,且失败那次 `prompt_tokens=0`、
|
||||
耗时远低于任何成功调用——请求在推理开始前就被挡了。本库仍按确定性失败处理
|
||||
(对直连供应商而言重试只会白烧配额),批处理场景的下游宜自备兜底分类;
|
||||
`body_text` 即为此提供判据: 中转抖动的响应体与供应商的 `invalid_request_error`
|
||||
形态不同。
|
||||
"""
|
||||
|
||||
|
||||
class ResultInvalidError(PolyGatewayError):
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""HTTP 错误响应体的取用与摘要(issue #10 设计 §3.2)。
|
||||
|
||||
两个 transport 各有自己的状态码分类逻辑(OCR 有意不做 429 细分),但**摘要口径
|
||||
必须是同一份**——issue #10 的教训正是"只有一个分支用了响应体",一处例外就是
|
||||
下一次事后查不到原因。故本模块是全库唯一的摘要实现,不得在别处复制。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
_ERROR_BODY_CAP = 2048
|
||||
"""摘要总长上限(**字符**,含省略标记在内)。
|
||||
|
||||
取值对齐 Kubernetes client-go `rest/request.go` 的 `maxUnstructuredResponseTextBytes
|
||||
= 2048`——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。按字符而非
|
||||
字节切,多字节字符不会被切成半个;`error` 列是 TEXT,无定长约束,不需要字节口径。
|
||||
"""
|
||||
|
||||
_HEAD_CHARS = 1400
|
||||
_TAIL_CHARS = 600
|
||||
|
||||
|
||||
def summarize_body(text: str) -> str:
|
||||
"""折叠空白后按头尾策略摘要;空/空白入参返回空串。
|
||||
|
||||
**折叠空白**不是洁癖: 错误体常是缩进 JSON,原样拼进 message 会把一行日志
|
||||
炸成多行、把遥测列变得不可读。
|
||||
|
||||
**保头保尾**而非头部硬切: 截断的对象是结构化 JSON,信息分布头重尾也重——
|
||||
人话(`message`)在前,机器可判的 `type`/`code`/`param`/`request_id` 在后。
|
||||
k8s/Sentry 用头部硬切是因为它们截的是任意文本;本函数截的是错误 JSON,
|
||||
头部硬切正好切掉向网关方追查时唯一有用的那部分。策略取自标准库 `reprlib`
|
||||
对"给人读的长字符串"的处置。
|
||||
|
||||
**标记记下省略字数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。
|
||||
"""
|
||||
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:
|
||||
"""取**已缓冲**的响应文本;未读缓冲一律降级空串。
|
||||
|
||||
绝不在此触发网络读: 那会在错误路径上凭空插入一次可能挂住的 IO。降级方向
|
||||
与缓存/遥测同档(库铁律)——诊断信息缺失不得把一次本可正确分类的失败变成
|
||||
不可分类的崩溃,那正是 `ResponseNotRead` 泄漏出四分类之外的后果。
|
||||
"""
|
||||
try:
|
||||
return response.text
|
||||
except httpx.ResponseNotRead:
|
||||
return ""
|
||||
@@ -24,6 +24,11 @@ from polygateway.errors import (
|
||||
SourceDeadError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.transports._http_errors import (
|
||||
compose_message,
|
||||
response_body,
|
||||
summarize_body,
|
||||
)
|
||||
from polygateway.types import (
|
||||
OcrLayoutElement,
|
||||
OcrLayoutTransportResult,
|
||||
@@ -74,13 +79,20 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
|
||||
def _classify_status(
|
||||
exc: httpx.HTTPStatusError, source_name: str, operation: str
|
||||
) -> TransientError | SourceDeadError | RequestRejectedError:
|
||||
"""HTTP 状态码 → 错误四分类,**全部分支**携带响应体摘要(issue #10)。
|
||||
|
||||
分类映射本身零变更;摘要口径与 chat 侧共用同一实现,不得在此另起一份——
|
||||
"只有一个分支用了响应体"正是 issue #10 的成因。
|
||||
"""
|
||||
status = exc.response.status_code
|
||||
summary = summarize_body(response_body(exc.response))
|
||||
ctx: dict[str, Any] = {
|
||||
"source_name": source_name,
|
||||
"status_code": status,
|
||||
"operation": operation,
|
||||
"body_text": summary,
|
||||
}
|
||||
message = f"{source_name} OCR {operation} HTTP {status}"
|
||||
message = compose_message(f"{source_name} OCR {operation} HTTP {status}", summary)
|
||||
if status >= 500 or status == 429:
|
||||
return TransientError(message, **ctx)
|
||||
if status in (401, 403):
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import httpx
|
||||
|
||||
from polygateway.errors import (
|
||||
PolyGatewayError,
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
@@ -30,6 +31,7 @@ from polygateway.providers import (
|
||||
resolve_thinking,
|
||||
)
|
||||
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -107,40 +109,59 @@ def _parse_retry_after(raw: str | None) -> float | None:
|
||||
return seconds if seconds > 0 else None
|
||||
|
||||
|
||||
def _translate_429(source: SourceConfig, body_text: str, headers: Mapping[str, str]) -> Exception:
|
||||
def _translate_429(
|
||||
source: SourceConfig, body_text: str, headers: Mapping[str, str], ctx: dict[str, Any]
|
||||
) -> Exception:
|
||||
"""429 细分。`body_text` 必须是**未截断的原文**——`ctx["body_text"]` 是摘要,
|
||||
头尾保留会破坏 JSON 结构,拿它解析会让超长 body 的配额耗尽退化成普通限速
|
||||
(该源不再 force_open),把一个诊断改进变成治理 bug(issue #10 实现红线)。
|
||||
"""
|
||||
try:
|
||||
err_type = json.loads(body_text).get("error", {}).get("type", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
err_type = ""
|
||||
summary = ctx["body_text"]
|
||||
if err_type == "insufficient_quota":
|
||||
return SourceDeadError(
|
||||
f"{source.name} 配额耗尽(insufficient_quota)",
|
||||
source_name=source.name,
|
||||
status_code=429,
|
||||
operation="chat",
|
||||
compose_message(f"{source.name} 配额耗尽(insufficient_quota)", summary), **ctx
|
||||
)
|
||||
return TransientError(
|
||||
f"{source.name} 限速: 429",
|
||||
compose_message(f"{source.name} 限速: 429", summary),
|
||||
retry_after_s=_parse_retry_after(headers.get("retry-after")),
|
||||
source_name=source.name,
|
||||
status_code=429,
|
||||
operation="chat",
|
||||
**ctx,
|
||||
)
|
||||
|
||||
|
||||
def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
|
||||
"""状态码 → (错误类, message 标签);映射与 ARCH §6.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:
|
||||
ctx: dict[str, Any] = {"source_name": source.name, "status_code": status, "operation": "chat"}
|
||||
if status in (401, 403):
|
||||
return SourceDeadError(f"{source.name} 凭据失效/欠费: {status}", **ctx)
|
||||
if status == 400:
|
||||
return RequestRejectedError(f"{source.name} 请求被拒: 400", **ctx)
|
||||
"""非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
|
||||
|
||||
摘要只算一次,message 与 `body_text` 共用同一份串: 两份不同长度会让"遥测里
|
||||
看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
|
||||
"""
|
||||
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)
|
||||
if status >= 500:
|
||||
return TransientError(f"{source.name} 瞬时错误: {status}", **ctx)
|
||||
return RequestRejectedError(f"{source.name} 客户端错误: {status}", **ctx)
|
||||
return _translate_429(source, body_text, headers, ctx)
|
||||
cls, label = _classify(status)
|
||||
return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
|
||||
|
||||
|
||||
def _strip_think(content: str) -> tuple[str, str]:
|
||||
|
||||
@@ -11,7 +11,12 @@ import sqlite3
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from polygateway import CircuitOpenError, GatewayClient, TransientError
|
||||
from polygateway import (
|
||||
CircuitOpenError,
|
||||
GatewayClient,
|
||||
RequestRejectedError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.backends.memory.breaker import InMemoryGate
|
||||
from polygateway.backends.memory.cache import InMemoryCache
|
||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||
@@ -173,6 +178,40 @@ class TestTelemetryAcrossPaths:
|
||||
assert len({cid for _, cid in rows}) == 2 # call_id 逐次独立
|
||||
|
||||
|
||||
class TestRejectionReasonIsQueryable:
|
||||
"""issue #10 的验收主张: 400 之后,网关说的话必须能在遥测表里查到。
|
||||
|
||||
下游一轮 1050 张影像的批处理里,1 张在读表格时收到 400 被判确定性失败,
|
||||
事后"这张图到底哪里不合规"无从查起——响应体在 transport 翻译层就没了。
|
||||
"""
|
||||
|
||||
# issue #10 原文给出的真实响应体(一字不改)
|
||||
_BODY = (
|
||||
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
|
||||
'and cannot be opened","type":"invalid_request_error","param":"",'
|
||||
'"code":"invalid_parameter_error"}}'
|
||||
)
|
||||
|
||||
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
client = _full_client(
|
||||
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
|
||||
)
|
||||
|
||||
with pytest.raises(RequestRejectedError):
|
||||
await client.chat([{"role": "user", "content": "hi"}])
|
||||
|
||||
recorder.close()
|
||||
rows = sqlite3.connect(tmp_path / "t.db").execute("SELECT error FROM llm_calls").fetchall()
|
||||
assert rows, "400 必须留下遥测行(遥测必录)"
|
||||
errors = " ".join(r[0] or "" for r in rows)
|
||||
# 修复前这里只有 "qwen_1 请求被拒: 400"——诊断信息一个字都不在
|
||||
assert "InvalidParameter" in errors
|
||||
assert "The image format is illegal" in errors
|
||||
# 尾部的 code 才是向网关方追查的凭据,头部硬切正好会丢掉它
|
||||
assert "invalid_parameter_error" in errors
|
||||
|
||||
|
||||
class TestStructuredThroughStack:
|
||||
async def test_feedback_reask_passes_through_governance(self):
|
||||
"""重问经过内层治理: 第二次真实请求同样被限流/熔断记账。"""
|
||||
|
||||
@@ -34,6 +34,43 @@ class TestBaseShape:
|
||||
assert TransientError("429", retry_after_s=2.5).retry_after_s == 2.5
|
||||
|
||||
|
||||
class TestBodyText:
|
||||
"""issue #10: 非 2xx 的响应体摘要必须有承载处,否则拒绝理由事后不可查。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cls", (PolyGatewayError, TransientError, SourceDeadError, RequestRejectedError)
|
||||
)
|
||||
def test_defaults_empty_and_accepts_summary(self, cls):
|
||||
assert cls("boom").body_text == ""
|
||||
assert cls("boom", body_text='{"error":{"code":"bad"}}').body_text == (
|
||||
'{"error":{"code":"bad"}}'
|
||||
)
|
||||
|
||||
def test_result_invalid_keeps_both_fields_apart(self):
|
||||
"""`body_text`(非 2xx 的拒绝理由)与 `raw_text`(2xx 的不可解析输出)不得混用。"""
|
||||
exc = ResultInvalidError("bad json", raw_text="{oops", body_text="")
|
||||
assert exc.raw_text == "{oops"
|
||||
assert exc.body_text == ""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
(
|
||||
AllSourcesExhausted(scope="llm", reason="stalled", retry_after_s=1.0),
|
||||
CircuitOpenError(scope="llm", retry_after_s=1.0),
|
||||
GovernanceBackendError("redis down", scope="llm"),
|
||||
),
|
||||
)
|
||||
def test_scope_level_errors_carry_no_body(self, exc):
|
||||
"""scope 级失败没有单一响应体可言,空串是如实表达而非噪音。"""
|
||||
assert exc.body_text == ""
|
||||
|
||||
def test_body_text_does_not_leak_into_str(self):
|
||||
"""字段是旁路数据: 加了它不得改变任何既有异常的 str() 输出。"""
|
||||
assert str(RequestRejectedError("qwen_1 请求被拒: 400", body_text="whatever")) == (
|
||||
"qwen_1 请求被拒: 400"
|
||||
)
|
||||
|
||||
|
||||
class TestResultInvalid:
|
||||
def test_carries_diagnosis(self):
|
||||
exc = ResultInvalidError(
|
||||
@@ -134,9 +171,7 @@ class TestGovernanceBackendReason:
|
||||
assert "governance_backend_down" in SCOPE_REASONS
|
||||
|
||||
def test_gateway_unavailable_accepts_the_new_reason(self):
|
||||
exc = AllSourcesExhausted(
|
||||
scope="LLM", reason="governance_backend_down", retry_after_s=0.0
|
||||
)
|
||||
exc = AllSourcesExhausted(scope="LLM", reason="governance_backend_down", retry_after_s=0.0)
|
||||
assert exc.reason == "governance_backend_down"
|
||||
|
||||
def test_retry_after_default_is_non_zero(self):
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""HTTP 错误响应体摘要口径(issue #10 设计 §3.2/§3.4)。
|
||||
|
||||
摘要是 message 与 `body_text` 共用的**同一份串**,故它的边界行为直接决定
|
||||
遥测里看到的与下游 catch 到的是否一致——本组用例把规则钉成算术。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from polygateway.transports._http_errors import (
|
||||
_ERROR_BODY_CAP,
|
||||
_HEAD_CHARS,
|
||||
_TAIL_CHARS,
|
||||
compose_message,
|
||||
response_body,
|
||||
summarize_body,
|
||||
)
|
||||
|
||||
# issue #10 原文给出的真实响应体(一字不改),关键在于 code 收尾
|
||||
_REAL_SAMPLE = (
|
||||
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
|
||||
'and cannot be opened","type":"invalid_request_error","param":"",'
|
||||
'"code":"invalid_parameter_error"}}'
|
||||
)
|
||||
|
||||
|
||||
class TestSummarizeBody:
|
||||
def test_short_body_passes_through(self):
|
||||
assert summarize_body(_REAL_SAMPLE) == _REAL_SAMPLE
|
||||
|
||||
def test_whitespace_collapsed(self):
|
||||
"""错误体常是缩进 JSON: 不折叠会把一行日志炸成多行、遥测列不可读。"""
|
||||
assert (
|
||||
summarize_body('{\n "error": {\n "code": "x"\n }\n}')
|
||||
== '{ "error": { "code": "x" } }'
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("raw", ("", " ", "\n\t \n"))
|
||||
def test_blank_yields_empty(self, raw):
|
||||
assert summarize_body(raw) == ""
|
||||
|
||||
def test_exactly_at_cap_is_untouched(self):
|
||||
body = "x" * _ERROR_BODY_CAP
|
||||
assert summarize_body(body) == body
|
||||
|
||||
def test_one_over_cap_is_summarized(self):
|
||||
summary = summarize_body("x" * (_ERROR_BODY_CAP + 1))
|
||||
assert summary != "x" * (_ERROR_BODY_CAP + 1)
|
||||
assert "略" in summary
|
||||
|
||||
def test_head_and_tail_both_survive(self):
|
||||
"""头部硬切会丢掉尾部,而 JSON 错误体的 code/request_id 正在尾部。"""
|
||||
body = "H" * 5000 + "T" * 5000
|
||||
summary = summarize_body(body)
|
||||
assert summary[:_HEAD_CHARS] == body[:_HEAD_CHARS]
|
||||
assert summary[-_TAIL_CHARS:] == body[-_TAIL_CHARS:]
|
||||
assert f"…(略 {10000 - _HEAD_CHARS - _TAIL_CHARS} 字)…" in summary
|
||||
|
||||
def test_real_sample_tail_visible_in_oversized_body(self):
|
||||
"""设计 §7 用例 3c: 超长体里,追查网关方所需的 code 仍须可见。"""
|
||||
summary = summarize_body("PADDING" * 1000 + _REAL_SAMPLE)
|
||||
assert '"code":"invalid_parameter_error"}}' in summary
|
||||
|
||||
def test_idempotent(self):
|
||||
"""再摘要一次不得嵌套标记,否则重复经手的串会层层套娃。"""
|
||||
once = summarize_body("y" * 9999)
|
||||
assert summarize_body(once) == once
|
||||
|
||||
|
||||
class TestComposeMessage:
|
||||
def test_empty_summary_leaves_message_intact(self):
|
||||
assert compose_message("qwen_1 请求被拒: 400", "") == "qwen_1 请求被拒: 400"
|
||||
|
||||
def test_non_empty_summary_is_appended(self):
|
||||
assert compose_message("qwen_1 请求被拒: 400", "{}") == "qwen_1 请求被拒: 400 | {}"
|
||||
|
||||
|
||||
class TestResponseBody:
|
||||
def test_reads_buffered_text(self):
|
||||
assert response_body(httpx.Response(400, content=b'{"e":1}')) == '{"e":1}'
|
||||
|
||||
def test_unread_stream_degrades_to_empty(self):
|
||||
"""取不到诊断信息绝不能升级为崩溃: 未读缓冲返回空串,且不触发网络读。"""
|
||||
|
||||
class _Unread(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"body"
|
||||
|
||||
assert response_body(httpx.Response(400, stream=_Unread())) == ""
|
||||
@@ -19,7 +19,11 @@ from polygateway.errors import (
|
||||
SourceDeadError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.transports.monkey_ocr import MonkeyOcrTransport, _parse_middle_json
|
||||
from polygateway.transports.monkey_ocr import (
|
||||
MonkeyOcrTransport,
|
||||
_classify_status,
|
||||
_parse_middle_json,
|
||||
)
|
||||
from polygateway.types import SourceConfig
|
||||
|
||||
|
||||
@@ -306,6 +310,45 @@ class TestErrorTranslation:
|
||||
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
|
||||
assert ei.value.status_code == status
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "exc_type"),
|
||||
[
|
||||
(502, TransientError),
|
||||
(429, TransientError), # 与 5xx 共用分支,但仍单列: 漏分支正是 issue #10 的成因
|
||||
(401, SourceDeadError),
|
||||
(404, RequestRejectedError),
|
||||
],
|
||||
)
|
||||
async def test_body_survives_every_branch(self, status, exc_type):
|
||||
"""issue #10: OCR 侧 message 原本只有 HTTP 状态码,拒绝理由同样丢失。"""
|
||||
body = '{"detail":"unsupported image mode CMYK"}'
|
||||
t = _transport_for(_routes(text_resp=httpx.Response(status, content=body.encode())))
|
||||
with pytest.raises(exc_type) as ei:
|
||||
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
|
||||
assert ei.value.body_text == body
|
||||
assert str(ei.value).endswith(f" | {body}")
|
||||
|
||||
def test_unread_body_degrades_without_changing_class(self):
|
||||
"""取不到 body 时降级空串: 绝不能让 ResponseNotRead 逃出错误四分类。
|
||||
|
||||
直接测纯函数而非走 MockTransport——真实客户端对非 stream 请求总会读完
|
||||
响应,未读态只可能在将来给 OCR 加 stream 时出现,而那正是要防的场景。
|
||||
"""
|
||||
|
||||
class _Unread(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"body"
|
||||
|
||||
exc = httpx.HTTPStatusError(
|
||||
"404",
|
||||
request=httpx.Request("POST", "http://ocr.example/ocr/text"),
|
||||
response=httpx.Response(404, stream=_Unread()),
|
||||
)
|
||||
err = _classify_status(exc, "monkey_1", "text")
|
||||
assert isinstance(err, RequestRejectedError)
|
||||
assert err.body_text == ""
|
||||
assert str(err) == "monkey_1 OCR text HTTP 404"
|
||||
|
||||
async def test_connect_error_transient(self):
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("refused", request=request)
|
||||
|
||||
@@ -16,6 +16,7 @@ from polygateway.errors import (
|
||||
)
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.pricing import ModelPrice, PricingTable
|
||||
from polygateway.transports._http_errors import summarize_body
|
||||
from polygateway.transports.openai_compat import (
|
||||
OpenAICompatTransport,
|
||||
_iter_sse_deltas,
|
||||
@@ -691,6 +692,125 @@ class TestErrorTranslation:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
|
||||
|
||||
# issue #10 原文给出的真实响应体(一字不改): 关键在于 code 收尾
|
||||
_REJECT_BODY = (
|
||||
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
|
||||
'and cannot be opened","type":"invalid_request_error","param":"",'
|
||||
'"code":"invalid_parameter_error"}}'
|
||||
)
|
||||
|
||||
|
||||
class TestErrorBodyRetention:
|
||||
"""issue #10: 网关说了什么必须活着离开翻译层——message 与字段各留一份。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "exc"),
|
||||
[
|
||||
(400, RequestRejectedError),
|
||||
(401, SourceDeadError),
|
||||
(403, SourceDeadError),
|
||||
(404, RequestRejectedError),
|
||||
(500, TransientError),
|
||||
(503, TransientError),
|
||||
],
|
||||
)
|
||||
async def test_every_non_2xx_branch_keeps_the_body(self, status, exc):
|
||||
def handler(request):
|
||||
return httpx.Response(status, content=_REJECT_BODY.encode())
|
||||
|
||||
with pytest.raises(exc) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert ei.value.body_text == _REJECT_BODY
|
||||
# message 与字段共用同一份串: 遥测里看到的与下游 catch 到的不得打架
|
||||
assert str(ei.value).endswith(f" | {_REJECT_BODY}")
|
||||
assert "invalid_parameter_error" in str(ei.value)
|
||||
|
||||
async def test_rate_limited_429_keeps_body_and_retry_after(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
429,
|
||||
content=b'{"error":{"message":"per-minute cap 3"}}',
|
||||
headers={"retry-after": "2.5"},
|
||||
)
|
||||
|
||||
with pytest.raises(TransientError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert "per-minute cap 3" in str(ei.value)
|
||||
assert ei.value.retry_after_s == 2.5 # 摘要不得干扰既有解析
|
||||
|
||||
async def test_insufficient_quota_429_keeps_body(self):
|
||||
body = json.dumps(
|
||||
{"error": {"type": "insufficient_quota", "message": "daily budget spent"}}
|
||||
)
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(429, content=body.encode())
|
||||
|
||||
with pytest.raises(SourceDeadError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert "daily budget spent" in str(ei.value)
|
||||
|
||||
async def test_oversized_insufficient_quota_still_classified_dead(self):
|
||||
"""实现红线: 类型判定必须读**原文**。
|
||||
|
||||
摘要会破坏 JSON 结构,若改用摘要解析,超长 body 的配额耗尽将退化成普通
|
||||
限速——配额已耗尽的源不再 force_open,一个诊断改进就变成了治理 bug。
|
||||
|
||||
**填充必须是多个键**,不能是单个超长字符串值: 后者的截断点落在字符串
|
||||
*内部*,省略标记成了合法的字符串内容,而头尾保留又让尾部的 error 对象
|
||||
幸存——摘要照样解析得出 `insufficient_quota`,用例即告空转(2026-08-16
|
||||
verifier 变异测试发现: 按错误写法实现,全套件 824 项依然全绿)。多键
|
||||
填充让截断点落在结构记号之间,摘要才真正不可解析。
|
||||
"""
|
||||
body = json.dumps(
|
||||
{**{f"k{i}": "v" * 10 for i in range(300)}, "error": {"type": "insufficient_quota"}}
|
||||
)
|
||||
assert len(body) > 2048
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
# 判别力的前提: 摘要确实不再是合法 JSON,读它必然拿不到 type
|
||||
json.loads(summarize_body(body))
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(429, content=body.encode())
|
||||
|
||||
with pytest.raises(SourceDeadError):
|
||||
await _complete(_transport_for(handler), _source())
|
||||
|
||||
async def test_empty_body_leaves_no_dangling_separator(self):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=b"")
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert str(ei.value) == "qwen_1 请求被拒: 400"
|
||||
assert ei.value.body_text == ""
|
||||
|
||||
@pytest.mark.parametrize("body", [b"<html>gateway down</html>", b"\xff\xfe not utf-8"])
|
||||
async def test_non_json_and_non_utf8_bodies_do_not_explode(self, body):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=body)
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _complete(_transport_for(handler), _source())
|
||||
assert ei.value.status_code == 400 # 分类不受 body 形态影响
|
||||
|
||||
async def test_non_stream_path_keeps_the_body(self):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=_REJECT_BODY.encode())
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _complete(_transport_for(handler), _source(), stream=False)
|
||||
assert ei.value.body_text == _REJECT_BODY
|
||||
|
||||
async def test_embedding_path_keeps_the_body(self):
|
||||
def handler(request):
|
||||
return httpx.Response(400, content=_REJECT_BODY.encode())
|
||||
|
||||
with pytest.raises(RequestRejectedError) as ei:
|
||||
await _transport_for(handler).embed(texts=["hi"], source=_source(), call_id="cid-embed")
|
||||
assert ei.value.body_text == _REJECT_BODY
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
async def test_aclose_idempotent(self):
|
||||
def handler(request):
|
||||
|
||||
Reference in New Issue
Block a user