Files
patbond-ai/app/providers/openai/errors.py
T
lixi 8275a04903
CI / ai-gates (push) Successful in 38s
feat(ai): 接入 gpt-image-2 图片生成(文生图 + 图生图)
新增 app/providers/openai/:client 传输(JSON + multipart)、errors 错误映射、
models 厂商 DTO、capabilities 尺寸算术约束、tasks 同步→异步桥、image 图片业务。
装配与配置同步接入,缺 OPENAI_API_KEY 时跳过装配、服务仍可启动。

与火山视频的三处根本差异及其处置:
- 接口同步(无任务 ID、无查询端点)→ SyncTaskRegistry 在本服务内部吸收,
  对外仍是 ADR-023 §4 的两段式提交/轮询。代价:本服务从此持有状态,进程重启
  丢在途任务,且横向扩容前须先解决任务 ID 只在签发进程内有效的问题。
- 产物是 base64 字节而非 URL → 无过期问题,但字节只在内存里。asset_url 放
  data URI 仅为满足领域不变式,绝不能原样写进 media.assets.external_url。
- 图生图走 multipart → 先下载参考图再上传;下载失败与超限按参数错误同步拒绝,
  不变成注定失败的后台任务。

两处需要记住的判断:
- 尺寸上限「3840x2160」文档未说清是逐边还是按像素。逐边理解会禁掉所有高于
  2160 的竖图,而 1024x1536 是文档明示的标准竖图,故必然过严。采用最长边
  ≤3840 且总像素 ≤3840×2160,已在 capabilities.py 与取证页标为推断。
- 两份文档都没有错误码表,故 errors.py 以 HTTP 状态码为主、type/code 为辅,
  顶部标注「未经文档证实」。429 无线索时按限流而非配额耗尽(猜成配额会把可
  重试的限流判死);400 无线索时归服务端错误而非内容被拒(错报会让用户去改
  一个其实没问题的提示词)。

一处死锁已修并回归钉住:SyncTaskRegistry 原用 add_done_callback 记录完成时刻,
而快速完成的任务在注册时已 done,回调便同步地在持有非可重入锁的线程里执行。
改为首次被观察到时惰性记录。

测试 166 → 271(+105)。四道门禁逐条读退出码,全绿。
产物转存仍阻塞于存储决策(ADR-023 §7),图片模型因此未播种进 V7 目录。
2026-09-21 12:10:07 +08:00

106 lines
4.9 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`。
⚠️ **本模块的映射未经官方文档证实。**
用户提供的两份文档(`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"