Merge pull request #38296 from BerriAI/litellm_fix_otel_provider_error_stack_trace
Some checks are pending
Publish basedpyright base counts / publish (push) Waiting to run
Code Quality Checks / code-quality (push) Waiting to run
UI Unit Tests / ui-unit-tests (push) Waiting to run
Postgres Tests / proxy-security (push) Waiting to run
Postgres Tests / schema-migration (push) Waiting to run
Postgres Tests / proxy-behavior (push) Waiting to run
Unit Tests: Documentation Validation / documentation (push) Waiting to run
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Waiting to run
Unit Tests: Proxy DB Operations / auth-checks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / budgets (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / custom-logging (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / db-and-spend (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / key-generation (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / logging-misc (push) Blocked by required conditions
CI Coverage / assert-ci-coverage (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Unit Tests: Proxy DB Operations / proxy-server-core (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-utils (push) Blocked by required conditions
Unit Tests / integrations (push) Waiting to run
Unit Tests / All Other Providers (push) Waiting to run
Unit Tests / Vertex AI (push) Waiting to run
Unit Tests / misc (push) Waiting to run
Unit Tests / caching-local (push) Waiting to run
Unit Tests / core-utils (push) Waiting to run
Unit Tests / enterprise-package (push) Waiting to run
Unit Tests / enterprise-routing (push) Waiting to run
Unit Tests / proxy-auth (push) Waiting to run
Unit Tests / proxy-endpoints (push) Waiting to run
Unit Tests / proxy-extras (push) Waiting to run
Unit Tests / proxy-infra (push) Waiting to run
Unit Tests / proxy-server (push) Waiting to run
Unit Tests / responses-caching-types (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Unit Tests: Proxy DB Operations / proxy-runtime (push) Blocked by required conditions

fix(logging): keep tracebacks for provider-originated 4xx errors
This commit is contained in:
Mateo Wang 2026-08-25 21:15:14 -07:00 committed by GitHub
commit 3e2927de9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 187 additions and 1 deletions

View file

@ -58,15 +58,56 @@ def safe_divide(
return numerator / denominator
def _is_litellm_limit_rejection(exception: BaseException) -> bool:
from litellm.exceptions import RateLimitErrorCategory
litellm_limit_categories: Final = frozenset(
(RateLimitErrorCategory.LITELLM_RATE_LIMIT.value, RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT.value)
)
return getattr(exception, "category", None) in litellm_limit_categories
def _is_proxy_rejection(exception: BaseException) -> bool:
if _is_litellm_limit_rejection(exception):
return True
try:
from starlette.exceptions import HTTPException
except ImportError:
return False
return isinstance(exception, HTTPException)
def _is_provider_originated(exception: BaseException) -> bool:
if _is_proxy_rejection(exception):
return False
if getattr(exception, "llm_provider", None):
return True
from litellm.llms.base_llm.chat.transformation import BaseLLMException
return isinstance(exception, BaseLLMException)
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 is an upstream or deployment problem, so it is never an expected
client error and keeps its traceback: a mapped litellm exception carries
``llm_provider``, and the raw ``BaseLLMException`` that provider handlers
raise before mapping (the /v1/messages route surfaces it as-is) is one too.
The proxy's own limiters raise ``HTTPException`` subclasses that also carry
an ``llm_provider``, so any ``HTTPException`` stays a proxy rejection, and
so does any exception whose unified rate-limit ``category`` names litellm's
own limiter (``BudgetExceededError`` is a plain ``Exception`` that the auth
handler decorates with the requested model's provider).
ProxyException stores the status on .code (as a str), HTTPException and
litellm exceptions on .status_code.
"""
if exception is None:
return False
if _is_provider_originated(exception):
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,50 @@ 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_unmapped_provider_auth_failure_span_carries_stack_trace():
"""Regression for LIT-6163 on /v1/messages: that route logs the provider's
raw exception (no llm_provider), and its error span keeps the stack trace."""
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.llms.anthropic.common_utils import AnthropicError
try:
raise AnthropicError(status_code=401, message='{"type":"authentication_error","message":"API key is invalid."}')
except AnthropicError 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"] == "AnthropicError"
assert span.attributes["litellm.provider.error.code"] == "401"
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,59 @@ 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
from litellm.llms.anthropic.common_utils import AnthropicError
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
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
unmapped_provider_failure = AnthropicError(status_code=401, message='{"type":"authentication_error"}')
assert is_expected_client_error(unmapped_provider_failure) is False
proxy_rate_limit = ProxyRateLimitError(
detail={"error": "Max parallel requests reached"}, model="claude-haiku-4-5", llm_provider="anthropic"
)
assert proxy_rate_limit.llm_provider == "anthropic"
assert is_expected_client_error(proxy_rate_limit) is True
class RouterRejection(Exception):
def __init__(self):
self.status_code = 429
self.llm_provider = ""
assert is_expected_client_error(RouterRejection()) is True
def test_budget_rejection_decorated_with_provider_is_expected(self):
"""The auth handler stamps the requested model's provider onto the proxy's
own BudgetExceededError before logging it, which must not turn a key-over-budget
429 into a provider error that keeps its traceback."""
from litellm.exceptions import BudgetExceededError, RateLimitError, RateLimitErrorCategory
from litellm.litellm_core_utils.core_helpers import is_expected_client_error
over_budget = BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic")
assert over_budget.llm_provider == "anthropic"
assert is_expected_client_error(over_budget) is True
litellm_limit = RateLimitError(
message="key over rpm", llm_provider="anthropic", model="claude-haiku-4-5",
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
)
assert is_expected_client_error(litellm_limit) is True
vendor_limit = RateLimitError(
message="rate limited upstream", llm_provider="anthropic", model="claude-haiku-4-5",
category=RateLimitErrorCategory.VENDOR_RATE_LIMIT,
)
assert is_expected_client_error(vendor_limit) is False

View file

@ -5714,6 +5714,51 @@ 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_get_error_information_keeps_traceback_for_unmapped_provider_4xx():
"""Regression for LIT-6163 on /v1/messages: that route logs the provider's
raw BaseLLMException (no llm_provider), which still keeps its traceback."""
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.llms.anthropic.common_utils import AnthropicError
assert litellm.log_client_error_tracebacks is False
raw_provider_exc = _raise_and_catch(AnthropicError(status_code=401, message='{"type":"authentication_error"}'))
result = StandardLoggingPayloadSetup.get_error_information(raw_provider_exc)
assert result["error_code"] == "401"
assert result["error_class"] == "AnthropicError"
assert "test_litellm_logging" in result["traceback"]
def test_get_error_information_skips_traceback_for_budget_rejection_with_provider():
"""A key-over-budget 429 is the proxy's own rejection even after the auth
handler stamps the requested model's provider onto it, so it stays cheap."""
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
assert litellm.log_client_error_tracebacks is False
over_budget = _raise_and_catch(litellm.BudgetExceededError(current_cost=0.01, max_budget=0.0, llm_provider="anthropic"))
result = StandardLoggingPayloadSetup.get_error_information(over_budget)
assert result["error_code"] == "429"
assert result["llm_provider"] == "anthropic"
assert 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