"""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"