"""OpenAI 错误分类的逐条测试。 `errors.py` 顶部标了「映射未经官方文档证实」——正因为它是推断出来的,才更要把 每条分支钉住:日后拿到真实错误数据来校正时,改动的影响面一眼可见。 """ from __future__ import annotations import pytest from app.domain.generation import Retry, UserFacing from app.providers.openai.errors import classify class TestHighConfidenceOverrides: """文档没给错误码表,故只对少数语义明确的取值加细。""" @pytest.mark.parametrize("field", ["code", "type"]) def test_insufficient_quota_is_not_worth_retrying(self, field): failure = classify(429, **{f"error_{field}": "insufficient_quota"}) assert failure.retry is Retry.QUOTA assert failure.user_facing is UserFacing.QUOTA_EXHAUSTED @pytest.mark.parametrize("field", ["code", "type"]) def test_rate_limit_backs_off(self, field): failure = classify(429, **{f"error_{field}": "rate_limit_exceeded"}) 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_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_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 两义兼有;猜成配额耗尽会把一次可重试的限流判死,代价更大。""" 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 同时用于参数错与内容被拒。 错报「内容被拒」会让用户去改一个其实没问题的提示词,故归服务端错误。 """ failure = classify(400) 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 @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]) 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 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 == ""