fix(passthrough): propagate Azure 429/5xx errors in async streaming instead of silent HTTP 200 (#22913)

* fix(passthrough): raise_for_status in _async_streaming to propagate Azure 429s

* address greptile review feedback (greploop iteration 1)

Guard data/json args when content is provided to avoid httpx ValueError

* address greptile review feedback (greploop iteration 2)

Use bare raise to preserve original traceback in _async_streaming exception handler

* address greptile review feedback (greploop iteration 3)

Close httpx streaming response on error to prevent connection pool exhaustion

* address greptile review feedback (greploop iteration 4)

Guard aclose() call to prevent masking original exception; add explicit test for content param forwarding

* address greptile review feedback (greploop iteration 5)

Pass content to sign_request so AWS body-hash signing is correct when content is the sole body source

* revert sign_request content change - request_data expects dict, not bytes

Bedrock's sign_request calls json.dumps(request_data) — passing content bytes
would TypeError. sign_request should only receive data/json (dict), not raw bytes.
This commit is contained in:
Ishaan Jaff 2026-03-05 10:12:43 -08:00 committed by GitHub
parent 8dca085640
commit a42132f329
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 309 additions and 10 deletions

View File

@ -289,10 +289,10 @@ def llm_passthrough_route(
request = client.client.build_request(
method=method,
url=updated_url,
content=signed_json_body,
data=data if signed_json_body is None else None,
content=signed_json_body if signed_json_body is not None else content,
data=data if (signed_json_body is None and content is None) else None,
files=files,
json=json if signed_json_body is None else None,
json=json if (signed_json_body is None and content is None) else None,
params=params,
headers=headers,
cookies=cookies,
@ -410,8 +410,9 @@ async def _async_streaming(
litellm_logging_obj: "LiteLLMLoggingObj",
provider_config: "BasePassthroughConfig",
):
iter_response = await response
try:
iter_response = await response
iter_response.raise_for_status()
raw_bytes: List[bytes] = []
async for chunk in iter_response.aiter_bytes(): # type: ignore
@ -425,5 +426,9 @@ async def _async_streaming(
provider_config=provider_config,
)
)
except Exception as e:
raise e
except Exception:
try:
await iter_response.aclose()
except Exception:
pass
raise

View File

@ -0,0 +1,122 @@
"""
Tests for error propagation in _async_streaming passthrough routes.
Verifies that HTTP 4xx/5xx errors from upstream (e.g. Azure 429 rate limits)
raise exceptions instead of being silently forwarded as raw bytes under HTTP 200.
See: litellm/passthrough/main.py _async_streaming()
"""
import json
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
def _make_mock_response(status_code: int, body: bytes, headers: dict = None): # type: ignore[assignment]
mock = MagicMock(spec=httpx.Response)
mock.status_code = status_code
mock.headers = httpx.Headers(headers or {"content-type": "text/event-stream"})
def _raise_for_status():
if status_code >= 400:
request = httpx.Request("POST", "https://azure.example.com/openai/responses")
real_response = httpx.Response(
status_code=status_code,
content=body,
request=request,
headers=headers or {},
)
raise httpx.HTTPStatusError(
message=f"{status_code} Error",
request=request,
response=real_response,
)
mock.raise_for_status = _raise_for_status
async def _aiter_bytes():
yield body
mock.aiter_bytes = _aiter_bytes
return mock
def _make_mock_logging_obj():
mock = MagicMock()
mock.async_flush_passthrough_collected_chunks = AsyncMock()
return mock
@pytest.mark.asyncio
async def test_async_streaming_429_raises():
"""429 from upstream should raise HTTPStatusError, not yield error bytes."""
from litellm.passthrough.main import _async_streaming
error_body = json.dumps(
{"error": {"code": "429", "message": "Rate limit exceeded."}}
).encode()
mock_response = _make_mock_response(429, error_body)
async def response_coro():
return mock_response
chunks = []
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async for chunk in _async_streaming(
response=response_coro(),
litellm_logging_obj=_make_mock_logging_obj(),
provider_config=MagicMock(),
):
chunks.append(chunk)
assert exc_info.value.response.status_code == 429
assert len(chunks) == 0
@pytest.mark.asyncio
async def test_async_streaming_500_raises():
"""500 from upstream should also raise, not yield error bytes."""
from litellm.passthrough.main import _async_streaming
error_body = json.dumps(
{"error": {"code": "500", "message": "Internal server error"}}
).encode()
mock_response = _make_mock_response(500, error_body)
async def response_coro():
return mock_response
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async for _ in _async_streaming(
response=response_coro(),
litellm_logging_obj=_make_mock_logging_obj(),
provider_config=MagicMock(),
):
pass
assert exc_info.value.response.status_code == 500
@pytest.mark.asyncio
async def test_async_streaming_200_yields_chunks():
"""Successful 200 streaming responses should continue to work normally."""
from litellm.passthrough.main import _async_streaming
sse_data = b'data: {"type":"response.created"}\n\ndata: [DONE]\n\n'
mock_response = _make_mock_response(200, sse_data)
async def response_coro():
return mock_response
chunks = []
async for chunk in _async_streaming(
response=response_coro(),
litellm_logging_obj=_make_mock_logging_obj(),
provider_config=MagicMock(),
):
chunks.append(chunk)
assert len(chunks) == 1
assert b"response.created" in chunks[0]

View File

@ -1,13 +1,13 @@
import json
import os
import sys
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi.testclient import TestClient
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
sys.path.insert(
0, os.path.abspath("../../..")
@ -17,7 +17,7 @@ sys.path.insert(
from unittest.mock import MagicMock, patch
import litellm
from litellm.passthrough.main import llm_passthrough_route
from litellm.passthrough.main import allm_passthrough_route, llm_passthrough_route
def test_llm_passthrough_route():
@ -507,4 +507,176 @@ def test_azure_with_custom_api_base_and_key():
json_body = call_args.kwargs["json"]
assert json_body["model"] == "gpt-4.1"
assert response.status_code == 200
assert response.status_code == 200 # type: ignore[union-attr]
def test_content_param_forwarded_to_build_request():
"""
Regression test: the `content` parameter passed to llm_passthrough_route
must be forwarded to build_request instead of silently dropped.
When content is provided and signed_json_body is None, build_request should
receive content=<value> and data=None, json=None.
"""
client = HTTPHandler()
mock_provider_config = MagicMock()
mock_provider_config.get_complete_url.return_value = (
httpx.URL("https://my-azure.openai.azure.com/openai/deployments/gpt-4/chat/completions"),
"https://my-azure.openai.azure.com",
)
mock_provider_config.get_api_key.return_value = "test-key"
mock_provider_config.validate_environment.return_value = {"api-key": "test-key"}
# sign_request returns (headers, None) — no signed body, so content should be used
mock_provider_config.sign_request.return_value = ({"api-key": "test-key"}, None)
mock_provider_config.is_streaming_request.return_value = False
raw_content = b'{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}'
with patch(
"litellm.utils.ProviderConfigManager.get_provider_passthrough_config",
return_value=mock_provider_config,
), patch(
"litellm.litellm_core_utils.get_litellm_params.get_litellm_params",
return_value={},
), patch(
"litellm.litellm_core_utils.get_llm_provider_logic.get_llm_provider",
return_value=("gpt-4", "azure", "test-key", "https://my-azure.openai.azure.com"),
), patch.object(
client.client, "send", return_value=MagicMock(status_code=200)
), patch.object(
client.client, "build_request"
) as mock_build_request:
mock_logging_obj = MagicMock()
mock_logging_obj.update_environment_variables = MagicMock()
llm_passthrough_route(
model="azure/gpt-4",
endpoint="openai/deployments/gpt-4/chat/completions",
method="POST",
custom_llm_provider="azure",
content=raw_content,
data=None,
json=None,
client=client,
litellm_logging_obj=mock_logging_obj,
)
mock_build_request.assert_called_once()
call_kwargs = mock_build_request.call_args.kwargs
# content must be forwarded (not dropped)
assert call_kwargs["content"] == raw_content
# data and json must be None when content is provided
assert call_kwargs["data"] is None
assert call_kwargs["json"] is None
def _make_429_streaming_response() -> MagicMock:
"""Build a mock httpx.Response that looks like a streaming 429 from Azure."""
error_body = json.dumps(
{"error": {"code": "429", "message": "Rate limit exceeded. Retry after 10 seconds."}}
).encode()
mock = MagicMock(spec=httpx.Response)
mock.status_code = 429
mock.headers = httpx.Headers({"content-type": "application/json"})
def _raise_for_status():
request = httpx.Request(
"POST",
"https://my-azure.openai.azure.com/openai/deployments/gpt-4/responses",
)
raise httpx.HTTPStatusError(
message="429 Too Many Requests",
request=request,
response=httpx.Response(
status_code=429,
content=error_body,
request=request,
),
)
mock.raise_for_status = _raise_for_status
async def _aiter_bytes():
yield error_body
mock.aiter_bytes = _aiter_bytes
return mock
@pytest.mark.asyncio
async def test_allm_passthrough_route_429_streaming_raises():
"""
Regression test: Azure 429 during streaming must raise HTTPStatusError,
not be silently forwarded as raw bytes under HTTP 200.
Before the fix, _async_streaming() would yield the 429 error JSON as
chunks and allm_passthrough_route returned an async generator. The
caller (azure_proxy_route) wrapped it in StreamingResponse(status_code=200),
so the client saw HTTP 200 + unparseable SSE body silent task_complete(null).
After the fix, raise_for_status() fires inside _async_streaming() before
any chunks are yielded, so the exception propagates all the way up.
"""
mock_provider_config = MagicMock()
mock_provider_config.get_complete_url.return_value = (
httpx.URL(
"https://my-azure.openai.azure.com/openai/deployments/gpt-4/responses"
),
"https://my-azure.openai.azure.com",
)
mock_provider_config.get_api_key.return_value = "fake-azure-key"
mock_provider_config.validate_environment.return_value = {"api-key": "fake-azure-key"}
mock_provider_config.sign_request.return_value = ({"api-key": "fake-azure-key"}, None)
mock_provider_config.is_streaming_request.return_value = True
mock_429_response = _make_429_streaming_response()
async_client = AsyncHTTPHandler()
mock_send = AsyncMock(return_value=mock_429_response)
mock_build_request = MagicMock(return_value=MagicMock())
mock_logging_obj = MagicMock()
mock_logging_obj.update_environment_variables = MagicMock()
mock_logging_obj.async_flush_passthrough_collected_chunks = AsyncMock()
with patch(
"litellm.utils.ProviderConfigManager.get_provider_passthrough_config",
return_value=mock_provider_config,
), patch(
"litellm.litellm_core_utils.get_litellm_params.get_litellm_params",
return_value={},
), patch(
"litellm.litellm_core_utils.get_llm_provider_logic.get_llm_provider",
return_value=(
"gpt-4",
"azure",
"fake-azure-key",
"https://my-azure.openai.azure.com",
),
), patch.object(
async_client.client, "send", mock_send
), patch.object(
async_client.client, "build_request", mock_build_request
):
result = await allm_passthrough_route(
model="azure/gpt-4",
endpoint="openai/deployments/gpt-4/responses",
method="POST",
custom_llm_provider="azure",
api_base="https://my-azure.openai.azure.com",
api_key="fake-azure-key",
json={"model": "gpt-4", "input": "hello", "stream": True},
client=async_client,
litellm_logging_obj=mock_logging_obj,
)
# result is an async generator — consuming it must raise, not silently yield error bytes
chunks = []
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async for chunk in result: # type: ignore[union-attr]
chunks.append(chunk)
assert exc_info.value.response.status_code == 429
assert len(chunks) == 0, "No chunks should be yielded before the 429 raises"