"""OpenAI 错误分类的逐条测试,对照官方 Error codes 页(`doc_temp/gpt错误码.md`)。 每个用例的码名都**抄自文档**,不是记忆。本文件此前钉的是五个并不存在的码名 (见 `errors.py` 顶部),重写后每条都能在文档里搜到。 """ from __future__ import annotations import pytest from app.domain.generation import Retry, UserFacing from app.providers.openai.errors import classify class TestBillingAndQuotaCodes: """官方表 429 的六种里有四种是钱的问题,且文档明说重试不会恢复访问。""" @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"]) @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 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_a_bare_429_defaults_to_rate_limiting(self): """这个默认值现在是**文档支持的**,不再是猜测。 官方表 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 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 改成不可重试,它会红。 """ 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 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, 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): 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): 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: """它是排查时的第一线索,空字符串等于把线索丢了。""" def test_code_is_preferred(self): assert classify(400, error_type="t", error_code="c").provider_code == "c" def test_type_is_used_when_code_is_absent(self): assert classify(400, error_type="t").provider_code == "t" def test_status_label_is_the_last_resort(self): assert classify(503).provider_code == "HTTP_503" def test_network_failures_get_a_named_label_too(self): assert classify(None).provider_code == "NETWORK_ERROR" class TestMessageIsCarriedThrough: def test_message_is_kept(self): assert classify(400, message="bad size").message == "bad size" def test_missing_message_becomes_empty_not_none(self): # 契约里 message 是字符串;None 会在序列化处变成 null 而非 ""。 assert classify(400).message == ""