From 321e83b352511dea5dc18b27f6317de559aa3691 Mon Sep 17 00:00:00 2001 From: Sinfony8838 Date: Tue, 6 Oct 2026 20:54:56 +0800 Subject: [PATCH 1/2] fix(proxy): report client disconnect during body upload as 499 instead of an empty body --- .../proxy/common_utils/http_parsing_utils.py | 52 +++++++++----- .../common_utils/test_http_parsing_utils.py | 67 +++++++++++++++++++ 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index fa288bad9fe..13c6b4a3522 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -7,12 +7,15 @@ from typing import Annotated, Any, Final, Literal, Union, get_args, get_origin import orjson from fastapi import Request, UploadFile, status from starlette._utils import get_route_path +from starlette.datastructures import FormData +from starlette.requests import ClientDisconnect 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 @@ -186,6 +189,29 @@ def is_otlp_trace_request(request: Request) -> bool: return request.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. @@ -216,23 +242,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"]) @@ -292,6 +302,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) diff --git a/tests/unit/proxy/common_utils/test_http_parsing_utils.py b/tests/unit/proxy/common_utils/test_http_parsing_utils.py index 16821222782..c208d149910 100644 --- a/tests/unit/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/unit/proxy/common_utils/test_http_parsing_utils.py @@ -1135,6 +1135,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): From eb65c93255f815905d7ae619eee05c3b45aaaae4 Mon Sep 17 00:00:00 2001 From: Sinfony8838 Date: Wed, 7 Oct 2026 21:30:46 +0800 Subject: [PATCH 2/2] test(proxy): cover auth rejecting a client disconnect with 499 --- .../test_user_api_key_auth_request_flow.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 8b33202b483..66cc7accc09 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -6945,6 +6945,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