fix(logging): keep tracebacks for provider-originated 4xx errors

is_expected_client_error treated every HTTP 4xx as a rejection the proxy
issued itself, so a 401 or 429 the provider returned lost its traceback in
the standard logging payload and the OTel error span dropped
litellm.provider.error.stack_trace. An exception carrying llm_provider is
an upstream or deployment problem and keeps its traceback; the proxy's own
pre-call rejections still skip it
This commit is contained in:
mateo-berri 2026-08-25 20:09:31 -07:00
parent 72b8b47ee8
commit 68f5cbd522
4 changed files with 71 additions and 1 deletions

View file

@ -60,13 +60,19 @@ def safe_divide(
def is_expected_client_error(exception: BaseException | None) -> bool:
"""
True when the exception maps to an HTTP 4xx status.
True when the proxy itself rejected the request with an HTTP 4xx before any
provider call (bad key, budget, unknown model, guardrail). A 4xx returned by
a provider (the exception carries ``llm_provider``) is an upstream or
deployment problem, so it is never an expected client error and keeps its
traceback.
ProxyException stores the status on .code (as a str), HTTPException and
litellm exceptions on .status_code.
"""
if exception is None:
return False
if getattr(exception, "llm_provider", None):
return False
code: Final[object] = getattr(exception, "code", None)
status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None)
if status_code is None or isinstance(status_code, bool):

View file

@ -330,6 +330,30 @@ def test_real_llm_failure_still_emitted():
assert span.status.status_code is StatusCode.ERROR
def test_provider_auth_failure_span_carries_stack_trace():
"""Regression for LIT-6163: a 401 the provider returned is not an expected
client error, so the error span built from the real failure payload keeps
``litellm.provider.error.stack_trace`` alongside code and llm_provider."""
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
try:
raise AuthenticationError(
message="AnthropicException - API key is invalid.", llm_provider="anthropic", model="claude-haiku-4-5"
)
except AuthenticationError as caught:
error_information = StandardLoggingPayloadSetup.get_error_information(caught)
logger, exporter = _logger()
payload = _payload(status="failure", custom_llm_provider="anthropic", error_information=error_information)
_emit_llm(logger, _kwargs(payload=payload), fail=True)
(span,) = exporter.get_finished_spans()
assert span.status.status_code is StatusCode.ERROR
assert span.attributes["error.type"] == "AuthenticationError"
assert span.attributes["litellm.provider.error.code"] == "401"
assert span.attributes["litellm.provider.error.llm_provider"] == "anthropic"
assert "test_otel_v2_logger" in span.attributes["litellm.provider.error.stack_trace"]
def test_idempotent_on_repeat_callback():
"""The carrier is the dedup: once the async callback closes the span and
clears the carrier, a second callback firing emits nothing."""

View file

@ -278,3 +278,25 @@ class TestIsExpectedClientError:
assert is_expected_client_error(WithCode("invalid_request_error")) is False
assert is_expected_client_error(Exception("no status")) is False
assert is_expected_client_error(None) is False
def test_provider_originated_4xx_is_not_expected(self):
"""Regression for LIT-6163: a 4xx the provider returned is an upstream or
deployment problem, so it keeps its traceback; only the proxy's own
pre-call rejections (no llm_provider) are expected client errors."""
from litellm.exceptions import AuthenticationError, RateLimitError
from litellm.litellm_core_utils.core_helpers import is_expected_client_error
provider_auth_failure = AuthenticationError(
message="AnthropicException - API key is invalid.", llm_provider="anthropic", model="claude-haiku-4-5"
)
assert is_expected_client_error(provider_auth_failure) is False
provider_rate_limit = RateLimitError(message="rate limited upstream", llm_provider="openai", model="gpt-4o")
assert is_expected_client_error(provider_rate_limit) is False
class RouterRejection(Exception):
def __init__(self):
self.status_code = 429
self.llm_provider = ""
assert is_expected_client_error(RouterRejection()) is True

View file

@ -5714,6 +5714,24 @@ def test_get_error_information_skips_traceback_for_expected_4xx(monkeypatch):
assert "test_litellm_logging" in result["traceback"]
def test_get_error_information_keeps_traceback_for_provider_4xx():
"""Regression for LIT-6163: a 4xx the provider returned (invalid deployment
key, upstream validation) is an operator problem, so its traceback must
survive the expected-client-error gate and reach every payload consumer."""
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
assert litellm.log_client_error_tracebacks is False
provider_exc = _raise_and_catch(
litellm.AuthenticationError(
message="AnthropicException - API key is invalid.", llm_provider="anthropic", model="claude-haiku-4-5"
)
)
result = StandardLoggingPayloadSetup.get_error_information(provider_exc)
assert result["error_code"] == "401"
assert result["llm_provider"] == "anthropic"
assert "test_litellm_logging" in result["traceback"]
def test_failure_handler_helper_fn_builds_payload_once_per_exception():
"""Regression for LIT-6043: async and sync failure handlers both call
_failure_handler_helper_fn for the same failed request; the standardized