From a1aa1528df673b1ea298ce9f30b03470fcb22af8 Mon Sep 17 00:00:00 2001 From: richboyneedcash <273099414+richboyneedcash@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:13:08 +0800 Subject: [PATCH 1/2] fix: map missing OpenAI credentials to 401 instead of 500 The OpenAI SDK raises openai.OpenAIError (e.g. "Missing credentials") at client construction, before any HTTP request. These exceptions carry no status_code, and the broad exception handlers in openai.py defaulted the status to 500. Since 500 is retryable under standard retry policies, a permanent, client-side configuration error (missing api_key) got retried until attempts ran out, and the useful "Missing credentials" message arrived last, surfaced as InternalServerError. Add a _get_openai_exception_status_code helper that returns 401 for a pre-request openai.OpenAIError (no status_code), so it maps to a non-retryable AuthenticationError. Exceptions that already carry a status_code (a real HTTP response) are unchanged, and non-OpenAI exceptions keep the 500 default. Applied to the completion and streaming handlers. Fixes #35860 Co-authored-by: TRAE CLI --- litellm/llms/openai/openai.py | 31 ++++++++++-- .../test_openai_exception_status_code.py | 49 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 tests/test_litellm/llms/openai/test_openai_exception_status_code.py diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index 998319f3e85..4f7efaacbff 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -318,6 +318,31 @@ class OpenAIChatCompletionResponseIterator(BaseModelResponseIterator): raise e +def _get_openai_exception_status_code(e: Exception) -> int: + """ + Determine the status code to attach to an ``OpenAIError`` raised from a + caught exception. + + The OpenAI SDK raises ``openai.OpenAIError`` (e.g. "Missing credentials") + at *client construction*, before any HTTP request is made. Such exceptions + carry no ``status_code``. Defaulting these to 500 asserts that a server + responded with a server error, so the failure is treated as transient and + retried even though it is a permanent, client-side configuration problem. + + For these pre-request client errors we return 401 so they surface as a + non-retryable ``AuthenticationError``. Any exception that already carries a + ``status_code`` (i.e. a real HTTP response happened) keeps that code. + """ + status_code = getattr(e, "status_code", None) + if status_code is not None: + return status_code + if isinstance(e, openai.OpenAIError): + # openai.OpenAIError with no status_code == raised before any HTTP + # exchange (e.g. missing api_key), which is an auth/config problem. + return 401 + return 500 + + class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): def __init__(self) -> None: super().__init__() @@ -797,7 +822,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): except OpenAIError as e: raise e except Exception as e: - status_code: Final = getattr(e, "status_code", 500) + status_code: Final = _get_openai_exception_status_code(e) error_headers = getattr(e, "headers", None) error_text: Final = getattr(e, "text", str(e)) error_response: Final = getattr(e, "response", None) @@ -920,7 +945,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 = _get_openai_exception_status_code(e) exception_body = getattr(e, "body", None) error_headers = getattr(e, "headers", None) if error_headers is None and exception_response: @@ -1075,7 +1100,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): raise e error_headers = getattr(e, "headers", None) - status_code = getattr(e, "status_code", 500) + status_code = _get_openai_exception_status_code(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/test_litellm/llms/openai/test_openai_exception_status_code.py b/tests/test_litellm/llms/openai/test_openai_exception_status_code.py new file mode 100644 index 00000000000..70d10024992 --- /dev/null +++ b/tests/test_litellm/llms/openai/test_openai_exception_status_code.py @@ -0,0 +1,49 @@ +""" +Tests for _get_openai_exception_status_code. + +Regression test for https://github.com/BerriAI/litellm/issues/35860: +a missing API key raises openai.OpenAIError at client construction (with no +status_code). Defaulting that to 500 makes it a retryable InternalServerError, +so a permanent credential/config error is retried instead of failing fast. +It should map to 401 (AuthenticationError) instead. +""" + +import openai + +from litellm.llms.openai.openai import _get_openai_exception_status_code + + +class TestGetOpenAIExceptionStatusCode: + def test_openai_error_without_status_code_maps_to_401(self): + """openai.OpenAIError with no status_code (pre-request, e.g. missing key) -> 401.""" + err = openai.OpenAIError("Missing credentials. Please pass an `api_key`.") + assert not hasattr(err, "status_code") or getattr(err, "status_code") is None + assert _get_openai_exception_status_code(err) == 401 + + def test_existing_status_code_is_preserved(self): + """An exception that already carries a status_code keeps it (real HTTP response).""" + + class FakeHTTPError(Exception): + status_code = 429 + + assert _get_openai_exception_status_code(FakeHTTPError("rate limited")) == 429 + + def test_status_code_zero_is_preserved(self): + """A falsy-but-present status_code (0) is still honored, not defaulted.""" + + class ZeroStatus(Exception): + status_code = 0 + + assert _get_openai_exception_status_code(ZeroStatus("weird")) == 0 + + def test_generic_exception_without_status_code_defaults_to_500(self): + """A non-OpenAI exception with no status_code keeps the 500 default.""" + assert _get_openai_exception_status_code(ValueError("boom")) == 500 + + def test_openai_error_subclass_with_status_code_kept(self): + """A real openai HTTP error (has status_code) is unchanged.""" + + class FakeOpenAIHTTPError(openai.OpenAIError): + status_code = 400 + + assert _get_openai_exception_status_code(FakeOpenAIHTTPError("bad request")) == 400 From 11ae1d49974f8fd526eaeead67a179dbf94bb18a Mon Sep 17 00:00:00 2001 From: richboyneedcash <273099414+richboyneedcash@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:02:30 +0800 Subject: [PATCH 2/2] refactor: narrow to exact OpenAIError type to keep transient errors retryable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback (#36044): using isinstance(e, openai.OpenAIError) was too broad — openai.APIConnectionError and openai.APITimeoutError subclass OpenAIError and carry no status_code, so they hit the 401 branch and became non-retryable AuthenticationError, which is wrong for transient failures. Narrow to type(e) is openai.OpenAIError so only the bare base class (missing-credentials, raised at client construction) maps to 401; transient subclasses keep the retryable 500 default. Add a regression test asserting APIConnectionError / APITimeoutError stay at 500. Co-authored-by: TRAE CLI --- litellm/llms/openai/openai.py | 13 ++++++++++--- .../openai/test_openai_exception_status_code.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index 4f7efaacbff..46ac9d4eef0 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -336,9 +336,16 @@ def _get_openai_exception_status_code(e: Exception) -> int: status_code = getattr(e, "status_code", None) if status_code is not None: return status_code - if isinstance(e, openai.OpenAIError): - # openai.OpenAIError with no status_code == raised before any HTTP - # exchange (e.g. missing api_key), which is an auth/config problem. + # Match the *bare* ``openai.OpenAIError`` base class exactly. The + # missing-credentials error is raised as the base class at client + # construction (before any HTTP request), so it has no ``status_code`` and + # should surface as a non-retryable 401 ``AuthenticationError``. + # + # Do NOT use ``isinstance`` here: ``openai.APIConnectionError`` and + # ``openai.APITimeoutError`` subclass ``OpenAIError`` and also carry no + # ``status_code``, but they are genuinely transient and must stay retryable + # (keep the 500 default). + if type(e) is openai.OpenAIError: return 401 return 500 diff --git a/tests/test_litellm/llms/openai/test_openai_exception_status_code.py b/tests/test_litellm/llms/openai/test_openai_exception_status_code.py index 70d10024992..58564e1a23e 100644 --- a/tests/test_litellm/llms/openai/test_openai_exception_status_code.py +++ b/tests/test_litellm/llms/openai/test_openai_exception_status_code.py @@ -8,6 +8,7 @@ so a permanent credential/config error is retried instead of failing fast. It should map to 401 (AuthenticationError) instead. """ +import httpx import openai from litellm.llms.openai.openai import _get_openai_exception_status_code @@ -20,6 +21,17 @@ class TestGetOpenAIExceptionStatusCode: assert not hasattr(err, "status_code") or getattr(err, "status_code") is None assert _get_openai_exception_status_code(err) == 401 + def test_transient_openai_subclasses_stay_retryable(self): + """APIConnectionError / APITimeoutError subclass OpenAIError and carry no + status_code, but they are genuinely transient and must stay retryable + (500), not be turned into a non-retryable 401. Only the bare + OpenAIError base class (missing credentials) maps to 401.""" + conn_err = openai.APIConnectionError(request=httpx.Request("POST", "https://api.openai.com/v1")) + timeout_err = openai.APITimeoutError(request=httpx.Request("POST", "https://api.openai.com/v1")) + assert _get_openai_exception_status_code(conn_err) == 500 + assert _get_openai_exception_status_code(timeout_err) == 500 + + def test_existing_status_code_is_preserved(self): """An exception that already carries a status_code keeps it (real HTTP response)."""