diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index d6340d182ae..49465ade3e1 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -358,6 +358,27 @@ def _embedding_request_without_sdk_defaults( return body, options +def _status_code_for_openai_sdk_error(e: Exception) -> int: + """ + Status code to attribute to an exception raised by the OpenAI SDK. + + A bare ``openai.OpenAIError`` is raised at client construction, before any HTTP + exchange, so it carries no status code; missing credentials is the common case. + Defaulting it to 500 asserts that a server responded with a server error, which + makes a permanently unfixable configuration error retryable. Subclasses such as + ``APIConnectionError`` and ``APIStatusError`` keep their existing treatment, so + genuinely transient failures are still retried. + + See https://github.com/BerriAI/litellm/issues/35860 + """ + status_code: Final = getattr(e, "status_code", None) + if status_code is not None: + return status_code + if type(e) is openai.OpenAIError: + return 400 + return 500 + + class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): def __init__(self) -> None: super().__init__() @@ -998,7 +1019,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): # e.message except Exception as e: exception_response = getattr(e, "response", None) - status_code = getattr(e, "status_code", 500) + status_code = _status_code_for_openai_sdk_error(e) exception_body = getattr(e, "body", None) error_headers = getattr(e, "headers", None) if error_headers is None and exception_response: @@ -1153,7 +1174,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): raise e error_headers = getattr(e, "headers", None) - status_code = getattr(e, "status_code", 500) + status_code = _status_code_for_openai_sdk_error(e) error_response = getattr(e, "response", None) exception_body = getattr(e, "body", None) if error_headers is None and error_response: diff --git a/tests/unit/llms/openai/test_openai_sdk_error_status_code.py b/tests/unit/llms/openai/test_openai_sdk_error_status_code.py new file mode 100644 index 00000000000..795dc90c93f --- /dev/null +++ b/tests/unit/llms/openai/test_openai_sdk_error_status_code.py @@ -0,0 +1,50 @@ +import httpx +import openai +import pytest + +from litellm.llms.openai.openai import _status_code_for_openai_sdk_error + + +def test_missing_credentials_is_not_a_server_error(): + """ + Regression test for https://github.com/BerriAI/litellm/issues/35860 + + The SDK raises a bare OpenAIError at client construction when no key is + configured. That happens before any HTTP exchange, so calling it a 500 marks a + permanently unfixable configuration error as a retryable server error. + """ + with pytest.raises(openai.OpenAIError) as exc_info: + openai.OpenAI(api_key=None) + + error = exc_info.value + assert not hasattr(error, "status_code") + assert _status_code_for_openai_sdk_error(error) == 400 + + +def test_connection_error_stays_retryable(): + """Transient failures must keep the 500 default so retries still happen.""" + error = openai.APIConnectionError(request=httpx.Request("POST", "http://example.com")) + + assert not hasattr(error, "status_code") + assert _status_code_for_openai_sdk_error(error) == 500 + + +def test_existing_status_code_is_preserved(): + class _WithStatus(Exception): + status_code = 429 + + assert _status_code_for_openai_sdk_error(_WithStatus()) == 429 + + +def test_unknown_exception_keeps_the_server_error_default(): + """Anything that is not a bare OpenAIError keeps the previous behaviour.""" + assert _status_code_for_openai_sdk_error(ValueError("boom")) == 500 + + +def test_status_code_zero_is_preserved(): + """A falsy-but-present status code must not fall through to the default.""" + + class _ZeroStatus(Exception): + status_code = 0 + + assert _status_code_for_openai_sdk_error(_ZeroStatus()) == 0