RequestRejectedError 丢掉了 400 的响应体,导致这类失败事后不可诊断 #10

Closed
opened 2026-08-16 16:03:43 +08:00 by iomgaa · 1 comment
Owner

版本

polygateway[redis,postgres,structured]==1.1.2

现象

_status_to_errorpolygateway/transports/openai_compat.py:131-145)已经把响应体
作为 body_text 参数收进来了,_translate_429 那一支也确实用了它
:111-121,靠解析 body 里的 error.type 区分配额耗尽和普通限速)。

但 400 那一支没有用:

if status == 400:
    return RequestRejectedError(f"{source.name} 请求被拒: 400", **ctx)

:144 那条 4xx 兜底同样没有用:

return RequestRejectedError(f"{source.name} 客户端错误: {status}", **ctx)

RequestRejectedErrorpolygateway/errors.py:66)只从基类继承了
source_name / status_code / operation,没有携带响应体的地方。
该模块也没有任何 logger 调用。所以响应体在这一层之后就不存在了
下游拿到的信息只有一句「请求被拒: 400」。

为什么这件事有代价

响应体里的信息量是足够定位问题的。我们用一个故意构造的坏 data URL 撞出来的是:

{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal and cannot be opened","type":"invalid_request_error","param":"","code":"invalid_parameter_error"}}

而实际生产里遇到的那次 400,因为响应体没有被任何地方保存,已经永远查不到原因了

我们的场景:一轮 1050 张医学影像的批处理,其中 1 张在读表格这一步收到 400、
被判定为确定性失败而放弃。事后想知道「这张图到底哪里不合规」,无从查起。

顺带一个可能对库有价值的观察

那次 400 不是确定性的。我们把同一份字节(sha256 核对过一致)重发了 15 次,
15 次全部成功。

旁证:那次调用 prompt_tokenscompletion_tokens 都是 0、耗时 2996 ms,
而同一批 631 次成功调用的最快一次是 7366 ms、没有任何一次低于 5 秒。
说明请求在推理开始之前就被挡了。

我们的部署在模型供应商前面还隔着一个第三方 API 中转服务,
中转服务自己抖动的时候也会回 400,从状态码上和供应商说「你的输入有问题」分不开。

RequestRejectedError 的 docstring 现在写着「不重试不换源」。
对直连供应商的部署这大概是对的;对经中转的部署,这个语义会让偶发抖动变成永久失败。
我们下游已经自己改掉了分类(不再把库的 400 当成确定性失败),
所以这一条不是请求你们改行为,只是把观察同步过来——
如果别的使用方也在中转后面,这个语义可能值得在文档里加一句提醒。

建议的方向(具体怎么做由你们定)

想要的最小能力是:拿到 400 的时候,能知道对方说了什么。

想到两个方向,都不改现有语义:

一、让异常带上截断后的响应体。 库里已经有这个先例——ResultInvalidError
polygateway/errors.py:73)就带着 raw_text。同样给 RequestRejectedError
加一个字段(比如 body_text,截断到几百字符),就能让下游把它记进日志和遥测。

二、在这一层记一条日志。 如果不想动异常的形状,
_status_to_error 里对非 2xx 打一条带响应体的 WARNING 也能解决我们的问题。

我们更倾向第一种,因为异常会被下游写进遥测表,而日志和遥测是两套留存。
但这是你们的接口设计,按你们觉得合适的来。

我们这边现在怎么处理的

不再把库的 RequestRejectedError(400 那一支)当成确定性失败,
让它落进我们自己的兜底分类,重投到失败预算耗尽为止。
这解决了「偶发抖动导致数据丢失」,但不解决「事后查不出原因」——
后者需要上面那个能力。

## 版本 `polygateway[redis,postgres,structured]==1.1.2` ## 现象 `_status_to_error`(`polygateway/transports/openai_compat.py:131-145`)已经把响应体 作为 `body_text` 参数收进来了,`_translate_429` 那一支也确实用了它 (`:111-121`,靠解析 body 里的 `error.type` 区分配额耗尽和普通限速)。 但 400 那一支没有用: ```python if status == 400: return RequestRejectedError(f"{source.name} 请求被拒: 400", **ctx) ``` `:144` 那条 4xx 兜底同样没有用: ```python return RequestRejectedError(f"{source.name} 客户端错误: {status}", **ctx) ``` `RequestRejectedError`(`polygateway/errors.py:66`)只从基类继承了 `source_name` / `status_code` / `operation`,没有携带响应体的地方。 该模块也没有任何 logger 调用。所以**响应体在这一层之后就不存在了**, 下游拿到的信息只有一句「请求被拒: 400」。 ## 为什么这件事有代价 响应体里的信息量是足够定位问题的。我们用一个故意构造的坏 data URL 撞出来的是: ```json {"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal and cannot be opened","type":"invalid_request_error","param":"","code":"invalid_parameter_error"}} ``` 而实际生产里遇到的那次 400,因为响应体没有被任何地方保存,**已经永远查不到原因了**。 我们的场景:一轮 1050 张医学影像的批处理,其中 1 张在读表格这一步收到 400、 被判定为确定性失败而放弃。事后想知道「这张图到底哪里不合规」,无从查起。 ## 顺带一个可能对库有价值的观察 那次 400 **不是确定性的**。我们把同一份字节(sha256 核对过一致)重发了 15 次, 15 次全部成功。 旁证:那次调用 `prompt_tokens` 和 `completion_tokens` 都是 0、耗时 2996 ms, 而同一批 631 次成功调用的最快一次是 7366 ms、没有任何一次低于 5 秒。 说明请求在推理开始之前就被挡了。 我们的部署在模型供应商前面还隔着一个第三方 API 中转服务, **中转服务自己抖动的时候也会回 400**,从状态码上和供应商说「你的输入有问题」分不开。 `RequestRejectedError` 的 docstring 现在写着「不重试不换源」。 对直连供应商的部署这大概是对的;对经中转的部署,这个语义会让偶发抖动变成永久失败。 我们下游已经自己改掉了分类(不再把库的 400 当成确定性失败), 所以**这一条不是请求你们改行为**,只是把观察同步过来—— 如果别的使用方也在中转后面,这个语义可能值得在文档里加一句提醒。 ## 建议的方向(具体怎么做由你们定) 想要的最小能力是:**拿到 400 的时候,能知道对方说了什么。** 想到两个方向,都不改现有语义: **一、让异常带上截断后的响应体。** 库里已经有这个先例——`ResultInvalidError` (`polygateway/errors.py:73`)就带着 `raw_text`。同样给 `RequestRejectedError` 加一个字段(比如 `body_text`,截断到几百字符),就能让下游把它记进日志和遥测。 **二、在这一层记一条日志。** 如果不想动异常的形状, 在 `_status_to_error` 里对非 2xx 打一条带响应体的 WARNING 也能解决我们的问题。 我们更倾向第一种,因为异常会被下游写进遥测表,而日志和遥测是两套留存。 但这是你们的接口设计,按你们觉得合适的来。 ## 我们这边现在怎么处理的 不再把库的 `RequestRejectedError`(400 那一支)当成确定性失败, 让它落进我们自己的兜底分类,重投到失败预算耗尽为止。 这解决了「偶发抖动导致数据丢失」,但不解决「事后查不出原因」—— 后者需要上面那个能力。
Author
Owner

已在 1.2.0 修复并发布。

根因是三条留存通道同时为空:_status_to_error 拿到了 body_text 却只在 429 分支用于类型细分,该模块没有任何 logger 调用,异常类也没有承载响应体的字段。且库的逐次遥测写的是 str(exc)——所以只给异常加字段并不会让响应体进遥测表,两者必须都做。

修复内容:

  • PolyGatewayError 基类新增 body_text 字段,承载非 2xx 响应体的摘要;与 ResultInvalidError.raw_text 分工明确(前者是对方拒绝的理由,后者是 2xx 但内容不可解析时的模型输出)。scope 级错误(GatewayUnavailableError 一族)恒为空串,它们没有单一响应体可言。
  • 同一份摘要同时追加到异常 message 末尾,因此 SQLite/Postgres 遥测的 error 列直接可查,下游不必为此单独埋点。
  • 覆盖范围超出本 issue 报告的 chat 400:chat 的 401·403 / 4xx 兜底 / 5xx / 429 两支(含 insufficient_quota)与 OCR 的全部分支都是同一缺陷的其余分支,一并修了。
  • 摘要口径:先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 2048 字符;超长时保留头 1400 + 尾 600 并记下省略字数——JSON 错误体的 code / request_id 收在尾部,头部硬切会正好切掉向网关方追查时唯一有用的那部分。

关于「那次 400 不是确定性的」这个观察:body_text 现在正是区分它的判据——中转服务抖动的响应体与供应商的 invalid_request_error 在正文上是能分开的,尽管状态码相同。这一点写进了 errors.py 的 docstring。

请升级到 1.2.0。

已在 **1.2.0** 修复并发布。 根因是三条留存通道同时为空:`_status_to_error` 拿到了 `body_text` 却只在 429 分支用于类型细分,该模块没有任何 logger 调用,异常类也没有承载响应体的字段。且库的逐次遥测写的是 `str(exc)`——所以只给异常加字段并不会让响应体进遥测表,两者必须都做。 修复内容: - `PolyGatewayError` 基类新增 `body_text` 字段,承载非 2xx 响应体的摘要;与 `ResultInvalidError.raw_text` 分工明确(前者是对方拒绝的理由,后者是 2xx 但内容不可解析时的模型输出)。scope 级错误(`GatewayUnavailableError` 一族)恒为空串,它们没有单一响应体可言。 - 同一份摘要同时追加到异常 message 末尾,因此 SQLite/Postgres 遥测的 `error` 列直接可查,下游不必为此单独埋点。 - 覆盖范围超出本 issue 报告的 chat 400:chat 的 401·403 / 4xx 兜底 / 5xx / 429 两支(含 `insufficient_quota`)与 OCR 的全部分支都是同一缺陷的其余分支,一并修了。 - 摘要口径:先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 2048 字符;超长时保留头 1400 + 尾 600 并记下省略字数——JSON 错误体的 `code` / `request_id` 收在尾部,头部硬切会正好切掉向网关方追查时唯一有用的那部分。 关于「那次 400 不是确定性的」这个观察:`body_text` 现在正是区分它的判据——中转服务抖动的响应体与供应商的 `invalid_request_error` 在正文上是能分开的,尽管状态码相同。这一点写进了 `errors.py` 的 docstring。 请升级到 1.2.0。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#10