新增 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 目录。
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user