Files
patbond-ai/app/providers/openai/errors.py
T
lixi 541376e9be
CI / ai-gates (push) Successful in 55s
fix(ai): 按官方 Error codes 页重写 OpenAI 错误映射
拿到 doc_temp/gpt错误码.md 后核对,**此前猜的六个键里错了五个**:
rate_limit_exceeded / moderation_blocked / content_policy_violation /
invalid_api_key / image_generation_user_error 在官方文档里都不存在。真实的是
type rate_limit_error + code slow_down、type service_unavailable_error + code
server_is_overloaded、四个 billing 码。唯一对的 insufficient_quota 也是宽 type
而非 code。这段历史留在 errors.py 顶部,作为凭记忆列错误码表代价的记录。

官方表的 13 条全是基础设施与计费,**没有任何内容审核/侵权码**——但图片接口确有
moderation 参数,说明审核存在、只是错误形态未文档化。直接后果:一次侵权或违规
拒绝很可能以普通 400 到达,只带一句人类可读消息。若一律归「服务端错误」,用户会
看到「服务出错了」并反复重试一个注定被拒的提示词。

故用 error.param 的有无做结构化区分(带 param = 参数错,不带 = 按内容被拒),
三条依据:文档的 service_tier 那条明确说参数错会带 param;内容拒绝不指向任何
具体参数;我方已在调用前校验过尺寸/画质/格式,能被 400 拒掉的参数嫌疑本就不大。
这一条是推断,走到时记 WARNING——那是日后校正它的唯一素材。client.py 相应补上
param 的解析。

两处顺带收获:
- 无 code 的 429 按限流处理,现在是**文档支持的默认**(六种 429 里只有「Rate
  limit reached for requests」没有文档化的 code),不再是猜测。
- 文档提醒视频过载已从 429 改判为 503。我们两者都归可重试,故不受影响;加了一条
  测试钉住这个「无影响」。

同一张表按 code 与 type 两个字段分别查:文档把标识列在哪个字段下,不保证响应里
它就在那个字段,查错的代价是把配额耗尽降级成限流、白烧一轮重试预算。

未接 Retry-After 并记录理由:Java 侧退避基线 30 秒、上限 10 分钟,已长于服务商
通常给的值,忽略只会偏慢、不会加剧限流;接它是一次跨语言契约变更。

测试 271 → 284。四道门禁逐条读退出码,全绿。
2026-09-21 15:52:25 +08:00

173 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OpenAI 错误 → 领域 `Failure`。
映射依据:用户提供的 `doc_temp/gpt错误码.md`(官方 Error codes 页)。本模块曾经是
按 HTTP 状态码推断的,拿到该文档后已按实际码名重写——**此前猜的五个码名
(`rate_limit_exceeded`、`moderation_blocked`、`content_policy_violation`、
`invalid_api_key`、`image_generation_user_error`)在官方文档里一个都不存在**,
已全部删除。这段历史留在这里,作为「凭记忆列错误码表」代价的记录。
## ⚠️ 官方错误码表里没有任何业务层失败
文档的 13 条全是**基础设施与计费**:鉴权、IP/地域、限流、余额与限额、服务过载。
**没有内容审核、没有版权/侵权、没有「这个提示词不能生成」。** 但图片接口明确有
`moderation` 参数(`low`/`auto`),说明审核确实存在——只是它的错误形态未被文档化。
这对我们的直接后果:**一次侵权或违规拒绝很可能以一个普通 400 到达**,只带一句
人类可读的消息。若把这种 400 一律归为「服务端错误」,用户会看到「服务出错了」
然后反复重试同一个注定被拒的提示词。故本模块用 `error.param` 做结构化区分,
见 `_classify_bad_request()`。那是**推断,不是文档事实**,已就地标注。
## 未处理的一项:`Retry-After`
文档在 429 与 503 处反复要求遵守 `Retry-After` 响应头。本服务目前**不读它**,
理由是 Java 侧 Worker 的退避阶梯基线已是 30 秒、指数增长、上限 10 分钟,**在绝大
多数情形下已长于服务商给的 `Retry-After`**——也就是我们已经比要求更保守,忽略它
只会让重试偏慢,不会加剧限流。要把它接起来是一次跨语言契约变更(领域 `Failure`
加字段 + `openapi-internal.yaml` 小版本 + Java 侧 DTO 与调度),收益只是延迟优化,
故记在取证页的待办里而不是现在做。
"""
from __future__ import annotations
import logging
from app.domain.generation import Failure, Retry, UserFacing
log = logging.getLogger(__name__)
#: 文档化的错误标识 → 领域处置。
#:
#: **按 `code` 与 `type` 两个字段分别查同一张表**,而不是各维护一张。理由:文档
#: 把某个标识列在哪个字段下,并不保证实际响应里它一定出现在那个字段(`insufficient_quota`
#: 就既被描述为「broader `error.type`」,又很容易出现在 `code` 里)。查错字段的代价
#: 是把配额耗尽降级成限流、白烧一轮重试预算;而两边都查不引入任何**新**词汇——
#: 表里每个键都抄自文档。
_RULES: dict[str, tuple[Retry, UserFacing]] = {
# ---- 计费/额度(官方表 429 一节,文档列在 `code` 下)----
#
# 这四条共同的性质是文档明说的那句:「Retrying billing, spend, or quota errors
# won't restore API access」——重试不会恢复访问,要人去充值或改限额。这正是
# `Retry.QUOTA` 存在的理由:Worker 不该把重试预算花在这上面,应直接终态失败
# 并让用户看到出口。
#
# 预付余额耗尽。
"credit_balance_exhausted": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# 组织月度消费限额(自己设的)。
"organization_spend_limit_exceeded": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# 项目月度消费限额(自己设的)。
"project_spend_limit_exceeded": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# OpenAI 分配的用量限额(不是自己设的,要申请提额)。
"organization_usage_limit_exceeded": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# 文档原文:「The broader `error.type` can still be `insufficient_quota`」
# ——计费类错误的宽类型,具体原因要看 code。
"insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
# ---- 限流 ----
#
# `slow_down` 是「涨得太快」而非「超了额定速率」——文档说即使在 RPM/TPM 限额
# 之内也会触发。两者处置相同(退避后重试),故不再细分。
"slow_down": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
"rate_limit_error": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
# ---- 服务过载 ----
#
# 文档特别提醒:**视频过载已从 429 改判为 503**,且 Python SDK 分别抛
# RateLimitError 与 InternalServerError,只捕一类会漏。我们两者都归可重试,
# 故这次厂商侧的改判对本服务无影响——记下来是为了说明「为什么我们没被影响」。
"server_is_overloaded": (Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE),
"service_unavailable_error": (Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE),
}
def classify(
http_status: int | None,
error_type: str | None = None,
error_code: str | None = None,
message: str | None = None,
error_param: str | None = None,
) -> Failure:
"""把一次失败分类。
判定顺序:`code` → `type` → HTTP 状态码。越具体的依据优先——`code` 是文档明确
列出取值的字段,状态码只是最后的兜底。
:param http_status: `None` 表示网络层失败(连不上、超时),按可重试处理。
:param error_param: 厂商指出的出错参数名。**它的有无是我们区分「参数错」与
「内容被拒」的唯一结构化线索**,见 `_classify_bad_request()`。
"""
text = message or ""
# provider_code 永远不为空——它是排查时的第一线索。
provider_code = error_code or error_type or _status_label(http_status)
# code 优先于 type:文档里 code 比 type 具体(同一个 429 下六种 code 共享
# 少数几个 type)。两者都查同一张表,理由见 `_RULES`。
rule = _RULES.get(error_code or "") or _RULES.get(error_type or "")
if rule:
retry, user_facing = rule
return Failure(retry, user_facing, provider_code, text)
if http_status is None:
return Failure(
Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text
)
if http_status == 429:
# 到这里说明既没有 code 也没有认识的 type。官方表里 429 的六种中,只有
# 「Rate limit reached for requests」没有文档化的 code——故无码的 429
# **按限流处理是文档支持的默认**,不再是一个猜测。
return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, provider_code, text)
if http_status == 400:
return _classify_bad_request(provider_code, text, error_param)
if http_status in (401, 403):
# 401:密钥无效/过期/不属于该组织;403:地域不受支持。
# 两者都是**我方配置问题**,重试无用,且都不该把锅甩给用户的提示词。
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
if 400 < http_status < 500:
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
# 5xx 与一切未知:可重试。把一次可恢复的抖动判死是更糟的错误,而重试预算
# 本身有上限(max_attempts),故偏向重试是有界的。
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text)
def _classify_bad_request(
provider_code: str, text: str, error_param: str | None
) -> Failure:
"""400 的两义区分:参数错 vs 内容被拒。
**这是本模块唯一的推断,不是文档事实。**
依据与理由:
- 文档在 `service_tier` 那条明确写了「as an `invalid_request_error` with
`error.param` set to `service_tier`」——即**参数类错误会带 `param`**。
- 官方错误码表**没有任何内容审核/侵权码**,故一次内容拒绝只能以某种通用
400 到达,且它不指向任何具体参数,`param` 应为空。
- 我方在调用前已用 `capabilities.py` 校验过尺寸、画质、格式,`model`/`n` 由
程序生成。**能活着到达服务商并被 400 拒掉的,参数嫌疑本来就不大。**
故:带 `param` → 参数错(服务端错误);不带 `param` → 按内容被拒处理。
取错的代价是不对称的,这也是选这个方向的理由:把内容拒绝报成「服务出错」会
让用户反复重试同一个注定被拒的提示词;反过来把一个参数 bug 报成「内容可能不
被支持」只是让用户多试一次,而排查者看到的 `provider_code` 与日志仍是准确的。
拿到真实的审核错误形态后,回来把这里换成确定的判定并更新取证页。
"""
if error_param:
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
# 记 warning 而不是静默:这条分支是推断出来的,日志里能看到它被走了多少次、
# 携带的是什么消息,是日后校正它的唯一素材来源。
log.warning(
"400 且无 error.param,按内容被拒处理(推断,非文档事实):code=%s message=%s",
provider_code,
text[:200],
)
return Failure(Retry.NEVER, UserFacing.CONTENT_REJECTED, provider_code, text)
def _status_label(http_status: int | None) -> str:
return f"HTTP_{http_status}" if http_status else "NETWORK_ERROR"