541376e9be
CI / ai-gates (push) Successful in 55s
拿到 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。四道门禁逐条读退出码,全绿。
173 lines
9.2 KiB
Python
173 lines
9.2 KiB
Python
"""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"
|