fix(http): decode a compressed response under the bounded reader's byte cap instead of refusing it

This commit is contained in:
mateo-berri 2026-09-25 18:49:08 -07:00
parent 328fe2aec2
commit a0866d7f0d
4 changed files with 48 additions and 16 deletions

View file

@ -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()

View file

@ -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,

View file

@ -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")

View file

@ -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")