From a0866d7f0d5cb689a8edc5ec1b8376decce9c078 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:49:08 -0700 Subject: [PATCH] fix(http): decode a compressed response under the bounded reader's byte cap instead of refusing it --- litellm/llms/custom_httpx/http_handler.py | 16 ++++++--- litellm/proxy/rag_endpoints/endpoints.py | 3 -- .../proxy/rag_endpoints/test_rag_endpoints.py | 9 +---- .../llms/custom_httpx/test_http_handler.py | 36 +++++++++++++++++++ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index f9581aa3841..216b32c9b50 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -553,8 +553,7 @@ class HTTPResponseLimitError(ValueError): pass -class HTTPResponseEncodingError(HTTPResponseLimitError): - pass +_WIRE_BODY_HEADERS: Final = frozenset({"content-encoding", "content-length"}) class MaskedHTTPStatusError(httpx.HTTPStatusError): @@ -609,6 +608,12 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError): self.status_code = original_error.response.status_code +def _headers_of_the_decoded_body(headers: httpx.Headers) -> httpx.Headers: + return httpx.Headers( + tuple((name, value) for name, value in headers.multi_items() if name not in _WIRE_BODY_HEADERS) + ) + + class AsyncHTTPHandler: def __init__( self, @@ -784,8 +789,6 @@ class AsyncHTTPHandler: ) if response.is_redirect or response.is_error: return httpx.Response(response.status_code, headers=response.headers, request=response.request) - if response.headers.get("content-encoding", "identity").lower() != "identity": - raise HTTPResponseEncodingError("Response size limits require an uncompressed response") if int(response.headers.get("content-length", "0")) > max_bytes: raise HTTPResponseLimitError("Response exceeds the configured size limit") with BytesIO() as body: @@ -794,7 +797,10 @@ class AsyncHTTPHandler: raise HTTPResponseLimitError("Response exceeds the configured size limit") body.write(chunk) return httpx.Response( - response.status_code, headers=response.headers, content=body.getvalue(), request=response.request + response.status_code, + headers=_headers_of_the_decoded_body(response.headers), + content=body.getvalue(), + request=response.request, ) finally: await response.aclose() diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index 43a1cbfc1c7..908e702bb94 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -27,7 +27,6 @@ from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook i ) from litellm.litellm_core_utils.url_utils import async_safe_get from litellm.llms.custom_httpx.http_handler import ( - HTTPResponseEncodingError, HTTPResponseLimitError, get_async_httpx_client, ) @@ -420,8 +419,6 @@ async def fetch_file_url(url: str) -> httpx.Response: async def _download_file_url(file_url: str, fetch_url: UrlFetcher) -> bytes: try: response: Final = await fetch_url(file_url) - except HTTPResponseEncodingError as e: - raise HTTPException(status_code=400, detail={"error": f"Could not fetch file_url: {e}"}) from e except HTTPResponseLimitError as e: raise HTTPException( status_code=400, diff --git a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py index 4907e5a9ba9..22e4914912d 100644 --- a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py +++ b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py @@ -17,7 +17,7 @@ from fastapi import HTTPException from fastapi.testclient import TestClient import litellm -from litellm.llms.custom_httpx.http_handler import HTTPResponseEncodingError, HTTPResponseLimitError +from litellm.llms.custom_httpx.http_handler import HTTPResponseLimitError from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import app @@ -1201,13 +1201,6 @@ class TestFileUrlUploadControls: detail = await self._expect_rejection(fetch_url, "file_too_large") assert str(MAX_UPLOAD_SIZE_BYTES) in detail["error"] - async def test_compressed_url_response_is_refused_without_calling_it_too_large(self): - async def fetch_url(url: str) -> httpx.Response: - raise HTTPResponseEncodingError("Response size limits require an uncompressed response") - - detail = await self._expect_rejection(fetch_url, None) - assert "uncompressed" in detail["error"] - @pytest.mark.parametrize("status_code", [301, 403, 404, 500]) async def test_non_2xx_fetch_is_a_client_error_naming_the_status(self, status_code): fetch_url, _seen = _fetcher_answering(status_code, b"benign document text") diff --git a/tests/unit/llms/custom_httpx/test_http_handler.py b/tests/unit/llms/custom_httpx/test_http_handler.py index 8358d15d30e..81e462bb46c 100644 --- a/tests/unit/llms/custom_httpx/test_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_http_handler.py @@ -1,5 +1,6 @@ import asyncio import gc +import gzip import io import os import pathlib @@ -21,6 +22,7 @@ from litellm.llms.custom_httpx.http_handler import ( _CLIENT_REFCOUNT_WHEN_HANDLER_IS_SOLE_REFERRER, AsyncHTTPHandler, HTTPHandler, + HTTPResponseLimitError, MaskedHTTPStatusError, _get_httpx_client, get_ssl_configuration, @@ -1773,6 +1775,40 @@ async def test_bounded_get_stops_redirect_loops(respx_mock, monkeypatch): assert route.call_count == 11 +@pytest.mark.asyncio +async def test_bounded_get_decodes_a_compressed_body_and_reports_the_decoded_length(respx_mock, monkeypatch): + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + document = b"benign document text\n" * 40 + compressed = gzip.compress(document) + respx_mock.get("https://cdn.example/notes.txt").respond( + 200, content=compressed, headers={"content-encoding": "gzip", "content-length": str(len(compressed))} + ) + handler = AsyncHTTPHandler() + try: + response = await handler.get("https://cdn.example/notes.txt", max_response_bytes=len(document)) + finally: + await handler.close() + assert response.content == document + assert "content-encoding" not in response.headers + assert response.headers["content-length"] == str(len(document)) + + +@pytest.mark.asyncio +async def test_bounded_get_caps_the_decoded_size_of_a_compressed_body(respx_mock, monkeypatch): + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + compressed = gzip.compress(b"0" * 200_000) + assert len(compressed) < 50_000 + respx_mock.get("https://cdn.example/bomb.txt").respond( + 200, content=compressed, headers={"content-encoding": "gzip", "content-length": str(len(compressed))} + ) + handler = AsyncHTTPHandler() + try: + with pytest.raises(HTTPResponseLimitError, match="size limit"): + await handler.get("https://cdn.example/bomb.txt", max_response_bytes=50_000) + finally: + await handler.close() + + @pytest.mark.asyncio async def test_bounded_get_closes_stream_on_cancellation(respx_mock, monkeypatch): monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")