拿到 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。四道门禁逐条读退出码,全绿。
This commit is contained in:
@@ -138,8 +138,11 @@ app/
|
|||||||
|
|
||||||
- **火山不支持幂等键**,「提交超时后不知道有没有扣费」只能由 `max_attempts` 兜住;
|
- **火山不支持幂等键**,「提交超时后不知道有没有扣费」只能由 `max_attempts` 兜住;
|
||||||
- **火山产物 URL 只有 24 小时有效期**,因此厂商 URL 不能当存储用,必须在时限内转存;
|
- **火山产物 URL 只有 24 小时有效期**,因此厂商 URL 不能当存储用,必须在时限内转存;
|
||||||
- **OpenAI 的两份文档都没有错误码表**,故 `providers/openai/errors.py` 的映射是
|
- **OpenAI 的官方错误码表里全是基础设施与计费错误**,**没有任何内容审核/侵权码**
|
||||||
按 HTTP 状态码为主推断的,**未经文档证实**,拿到真实错误数据后须校正。
|
——一次「这个提示词不能生成」只能以通用 400 到达。故 `providers/openai/errors.py`
|
||||||
|
用 `error.param` 的有无区分「参数错」与「内容被拒」,那一条是推断并记 WARNING;
|
||||||
|
- **`Retry-After` 目前不读**:Java 侧退避阶梯基线 30 秒、上限 10 分钟,已长于服务商
|
||||||
|
通常给的值,忽略它只会偏慢、不会加剧限流。接它是一次跨语言契约变更。
|
||||||
|
|
||||||
### 同步型供应商的代价
|
### 同步型供应商的代价
|
||||||
|
|
||||||
|
|||||||
@@ -111,10 +111,10 @@ class OpenAiClient:
|
|||||||
def _unwrap(response: httpx.Response) -> dict[str, Any]:
|
def _unwrap(response: httpx.Response) -> dict[str, Any]:
|
||||||
"""把响应转成 dict;非 2xx 转成带分类的 `OpenAiError`。
|
"""把响应转成 dict;非 2xx 转成带分类的 `OpenAiError`。
|
||||||
|
|
||||||
错误体按 OpenAI 通用约定解析(`{"error": {message, type, code, param}}`)。
|
错误体形状取自官方 Error codes 页:`{"error": {message, type, code, param}}`。
|
||||||
**该形状未经用户提供的文档证实**——两份文档都没有错误码一节,见
|
四个字段**都要取**——`param` 是区分「参数错」与「内容被拒」的唯一结构化
|
||||||
`errors.py` 顶部说明。解析不出这个形状时不静默,按状态码分类并把原始
|
线索(理由见 `errors.py` 的 `_classify_bad_request`)。解析不出这个形状时
|
||||||
文本带上。
|
不静默,按状态码分类并把原始文本带上。
|
||||||
"""
|
"""
|
||||||
if response.is_success:
|
if response.is_success:
|
||||||
try:
|
try:
|
||||||
@@ -130,21 +130,26 @@ class OpenAiClient:
|
|||||||
|
|
||||||
error_type: str | None = None
|
error_type: str | None = None
|
||||||
error_code: str | None = None
|
error_code: str | None = None
|
||||||
|
error_param: str | None = None
|
||||||
message = response.text[:500]
|
message = response.text[:500]
|
||||||
try:
|
try:
|
||||||
error = response.json().get("error") or {}
|
error = response.json().get("error") or {}
|
||||||
error_type = error.get("type")
|
error_type = error.get("type")
|
||||||
error_code = error.get("code")
|
error_code = error.get("code")
|
||||||
|
error_param = error.get("param")
|
||||||
message = error.get("message") or message
|
message = error.get("message") or message
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
failure = classify(response.status_code, error_type, error_code, message)
|
failure = classify(
|
||||||
|
response.status_code, error_type, error_code, message, error_param
|
||||||
|
)
|
||||||
log.warning(
|
log.warning(
|
||||||
"OpenAI 返回错误 http=%s type=%s code=%s retry=%s",
|
"OpenAI 返回错误 http=%s type=%s code=%s param=%s retry=%s",
|
||||||
response.status_code,
|
response.status_code,
|
||||||
error_type,
|
error_type,
|
||||||
error_code,
|
error_code,
|
||||||
|
error_param,
|
||||||
failure.retry.value,
|
failure.retry.value,
|
||||||
)
|
)
|
||||||
raise OpenAiError(failure, response.status_code)
|
raise OpenAiError(failure, response.status_code)
|
||||||
|
|||||||
+123
-56
@@ -1,19 +1,30 @@
|
|||||||
"""OpenAI 图片接口的错误 → 领域 `Failure`。
|
"""OpenAI 错误 → 领域 `Failure`。
|
||||||
|
|
||||||
⚠️ **本模块的映射未经官方文档证实。**
|
映射依据:用户提供的 `doc_temp/gpt错误码.md`(官方 Error codes 页)。本模块曾经是
|
||||||
|
按 HTTP 状态码推断的,拿到该文档后已按实际码名重写——**此前猜的五个码名
|
||||||
|
(`rate_limit_exceeded`、`moderation_blocked`、`content_policy_violation`、
|
||||||
|
`invalid_api_key`、`image_generation_user_error`)在官方文档里一个都不存在**,
|
||||||
|
已全部删除。这段历史留在这里,作为「凭记忆列错误码表」代价的记录。
|
||||||
|
|
||||||
用户提供的两份文档(`gpt生图.md` / `gpt图像编辑.md`)只覆盖了请求参数与返回体,
|
## ⚠️ 官方错误码表里没有任何业务层失败
|
||||||
**完全没有错误码表**——这与火山那边给了完整错误码表的情况不同。因此这里的映射
|
|
||||||
建立在 OpenAI 通用错误约定之上:错误体形如
|
|
||||||
`{"error": {"message", "type", "code", "param"}}`,并以 HTTP 状态码作为主要依据。
|
|
||||||
|
|
||||||
**处置策略**:以 **HTTP 状态码为主、`type`/`code` 为辅**。状态码语义是 HTTP 规范
|
文档的 13 条全是**基础设施与计费**:鉴权、IP/地域、限流、余额与限额、服务过载。
|
||||||
保证的,而 `type`/`code` 的取值集没有文档支撑,凭记忆列一张表只会得到一张看起来
|
**没有内容审核、没有版权/侵权、没有「这个提示词不能生成」。** 但图片接口明确有
|
||||||
权威、实则可能错漏的映射。故这里对 `type`/`code` 只做**少量高置信度的加细**,
|
`moderation` 参数(`low`/`auto`),说明审核确实存在——只是它的错误形态未被文档化。
|
||||||
其余一律按状态码归类。
|
|
||||||
|
|
||||||
**待补**:拿到官方错误码文档、或在真实调用中观察到实际取值后,回来校正本模块并
|
这对我们的直接后果:**一次侵权或违规拒绝很可能以一个普通 400 到达**,只带一句
|
||||||
更新 patbond-doc 的取证页。在那之前,本文件顶部这段说明不要删。
|
人类可读的消息。若把这种 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
|
from __future__ import annotations
|
||||||
@@ -24,25 +35,45 @@ from app.domain.generation import Failure, Retry, UserFacing
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
#: 高置信度的 `type` 取值:这几个在 OpenAI 的错误体里语义明确且与状态码一致,
|
#: 文档化的错误标识 → 领域处置。
|
||||||
#: 加细它们不会引入猜测——它们只是让分类比"仅看状态码"更准。
|
#:
|
||||||
#: 未列出的 type 一律回落到状态码判定。
|
#: **按 `code` 与 `type` 两个字段分别查同一张表**,而不是各维护一张。理由:文档
|
||||||
_TYPE_OVERRIDES: dict[str, tuple[Retry, UserFacing]] = {
|
#: 把某个标识列在哪个字段下,并不保证实际响应里它一定出现在那个字段(`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),
|
"insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
|
||||||
# 限流:等几秒。
|
# ---- 限流 ----
|
||||||
"rate_limit_exceeded": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
|
#
|
||||||
# 内容被安全策略拒绝:同样的输入必然同样被拒。
|
# `slow_down` 是「涨得太快」而非「超了额定速率」——文档说即使在 RPM/TPM 限额
|
||||||
"image_generation_user_error": (Retry.NEVER, UserFacing.CONTENT_REJECTED),
|
# 之内也会触发。两者处置相同(退避后重试),故不再细分。
|
||||||
}
|
"slow_down": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
|
||||||
|
"rate_limit_error": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
|
||||||
#: 同上,但匹配 `code` 字段。
|
# ---- 服务过载 ----
|
||||||
_CODE_OVERRIDES: dict[str, tuple[Retry, UserFacing]] = {
|
#
|
||||||
"insufficient_quota": (Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED),
|
# 文档特别提醒:**视频过载已从 429 改判为 503**,且 Python SDK 分别抛
|
||||||
"rate_limit_exceeded": (Retry.BACKOFF, UserFacing.RATE_LIMITED),
|
# RateLimitError 与 InternalServerError,只捕一类会漏。我们两者都归可重试,
|
||||||
"moderation_blocked": (Retry.NEVER, UserFacing.CONTENT_REJECTED),
|
# 故这次厂商侧的改判对本服务无影响——记下来是为了说明「为什么我们没被影响」。
|
||||||
"content_policy_violation": (Retry.NEVER, UserFacing.CONTENT_REJECTED),
|
"server_is_overloaded": (Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE),
|
||||||
"invalid_api_key": (Retry.NEVER, UserFacing.SERVER_ERROR),
|
"service_unavailable_error": (Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -51,24 +82,26 @@ def classify(
|
|||||||
error_type: str | None = None,
|
error_type: str | None = None,
|
||||||
error_code: str | None = None,
|
error_code: str | None = None,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
|
error_param: str | None = None,
|
||||||
) -> Failure:
|
) -> Failure:
|
||||||
"""把一次失败分类。
|
"""把一次失败分类。
|
||||||
|
|
||||||
:param http_status: `None` 表示网络层失败(连接不上、超时),按可重试处理。
|
判定顺序:`code` → `type` → HTTP 状态码。越具体的依据优先——`code` 是文档明确
|
||||||
|
列出取值的字段,状态码只是最后的兜底。
|
||||||
|
|
||||||
未知情形一律偏向**可退避重试**而非判死:把一次可恢复的抖动判成用户可见的
|
:param http_status: `None` 表示网络层失败(连不上、超时),按可重试处理。
|
||||||
失败是更糟的错误,而重试预算本身有上限(`max_attempts`)。
|
:param error_param: 厂商指出的出错参数名。**它的有无是我们区分「参数错」与
|
||||||
|
「内容被拒」的唯一结构化线索**,见 `_classify_bad_request()`。
|
||||||
"""
|
"""
|
||||||
text = message or ""
|
text = message or ""
|
||||||
# provider_code 优先用 code,其次 type——两者都缺时用状态码,保证这个字段
|
# provider_code 永远不为空——它是排查时的第一线索。
|
||||||
# 永远不为空(它是排查时的第一线索)。
|
provider_code = error_code or error_type or _status_label(http_status)
|
||||||
provider_code = error_code or error_type or _status_code_label(http_status)
|
|
||||||
|
|
||||||
override = _CODE_OVERRIDES.get(error_code or "") or _TYPE_OVERRIDES.get(
|
# code 优先于 type:文档里 code 比 type 具体(同一个 429 下六种 code 共享
|
||||||
error_type or ""
|
# 少数几个 type)。两者都查同一张表,理由见 `_RULES`。
|
||||||
)
|
rule = _RULES.get(error_code or "") or _RULES.get(error_type or "")
|
||||||
if override:
|
if rule:
|
||||||
retry, user_facing = override
|
retry, user_facing = rule
|
||||||
return Failure(retry, user_facing, provider_code, text)
|
return Failure(retry, user_facing, provider_code, text)
|
||||||
|
|
||||||
if http_status is None:
|
if http_status is None:
|
||||||
@@ -77,29 +110,63 @@ def classify(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if http_status == 429:
|
if http_status == 429:
|
||||||
# 429 在 OpenAI 上同时用于「限流」与「配额耗尽」,靠 type/code 区分;
|
# 到这里说明既没有 code 也没有认识的 type。官方表里 429 的六种中,只有
|
||||||
# 上面的 override 没命中说明拿不到那两个字段,此时**按限流处理**:
|
# 「Rate limit reached for requests」没有文档化的 code——故无码的 429
|
||||||
# 猜成配额耗尽会让一次本可重试的限流被判死,代价更大。
|
# **按限流处理是文档支持的默认**,不再是一个猜测。
|
||||||
log.info("429 但无 type/code 可据,按限流处理(http=%s)", http_status)
|
|
||||||
return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, provider_code, text)
|
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:
|
if http_status == 400:
|
||||||
# 参数问题**或**内容被拒——OpenAI 两者都用 400。拿不到 type/code 时无法
|
return _classify_bad_request(provider_code, text, error_param)
|
||||||
# 区分,按不可重试处理(两种情形重试同一请求都不会变好),用户侧归
|
|
||||||
# SERVER_ERROR 而不猜成 CONTENT_REJECTED:错报「内容被拒」会让用户去改
|
if http_status in (401, 403):
|
||||||
# 一个其实没问题的提示词。
|
# 401:密钥无效/过期/不属于该组织;403:地域不受支持。
|
||||||
|
# 两者都是**我方配置问题**,重试无用,且都不该把锅甩给用户的提示词。
|
||||||
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
|
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
|
||||||
|
|
||||||
if 400 <= http_status < 500:
|
if 400 < http_status < 500:
|
||||||
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
|
return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, provider_code, text)
|
||||||
|
|
||||||
# 5xx 与其余一切:可重试。
|
# 5xx 与一切未知:可重试。把一次可恢复的抖动判死是更糟的错误,而重试预算
|
||||||
|
# 本身有上限(max_attempts),故偏向重试是有界的。
|
||||||
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text)
|
return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, provider_code, text)
|
||||||
|
|
||||||
|
|
||||||
def _status_code_label(http_status: int | None) -> str:
|
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"
|
return f"HTTP_{http_status}" if http_status else "NETWORK_ERROR"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.domain.generation import Retry
|
from app.domain.generation import Retry, UserFacing
|
||||||
from app.providers.openai.client import (
|
from app.providers.openai.client import (
|
||||||
DEFAULT_BASE_URL,
|
DEFAULT_BASE_URL,
|
||||||
OpenAiClient,
|
OpenAiClient,
|
||||||
@@ -130,9 +130,9 @@ class TestErrorUnwrapping:
|
|||||||
429,
|
429,
|
||||||
json={
|
json={
|
||||||
"error": {
|
"error": {
|
||||||
"message": "Rate limit reached",
|
"message": "Your request rate increased too quickly",
|
||||||
"type": "rate_limit_exceeded",
|
"type": "rate_limit_error",
|
||||||
"code": None,
|
"code": "slow_down",
|
||||||
"param": None,
|
"param": None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -144,10 +144,38 @@ class TestErrorUnwrapping:
|
|||||||
error = caught.value
|
error = caught.value
|
||||||
assert error.http_status == 429
|
assert error.http_status == 429
|
||||||
assert error.failure.retry is Retry.BACKOFF
|
assert error.failure.retry is Retry.BACKOFF
|
||||||
assert "Rate limit reached" in error.failure.message
|
assert error.failure.provider_code == "slow_down"
|
||||||
|
assert "increased too quickly" in error.failure.message
|
||||||
|
|
||||||
|
def test_the_param_field_is_extracted_because_classification_needs_it(self):
|
||||||
|
"""`param` 是区分「参数错」与「内容被拒」的唯一结构化线索。
|
||||||
|
|
||||||
|
漏了它,所有 400 都会被当成内容被拒——包括真正的参数错。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
400,
|
||||||
|
json={
|
||||||
|
"error": {
|
||||||
|
"message": "Invalid service_tier argument",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"param": "service_tier",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(OpenAiError) as caught:
|
||||||
|
client_with(handler).post_json("/x", {})
|
||||||
|
|
||||||
|
# 带 param ⇒ 参数错 ⇒ 服务端错误,而不是让用户去改提示词。
|
||||||
|
assert caught.value.failure.user_facing is UserFacing.SERVER_ERROR
|
||||||
|
|
||||||
def test_a_non_json_error_body_still_classifies_by_status(self):
|
def test_a_non_json_error_body_still_classifies_by_status(self):
|
||||||
"""错误体形状未经文档证实,解析不出时不能静默丢掉线索。"""
|
"""错误体不一定是文档描述的那个形状(网关、代理会插自己的页面)。
|
||||||
|
|
||||||
|
解析不出时不能静默丢掉线索。
|
||||||
|
"""
|
||||||
|
|
||||||
def handler(request):
|
def handler(request):
|
||||||
return httpx.Response(502, text="<html>bad gateway</html>")
|
return httpx.Response(502, text="<html>bad gateway</html>")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""OpenAI 错误分类的逐条测试。
|
"""OpenAI 错误分类的逐条测试,对照官方 Error codes 页(`doc_temp/gpt错误码.md`)。
|
||||||
|
|
||||||
`errors.py` 顶部标了「映射未经官方文档证实」——正因为它是推断出来的,才更要把
|
每个用例的码名都**抄自文档**,不是记忆。本文件此前钉的是五个并不存在的码名
|
||||||
每条分支钉住:日后拿到真实错误数据来校正时,改动的影响面一眼可见。
|
(见 `errors.py` 顶部),重写后每条都能在文档里搜到。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,88 +12,180 @@ from app.domain.generation import Retry, UserFacing
|
|||||||
from app.providers.openai.errors import classify
|
from app.providers.openai.errors import classify
|
||||||
|
|
||||||
|
|
||||||
class TestHighConfidenceOverrides:
|
class TestBillingAndQuotaCodes:
|
||||||
"""文档没给错误码表,故只对少数语义明确的取值加细。"""
|
"""官方表 429 的六种里有四种是钱的问题,且文档明说重试不会恢复访问。"""
|
||||||
|
|
||||||
@pytest.mark.parametrize("field", ["code", "type"])
|
@pytest.mark.parametrize(
|
||||||
def test_insufficient_quota_is_not_worth_retrying(self, field):
|
"code",
|
||||||
failure = classify(429, **{f"error_{field}": "insufficient_quota"})
|
[
|
||||||
|
"credit_balance_exhausted",
|
||||||
|
"organization_spend_limit_exceeded",
|
||||||
|
"project_spend_limit_exceeded",
|
||||||
|
"organization_usage_limit_exceeded",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_they_are_all_quota_not_backoff(self, code):
|
||||||
|
failure = classify(429, error_code=code)
|
||||||
|
# Retry.QUOTA 的语义正是文档那句「Retrying billing, spend, or quota errors
|
||||||
|
# won't restore API access」——Worker 不该把重试预算花在这上面。
|
||||||
assert failure.retry is Retry.QUOTA
|
assert failure.retry is Retry.QUOTA
|
||||||
assert failure.user_facing is UserFacing.QUOTA_EXHAUSTED
|
assert failure.user_facing is UserFacing.QUOTA_EXHAUSTED
|
||||||
|
assert failure.provider_code == code
|
||||||
|
|
||||||
|
def test_the_broad_type_is_recognised_when_the_code_is_absent(self):
|
||||||
|
"""文档:「The broader `error.type` can still be `insufficient_quota`」。"""
|
||||||
|
failure = classify(429, error_type="insufficient_quota")
|
||||||
|
assert failure.retry is Retry.QUOTA
|
||||||
|
|
||||||
|
def test_a_known_code_wins_over_the_broad_type(self):
|
||||||
|
# code 比 type 具体;此例两者同向,测的是优先级本身被实现了。
|
||||||
|
failure = classify(
|
||||||
|
429, error_type="insufficient_quota", error_code="credit_balance_exhausted"
|
||||||
|
)
|
||||||
|
assert failure.provider_code == "credit_balance_exhausted"
|
||||||
|
assert failure.retry is Retry.QUOTA
|
||||||
|
|
||||||
@pytest.mark.parametrize("field", ["code", "type"])
|
@pytest.mark.parametrize("field", ["code", "type"])
|
||||||
def test_rate_limit_backs_off(self, field):
|
@pytest.mark.parametrize(
|
||||||
failure = classify(429, **{f"error_{field}": "rate_limit_exceeded"})
|
"token", ["insufficient_quota", "credit_balance_exhausted", "slow_down"]
|
||||||
|
)
|
||||||
|
def test_a_documented_token_is_honoured_in_either_field(self, field, token):
|
||||||
|
"""同一张表按 `code` 与 `type` 两个字段分别查。
|
||||||
|
|
||||||
|
文档把某个标识列在哪个字段下,不保证实际响应里它一定出现在那个字段。查错
|
||||||
|
字段的代价是把配额耗尽降级成限流、白烧一轮重试预算;而两边都查不引入任何
|
||||||
|
**新**词汇——表里每个键都抄自文档。
|
||||||
|
"""
|
||||||
|
expected = Retry.BACKOFF if token == "slow_down" else Retry.QUOTA
|
||||||
|
assert classify(429, **{f"error_{field}": token}).retry is expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimitCodes:
|
||||||
|
def test_slow_down_backs_off(self):
|
||||||
|
"""`slow_down` 是「涨得太快」,文档说即使在 RPM/TPM 额度内也会触发。"""
|
||||||
|
failure = classify(429, error_type="rate_limit_error", error_code="slow_down")
|
||||||
assert failure.retry is Retry.BACKOFF
|
assert failure.retry is Retry.BACKOFF
|
||||||
assert failure.user_facing is UserFacing.RATE_LIMITED
|
assert failure.user_facing is UserFacing.RATE_LIMITED
|
||||||
|
|
||||||
@pytest.mark.parametrize("code", ["moderation_blocked", "content_policy_violation"])
|
def test_the_rate_limit_type_alone_is_enough(self):
|
||||||
def test_content_rejection_is_terminal(self, code):
|
failure = classify(429, error_type="rate_limit_error")
|
||||||
# 同样的输入必然同样被拒——重试只是白花一次配额。
|
assert failure.retry is Retry.BACKOFF
|
||||||
failure = classify(400, error_code=code)
|
assert failure.user_facing is UserFacing.RATE_LIMITED
|
||||||
assert failure.retry is Retry.NEVER
|
|
||||||
assert failure.user_facing is UserFacing.CONTENT_REJECTED
|
|
||||||
|
|
||||||
def test_invalid_key_is_our_problem_not_the_users(self):
|
def test_a_bare_429_defaults_to_rate_limiting(self):
|
||||||
failure = classify(401, error_code="invalid_api_key")
|
"""这个默认值现在是**文档支持的**,不再是猜测。
|
||||||
assert failure.retry is Retry.NEVER
|
|
||||||
# 用户看到的是「服务端错误」,不是「你的内容有问题」。
|
|
||||||
assert failure.user_facing is UserFacing.SERVER_ERROR
|
|
||||||
|
|
||||||
def test_code_wins_over_type_when_both_are_present(self):
|
官方表 429 的六种里,只有「Rate limit reached for requests」没有文档化的
|
||||||
# code 比 type 更具体,故优先。
|
code——故一个既无 code 又无已知 type 的 429 就是它。
|
||||||
failure = classify(
|
"""
|
||||||
429, error_type="rate_limit_exceeded", error_code="insufficient_quota"
|
|
||||||
)
|
|
||||||
assert failure.retry is Retry.QUOTA
|
|
||||||
|
|
||||||
|
|
||||||
class TestStatusCodeFallback:
|
|
||||||
"""拿不到 type/code 时只靠状态码——这是本模块的主依据。"""
|
|
||||||
|
|
||||||
def test_429_without_any_hint_is_treated_as_rate_limiting(self):
|
|
||||||
"""429 两义兼有;猜成配额耗尽会把一次可重试的限流判死,代价更大。"""
|
|
||||||
failure = classify(429)
|
failure = classify(429)
|
||||||
assert failure.retry is Retry.BACKOFF
|
assert failure.retry is Retry.BACKOFF
|
||||||
assert failure.user_facing is UserFacing.RATE_LIMITED
|
assert failure.user_facing is UserFacing.RATE_LIMITED
|
||||||
|
|
||||||
def test_400_without_any_hint_does_not_blame_the_prompt(self):
|
|
||||||
"""400 同时用于参数错与内容被拒。
|
|
||||||
|
|
||||||
错报「内容被拒」会让用户去改一个其实没问题的提示词,故归服务端错误。
|
class TestOverload:
|
||||||
|
def test_server_is_overloaded_is_retryable(self):
|
||||||
|
failure = classify(
|
||||||
|
503,
|
||||||
|
error_type="service_unavailable_error",
|
||||||
|
error_code="server_is_overloaded",
|
||||||
|
)
|
||||||
|
assert failure.retry is Retry.BACKOFF
|
||||||
|
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
|
||||||
|
|
||||||
|
def test_the_vendors_429_to_503_reclassification_does_not_affect_us(self):
|
||||||
|
"""文档提醒:视频过载已从 429 改判为 503,只捕一类会漏。
|
||||||
|
|
||||||
|
我们两者都归可重试,故这次改判对本服务无影响。这条测试就是那个「无影响」
|
||||||
|
的凭据——哪天有人把 5xx 改成不可重试,它会红。
|
||||||
"""
|
"""
|
||||||
failure = classify(400)
|
assert classify(429).retry is Retry.BACKOFF
|
||||||
|
assert classify(503).retry is Retry.BACKOFF
|
||||||
|
|
||||||
|
|
||||||
|
class TestBadRequestIsTwoThingsAtOnce:
|
||||||
|
"""400 既是参数错也是(推断的)内容被拒,用 `error.param` 区分。
|
||||||
|
|
||||||
|
官方错误码表里**没有任何内容审核/侵权码**,故内容拒绝只能以通用 400 到达。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_param_error_is_our_problem_not_the_prompts(self):
|
||||||
|
# 文档的 service_tier 那条明确说会带 param。
|
||||||
|
failure = classify(
|
||||||
|
400,
|
||||||
|
error_type="invalid_request_error",
|
||||||
|
error_param="service_tier",
|
||||||
|
message="Invalid service_tier argument",
|
||||||
|
)
|
||||||
assert failure.retry is Retry.NEVER
|
assert failure.retry is Retry.NEVER
|
||||||
assert failure.user_facing is UserFacing.SERVER_ERROR
|
assert failure.user_facing is UserFacing.SERVER_ERROR
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", [401, 403])
|
def test_a_400_without_param_is_treated_as_a_content_refusal(self):
|
||||||
def test_auth_failures_are_terminal(self, status):
|
"""推断分支:不指向任何参数的 400,按内容被拒处理。
|
||||||
assert classify(status).retry is Retry.NEVER
|
|
||||||
|
|
||||||
|
若归「服务端错误」,用户会看到「服务出错了」并反复重试同一个注定被拒的
|
||||||
|
提示词——那是更糟的结果。
|
||||||
|
"""
|
||||||
|
failure = classify(400, message="Your request was rejected")
|
||||||
|
assert failure.retry is Retry.NEVER
|
||||||
|
assert failure.user_facing is UserFacing.CONTENT_REJECTED
|
||||||
|
|
||||||
|
def test_the_inferred_branch_logs_a_warning(self, caplog):
|
||||||
|
"""这条分支是推断的,日志是日后校正它的唯一素材来源。"""
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
classify(400, message="something was refused")
|
||||||
|
assert "推断" in caplog.text
|
||||||
|
|
||||||
|
def test_an_empty_param_counts_as_absent(self):
|
||||||
|
# 厂商可能回 "" 或 null,两者语义相同。
|
||||||
|
assert classify(400, error_param="").user_facing is UserFacing.CONTENT_REJECTED
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthAndGeo:
|
||||||
|
def test_invalid_authentication_is_terminal_and_not_the_users_fault(self):
|
||||||
|
failure = classify(401, message="Incorrect API key provided")
|
||||||
|
assert failure.retry is Retry.NEVER
|
||||||
|
assert failure.user_facing is UserFacing.SERVER_ERROR
|
||||||
|
|
||||||
|
def test_unsupported_country_is_also_our_configuration_problem(self):
|
||||||
|
failure = classify(403, message="Country, region, or territory not supported")
|
||||||
|
assert failure.retry is Retry.NEVER
|
||||||
|
assert failure.user_facing is UserFacing.SERVER_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusCodeFallback:
|
||||||
@pytest.mark.parametrize("status", [404, 409, 422])
|
@pytest.mark.parametrize("status", [404, 409, 422])
|
||||||
def test_other_4xx_are_terminal(self, status):
|
def test_other_4xx_are_terminal(self, status):
|
||||||
assert classify(status).retry is Retry.NEVER
|
assert classify(status).retry is Retry.NEVER
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", [500, 502, 503, 504])
|
@pytest.mark.parametrize("status", [500, 502, 504])
|
||||||
def test_5xx_is_retryable(self, status):
|
def test_5xx_is_retryable(self, status):
|
||||||
failure = classify(status)
|
failure = classify(status)
|
||||||
assert failure.retry is Retry.BACKOFF
|
assert failure.retry is Retry.BACKOFF
|
||||||
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
|
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
|
||||||
|
|
||||||
def test_network_failure_is_retryable(self):
|
def test_network_failure_is_retryable(self):
|
||||||
# http_status=None 表示连都没连上。
|
|
||||||
failure = classify(None, message="connect timeout")
|
failure = classify(None, message="connect timeout")
|
||||||
assert failure.retry is Retry.BACKOFF
|
assert failure.retry is Retry.BACKOFF
|
||||||
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
|
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
|
||||||
|
|
||||||
def test_an_unknown_status_leans_towards_retrying(self):
|
def test_an_unknown_status_leans_towards_retrying(self):
|
||||||
"""未知一律偏可重试:把可恢复的抖动判死是更糟的错误。
|
|
||||||
|
|
||||||
重试预算本身有上限(max_attempts),所以偏向重试是有界的。
|
|
||||||
"""
|
|
||||||
assert classify(599).retry is Retry.BACKOFF
|
assert classify(599).retry is Retry.BACKOFF
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnknownCodesFallThroughRatherThanBlowUp:
|
||||||
|
"""厂商随时可能加新码;不认识的码必须回落,不能抛异常或判死。"""
|
||||||
|
|
||||||
|
def test_an_unknown_code_on_a_429_still_backs_off(self):
|
||||||
|
failure = classify(429, error_code="some_future_code")
|
||||||
|
assert failure.retry is Retry.BACKOFF
|
||||||
|
# 码本身照原样带出去——它是排查这条新码的唯一线索。
|
||||||
|
assert failure.provider_code == "some_future_code"
|
||||||
|
|
||||||
|
def test_an_unknown_type_on_a_5xx_still_backs_off(self):
|
||||||
|
assert classify(500, error_type="brand_new_error").retry is Retry.BACKOFF
|
||||||
|
|
||||||
|
|
||||||
class TestProviderCodeIsNeverEmpty:
|
class TestProviderCodeIsNeverEmpty:
|
||||||
"""它是排查时的第一线索,空字符串等于把线索丢了。"""
|
"""它是排查时的第一线索,空字符串等于把线索丢了。"""
|
||||||
|
|
||||||
|
|||||||
@@ -357,11 +357,22 @@ class TestResponseMapping:
|
|||||||
|
|
||||||
|
|
||||||
class TestFailurePropagation:
|
class TestFailurePropagation:
|
||||||
|
"""只验「厂商错误能原样穿过后台线程到达查询结果」,分类本身在 test_errors.py。
|
||||||
|
|
||||||
|
码名全部抄自官方 Error codes 页(`doc_temp/gpt错误码.md`)。
|
||||||
|
"""
|
||||||
|
|
||||||
def test_vendor_rate_limit_becomes_a_retryable_failure(self):
|
def test_vendor_rate_limit_becomes_a_retryable_failure(self):
|
||||||
def handler(request):
|
def handler(request):
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
429,
|
429,
|
||||||
json={"error": {"message": "slow down", "type": "rate_limit_exceeded"}},
|
json={
|
||||||
|
"error": {
|
||||||
|
"message": "Your request rate increased too quickly",
|
||||||
|
"type": "rate_limit_error",
|
||||||
|
"code": "slow_down",
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
provider = provider_with(handler)
|
provider = provider_with(handler)
|
||||||
@@ -371,11 +382,17 @@ class TestFailurePropagation:
|
|||||||
assert result.failure.retry is Retry.BACKOFF
|
assert result.failure.retry is Retry.BACKOFF
|
||||||
assert result.failure.user_facing is UserFacing.RATE_LIMITED
|
assert result.failure.user_facing is UserFacing.RATE_LIMITED
|
||||||
|
|
||||||
def test_insufficient_quota_is_not_worth_retrying(self):
|
def test_an_exhausted_credit_balance_is_not_worth_retrying(self):
|
||||||
def handler(request):
|
def handler(request):
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
429,
|
429,
|
||||||
json={"error": {"message": "no credit", "code": "insufficient_quota"}},
|
json={
|
||||||
|
"error": {
|
||||||
|
"message": "Your organization has no prepaid credits remaining",
|
||||||
|
"type": "insufficient_quota",
|
||||||
|
"code": "credit_balance_exhausted",
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
provider = provider_with(handler)
|
provider = provider_with(handler)
|
||||||
@@ -384,11 +401,22 @@ class TestFailurePropagation:
|
|||||||
assert result.failure.retry is Retry.QUOTA
|
assert result.failure.retry is Retry.QUOTA
|
||||||
assert result.failure.user_facing is UserFacing.QUOTA_EXHAUSTED
|
assert result.failure.user_facing is UserFacing.QUOTA_EXHAUSTED
|
||||||
|
|
||||||
def test_content_policy_violation_is_terminal(self):
|
def test_a_bare_400_is_surfaced_as_a_content_refusal(self):
|
||||||
|
"""官方错误码表里**没有任何内容审核/侵权码**。
|
||||||
|
|
||||||
|
故一次「这个提示词不能生成」只能以通用 400 到达。不带 `error.param` 时按
|
||||||
|
内容被拒处理——详见 `errors.py` 的 `_classify_bad_request`。
|
||||||
|
"""
|
||||||
|
|
||||||
def handler(request):
|
def handler(request):
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
400,
|
400,
|
||||||
json={"error": {"message": "blocked", "code": "moderation_blocked"}},
|
json={
|
||||||
|
"error": {
|
||||||
|
"message": "Your request was rejected",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
provider = provider_with(handler)
|
provider = provider_with(handler)
|
||||||
|
|||||||
Reference in New Issue
Block a user