fix(otel): mark v2 server spans as failed for pre-call errors (#34546)

* fix(otel): mark v2 server spans as failed for pre-call errors (LIT-4780)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel): authenticate malformed-body requests before rejecting them (LIT-4780)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(auth): cover malformed-body rejection when auth error is recovered

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(auth): skip authorization for a request whose body never parsed

Deferring the parse failure ran the full auth phase, including budget reservation, whose reserved amount is only released by the endpoint's post call path; the endpoint never runs, so malformed requests leaked reservations and locked a budgeted key out. Authorization now runs only when the body parsed, and a parse failure with a rejected key keeps returning the 400 it returned before.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: shivam <shivam@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-08-08 12:40:00 -07:00 • committed by GitHub
parent cfd64d45a8
commit 12aeb53aec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 269 additions and 67 deletions

View file

@ -33,6 +33,7 @@ from litellm.integrations.otel.model.payloads import (
is_mcp_list_tools,
is_mcp_tool_call,
)
from litellm.integrations.otel.model.semconv import Error
from litellm.integrations.otel.model.spans import SpanRole, span_role_for_service
from litellm.integrations.otel.model.utils import to_ns
from litellm.integrations.otel.plumbing.context import (
@ -634,18 +635,23 @@ class OpenTelemetryV2(CustomLogger):
"""Stamp the v2 error.* attributes on the FastAPI-owned SERVER span for a
failure that dies before any LLM-call span exists (malformed body, auth /
validation rejection). Called from the proxy's global exception handler via
``_close_dangling_otel_server_span``. The instrumentor still owns the span's
status and lifecycle, so this only decorates it — never sets status, never
ends it — and emits no exception event, matching v1's SERVER-span behavior
and avoiding a duplicate of the event ``async_post_call_failure_hook`` or
the ``auth`` phase span already records."""
``_close_dangling_otel_server_span``, which swallows the exception into a
``JSONResponse`` so the instrumentor never sees it and leaves the span
``UNSET``; the status is set here instead (v1 did the same from the handler)
so a failed request reads as failed and not merely as a span carrying an
error message. The instrumentor still owns the span's lifecycle, so this
never ends it. The exception event is recorded only when nothing stamped
this span already — ``async_post_call_failure_hook`` and the ``auth`` phase
span record their own, and a second event would duplicate it — while the
attributes are always restamped so ``error.code`` stays pinned to the real
response status."""
if span is None or not is_recordable_span(span):
return
already_stamped: Final = Error.TYPE in (getattr(span, "attributes", None) or ())
stamp_error(
span,
_span_error_from_exception(exception, status_code=status_code),
record_event=False,
set_status=False,
record_event=not already_stamped,
)
async def async_post_call_failure_hook(

View file

@ -1044,6 +1044,22 @@ def _ensure_parent_otel_span_on_request_state(request: Request) -> None:
request.state.parent_otel_span = parent_otel_span
async def _read_request_body_deferring_parse_failure(
request: Request,
) -> tuple[dict, ProxyException | None]:
"""Parse the body, returning a parse failure instead of raising it.
A body that fails to parse is still a request from a known caller, so auth
must run (resolving identity onto the request's trace) before the 400 goes
out; the caller re-raises the returned exception once identity is seeded.
"""
try:
parsed_body: Final = await _read_request_body(request=request)
except ProxyException as parse_exception:
return {}, parse_exception # mutable-ok: request_data is a plain dict across the whole auth path
return populate_request_with_path_params(request_data=parsed_body, request=request), None
async def _user_api_key_auth_builder(
request: Request,
api_key: str,
@ -2516,6 +2532,72 @@ def _resolve_request_principal(request: Request, valid_token: UserAPIKeyAuth) ->
)
async def _authorize_authenticated_request(
user_api_key_auth_obj: UserAPIKeyAuth,
request: Request,
request_data: dict,
route: str,
api_key: str,
) -> UserAPIKeyAuth | None:
"""Authorize an already-authenticated request: disabled-route check, the single
``common_checks`` gate (which also reserves budget), and end-user fallback
resolution. Returns the auth object the exception handler recovered when a check
failed but the request may proceed anyway, else ``None``.
"""
## ENSURE DISABLE ROUTE WORKS ACROSS ALL USER AUTH FLOWS ##
RouteChecks.should_call_route(route=route, valid_token=user_api_key_auth_obj, request=request)
# Single authorization point. Builder paths MUST NOT call common_checks.
# Route through the same exception handler the builder uses so
# authorization failures (ProxyException, or plain Exception from
# admin-only-route / model-access / budget checks) surface as
# ProxyException consistently with pre-refactor behavior.
try:
await _run_centralized_common_checks(
user_api_key_auth_obj=user_api_key_auth_obj,
request=request,
request_data=request_data,
route=route,
)
except Exception as e:
return await UserAPIKeyAuthExceptionHandler._handle_authentication_error(
e=e,
request=request,
request_data=request_data,
route=route,
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
api_key=api_key,
resolved_identity=user_api_key_auth_obj,
)
# Defense-in-depth: ``_user_api_key_auth_builder`` has multiple early-return
# paths (no master key, /user/auth route, JWT short-circuits) that bypass
# the end-user resolution block. If those paths produced an auth obj
# without an ``end_user_id`` set, fall back to extracting from the request
# body so spend logs are still attributed correctly. Validation honours
# ``litellm.validate_end_user_id_in_db``.
if user_api_key_auth_obj.end_user_id is None:
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
raw_end_user_id: Final = get_end_user_id_from_request_body(request_data, _safe_get_request_headers(request))
if raw_end_user_id is not None:
resolved_end_user_id: Final = await resolve_and_validate_end_user_id(
raw_end_user_id=raw_end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
)
if resolved_end_user_id is not None:
user_api_key_auth_obj.end_user_id = resolved_end_user_id
return None
@tracer.wrap()
async def user_api_key_auth(
request: Request,
@ -2536,8 +2618,7 @@ async def user_api_key_auth(
# close, and the trace never reaches the backend.
_ensure_parent_otel_span_on_request_state(request)
request_data = await _read_request_body(request=request)
request_data = populate_request_with_path_params(request_data=request_data, request=request)
request_data, body_parse_exception = await _read_request_body_deferring_parse_failure(request=request)
route: Final[str] = get_request_route(request=request)
## CHECK IF ROUTE IS ALLOWED
@ -2545,69 +2626,41 @@ async def user_api_key_auth(
# triggers (key/user/team object reads) nest under it instead of flattening
# onto the server span. No-op when OTel V2 isn't active.
with phase_span(f"auth {route}"):
user_api_key_auth_obj: Final = await _user_api_key_auth_builder(
request=request,
api_key=api_key,
azure_api_key_header=azure_api_key_header,
anthropic_api_key_header=anthropic_api_key_header,
google_ai_studio_api_key_header=google_ai_studio_api_key_header,
azure_apim_header=azure_apim_header,
request_data=request_data,
custom_litellm_key_header=custom_litellm_key_header,
)
try:
user_api_key_auth_obj: Final = await _user_api_key_auth_builder(
request=request,
api_key=api_key,
azure_api_key_header=azure_api_key_header,
anthropic_api_key_header=anthropic_api_key_header,
google_ai_studio_api_key_header=google_ai_studio_api_key_header,
azure_apim_header=azure_apim_header,
request_data=request_data,
custom_litellm_key_header=custom_litellm_key_header,
)
except Exception:
# The body was read first, so a caller who sent both a malformed body and
# a rejected key used to get the 400; the response is unchanged, and the
# auth failure is still recorded on the trace by the handler that ran.
if body_parse_exception is not None:
raise body_parse_exception
raise
user_api_key_auth_obj.budget_reservation = None
## ENSURE DISABLE ROUTE WORKS ACROSS ALL USER AUTH FLOWS ##
RouteChecks.should_call_route(route=route, valid_token=user_api_key_auth_obj, request=request)
# Single authorization point. Builder paths MUST NOT call common_checks.
# Route through the same exception handler the builder uses so
# authorization failures (ProxyException, or plain Exception from
# admin-only-route / model-access / budget checks) surface as
# ProxyException consistently with pre-refactor behavior.
try:
await _run_centralized_common_checks(
# A body that never parsed is authenticated (so the trace carries identity
# and this ``auth`` span) but not authorized: there is no model to check it
# against, and budget reservation would increment live spend counters that
# only the endpoint's post-call path releases; the endpoint never runs, since
# the parse failure is raised below.
if body_parse_exception is None:
recovered_auth_obj: Final = await _authorize_authenticated_request(
user_api_key_auth_obj=user_api_key_auth_obj,
request=request,
request_data=request_data,
route=route,
)
except Exception as e:
return await UserAPIKeyAuthExceptionHandler._handle_authentication_error(
e=e,
request=request,
request_data=request_data,
route=route,
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
api_key=api_key,
resolved_identity=user_api_key_auth_obj,
)
# Defense-in-depth: ``_user_api_key_auth_builder`` has multiple early-return
# paths (no master key, /user/auth route, JWT short-circuits) that bypass
# the end-user resolution block. If those paths produced an auth obj
# without an ``end_user_id`` set, fall back to extracting from the request
# body so spend logs are still attributed correctly. Validation honours
# ``litellm.validate_end_user_id_in_db``.
if user_api_key_auth_obj.end_user_id is None:
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
raw_end_user_id: Final = get_end_user_id_from_request_body(request_data, _safe_get_request_headers(request))
if raw_end_user_id is not None:
resolved_end_user_id: Final = await resolve_and_validate_end_user_id(
raw_end_user_id=raw_end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
route=route,
)
if resolved_end_user_id is not None:
user_api_key_auth_obj.end_user_id = resolved_end_user_id
if recovered_auth_obj is not None:
return recovered_auth_obj
# Identity is now resolved. Seed it AFTER the auth span closes so the Baggage
# persists on the request task (detaching the span's context token inside the
@ -2619,6 +2672,9 @@ async def user_api_key_auth(
)
user_api_key_auth_obj.request_route = normalize_request_route(route)
if body_parse_exception is not None:
raise body_parse_exception
# Resolve caller identity once, here at the seam, into a single per-request
# Principal projected off the key object the builder already fetched (no
# second lookup). Downstream consumers read identity off this instead of

View file

@ -1195,8 +1195,13 @@ def test_async_post_call_failure_hook_skips_a_transport_that_already_answered():
def test_record_error_attributes_on_span_decorates_without_ending():
"""PATH A: a failure that dies before any LLM-call span (malformed body,
validation) is stamped onto the instrumentor-owned SERVER span. The method must
not end the span or emit a duplicate exception event, and must pin error.code
to the real response status (not the exception's own code)."""
not end the span, and must pin error.code to the real response status (not the
exception's own code).
LIT-4780: the instrumentor never sees the exception (the proxy handler turns it
into a JSONResponse), so nothing else marks the span as failed; the status and
the exception event have to come from here or the trace shows the error message
on an otherwise successful-looking request."""
logger, exporter = _logger()
server = logger._emitter.start_span(SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME)
logger.record_error_attributes_on_span(server, _proxy_exc("Invalid JSON body", 400), 422)
@ -1206,7 +1211,31 @@ def test_record_error_attributes_on_span_decorates_without_ending():
assert span.attributes["error.type"] == "ProxyException"
assert span.attributes["error.message"] == "Invalid JSON body"
assert span.attributes["litellm.provider.error.code"] == "422"
assert all(e.name != "exception" for e in span.events)
assert span.status.status_code is StatusCode.ERROR
assert [e.name for e in span.events] == ["exception"]
def test_record_error_attributes_on_span_does_not_duplicate_an_already_stamped_error():
"""A failure that already went through ``async_post_call_failure_hook`` reaches
the exception handler too; the second stamp must keep one exception event while
still repinning error.code to the real response status."""
from litellm.proxy._types import UserAPIKeyAuth
logger, exporter = _logger()
server = logger._emitter.start_span(SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME)
set_request_root_span(server)
exc = _proxy_exc("Authentication Error, invalid key", 401)
asyncio.run(
logger.async_post_call_failure_hook(
request_data={}, original_exception=exc, user_api_key_dict=UserAPIKeyAuth()
)
)
logger.record_error_attributes_on_span(server, exc, 400)
server.end()
(span,) = exporter.get_finished_spans()
assert [e.name for e in span.events] == ["exception"]
assert span.attributes["litellm.provider.error.code"] == "400"
assert span.status.status_code is StatusCode.ERROR
def test_record_error_attributes_on_span_ignores_below_400_and_missing_span():

View file

@ -4786,6 +4786,117 @@ async def test_user_api_key_auth_does_not_overwrite_end_user_id_set_by_builder()
setattr(_proxy_server_mod, k, v)
@pytest.mark.asyncio
async def test_user_api_key_auth_authenticates_before_raising_malformed_body_error():
"""Regression (LIT-4780): a body that fails to parse must still be authenticated
first, so the rejected request's trace carries the caller's key / team / user
identity instead of an anonymous root span. The parse error is re-raised
unchanged once identity is seeded."""
from fastapi import Request
from starlette.datastructures import URL
import litellm.proxy.proxy_server as _proxy_server_mod
builder_token = UserAPIKeyAuth(api_key="sk-test", user_id="u1", team_id="team-1")
request = Request(
scope={
"type": "http",
"headers": [(b"content-type", b"application/json")],
"method": "POST",
}
)
request._url = URL(url="/chat/completions")
request._body = b'{}{"model": "gpt-4o"}'
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
try:
for k, v in attrs.items():
setattr(_proxy_server_mod, k, v)
with (
patch(
"litellm.proxy.auth.user_api_key_auth._user_api_key_auth_builder",
new_callable=AsyncMock,
return_value=builder_token,
) as mock_builder,
patch(
"litellm.proxy.auth.user_api_key_auth._run_centralized_common_checks",
new_callable=AsyncMock,
) as mock_common_checks,
patch(
"litellm.proxy.auth.user_api_key_auth.RouteChecks.should_call_route",
),
patch(
"litellm.proxy.auth.user_api_key_auth.seed_request_identity",
) as mock_seed,
):
with pytest.raises(ProxyException) as exc_info:
await user_api_key_auth(request=request, api_key="Bearer sk-test")
assert "Invalid JSON payload" in str(exc_info.value.message)
assert exc_info.value.code == str(status.HTTP_400_BAD_REQUEST)
mock_builder.assert_awaited_once()
assert mock_seed.call_args.args[0] is builder_token
# authorization must not run for a request that is about to be rejected:
# ``common_checks`` reserves budget against live spend counters that only the
# endpoint's post-call path releases, and the endpoint never runs here
mock_common_checks.assert_not_awaited()
finally:
for k, v in originals.items():
setattr(_proxy_server_mod, k, v)
@pytest.mark.asyncio
async def test_user_api_key_auth_malformed_body_with_rejected_key_still_returns_the_parse_error():
"""The body is read before the key is authenticated, so a caller who sends both a
malformed body and a key that fails auth gets the 400. Authenticating the request
first (LIT-4780) must not turn that into the auth status code."""
from fastapi import Request
from starlette.datastructures import URL
import litellm.proxy.proxy_server as _proxy_server_mod
request = Request(
scope={
"type": "http",
"headers": [(b"content-type", b"application/json")],
"method": "POST",
}
)
request._url = URL(url="/chat/completions")
request._body = b'{}{"model": "gpt-4o"}'
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
try:
for k, v in attrs.items():
setattr(_proxy_server_mod, k, v)
with (
patch(
"litellm.proxy.auth.user_api_key_auth._user_api_key_auth_builder",
new_callable=AsyncMock,
side_effect=ProxyException(
message="Authentication Error, invalid key",
type="auth_error",
param="None",
code=status.HTTP_401_UNAUTHORIZED,
),
),
patch(
"litellm.proxy.auth.user_api_key_auth.RouteChecks.should_call_route",
),
):
with pytest.raises(ProxyException) as exc_info:
await user_api_key_auth(request=request, api_key="Bearer sk-bad")
assert "Invalid JSON payload" in str(exc_info.value.message)
assert exc_info.value.code == str(status.HTTP_400_BAD_REQUEST)
finally:
for k, v in originals.items():
setattr(_proxy_server_mod, k, v)
def _proxy_attrs_for_db_lookup():
"""Minimal proxy_server attributes for driving the real
``_user_api_key_auth_builder`` down to the DB key lookup."""