diff --git a/litellm/error_categories.py b/litellm/error_categories.py index 530c15d576f..33fc8a5cd1c 100644 --- a/litellm/error_categories.py +++ b/litellm/error_categories.py @@ -86,14 +86,18 @@ def google_parse_error(data: dict, status: int) -> ParsedError: """Google Generative AI / Vertex AI error parser. Google returns error status strings in the response body (e.g. - 'UNAUTHENTICATED', 'RESOURCE_EXHAUSTED', 'UNAVAILABLE') that - override HTTP status for categorization. + 'UNAUTHENTICATED', 'PERMISSION_DENIED', 'RESOURCE_EXHAUSTED', + 'UNAVAILABLE', 'DEADLINE_EXCEEDED') that override HTTP status + for categorization. """ err = _extract_error_body(data) message = err.get("message") google_status = err.get("status", "").upper() - if status in (401, 403) or google_status == "UNAUTHENTICATED": + if status in (401, 403) or google_status in ( + "UNAUTHENTICATED", + "PERMISSION_DENIED", + ): return ParsedError( category=ErrorCategory.AUTH, message=message, status_code=status ) @@ -101,7 +105,11 @@ def google_parse_error(data: dict, status: int) -> ParsedError: return ParsedError( category=ErrorCategory.RATE_LIMIT, message=message, status_code=status ) - if status >= 500 or google_status in ("UNAVAILABLE", "INTERNAL"): + if status >= 500 or google_status in ( + "UNAVAILABLE", + "INTERNAL", + "DEADLINE_EXCEEDED", + ): return ParsedError( category=ErrorCategory.SERVER, message=message or "Server error", @@ -128,24 +136,58 @@ def categorize_exception(exc: Exception) -> Optional[ErrorCategory]: if isinstance(category, ErrorCategory): return category - # Fall back to status-code-based inference for existing exception types. + # Try status-code-based inference status = getattr(exc, "status_code", None) if status is not None: - if status in (401, 403): - return ErrorCategory.AUTH - if status == 429: - return ErrorCategory.RATE_LIMIT - if status >= 500: - return ErrorCategory.SERVER - if 400 <= status < 500: - return ErrorCategory.CLIENT + category_from_status = _categorize_by_status_code(status) + if category_from_status is not None: + return category_from_status - # Type-name heuristics for exceptions that lack a status_code attribute. + # Fall back to type-name heuristics + return _categorize_by_exception_name(exc) + + +def _categorize_by_status_code(status: any) -> Optional[ErrorCategory]: + """Categorize error by HTTP status code. + + Handles both integer and string status codes. + """ + # Normalize to integer + if not isinstance(status, int): + try: + status = int(status) + except (ValueError, TypeError): + return None + + # Auth errors + if status in (401, 403): + return ErrorCategory.AUTH + + # Rate limiting + if status == 429: + return ErrorCategory.RATE_LIMIT + + # Server errors (including 408 Request Timeout which should be retryable) + if status == 408 or status >= 500: + return ErrorCategory.SERVER + + # Client errors (4xx except auth and rate limit) + if 400 <= status < 500: + return ErrorCategory.CLIENT + + return None + + +def _categorize_by_exception_name(exc: Exception) -> Optional[ErrorCategory]: + """Categorize error by exception class name patterns.""" name = type(exc).__name__.lower() + if "auth" in name: return ErrorCategory.AUTH + if "rate" in name or "throttl" in name: return ErrorCategory.RATE_LIMIT + if "server" in name or "service" in name or "timeout" in name: return ErrorCategory.SERVER diff --git a/tests/test_error_categories.py b/tests/test_error_categories.py index d0b4158b523..9b068828c8d 100644 --- a/tests/test_error_categories.py +++ b/tests/test_error_categories.py @@ -93,6 +93,16 @@ class TestGoogleParseError: assert result.category == ErrorCategory.CLIENT assert result.message == "Invalid argument" + def test_body_permission_denied(self): + """PERMISSION_DENIED should map to AUTH.""" + result = google_parse_error({"error": {"status": "PERMISSION_DENIED"}}, 200) + assert result.category == ErrorCategory.AUTH + + def test_body_deadline_exceeded(self): + """DEADLINE_EXCEEDED should map to SERVER (retryable).""" + result = google_parse_error({"error": {"status": "DEADLINE_EXCEEDED"}}, 200) + assert result.category == ErrorCategory.SERVER + class TestCategorizeException: """Integration: extract ErrorCategory from existing LiteLLM exceptions.""" @@ -167,6 +177,25 @@ class TestCategorizeException: exc.status_code = 404 # type: ignore[attr-defined] assert categorize_exception(exc) == ErrorCategory.CLIENT + def test_exception_with_status_code_408_timeout(self): + """408 Request Timeout should be SERVER (retryable), not CLIENT.""" + exc = Exception() + exc.status_code = 408 # type: ignore[attr-defined] + assert categorize_exception(exc) == ErrorCategory.SERVER + + def test_exception_with_string_status_code(self): + """String status_code should be converted to int.""" + exc = Exception() + exc.status_code = "503" # type: ignore[attr-defined] + assert categorize_exception(exc) == ErrorCategory.SERVER + + def test_exception_with_invalid_status_code(self): + """Invalid status_code should fall through to name heuristics.""" + exc = Exception() + exc.status_code = "invalid" # type: ignore[attr-defined] + # Falls through to None since no name match + assert categorize_exception(exc) is None + def test_unknown_returns_none(self): assert categorize_exception(ValueError("unexpected")) is None