fix(responses): map all documented in-stream error codes to real HTTP statuses

This commit is contained in:
mateo-berri 2026-07-30 19:17:32 -07:00
parent 8e287652c6
commit 43fad507de
2 changed files with 102 additions and 12 deletions

View file

@ -7,7 +7,8 @@ import traceback
import uuid
from datetime import datetime
from functools import lru_cache
from typing import Any, Dict, List, Literal, Optional
from types import MappingProxyType
from typing import Any, Dict, List, Literal, Mapping, Optional
import httpx
from openai._streaming import SSEDecoder
@ -48,13 +49,32 @@ def _log_background_task_failure(task: "asyncio.Task[Any]", *, task_name: str) -
verbose_logger.error("%s failed: %s", task_name, exception)
_CLIENT_ERROR_CODES: frozenset[str] = frozenset(
(
"invalid_request_error",
"context_length_exceeded",
"content_policy_violation",
"model_not_found",
)
_ERROR_CODE_HTTP_STATUS: Mapping[str, int] = MappingProxyType(
{
"server_error": 500,
"rate_limit_exceeded": 429,
"insufficient_quota": 429,
"vector_store_timeout": 504,
"invalid_prompt": 400,
"invalid_image": 400,
"invalid_image_format": 400,
"invalid_base64_image": 400,
"invalid_image_url": 400,
"image_too_large": 400,
"image_too_small": 400,
"image_parse_error": 400,
"image_content_policy_violation": 400,
"invalid_image_mode": 400,
"image_file_too_large": 400,
"unsupported_image_media_type": 400,
"empty_image_file": 400,
"failed_to_download_image": 400,
"image_file_not_found": 400,
"invalid_request_error": 400,
"context_length_exceeded": 400,
"content_policy_violation": 400,
"model_not_found": 400,
}
)
@ -78,12 +98,13 @@ def _error_event_fields(error_obj: object) -> tuple[str, Optional[str], Optional
def _status_code_for_error_fields(error_type: Optional[str], error_code: Optional[str]) -> int:
fields = tuple(field for field in (error_type, error_code) if field is not None)
fields = tuple(field for field in (error_code, error_type) if field is not None)
if any(field.startswith("rate_limit") or field == "insufficient_quota" for field in fields):
return 429
if any(field in _CLIENT_ERROR_CODES for field in fields):
return 400
return 500
return next(
(_ERROR_CODE_HTTP_STATUS[field] for field in fields if field in _ERROR_CODE_HTTP_STATUS),
500,
)
class BaseResponsesAPIStreamingIterator:

View file

@ -28,9 +28,11 @@ from litellm.exceptions import MidStreamFallbackError
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.responses.streaming_iterator import (
_ERROR_CODE_HTTP_STATUS,
BaseResponsesAPIStreamingIterator,
ResponsesAPIStreamingIterator,
SyncResponsesAPIStreamingIterator,
_status_code_for_error_fields,
)
from litellm.types.llms.openai import (
ErrorEvent,
@ -355,3 +357,70 @@ def test_sync_iterator_raises_mid_stream_fallback_on_rate_limit_error_event():
pass
assert exc_info.value.status_code == 429
assert isinstance(exc_info.value.original_exception, litellm.APIError)
def test_every_openai_sdk_response_error_code_has_explicit_status_mapping():
from typing import get_args
from openai.types.responses.response_error import ResponseError
sdk_codes = set(get_args(ResponseError.model_fields["code"].annotation))
unmapped = sdk_codes - set(_ERROR_CODE_HTTP_STATUS)
assert unmapped == set(), (
f"OpenAI SDK ResponseError codes missing from _ERROR_CODE_HTTP_STATUS: {sorted(unmapped)}; "
"classify each new code with an explicit HTTP status instead of letting it default to 500"
)
@pytest.mark.parametrize(
"code,expected_status",
[
("server_error", 500),
("rate_limit_exceeded", 429),
("insufficient_quota", 429),
("vector_store_timeout", 504),
("invalid_prompt", 400),
("invalid_image", 400),
("invalid_image_format", 400),
("invalid_base64_image", 400),
("invalid_image_url", 400),
("image_too_large", 400),
("image_too_small", 400),
("image_parse_error", 400),
("image_content_policy_violation", 400),
("invalid_image_mode", 400),
("image_file_too_large", 400),
("unsupported_image_media_type", 400),
("empty_image_file", 400),
("failed_to_download_image", 400),
("image_file_not_found", 400),
("totally_unknown_future_code", 500),
],
)
def test_status_code_for_documented_response_error_codes(code: str, expected_status: int):
assert _status_code_for_error_fields(None, code) == expected_status
def test_specific_error_code_wins_over_generic_error_type():
assert _status_code_for_error_fields("server_error", "invalid_image") == 400
def test_maybe_raise_for_response_failed_event_maps_image_code_to_400():
iterator = _make_iterator()
mock_response_obj = Mock()
mock_response_obj.error = {"code": "image_content_policy_violation", "message": "image rejected"}
chunk = Mock()
chunk.type = "response.failed"
chunk.response = mock_response_obj
with pytest.raises(litellm.APIError) as exc_info:
iterator._maybe_raise_for_error_event(chunk)
assert exc_info.value.status_code == 400
assert not isinstance(exc_info.value, MidStreamFallbackError)
def test_maybe_raise_for_error_event_maps_vector_store_timeout_to_retriable_504():
iterator = _make_iterator()
chunk = _make_error_chunk("server_error", "vector_store_timeout", "vector store timed out")
with pytest.raises(MidStreamFallbackError) as exc_info:
iterator._maybe_raise_for_error_event(chunk)
assert exc_info.value.status_code == 504