"""OpenAI 图片接口的错误 → 领域 `Failure`。 ⚠️ **本模块的映射未经官方文档证实。** 用户提供的两份文档(`gpt生图.md` / `gpt图像编辑.md`)只覆盖了请求参数与返回体, **完全没有错误码表**——这与火山那边给了完整错误码表的情况不同。因此这里的映射 建立在 OpenAI 通用错误约定之上:错误体形如 `{"error": {"message", "type", "code", "param"}}`,并以 HTTP 状态码作为主要依据。 **处置策略**:以 **HTTP 状态码为主、`type`/`code` 为辅**。状态码语义是 HTTP 规范 保证的,而 `type`/`code` 的取值集没有文档支撑,凭记忆列一张表只会得到一张看起来 权威、实则可能错漏的映射。故这里对 `type`/`code` 只做**少量高置信度的加细**, 其余一律按状态码归类。 **待补**:拿到官方错误码文档、或在真实调用中观察到实际取值后,回来校正本模块并 更新 patbond-doc 的取证页。在那之前,本文件顶部这段说明不要删。 """ from __future__ import annotations import logging from app.domain.generation import Failure, Retry, UserFacing log = logging.getLogger(__name__) #: 高置信度的 `type` 取值:这几个在 OpenAI 的错误体里语义明确且与状态码一致, #: 加细它们不会引入猜测——它们只是让分类比"仅看状态码"更准。 #: 未列出的 type 一律回落到状态码判定。 _TYPE_OVERRIDES: dict[str, tuple[Retry, UserFacing]] = { # 配额/计费耗尽:等的是充值或下一个周期,重试无用。 "insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED), # 限流:等几秒。 "rate_limit_exceeded": (Retry.BACKOFF, UserFacing.RATE_LIMITED), # 内容被安全策略拒绝:同样的输入必然同样被拒。 "image_generation_user_error": (Retry.NEVER, UserFacing.CONTENT_REJECTED), } #: 同上,但匹配 `code` 字段。 _CODE_OVERRIDES: dict[str, tuple[Retry, UserFacing]] = { "insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED), "rate_limit_exceeded": (Retry.BACKOFF, UserFacing.RATE_LIMITED), "moderation_blocked": (Retry.NEVER, UserFacing.CONTENT_REJECTED), "content_policy_violation": (Retry.NEVER, UserFacing.CONTENT_REJECTED), "invalid_api_key": (Retry.NEVER, UserFacing.SERVER_ERROR), } def classify( http_status: int | None, error_type: str | None = None, error_code: str | None = None, message: str | None = None, ) -> Failure: """把一次失败分类。 :param http_status: `None` 表示网络层失败(连接不上、超时),按可重试处理。 未知情形一律偏向**可退避重试**而非判死:把一次可恢复的抖动判成用户可见的 失败是更糟的错误,而重试预算本身有上限(`max_attempts`)。 """ text = message or "" # provider_code 优先用 code,其次 type——两者都缺时用状态码,保证这个字段 # 永远不为空(它是排查时的第一线索)。 provider_code = error_code or error_type or _status_code_label(http_status) override = _CODE_OVERRIDES.get(error_code or "") or _TYPE_OVERRIDES.get( error_type or "" ) if override: retry, user_facing = override 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: # 429 在 OpenAI 上同时用于「限流」与「配额耗尽」,靠 type/code 区分; # 上面的 override 没命中说明拿不到那两个字段,此时**按限流处理**: # 猜成配额耗尽会让一次本可重试的限流被判死,代价更大。 log.info("429 但无 type/code 可据,按限流处理(http=%s)", http_status) return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, provider_code, text) if http_status in (401, 403): # 密钥无效或无权访问:我方配置问题,重试无用。 return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text) if http_status == 400: # 参数问题**或**内容被拒——OpenAI 两者都用 400。拿不到 type/code 时无法 # 区分,按不可重试处理(两种情形重试同一请求都不会变好),用户侧归 # SERVER_ERROR 而不猜成 CONTENT_REJECTED:错报「内容被拒」会让用户去改 # 一个其实没问题的提示词。 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 与其余一切:可重试。 return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text) def _status_code_label(http_status: int | None) -> str: return f"HTTP_{http_status}" if http_status else "NETWORK_ERROR"