This commit is contained in:
ChengyiZhang 2026-10-17 08:01:50 -07:00 • committed by GitHub
commit cbf95480ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 167 additions and 18 deletions

View file

@ -8,13 +8,15 @@ import orjson
from fastapi import Request, UploadFile, status
from pydantic import TypeAdapter
from starlette._utils import get_route_path
from starlette.requests import HTTPConnection
from starlette.datastructures import FormData
from starlette.requests import ClientDisconnect, HTTPConnection
from typing_extensions import NotRequired, ReadOnly, Required, assert_never
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
AZURE_SPEECH_PASS_THROUGH_ROUTE_PREFIX,
CLIENT_REQUESTED_MODEL_SCOPE_KEY,
LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED,
MAX_REQUEST_BODY_SIZE_TO_REPAIR_MB,
)
from litellm.integrations.otel.runtime import phase_event
@ -191,6 +193,29 @@ def is_otlp_trace_request(request: HTTPConnection) -> bool:
return request.scope.get("method") == "POST" and get_route_path(request.scope) in {"/v1/traces", "/v1/logs"}
async def _read_form_data(request: Request) -> FormData:
try:
form_data: Final = await request.form()
_mark_body_received(_declared_content_length(request.headers))
except ClientDisconnect:
raise
except Exception as e:
# ``request.form()`` raises on malformed multipart (missing
# boundary, malformed chunk encoding, …). Surface as 400 so
# the auth-time pre-read does not silently cache ``{}`` while
# a later raw-body re-read sees the original payload —
# banned-param checks must see the same body the handler
# acts on.
verbose_proxy_logger.error("Invalid form payload: %s", e)
raise ProxyException(
message=f"Invalid form payload: {e}",
type="invalid_request_error",
param="request_body",
code=status.HTTP_400_BAD_REQUEST,
)
return form_data
async def read_request_body(request: Request | None) -> dict:
"""
Safely read the request body and parse it as JSON.
@ -221,23 +246,7 @@ async def read_request_body(request: Request | None) -> dict:
_mark_body_received(len(binary_body))
parsed_body = _parse_binary_body(binary_body)
elif _is_form_content_type(content_type):
try:
form_data: Final = await request.form()
_mark_body_received(_declared_content_length(request.headers))
except Exception as e:
# ``request.form()`` raises on malformed multipart (missing
# boundary, malformed chunk encoding, …). Surface as 400 so
# the auth-time pre-read does not silently cache ``{}`` while
# a later raw-body re-read sees the original payload —
# banned-param checks must see the same body the handler
# acts on.
verbose_proxy_logger.error("Invalid form payload: %s", e)
raise ProxyException(
message=f"Invalid form payload: {e}",
type="invalid_request_error",
param="request_body",
code=status.HTTP_400_BAD_REQUEST,
)
form_data: Final = await _read_form_data(request)
parsed_body = dict(form_data)
if "metadata" in parsed_body and isinstance(parsed_body["metadata"], str):
parsed_body["metadata"] = json.loads(parsed_body["metadata"])
@ -297,6 +306,14 @@ async def read_request_body(request: Request | None) -> dict:
# Re-raise ProxyException as-is
verbose_proxy_logger.error("Invalid JSON payload received: %s", e)
raise
except ClientDisconnect:
verbose_proxy_logger.info("Client disconnected before the request body was fully received")
raise ProxyException(
message="Client disconnected the request",
type="client_disconnect",
param=None,
code=LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED,
)
except Exception as e:
# Catch unexpected errors to avoid crashes
verbose_proxy_logger.exception("Unexpected error reading request body - %s", e)

View file

@ -7859,6 +7859,71 @@ async def test_user_api_key_auth_returns_the_parse_error_even_if_logging_it_fail
assert raised.code == str(status.HTTP_400_BAD_REQUEST)
@pytest.mark.asyncio
async def test_user_api_key_auth_rejects_a_client_disconnect_with_499_instead_of_an_empty_body():
"""Regression (#44797): a client that disconnects mid-upload must be rejected by auth
with the 499 the body reader raised, not handed on to the endpoint as an empty body."""
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")
messages = iter(
[
{"type": "http.request", "body": b'{"model": "gpt-4o", "mess', "more_body": True},
{"type": "http.disconnect"},
]
)
async def receive():
return next(messages)
request = Request(
scope={
"type": "http",
"headers": [(b"content-type", b"application/json")],
"method": "POST",
"path": "/chat/completions",
},
receive=receive,
)
request._url = URL(url="/chat/completions")
hook = AsyncMock(return_value=None)
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
attrs["proxy_logging_obj"].post_call_failure_hook = hook
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",
),
):
with pytest.raises(ProxyException) as exc_info:
await user_api_key_auth(request=request, api_key="Bearer sk-test")
finally:
for k, v in originals.items():
setattr(_proxy_server_mod, k, v)
assert exc_info.value.code == "499"
assert exc_info.value.type == "client_disconnect"
mock_builder.assert_awaited_once()
mock_common_checks.assert_not_awaited()
assert hook.await_args.kwargs["original_exception"] is exc_info.value
@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

View file

@ -1141,6 +1141,73 @@ class TestReadRequestBodyFormParseFailure:
assert str(exc_info.value.code) == "400"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"content_type, partial_body",
[
("application/json", b'{"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "'),
(
"multipart/form-data; boundary=litellm",
b'--litellm\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhi',
),
("application/x-protobuf", b"\x0a\x05"),
],
)
async def test_client_disconnect_mid_upload_raises_499_and_caches_nothing(content_type: str, partial_body: bytes):
messages: Final = iter(
(
{"type": "http.request", "body": partial_body, "more_body": True},
{"type": "http.disconnect"},
)
)
async def receive() -> Mapping[str, object]:
return next(messages)
request: Final = Request(
{
"type": "http",
"method": "POST",
"path": "/v1/messages",
"headers": [(b"content-type", content_type.encode())],
},
receive,
)
with pytest.raises(ProxyException) as exc_info:
await read_request_body(request)
assert exc_info.value.code == "499"
assert exc_info.value.type == "client_disconnect"
assert _safe_get_request_parsed_body(request) is None
@pytest.mark.asyncio
async def test_auth_pre_read_defers_client_disconnect_instead_of_returning_an_empty_body():
from litellm.proxy.auth.user_api_key_auth import _read_request_body_deferring_parse_failure
messages: Final = iter(
(
{"type": "http.request", "body": b'{"model": "claude-sonnet-4-5", "messa', "more_body": True},
{"type": "http.disconnect"},
)
)
async def receive() -> Mapping[str, object]:
return next(messages)
request: Final = Request(
{"type": "http", "method": "POST", "path": "/v1/messages", "headers": [(b"content-type", b"application/json")]},
receive,
)
parsed, parse_error = await _read_request_body_deferring_parse_failure(request)
assert parsed == {}
assert parse_error is not None
assert parse_error.code == "499"
class TestGetRequestBody:
@pytest.mark.asyncio
async def test_json_with_charset_param_parses_as_json(self):