fix: classify provider code-only errors into structured failover reasons

A provider error body that carries only a structured code and no HTTP
status (Gemini UNAVAILABLE / DEADLINE_EXCEEDED / INTERNAL, Anthropic
API_ERROR, OpenAI SERVER_ERROR) fell through every classifier stage to
FailoverReason.unknown, so the retry loop treated a provider overload
or timeout as a generic retryable failure and never reached the
overload/timeout recovery hints. Add a per-provider code table consulted
after the shared _ERROR_CODE_VERDICTS map; codes are scoped to their
provider family so a same-named code from another backend stays unknown.

Fixes #70414
Salvages #70425 (@ooiuuii) onto the rule-table classifier layout.
This commit is contained in:
luyifan
2026-09-18 23:54:41 -07:00
committed by Teknium
parent ea51325186
commit f2651cb0f6
2 changed files with 41 additions and 1 deletions

View File

@@ -596,6 +596,19 @@ _ERROR_CODE_VERDICTS: Dict[str, Verdict] = {
"invalid_encrypted_content": _V_INVALID_ENCRYPTED,
}
# Provider-native status codes that arrive as a bare ``{"error": {"code": …}}`` body
# (no HTTP status, no prose): gRPC canonical names from Gemini, Anthropic error
# types, OpenAI's ``server_error``. Scoped per provider so a coincidentally named
# code from another backend stays ``unknown`` (#70414). Provider aliases collapse
# to the family key before lookup.
_PROVIDER_CODE_FAMILIES = {"openai-codex": "openai", "google": "gemini", "google-gemini": "gemini",
"google-ai-studio": "gemini", "vertex": "gemini", "google-vertex": "gemini"}
_PROVIDER_CODE_VERDICTS: Dict[str, Dict[str, Verdict]] = {
"openai": {"server_error": _V_SERVER_ERROR},
"gemini": {"unavailable": _V_OVERLOADED, "deadline_exceeded": _V_TIMEOUT, "internal": _V_SERVER_ERROR},
"anthropic": {"api_error": _V_SERVER_ERROR, "rate_limit_error": _V_RATE_LIMIT},
}
# Generic ``invalid_request_error`` is deliberately NOT a 400 validation
# signal — OpenAI stamps it on genuine overflow 400s too.
_400_VALIDATION_CODES = {"unknown_parameter", "unsupported_parameter"}
@@ -770,7 +783,11 @@ def _by_error_code(c: _Ctx) -> Optional[Verdict]:
# HTTP 200: retrying cannot succeed, a configured fallback still may.
if c.code == PROVIDER_STREAM_NON_JSON_ERROR_CODE and "request validation failed:" in c.msg:
return _V_FORMAT_ERROR
return _ERROR_CODE_VERDICTS.get(c.code)
verdict = _ERROR_CODE_VERDICTS.get(c.code)
if verdict is None:
family = _PROVIDER_CODE_FAMILIES.get(c.provider_slug, c.provider_slug)
verdict = _PROVIDER_CODE_VERDICTS.get(family, {}).get(c.code)
return verdict
def _by_message(c: _Ctx) -> Optional[Verdict]:

View File

@@ -1393,6 +1393,29 @@ class TestSSLCertVerificationFailFast:
# ── Test: RateLimitError without status_code (Copilot/GitHub Models) ──────────
class TestProviderCodeOnlyErrors:
"""Bare ``{"error": {"code": …}}`` bodies with no HTTP status map to the
provider's structured reason instead of ``unknown`` (#70414)."""
@pytest.mark.parametrize("provider, code, reason", [
("gemini", "UNAVAILABLE", FailoverReason.overloaded),
("google", "DEADLINE_EXCEEDED", FailoverReason.timeout),
("vertex", "INTERNAL", FailoverReason.server_error),
("anthropic", "API_ERROR", FailoverReason.server_error),
("openai-codex", "SERVER_ERROR", FailoverReason.server_error),
])
def test_provider_native_code_maps_to_structured_reason(self, provider, code, reason):
e = MockAPIError(code, body={"error": {"code": code}})
result = classify_api_error(e, provider=provider)
assert result.reason == reason
assert result.retryable is True
assert result.should_rotate_credential is False
def test_code_meaning_does_not_leak_across_providers(self):
e = MockAPIError("UNAVAILABLE", body={"error": {"code": "UNAVAILABLE"}})
assert classify_api_error(e, provider="openai").reason == FailoverReason.unknown
class TestRateLimitErrorWithoutStatusCode:
"""Regression tests for the Copilot/GitHub Models edge case where the
OpenAI SDK raises RateLimitError but does not populate .status_code."""