mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-01 02:02:20 +00:00
fix(http): decode a compressed response under the bounded reader's byte cap instead of refusing it
This commit is contained in:
parent
328fe2aec2
commit
a0866d7f0d
4 changed files with 48 additions and 16 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue