diff --git a/README.md b/README.md index 4d554f6..55f9d50 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,11 @@ app/ - **火山不支持幂等键**,「提交超时后不知道有没有扣费」只能由 `max_attempts` 兜住; - **火山产物 URL 只有 24 小时有效期**,因此厂商 URL 不能当存储用,必须在时限内转存; -- **OpenAI 的两份文档都没有错误码表**,故 `providers/openai/errors.py` 的映射是 - 按 HTTP 状态码为主推断的,**未经文档证实**,拿到真实错误数据后须校正。 +- **OpenAI 的官方错误码表里全是基础设施与计费错误**,**没有任何内容审核/侵权码** + ——一次「这个提示词不能生成」只能以通用 400 到达。故 `providers/openai/errors.py` + 用 `error.param` 的有无区分「参数错」与「内容被拒」,那一条是推断并记 WARNING; +- **`Retry-After` 目前不读**:Java 侧退避阶梯基线 30 秒、上限 10 分钟,已长于服务商 + 通常给的值,忽略它只会偏慢、不会加剧限流。接它是一次跨语言契约变更。 ### 同步型供应商的代价 diff --git a/app/providers/openai/client.py b/app/providers/openai/client.py index 6f58369..4be843c 100644 --- a/app/providers/openai/client.py +++ b/app/providers/openai/client.py @@ -111,10 +111,10 @@ class OpenAiClient: def _unwrap(response: httpx.Response) -> dict[str, Any]: """把响应转成 dict;非 2xx 转成带分类的 `OpenAiError`。 - 错误体按 OpenAI 通用约定解析(`{"error": {message, type, code, param}}`)。 - **该形状未经用户提供的文档证实**——两份文档都没有错误码一节,见 - `errors.py` 顶部说明。解析不出这个形状时不静默,按状态码分类并把原始 - 文本带上。 + 错误体形状取自官方 Error codes 页:`{"error": {message, type, code, param}}`。 + 四个字段**都要取**——`param` 是区分「参数错」与「内容被拒」的唯一结构化 + 线索(理由见 `errors.py` 的 `_classify_bad_request`)。解析不出这个形状时 + 不静默,按状态码分类并把原始文本带上。 """ if response.is_success: try: @@ -130,21 +130,26 @@ class OpenAiClient: error_type: str | None = None error_code: str | None = None + error_param: str | None = None message = response.text[:500] try: error = response.json().get("error") or {} error_type = error.get("type") error_code = error.get("code") + error_param = error.get("param") message = error.get("message") or message except ValueError: 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( - "OpenAI 返回错误 http=%s type=%s code=%s retry=%s", + "OpenAI 返回错误 http=%s type=%s code=%s param=%s retry=%s", response.status_code, error_type, error_code, + error_param, failure.retry.value, ) raise OpenAiError(failure, response.status_code) diff --git a/app/providers/openai/errors.py b/app/providers/openai/errors.py index c884cf8..b955096 100644 --- a/app/providers/openai/errors.py +++ b/app/providers/openai/errors.py @@ -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 规范 -保证的,而 `type`/`code` 的取值集没有文档支撑,凭记忆列一张表只会得到一张看起来 -权威、实则可能错漏的映射。故这里对 `type`/`code` 只做**少量高置信度的加细**, -其余一律按状态码归类。 +文档的 13 条全是**基础设施与计费**:鉴权、IP/地域、限流、余额与限额、服务过载。 +**没有内容审核、没有版权/侵权、没有「这个提示词不能生成」。** 但图片接口明确有 +`moderation` 参数(`low`/`auto`),说明审核确实存在——只是它的错误形态未被文档化。 -**待补**:拿到官方错误码文档、或在真实调用中观察到实际取值后,回来校正本模块并 -更新 patbond-doc 的取证页。在那之前,本文件顶部这段说明不要删。 +这对我们的直接后果:**一次侵权或违规拒绝很可能以一个普通 400 到达**,只带一句 +人类可读的消息。若把这种 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 @@ -24,25 +35,45 @@ from app.domain.generation import Failure, Retry, UserFacing log = logging.getLogger(__name__) -#: 高置信度的 `type` 取值:这几个在 OpenAI 的错误体里语义明确且与状态码一致, -#: 加细它们不会引入猜测——它们只是让分类比"仅看状态码"更准。 -#: 未列出的 type 一律回落到状态码判定。 -_TYPE_OVERRIDES: dict[str, tuple[Retry, UserFacing]] = { - # 配额/计费耗尽:等的是充值或下一个周期,重试无用。 +#: 文档化的错误标识 → 领域处置。 +#: +#: **按 `code` 与 `type` 两个字段分别查同一张表**,而不是各维护一张。理由:文档 +#: 把某个标识列在哪个字段下,并不保证实际响应里它一定出现在那个字段(`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), - # 限流:等几秒。 - "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), + # ---- 限流 ---- + # + # `slow_down` 是「涨得太快」而非「超了额定速率」——文档说即使在 RPM/TPM 限额 + # 之内也会触发。两者处置相同(退避后重试),故不再细分。 + "slow_down": (Retry.BACKOFF, UserFacing.RATE_LIMITED), + "rate_limit_error": (Retry.BACKOFF, UserFacing.RATE_LIMITED), + # ---- 服务过载 ---- + # + # 文档特别提醒:**视频过载已从 429 改判为 503**,且 Python SDK 分别抛 + # RateLimitError 与 InternalServerError,只捕一类会漏。我们两者都归可重试, + # 故这次厂商侧的改判对本服务无影响——记下来是为了说明「为什么我们没被影响」。 + "server_is_overloaded": (Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE), + "service_unavailable_error": (Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE), } @@ -51,24 +82,26 @@ def classify( error_type: str | None = None, error_code: str | None = None, message: str | None = None, + error_param: str | None = None, ) -> Failure: """把一次失败分类。 - :param http_status: `None` 表示网络层失败(连接不上、超时),按可重试处理。 + 判定顺序:`code` → `type` → HTTP 状态码。越具体的依据优先——`code` 是文档明确 + 列出取值的字段,状态码只是最后的兜底。 - 未知情形一律偏向**可退避重试**而非判死:把一次可恢复的抖动判成用户可见的 - 失败是更糟的错误,而重试预算本身有上限(`max_attempts`)。 + :param http_status: `None` 表示网络层失败(连不上、超时),按可重试处理。 + :param error_param: 厂商指出的出错参数名。**它的有无是我们区分「参数错」与 + 「内容被拒」的唯一结构化线索**,见 `_classify_bad_request()`。 """ text = message or "" - # provider_code 优先用 code,其次 type——两者都缺时用状态码,保证这个字段 - # 永远不为空(它是排查时的第一线索)。 - provider_code = error_code or error_type or _status_code_label(http_status) + # provider_code 永远不为空——它是排查时的第一线索。 + provider_code = error_code or error_type or _status_label(http_status) - override = _CODE_OVERRIDES.get(error_code or "") or _TYPE_OVERRIDES.get( - error_type or "" - ) - if override: - retry, user_facing = override + # code 优先于 type:文档里 code 比 type 具体(同一个 429 下六种 code 共享 + # 少数几个 type)。两者都查同一张表,理由见 `_RULES`。 + rule = _RULES.get(error_code or "") or _RULES.get(error_type or "") + if rule: + retry, user_facing = rule return Failure(retry, user_facing, provider_code, text) if http_status is None: @@ -77,29 +110,63 @@ def classify( ) if http_status == 429: - # 429 在 OpenAI 上同时用于「限流」与「配额耗尽」,靠 type/code 区分; - # 上面的 override 没命中说明拿不到那两个字段,此时**按限流处理**: - # 猜成配额耗尽会让一次本可重试的限流被判死,代价更大。 - log.info("429 但无 type/code 可据,按限流处理(http=%s)", http_status) + # 到这里说明既没有 code 也没有认识的 type。官方表里 429 的六种中,只有 + # 「Rate limit reached for requests」没有文档化的 code——故无码的 429 + # **按限流处理是文档支持的默认**,不再是一个猜测。 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 _classify_bad_request(provider_code, text, error_param) + + if http_status in (401, 403): + # 401:密钥无效/过期/不属于该组织;403:地域不受支持。 + # 两者都是**我方配置问题**,重试无用,且都不该把锅甩给用户的提示词。 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) - # 5xx 与其余一切:可重试。 + # 5xx 与一切未知:可重试。把一次可恢复的抖动判死是更糟的错误,而重试预算 + # 本身有上限(max_attempts),故偏向重试是有界的。 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" diff --git a/tests/providers/openai/test_client.py b/tests/providers/openai/test_client.py index 9601c8a..ea5ef04 100644 --- a/tests/providers/openai/test_client.py +++ b/tests/providers/openai/test_client.py @@ -8,7 +8,7 @@ from __future__ import annotations import httpx import pytest -from app.domain.generation import Retry +from app.domain.generation import Retry, UserFacing from app.providers.openai.client import ( DEFAULT_BASE_URL, OpenAiClient, @@ -130,9 +130,9 @@ class TestErrorUnwrapping: 429, json={ "error": { - "message": "Rate limit reached", - "type": "rate_limit_exceeded", - "code": None, + "message": "Your request rate increased too quickly", + "type": "rate_limit_error", + "code": "slow_down", "param": None, } }, @@ -144,10 +144,38 @@ class TestErrorUnwrapping: error = caught.value assert error.http_status == 429 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 handler(request): return httpx.Response(502, text="bad gateway") diff --git a/tests/providers/openai/test_errors.py b/tests/providers/openai/test_errors.py index de6643c..d3ae69d 100644 --- a/tests/providers/openai/test_errors.py +++ b/tests/providers/openai/test_errors.py @@ -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: """它是排查时的第一线索,空字符串等于把线索丢了。""" diff --git a/tests/providers/openai/test_image.py b/tests/providers/openai/test_image.py index 624258d..b683a8b 100644 --- a/tests/providers/openai/test_image.py +++ b/tests/providers/openai/test_image.py @@ -357,11 +357,22 @@ class TestResponseMapping: class TestFailurePropagation: + """只验「厂商错误能原样穿过后台线程到达查询结果」,分类本身在 test_errors.py。 + + 码名全部抄自官方 Error codes 页(`doc_temp/gpt错误码.md`)。 + """ + def test_vendor_rate_limit_becomes_a_retryable_failure(self): def handler(request): return httpx.Response( 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) @@ -371,11 +382,17 @@ class TestFailurePropagation: assert result.failure.retry is Retry.BACKOFF 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): return httpx.Response( 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) @@ -384,11 +401,22 @@ class TestFailurePropagation: assert result.failure.retry is Retry.QUOTA 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): return httpx.Response( 400, - json={"error": {"message": "blocked", "code": "moderation_blocked"}}, + json={ + "error": { + "message": "Your request was rejected", + "type": "invalid_request_error", + } + }, ) provider = provider_with(handler)