diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 10d9038d21..ce9e3f82a8 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -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]: diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index ac662bcaeb..dc6c4eed9f 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -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."""