fix(ai): 按官方 Error codes 页重写 OpenAI 错误映射
CI / ai-gates (push) Successful in 55s

拿到 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:
2026-09-21 15:52:25 +08:00
parent 8275a04903
commit 541376e9be
6 changed files with 345 additions and 122 deletions
+139 -47
View File
@@ -1,7 +1,7 @@
"""OpenAI 错误分类的逐条测试。
"""OpenAI 错误分类的逐条测试,对照官方 Error codes 页(`doc_temp/gpt错误码.md`)。
`errors.py` 顶部标了「映射未经官方文档证实」——正因为它是推断出来的,才更要把
每条分支钉住:日后拿到真实错误数据来校正时,改动的影响面一眼可见。
每个用例的码名都**抄自文档**,不是记忆。本文件此前钉的是五个并不存在的码名
(见 `errors.py` 顶部),重写后每条都能在文档里搜到。
"""
from __future__ import annotations
@@ -12,88 +12,180 @@ from app.domain.generation import Retry, UserFacing
from app.providers.openai.errors import classify
class TestHighConfidenceOverrides:
"""文档没给错误码表,故只对少数语义明确的取值加细。"""
class TestBillingAndQuotaCodes:
"""官方表 429 的六种里有四种是钱的问题,且文档明说重试不会恢复访问。"""
@pytest.mark.parametrize("field", ["code", "type"])
def test_insufficient_quota_is_not_worth_retrying(self, field):
failure = classify(429, **{f"error_{field}": "insufficient_quota"})
@pytest.mark.parametrize(
"code",
[
"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.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"])
def test_rate_limit_backs_off(self, field):
failure = classify(429, **{f"error_{field}": "rate_limit_exceeded"})
@pytest.mark.parametrize(
"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.user_facing is UserFacing.RATE_LIMITED
@pytest.mark.parametrize("code", ["moderation_blocked", "content_policy_violation"])
def test_content_rejection_is_terminal(self, code):
# 同样的输入必然同样被拒——重试只是白花一次配额。
failure = classify(400, error_code=code)
assert failure.retry is Retry.NEVER
assert failure.user_facing is UserFacing.CONTENT_REJECTED
def test_the_rate_limit_type_alone_is_enough(self):
failure = classify(429, error_type="rate_limit_error")
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.RATE_LIMITED
def test_invalid_key_is_our_problem_not_the_users(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_a_bare_429_defaults_to_rate_limiting(self):
"""这个默认值现在是**文档支持的**,不再是猜测。
def test_code_wins_over_type_when_both_are_present(self):
# code 比 type 更具体,故优先。
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 两义兼有;猜成配额耗尽会把一次可重试的限流判死,代价更大。"""
官方表 429 的六种里,只有「Rate limit reached for requests」没有文档化的
code——故一个既无 code 又无已知 type 的 429 就是它。
"""
failure = classify(429)
assert failure.retry is Retry.BACKOFF
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.user_facing is UserFacing.SERVER_ERROR
@pytest.mark.parametrize("status", [401, 403])
def test_auth_failures_are_terminal(self, status):
assert classify(status).retry is Retry.NEVER
def test_a_400_without_param_is_treated_as_a_content_refusal(self):
"""推断分支:不指向任何参数的 400,按内容被拒处理。
若归「服务端错误」,用户会看到「服务出错了」并反复重试同一个注定被拒的
提示词——那是更糟的结果。
"""
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])
def test_other_4xx_are_terminal(self, status):
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):
failure = classify(status)
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
def test_network_failure_is_retryable(self):
# http_status=None 表示连都没连上。
failure = classify(None, message="connect timeout")
assert failure.retry is Retry.BACKOFF
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
def test_an_unknown_status_leans_towards_retrying(self):
"""未知一律偏可重试:把可恢复的抖动判死是更糟的错误。
重试预算本身有上限(max_attempts),所以偏向重试是有界的。
"""
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:
"""它是排查时的第一线索,空字符串等于把线索丢了。"""