fix(openai): stop reporting a missing API key as a retryable server error

The OpenAI SDK raises a bare openai.OpenAIError at client construction when no
credentials are configured. That happens before any HTTP exchange, so the
exception carries no status code, and the broad handler defaulted it to 500.
Downstream that becomes an InternalServerError, which every standard retry
policy treats as transient, so a permanently unfixable configuration error is
retried until the attempts run out and the useful message arrives last

Attribute 400 to a bare OpenAIError instead. The check is on the exact type, so
APIConnectionError and APIStatusError keep the existing default and genuinely
transient failures are still retried

Worth noting for triage: the existing string match in exception_mapping_utils
targets 'The api_key client option must be set...', which current SDK versions
no longer emit; the message is now 'Missing credentials. Please pass an
api_key...', so that branch no longer catches this case

Fixes #35860
This commit is contained in:
Ambuj Upadhyay 2026-08-06 17:35:44 +05:30
parent 14f4c34c61
commit 3dac50ef92
2 changed files with 73 additions and 2 deletions

View file

@ -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:

View file

@ -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