fix(responses): keep the provider code and message on top-level stream error events (#46438)

* fix(responses): keep the provider code and message on top-level stream error events

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): log a mid-stream failure before the router falls back

CustomStreamWrapper now waits for failure logging before it raises the
error the router falls back on, so the cooldown lands on the deployment
that failed instead of the fallback that replaced it. Also keeps numeric
error codes and the top-level message on the WebSocket and ChatGPT paths.

* fix(streaming): run sync failure callbacks before a failed stream falls back to another deployment

The router cools a deployment down from a sync failure callback that reads the request's model_info. A failed stream submitted that callback to the logging executor without waiting, so it often ran after the router's fallback had overwritten model_info and cooled down the healthy fallback deployment instead, leaving the model group with no deployments

* test(responses): cover top-level OpenAI stream error events end to end

* test(streaming): read live worker pids so the second SIGKILL chaos variant sees two workers

* fix(streaming): run waited sync failure logging off the shared logging pool

* revert(streaming): keep failed-stream failure logging in the background

Waiting for sync failure callbacks before a failed stream raises fixed the router cooldown race, but it slowed healthy calls under many failing streams with a slow callback, since the wait shares a thread pool with every call's start. The race is pre-existing on main and is tracked separately, so this PR goes back to main's logging timing and keeps only the top-level error event changes

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mateo Wang 2026-10-14 16:16:46 -07:00 • committed by GitHub
parent a114356542
commit dba46c0513
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 963 additions and 130 deletions

View file

@ -226,7 +226,8 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
def _extract_error_message(self, parsed_chunk: dict[str, Any]) -> str | None:
error_obj: Final = parsed_chunk.get("error") or (parsed_chunk.get("response") or {}).get("error")
if error_obj is None:
return None
top_level_message: Final = parsed_chunk.get("message")
return top_level_message if isinstance(top_level_message, str) else None
if isinstance(error_obj, dict):
return error_obj.get("message") or str(error_obj)
return str(error_obj)

View file

@ -282,6 +282,21 @@ def _map_stream_error_to_exception(error_obj: object, model: str, custom_llm_pro
return mapped_exception
def _event_field(event: object, name: str) -> object:
return event.get(name) if _is_json_object(event) else getattr(event, name, None)
def _is_error_field_value(value: object) -> bool:
return isinstance(value, (str, int)) and not isinstance(value, bool)
def _error_event_error_object(event: object) -> object:
nested_error: Final = _event_field(event, "error")
if nested_error is not None:
return nested_error
return {field: value for field in ("message", "code") if _is_error_field_value(value := _event_field(event, field))}
def _mid_stream_fallback_eligible(mapped_exception: Exception) -> bool:
if isinstance(mapped_exception, litellm.ContentPolicyViolationError):
return True
@ -659,7 +674,7 @@ class BaseResponsesAPIStreamingIterator:
error_obj: Final[object] = (
getattr(getattr(result, "response", None), "error", None)
if chunk_type == "response.failed"
else getattr(result, "error", None)
else _error_event_error_object(result)
)
mapped_exception: Final = self._map_error_event_exception(error_obj)
@ -1837,7 +1852,7 @@ _RESPONSES_WS_OUTPUT_ITEM_EVENT_TYPES: Final = frozenset({"response.output_item.
def _ws_event_error(event: Mapping[str, object]) -> object:
if event.get("type") == "error":
return event.get("error")
return _error_event_error_object(event)
response: Final = event.get("response")
return response.get("error") if _is_json_object(response) else None

View file

@ -1932,7 +1932,10 @@ class ErrorEventError(BaseLiteLLMOpenAIResponseObject):
class ErrorEvent(BaseLiteLLMOpenAIResponseObject):
type: Literal[ResponsesAPIStreamEvents.ERROR]
sequence_number: int
error: ErrorEventError
error: ErrorEventError | None = None
code: str | int | None = None
message: str | None = None
param: str | Mapping[str, object] | None = None
class GenericEvent(BaseLiteLLMOpenAIResponseObject):

View file

@ -2,13 +2,17 @@ import json
from collections.abc import Callable, Iterator, Mapping, Sequence
from typing import Final
from integration._support.client import object_value, string_value
import httpx
from integration._support.client import JSON_OBJECT, eventually, object_value, string_value
from integration._support.database import read_rows
from integration._support.wire import Reply, Request
from pydantic import JsonValue
AZURE_TARGET: Final = "/openai/v1/responses?api-version="
OPENAI_TARGET: Final = "/responses"
RATE_LIMIT_MESSAGE: Final = "Your requests to gpt-6 have exceeded token rate limit."
RATE_LIMIT_PREFIX: Final = "litellm.RateLimitError: "
SENTINEL_PREFIX: Final = "litellm.MidStreamFallbackError: "
def frame(event: Mapping[str, JsonValue]) -> bytes:
@ -36,6 +40,14 @@ def error_event(error: Mapping[str, JsonValue] | None) -> Mapping[str, JsonValue
return {"type": "error", "sequence_number": 1, **({} if error is None else {"error": dict(error)})}
def openai_error_event(code: str, message: str) -> Mapping[str, JsonValue]:
return {"type": "error", "sequence_number": 1, "code": code, "message": message, "param": None}
def openai_error_stream(identity: str, code: str, message: str) -> tuple[bytes, ...]:
return (frame(created(identity)), frame(openai_error_event(code, message)))
def azure_rate_limit() -> Mapping[str, JsonValue]:
return {
"type": "too_many_requests",
@ -126,3 +138,78 @@ def chat_content(frames: Sequence[Mapping[str, JsonValue]]) -> str:
yield string_value(object_value(object_value(choice)["delta"]).get("content") or "")
return "".join(deltas())
def chat_body(model: str, marker: str, **extra: JsonValue) -> dict[str, JsonValue]:
return {
"model": model,
"stream": True,
"messages": [{"role": "user", "content": marker}],
"tools": function_tools(),
"num_retries": 0,
"cache": {"no-cache": True},
**extra,
}
def messages_body(model: str, marker: str) -> dict[str, JsonValue]:
return {
"model": model,
"max_tokens": 64,
"stream": True,
"messages": [{"role": "user", "content": marker}],
"tools": [
{
"name": "get_weather",
"description": "Weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
}
],
"num_retries": 0,
"cache": {"no-cache": True},
}
def error_body(response: httpx.Response) -> Mapping[str, JsonValue]:
return object_value(JSON_OBJECT.validate_json(response.content)["error"])
def data_frames(text: str) -> tuple[Mapping[str, JsonValue], ...]:
return tuple(
JSON_OBJECT.validate_json(line.removeprefix("data: "))
for line in text.splitlines()
if line.startswith("data: ") and line != "data: [DONE]"
)
def sse_events(text: str) -> tuple[tuple[str, Mapping[str, JsonValue]], ...]:
def parse(block: str) -> tuple[str, Mapping[str, JsonValue]]:
lines: Final = block.splitlines()
event: Final = next(line.removeprefix("event: ") for line in lines if line.startswith("event: "))
data: Final = next(line.removeprefix("data: ") for line in lines if line.startswith("data: "))
return event, JSON_OBJECT.validate_json(data)
return tuple(parse(block) for block in text.strip().split("\n\n") if "event: " in block)
def spend_row(call_id: str) -> Mapping[str, JsonValue]:
rows: Final = eventually(
lambda: read_rows(
'SELECT litellm_call_id, status, model_group, metadata FROM "LiteLLM_SpendLogs" WHERE litellm_call_id = %s',
(call_id,),
),
lambda found: len(found) >= 1,
seconds=70,
)
assert len(rows) == 1, rows
return rows[0]
def assert_provider_typed_rate_limit(
error: Mapping[str, JsonValue], provider_message: str = RATE_LIMIT_MESSAGE
) -> None:
assert error["type"] == "throttling_error", error
assert str(error["code"]) == "429", error
message: Final = string_value(error["message"])
assert message.startswith(RATE_LIMIT_PREFIX) and provider_message in message, message
assert SENTINEL_PREFIX not in message, message

View file

@ -0,0 +1,85 @@
import uuid
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from typing import Final
import httpx
import pytest
from integration._support import codex_vendor as cv
from integration._support.client import Gateway, gateway_from_environment, object_value, string_value
from integration._support.process import graceful_stop_seconds, owned_proxy_process
from integration._support.responses_stream import (
RATE_LIMIT_MESSAGE,
RATE_LIMIT_PREFIX,
data_frames,
error_body,
openai_error_stream,
)
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue
pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
_MODEL: Final = "chatgpt/gpt-6.1-sol"
_TOP_LEVEL_RATE_LIMIT: Final = "codex-top-level-rate-limit"
_VENDOR: Final = cv.CodexVendor()
@dataclass(frozen=True, slots=True)
class _Rig:
gateway: Gateway
wire: Wire
def _respond(request: Request) -> Reply:
if _TOP_LEVEL_RATE_LIMIT.encode() not in request.body:
return _VENDOR.respond(request)
stream: Final = openai_error_stream("resp_" + uuid.uuid4().hex, "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
return Reply(content_type="text/event-stream", chunks=stream)
@pytest.fixture(scope="module")
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
directory: Final = tmp_path_factory.mktemp("chatgpt-top-level-error-rig")
with gateway_from_environment() as gateway, wire_server(_respond) as wire:
overrides: Final = {"CHATGPT_TOKEN_DIR": str(cv.login(directory)), "CHATGPT_API_BASE": wire.url}
config: Final = cv.proxy_config(directory, probe=False)
with owned_proxy_process(gateway, directory, overrides, config=config, workers=2) as owned:
yield _Rig(owned.gateway, wire)
def _rate_limited(rig: _Rig, *, stream: bool) -> httpx.Response:
rig.wire.drain()
marker: Final = uuid.uuid4().hex
with rig.gateway.scenario() as scenario:
model: Final = scenario.model(model=_MODEL, api_base=rig.wire.url, api_key=None)
body: Final[Mapping[str, JsonValue]] = {
"model": model,
"input": [{"role": "user", "content": f"Say marker-{marker} {_TOP_LEVEL_RATE_LIMIT}"}],
"stream": stream,
"cache": {"no-cache": True},
}
response: Final = rig.gateway.request("POST", "/v1/responses", body)
(request,) = rig.wire.drain()
cv.forwarded(request, marker)
return response
def test_chatgpt_stream_top_level_rate_limit_fails_with_the_provider_code_and_message(rig: _Rig) -> None:
response: Final = _rate_limited(rig, stream=True)
assert response.status_code == 200, response.text
assert response.headers["content-type"].startswith("text/event-stream"), response.headers
frames: Final = data_frames(response.text)
assert [frame["type"] for frame in frames] == ["response.created", "response.failed"], response.text
error: Final = object_value(object_value(frames[-1]["response"])["error"])
assert error == {"code": "rate_limit_exceeded", "message": RATE_LIMIT_MESSAGE}, error
def test_chatgpt_aggregated_top_level_rate_limit_answers_429_with_the_provider_message(rig: _Rig) -> None:
response: Final = _rate_limited(rig, stream=False)
assert response.status_code == 429, (response.status_code, response.text)
assert response.headers["content-type"].startswith("application/json"), response.headers
message: Final = string_value(error_body(response)["message"])
assert message.startswith(RATE_LIMIT_PREFIX) and RATE_LIMIT_MESSAGE in message, message
assert "event:" not in message, message
assert rig.gateway.request("GET", "/health/liveliness", None).status_code == 200

View file

@ -1,30 +1,35 @@
import asyncio
import hashlib
import itertools
import json
import ssl
import threading
import uuid
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from queue import SimpleQueue
from typing import Final
from typing import Final, TypeAlias
import pytest
import websockets
from integration._support.client import Gateway, gateway_from_environment
from integration._support.process import owned_proxy
from integration._support.client import Gateway, eventually, gateway_from_environment, object_value, string_value
from integration._support.database import read_rows
from integration._support.process import graceful_stop_seconds, owned_proxy
from integration._support.responses_stream import RATE_LIMIT_MESSAGE, openai_error_event
from integration._support.tls import server_context, write_self_signed_cert
from pydantic import JsonValue
from websockets.asyncio.server import ServerConnection, serve
pytestmark: Final = pytest.mark.timeout(180)
pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
PROVIDER_MODEL: Final = "ws-peer-model"
USAGE: Final = {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7}
TERMINAL: Final = frozenset({"response.completed", "response.failed", "error"})
_Script: TypeAlias = Callable[[str], tuple[dict[str, JsonValue], ...]]
@dataclass(frozen=True, slots=True)
class Peer:
@ -58,8 +63,20 @@ def _events(response_id: str, text: str) -> tuple[dict[str, JsonValue], ...]:
)
def _seven(response_id: str) -> tuple[dict[str, JsonValue], ...]:
return _events(response_id, "seven")
def _top_level_rate_limit(response_id: str) -> tuple[dict[str, JsonValue], ...]:
created: Final = _events(response_id, "")[0]
return (created, dict(openai_error_event("rate_limit_exceeded", RATE_LIMIT_MESSAGE)))
async def _answer(
connection: ServerConnection, paths: SimpleQueue[str], frames: SimpleQueue[dict[str, JsonValue]]
connection: ServerConnection,
paths: SimpleQueue[str],
frames: SimpleQueue[dict[str, JsonValue]],
script: _Script,
) -> None:
paths.put(connection.request.path if connection.request is not None else "")
turns: Final = itertools.count(1)
@ -68,7 +85,7 @@ async def _answer(
frames.put(frame)
if frame.get("type") != "response.create":
continue
for event in _events(f"resp_peer_{next(turns)}", "seven"):
for event in script(f"resp_peer_{next(turns)}"):
await connection.send(json.dumps(event))
@ -78,21 +95,24 @@ async def _serve(
frames: SimpleQueue[dict[str, JsonValue]],
ports: SimpleQueue[int],
stop: asyncio.Event,
script: _Script,
) -> None:
async with serve(lambda connection: _answer(connection, paths, frames), "127.0.0.1", 0, ssl=tls) as server:
async with serve(lambda connection: _answer(connection, paths, frames, script), "127.0.0.1", 0, ssl=tls) as server:
ports.put(next(iter(server.sockets)).getsockname()[1])
await stop.wait()
@contextmanager
def responses_peer(cert: tuple[Path, Path]) -> Iterator[Peer]:
def responses_peer(cert: tuple[Path, Path], script: _Script = _seven) -> Iterator[Peer]:
loop: Final = asyncio.new_event_loop()
stop: Final = asyncio.Event()
paths: Final = SimpleQueue[str]()
frames: Final = SimpleQueue[dict[str, JsonValue]]()
ports: Final = SimpleQueue[int]()
thread: Final = threading.Thread(
target=loop.run_until_complete, args=(_serve(server_context(*cert), paths, frames, ports, stop),), daemon=True
target=loop.run_until_complete,
args=(_serve(server_context(*cert), paths, frames, ports, stop, script),),
daemon=True,
)
thread.start()
try:
@ -207,3 +227,29 @@ def test_previous_response_id_from_the_first_turn_reaches_the_provider_as_its_ow
assert [frame["input"][0]["content"][0]["text"] for frame in forwarded] == list(texts)
assert "previous_response_id" not in forwarded[0]
assert forwarded[1]["previous_response_id"] == "resp_peer_1"
def test_a_top_level_error_frame_reaches_the_client_and_logs_the_provider_code_and_message(
candidate: Gateway, cert: tuple[Path, Path]
) -> None:
with responses_peer(cert, _top_level_rate_limit) as peer, candidate.scenario() as scenario:
model: Final = scenario.model(model=f"openai/{PROVIDER_MODEL}", api_base=peer.url)
key: Final = scenario.key(models=[model])
text: Final = f"say seven {uuid.uuid4().hex}"
(events,) = asyncio.run(_session(str(candidate.client.base_url), key, model, (text,)))
assert [event["type"] for event in events] == ["response.created", "error"], events
assert (events[-1]["code"], events[-1]["message"]) == ("rate_limit_exceeded", RATE_LIMIT_MESSAGE), events
(forwarded,) = _drain(peer.frames)
assert forwarded["input"][0]["content"][0]["text"] == text
(row,) = eventually(
lambda: read_rows(
'SELECT status, metadata FROM "LiteLLM_SpendLogs" WHERE api_key = %s',
(hashlib.sha256(key.encode()).hexdigest(),),
),
lambda rows: len(rows) >= 1,
seconds=70,
)
assert row["status"] == "failure", row
error: Final = object_value(object_value(row["metadata"])["error_information"])
assert str(error["error_code"]) == "429", error
assert RATE_LIMIT_MESSAGE in string_value(error["error_message"]), error

View file

@ -6,6 +6,8 @@ from integration._support.responses_stream import (
AZURE_TARGET,
RATE_LIMIT_MESSAGE,
function_tools,
healthy_stream,
openai_error_stream,
rate_limited_stream,
serve,
)
@ -13,7 +15,7 @@ from integration._support.wire import Wire, wire_server
import litellm
from litellm import Router
from litellm.exceptions import MidStreamFallbackError, RateLimitError
from litellm.exceptions import APIError, MidStreamFallbackError, RateLimitError
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
_MODEL: Final = "azure/gpt-6"
@ -135,3 +137,130 @@ async def test_router_async_stream_in_stream_rate_limit_with_fallbacks_disabled_
async for _ in response:
pass
_assert_surfaces_the_provider_exception(raised.value, wire, marker)
def _openai_error_stream(marker: str, code: str, message: str) -> tuple[bytes, ...]:
return openai_error_stream(f"resp_{marker}", code, message)
def test_sync_completion_stream_openai_error_event_raises_the_provider_code_and_message() -> None:
marker: Final = uuid.uuid4().hex
message: Final = f"The server had an error processing your request {marker}"
with wire_server(serve(_openai_error_stream(marker, "server_error", message), AZURE_TARGET)) as wire:
response: Final = litellm.completion(
model=_MODEL,
messages=_messages(marker),
tools=_TOOLS,
stream=True,
num_retries=0,
api_base=wire.url,
api_key=_API_KEY,
)
with pytest.raises(MidStreamFallbackError) as raised:
for _ in response:
pass
assert type(raised.value.original_exception) is APIError, repr(raised.value.original_exception)
assert raised.value.status_code == 500 and message in str(raised.value), str(raised.value)
_assert_one_attempt(wire, marker)
async def test_router_async_stream_openai_error_event_rate_limit_surfaces_the_provider_exception() -> None:
marker: Final = uuid.uuid4().hex
stream: Final = _openai_error_stream(marker, "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
with wire_server(serve(stream, AZURE_TARGET)) as wire:
response: Final = await _router(wire).acompletion(
model=_GROUP, messages=_messages(marker), tools=_TOOLS, stream=True
)
with pytest.raises(RateLimitError) as raised:
async for _ in response:
pass
_assert_surfaces_the_provider_exception(raised.value, wire, marker)
async def test_async_completion_stream_top_level_rate_limit_wraps_the_provider_exception_once() -> None:
marker: Final = uuid.uuid4().hex
stream: Final = _openai_error_stream(marker, "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
with wire_server(serve(stream, AZURE_TARGET)) as wire:
response: Final = await litellm.acompletion(
model=_MODEL,
messages=_messages(marker),
tools=_TOOLS,
stream=True,
num_retries=0,
api_base=wire.url,
api_key=_API_KEY,
)
with pytest.raises(MidStreamFallbackError) as raised:
async for _ in response:
pass
_assert_wraps_the_provider_exception_once(raised.value, wire, marker)
def test_router_sync_stream_top_level_rate_limit_with_fallbacks_disabled_surfaces_the_provider_exception() -> None:
marker: Final = uuid.uuid4().hex
stream: Final = _openai_error_stream(marker, "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
with wire_server(serve(stream, AZURE_TARGET)) as wire:
response: Final = _router(wire).completion(
model=_GROUP, messages=_messages(marker), tools=_TOOLS, stream=True, disable_fallbacks=True
)
with pytest.raises(RateLimitError) as raised:
for _ in response:
pass
_assert_surfaces_the_provider_exception(raised.value, wire, marker)
async def test_router_async_stream_top_level_rate_limit_falls_back_to_the_healthy_deployment() -> None:
marker: Final = uuid.uuid4().hex
limited: Final = _openai_error_stream(marker, "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
with (
wire_server(serve(limited, AZURE_TARGET)) as primary,
wire_server(serve(healthy_stream(f"resp_{marker}", "fallback answer"), AZURE_TARGET)) as spare,
):
router: Final = Router(
model_list=[
{"model_name": name, "litellm_params": {"model": _MODEL, "api_base": wire.url, "api_key": _API_KEY}}
for name, wire in ((_GROUP, primary), ("bridged-spare", spare))
],
num_retries=0,
disable_cooldowns=True,
fallbacks=[{_GROUP: ["bridged-spare"]}],
)
response: Final = await router.acompletion(model=_GROUP, messages=_messages(marker), tools=_TOOLS, stream=True)
content: Final = "".join([chunk.choices[0].delta.content or "" async for chunk in response if chunk.choices])
assert content == "fallback answer", content
_assert_one_attempt(primary, marker)
_assert_one_attempt(spare, marker)
def _provider_rate_limit(raised: Exception, message: str) -> None:
inner: Final = raised.original_exception if isinstance(raised, MidStreamFallbackError) else raised
assert type(inner) is RateLimitError, repr(inner)
assert getattr(raised, "status_code", None) == 429 and message in str(raised), str(raised)
def test_sync_responses_stream_top_level_rate_limit_raises_the_provider_rate_limit() -> None:
marker: Final = uuid.uuid4().hex
stream: Final = _openai_error_stream(marker, "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
with wire_server(serve(stream, AZURE_TARGET)) as wire:
response: Final = litellm.responses(
model=_MODEL, input=marker, stream=True, api_base=wire.url, api_key=_API_KEY
)
with pytest.raises((MidStreamFallbackError, RateLimitError)) as raised:
for _ in response:
pass
_provider_rate_limit(raised.value, RATE_LIMIT_MESSAGE)
_assert_one_attempt(wire, marker)
async def test_async_responses_stream_top_level_insufficient_quota_raises_a_rate_limit() -> None:
marker: Final = uuid.uuid4().hex
quota: Final = "You exceeded your current quota, please check your plan and billing details."
with wire_server(serve(_openai_error_stream(marker, "insufficient_quota", quota), AZURE_TARGET)) as wire:
response: Final = await litellm.aresponses(
model=_MODEL, input=marker, stream=True, api_base=wire.url, api_key=_API_KEY
)
with pytest.raises((MidStreamFallbackError, RateLimitError)) as raised:
async for _ in response:
pass
_provider_rate_limit(raised.value, quota)
_assert_one_attempt(wire, marker)

View file

@ -27,9 +27,11 @@ from integration._support.database import read_rows
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
from integration._support.responses_stream import (
AZURE_TARGET,
RATE_LIMIT_MESSAGE,
chat_content,
function_tools,
healthy_stream,
openai_error_stream,
rate_limited_stream,
)
from integration._support.wire import Reply, Request, wire_server
@ -39,15 +41,33 @@ pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
_MODEL: Final = "bridged-stream-chaos"
_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})")
_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
_SENTINEL_PREFIX: Final = "litellm.MidStreamFallbackError: "
_RATE_LIMIT_PREFIX: Final = "litellm.RateLimitError: "
_HEADERS: Final[Mapping[str, str]] = MappingProxyType({"anthropic-version": "2023-06-01"})
Kind: TypeAlias = Literal["chat_limited", "chat_healthy", "messages_limited", "responses_limited"]
Kind: TypeAlias = Literal[
"chat_limited",
"chat_healthy",
"messages_limited",
"responses_limited",
"chat_top_level",
"messages_top_level",
"responses_top_level",
"chat_dropped",
]
_KINDS: Final[tuple[Kind, ...]] = ("chat_limited", "chat_healthy", "messages_limited", "responses_limited")
_TOP_LEVEL_KINDS: Final[tuple[Kind, ...]] = (
"chat_top_level",
"chat_healthy",
"messages_top_level",
"responses_top_level",
"chat_dropped",
)
_CHAT_KINDS: Final[tuple[Kind, ...]] = ("chat_limited", "chat_healthy")
_LOGGED_KINDS: Final[frozenset[Kind]] = frozenset({"chat_limited", "chat_healthy", "responses_limited"})
_TOP_LEVEL_CHAT_KINDS: Final[tuple[Kind, ...]] = ("chat_top_level", "chat_healthy")
_LOGGED_KINDS: Final[frozenset[Kind]] = frozenset(
{"chat_limited", "chat_healthy", "responses_limited", "chat_top_level", "responses_top_level", "chat_dropped"}
)
@dataclass(frozen=True, slots=True)
@ -128,16 +148,25 @@ def _respond(request: Request) -> Reply:
identity: Final = f"resp_{uuid.uuid4().hex}"
if b"chat_healthy" in request.body:
return Reply(content_type="text/event-stream", chunks=healthy_stream(identity, f"answer marker-{marker}"))
if b"chat_dropped" in request.body:
return Reply(
content_type="text/event-stream", chunks=healthy_stream(identity, f"answer marker-{marker}"), abort_after=1
)
if b"_top_level" in request.body:
return Reply(
content_type="text/event-stream",
chunks=openai_error_stream(identity, "rate_limit_exceeded", RATE_LIMIT_MESSAGE),
)
return Reply(content_type="text/event-stream", chunks=rate_limited_stream(identity))
def _path(kind: Kind) -> str:
match kind:
case "chat_limited" | "chat_healthy":
case "chat_limited" | "chat_healthy" | "chat_top_level" | "chat_dropped":
return "/v1/chat/completions"
case "messages_limited":
case "messages_limited" | "messages_top_level":
return "/v1/messages"
case "responses_limited":
case "responses_limited" | "responses_top_level":
return "/v1/responses"
@ -150,9 +179,9 @@ def _body(call: _Call) -> Mapping[str, JsonValue]:
"cache": {"no-cache": True},
}
match call.kind:
case "chat_limited" | "chat_healthy":
case "chat_limited" | "chat_healthy" | "chat_top_level" | "chat_dropped":
return {**common, "messages": [{"role": "user", "content": prompt}], "tools": function_tools()}
case "messages_limited":
case "messages_limited" | "messages_top_level":
return {
**common,
"max_tokens": 64,
@ -165,7 +194,7 @@ def _body(call: _Call) -> Mapping[str, JsonValue]:
}
],
}
case "responses_limited":
case "responses_limited" | "responses_top_level":
return {**common, "input": prompt}
@ -216,13 +245,14 @@ def _assert_answered_in_its_own_shape(served: _Served) -> None:
case "chat_healthy":
assert served.status == 200, served.text
assert chat_content(_data_frames(served.text)) == f"answer marker-{served.call.marker}", served.text
case "chat_limited":
case "chat_limited" | "chat_top_level":
assert served.status == 429, served.text
error: Final = object_value(JSON_OBJECT.validate_json(served.text)["error"])
assert error["type"] == "throttling_error" and str(error["code"]) == "429", error
message: Final = string_value(error["message"])
assert message.startswith(_RATE_LIMIT_PREFIX) and _SENTINEL_PREFIX not in message, message
case "messages_limited":
assert RATE_LIMIT_MESSAGE in message, message
case "messages_limited" | "messages_top_level":
assert served.status == 200, served.text
events: Final = _sse_events(served.text)
assert events[0][0] == "message_start" and events[-1][0] == "error", events
@ -230,10 +260,21 @@ def _assert_answered_in_its_own_shape(served: _Served) -> None:
assert frame_error["type"] == "rate_limit_error", frame_error
frame_message: Final = string_value(frame_error["message"])
assert frame_message.startswith(_RATE_LIMIT_PREFIX) and _SENTINEL_PREFIX not in frame_message, frame_message
assert RATE_LIMIT_MESSAGE in frame_message, frame_message
case "responses_limited":
assert served.status == 200, served.text
kinds: Final = [frame["type"] for frame in _data_frames(served.text)]
assert kinds == ["response.created", "response.failed"], served.text
case "responses_top_level":
assert served.status == 200, served.text
frames: Final = _data_frames(served.text)
assert [frame["type"] for frame in frames] == ["response.created", "response.failed"], served.text
failure: Final = object_value(object_value(frames[-1]["response"])["error"])
assert failure["code"] == "rate_limit_exceeded", failure
assert RATE_LIMIT_MESSAGE in string_value(failure["message"]), failure
case "chat_dropped":
assert served.status == 500, served.text
assert "error" in JSON_OBJECT.validate_json(served.text), served.text
def _assert_forwarded(received: tuple[Request, ...], calls: tuple[_Call, ...]) -> None:
@ -263,12 +304,8 @@ def _assert_each_lands_once(served: tuple[_Served, ...]) -> None:
assert by_call[item.call_id]["status"] == expected, (item.call_id, rows)
def _worker_pids(log: Path, count: int) -> tuple[int, ...]:
return eventually(
lambda: tuple(int(found.group(1)) for found in _STARTED_WORKER.finditer(log.read_text())),
lambda pids: len(pids) == count,
seconds=30,
)
def _worker_pids(proxy: OwnedProxy, count: int) -> frozenset[int]:
return eventually(proxy.worker_pids, lambda pids: len(pids) == count, seconds=30)
def _open_upstream_connections(pid: int, upstream: str) -> int:
@ -299,24 +336,28 @@ async def _held_burst(gateway: Gateway, calls: tuple[_Call, ...], held: _Held) -
return burst
@pytest.mark.parametrize("kinds", [_KINDS, _TOP_LEVEL_KINDS], ids=["nested", "top_level"])
async def test_mixed_burst_of_bridged_streams_answers_each_call_in_its_own_shape_and_logs_each_once(
rig: _Rig,
rig: _Rig, kinds: tuple[Kind, ...]
) -> None:
calls: Final = _calls(24, _KINDS)
calls: Final = _calls(6 * len(kinds), kinds)
with wire_server(_respond, port=rig.port) as wire:
served: Final = await _burst(rig.gateway, calls)
assert len(served) == 24
assert len(served) == len(calls)
for item in served:
_assert_answered_in_its_own_shape(item)
_assert_forwarded(wire.drain(), calls)
_assert_each_lands_once(served)
async def test_worker_sigkill_mid_burst_leaves_the_sibling_answering_the_bridged_streams(rig: _Rig) -> None:
calls: Final = _calls(20, _CHAT_KINDS)
@pytest.mark.parametrize("kinds", [_CHAT_KINDS, _TOP_LEVEL_CHAT_KINDS], ids=["nested", "top_level"])
async def test_worker_sigkill_mid_burst_leaves_the_sibling_answering_the_bridged_streams(
rig: _Rig, kinds: tuple[Kind, ...]
) -> None:
calls: Final = _calls(20, kinds)
held: Final = _Held(threading.Event(), SimpleQueue())
with wire_server(held.respond, port=rig.port) as wire:
workers: Final = _worker_pids(rig.proxy.log, 2)
workers: Final = _worker_pids(rig.proxy, 2)
burst: Final = await _held_burst(rig.gateway, calls, held)
held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, wire.url) for pid in workers})
assert sum(held_by.values()) == 20, held_by

View file

@ -13,104 +13,47 @@ import openai
import pytest
import yaml
from integration._support.client import (
JSON_OBJECT,
Gateway,
eventually,
gateway_from_environment,
object_value,
string_value,
)
from integration._support.database import read_rows
from integration._support.process import graceful_stop_seconds, owned_proxy
from integration._support.responses_stream import (
AZURE_TARGET,
OPENAI_TARGET,
RATE_LIMIT_MESSAGE,
RATE_LIMIT_PREFIX,
SENTINEL_PREFIX,
assert_provider_typed_rate_limit,
azure_rate_limit,
chat_body,
chat_content,
created,
data_frames,
delta,
error_body,
error_event,
failed,
frame,
function_tools,
healthy_stream,
messages_body,
openai_error_event,
openai_error_stream,
rate_limited_stream,
serve,
spend_row,
sse_events,
)
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue
_RATE_LIMIT_PREFIX: Final = "litellm.RateLimitError: "
_SENTINEL_PREFIX: Final = "litellm.MidStreamFallbackError: "
_PRIMARY: Final = "bridged-primary"
_SPARE: Final = "bridged-spare"
pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
def chat_body(model: str, marker: str, **extra: JsonValue) -> dict[str, JsonValue]:
return {
"model": model,
"stream": True,
"messages": [{"role": "user", "content": marker}],
"tools": function_tools(),
"num_retries": 0,
"cache": {"no-cache": True},
**extra,
}
def messages_body(model: str, marker: str) -> dict[str, JsonValue]:
return {
"model": model,
"max_tokens": 64,
"stream": True,
"messages": [{"role": "user", "content": marker}],
"tools": [
{
"name": "get_weather",
"description": "Weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
}
],
"num_retries": 0,
"cache": {"no-cache": True},
}
def error_body(response: httpx.Response) -> Mapping[str, JsonValue]:
return object_value(JSON_OBJECT.validate_json(response.content)["error"])
def data_frames(text: str) -> tuple[Mapping[str, JsonValue], ...]:
return tuple(
JSON_OBJECT.validate_json(line.removeprefix("data: "))
for line in text.splitlines()
if line.startswith("data: ") and line != "data: [DONE]"
)
def spend_row(call_id: str) -> Mapping[str, JsonValue]:
rows: Final = eventually(
lambda: read_rows(
'SELECT litellm_call_id, status, model_group FROM "LiteLLM_SpendLogs" WHERE litellm_call_id = %s',
(call_id,),
),
lambda found: len(found) >= 1,
seconds=70,
)
assert len(rows) == 1, rows
return rows[0]
def assert_provider_typed_rate_limit(error: Mapping[str, JsonValue]) -> None:
assert error["type"] == "throttling_error", error
assert str(error["code"]) == "429", error
message: Final = string_value(error["message"])
assert message.startswith(_RATE_LIMIT_PREFIX) and RATE_LIMIT_MESSAGE in message, message
assert _SENTINEL_PREFIX not in message, message
def assert_failed_once(wire: Wire, call_id: str, model: str, attempts: int = 1) -> tuple[Request, ...]:
received: Final = wire.drain()
assert len(received) == attempts, [request.target for request in received]
@ -237,6 +180,55 @@ def test_bridged_in_stream_rate_limit_whose_fallback_is_also_rate_limited_answer
assert spend_row(response.headers["x-litellm-call-id"])["status"] == "failure"
def test_bridged_top_level_rate_limit_falls_back_to_the_healthy_deployment(fallback_proxy: FallbackProxy) -> None:
identity: Final = "resp_" + uuid.uuid4().hex
limited: Final = openai_error_stream(identity, "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
with (
wire_server(serve(limited, AZURE_TARGET), port=fallback_proxy.primary_port) as primary,
wire_server(
serve(healthy_stream(identity, "fallback answer"), AZURE_TARGET), port=fallback_proxy.spare_port
) as spare,
):
response: Final = _chat(fallback_proxy.gateway, chat_body(_PRIMARY, identity))
assert response.status_code == 200, response.text
assert chat_content(data_frames(response.text)) == "fallback answer", response.text
assert len(primary.drain()) == 1 and len(spare.drain()) == 1
row: Final = spend_row(response.headers["x-litellm-call-id"])
assert row["status"] == "success" and row["model_group"] == _SPARE, row
_INVALID_PROMPT: Final = "Invalid prompt: your prompt was flagged."
@pytest.mark.parametrize(
"rejection",
[
pytest.param(
error_event({"type": "invalid_request_error", "code": "invalid_prompt", "message": _INVALID_PROMPT}),
id="nested",
),
pytest.param(openai_error_event("invalid_prompt", _INVALID_PROMPT), id="top_level"),
],
)
def test_bridged_invalid_prompt_answers_a_bad_request_without_trying_the_fallback(
fallback_proxy: FallbackProxy, rejection: Mapping[str, JsonValue]
) -> None:
identity: Final = "resp_" + uuid.uuid4().hex
with (
wire_server(
serve((frame(created(identity)), frame(rejection)), AZURE_TARGET), port=fallback_proxy.primary_port
) as primary,
wire_server(
serve(healthy_stream(identity, "fallback answer"), AZURE_TARGET), port=fallback_proxy.spare_port
) as spare,
):
response: Final = _chat(fallback_proxy.gateway, chat_body(_PRIMARY, identity))
assert response.status_code == 400, response.text
message: Final = string_value(error_body(response)["message"])
assert message.startswith("litellm.BadRequestError: ") and _INVALID_PROMPT in message, message
assert len(primary.drain()) == 1 and len(spare.drain()) == 0
def _in_stream_error_status(gateway: Gateway, stream: tuple[bytes, ...]) -> tuple[httpx.Response, str]:
with wire_server(serve(stream, AZURE_TARGET)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model="azure/gpt-6", api_base=wire.url, api_key="synthetic-azure-key")
@ -258,7 +250,7 @@ def test_bridged_in_stream_server_error_reaches_the_client_as_the_provider_error
message: Final = string_value(error["message"])
assert str(error["code"]) == "500", error
assert message.startswith("litellm.APIError: ") and "The server had an error" in message, message
assert _SENTINEL_PREFIX not in message, message
assert SENTINEL_PREFIX not in message, message
def test_bridged_in_stream_invalid_prompt_is_a_bad_request_on_both_legs(gateway: Gateway) -> None:
@ -274,7 +266,7 @@ def test_bridged_in_stream_invalid_prompt_is_a_bad_request_on_both_legs(gateway:
message: Final = string_value(error["message"])
assert str(error["code"]) == "400", error
assert message.startswith("litellm.BadRequestError: ") and "Invalid prompt" in message, message
assert _SENTINEL_PREFIX not in message, message
assert SENTINEL_PREFIX not in message, message
def test_bridged_error_event_without_an_error_object_is_a_provider_typed_internal_error(gateway: Gateway) -> None:
@ -286,7 +278,7 @@ def test_bridged_error_event_without_an_error_object_is_a_provider_typed_interna
message: Final = string_value(error["message"])
assert str(error["code"]) == "500", error
assert message.startswith("litellm.APIError: ") and "Response API in-stream error" in message, message
assert _SENTINEL_PREFIX not in message, message
assert SENTINEL_PREFIX not in message, message
def test_bridged_error_event_with_a_numeric_code_is_a_throttling_error(gateway: Gateway) -> None:
@ -297,6 +289,14 @@ def test_bridged_error_event_with_a_numeric_code_is_a_throttling_error(gateway:
assert_provider_typed_rate_limit(error_body(response))
def test_bridged_openai_error_event_keeps_the_provider_code_and_message(gateway: Gateway) -> None:
identity: Final = "resp_" + uuid.uuid4().hex
stream: Final = (frame(created(identity)), frame(openai_error_event("rate_limit_exceeded", RATE_LIMIT_MESSAGE)))
response, _ = _in_stream_error_status(gateway, stream)
assert response.status_code == 429, response.text
assert_provider_typed_rate_limit(error_body(response))
def test_bridged_response_failed_without_an_error_event_is_a_throttling_error(gateway: Gateway) -> None:
identity: Final = "resp_" + uuid.uuid4().hex
stream: Final = (frame(created(identity)), frame(failed(identity, "rate_limit_exceeded", RATE_LIMIT_MESSAGE)))
@ -323,8 +323,8 @@ def test_bridged_rate_limit_after_output_is_a_provider_typed_error_frame_behind_
assert str(error["code"]) == "429", error
assert error["type"] == "throttling_error", error
message: Final = string_value(error["message"])
assert message.startswith(_RATE_LIMIT_PREFIX) and RATE_LIMIT_MESSAGE in message, message
assert _SENTINEL_PREFIX not in message, message
assert message.startswith(RATE_LIMIT_PREFIX) and RATE_LIMIT_MESSAGE in message, message
assert SENTINEL_PREFIX not in message, message
def test_bridged_transport_drop_after_response_created_is_a_500_without_the_sentinel(gateway: Gateway) -> None:
@ -346,7 +346,7 @@ def test_bridged_transport_drop_after_response_created_is_a_500_without_the_sent
message: Final = string_value(error["message"])
assert str(error["code"]) == "500", error
assert "never sent" not in response.text
assert _SENTINEL_PREFIX not in message, message
assert SENTINEL_PREFIX not in message, message
assert_failed_once(wire, response.headers["x-litellm-call-id"], model)
@ -372,28 +372,18 @@ def test_plain_chat_http_rate_limit_is_a_throttling_error_on_both_legs(gateway:
error: Final = object_value(raised.value.body)
assert error["type"] == "throttling_error" and str(error["code"]) == "429", error
message: Final = string_value(error["message"])
assert message.startswith(_RATE_LIMIT_PREFIX) and "Rate limit reached" in message, message
assert message.startswith(RATE_LIMIT_PREFIX) and "Rate limit reached" in message, message
assert_failed_once(wire, raised.value.response.headers["x-litellm-call-id"], model)
def sse_events(text: str) -> tuple[tuple[str, Mapping[str, JsonValue]], ...]:
def parse(block: str) -> tuple[str, Mapping[str, JsonValue]]:
lines: Final = block.splitlines()
event: Final = next(line.removeprefix("event: ") for line in lines if line.startswith("event: "))
data: Final = next(line.removeprefix("data: ") for line in lines if line.startswith("data: "))
return event, JSON_OBJECT.validate_json(data)
return tuple(parse(block) for block in text.strip().split("\n\n") if "event: " in block)
def assert_messages_errorframe(events: Sequence[tuple[str, Mapping[str, JsonValue]]]) -> None:
assert events[0][0] == "message_start", events
assert events[-1][0] == "error", events
error: Final = object_value(events[-1][1]["error"])
assert error["type"] == "rate_limit_error", error
message: Final = string_value(error["message"])
assert message.startswith(_RATE_LIMIT_PREFIX) and RATE_LIMIT_MESSAGE in message, message
assert message.count(_RATE_LIMIT_PREFIX) == 1 and _SENTINEL_PREFIX not in message, message
assert message.startswith(RATE_LIMIT_PREFIX) and RATE_LIMIT_MESSAGE in message, message
assert message.count(RATE_LIMIT_PREFIX) == 1 and SENTINEL_PREFIX not in message, message
def test_messages_over_the_bridged_stream_carry_the_provider_error_once_in_the_errorframe(gateway: Gateway) -> None:

View file

@ -0,0 +1,407 @@
import json
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final
import anthropic
import httpx
import openai
import pytest
from integration._support.client import Gateway, Scenario, object_value, string_value
from integration._support.responses_stream import (
AZURE_TARGET,
OPENAI_TARGET,
RATE_LIMIT_MESSAGE,
RATE_LIMIT_PREFIX,
SENTINEL_PREFIX,
assert_provider_typed_rate_limit,
chat_body,
chat_content,
created,
data_frames,
delta,
error_body,
frame,
function_tools,
healthy_stream,
messages_body,
openai_error_event,
openai_error_stream,
serve,
spend_row,
sse_events,
)
from integration._support.wire import Wire, wire_server
from openai.types.responses import ResponseFailedEvent, ResponseStreamEvent
from pydantic import JsonValue
_AZURE_KEY: Final = "synthetic-azure-key"
_QUOTA_MESSAGE: Final = "You exceeded your current quota, please check your plan and billing details."
_SERVER_MESSAGE: Final = "The server had an error while processing your request. Sorry about that!"
_ANTHROPIC_HEADERS: Final = {"anthropic-version": "2023-06-01"}
@dataclass(frozen=True, slots=True)
class _Served:
response: httpx.Response
wire: Wire
def _azure_model(scenario: Scenario, wire: Wire) -> str:
return scenario.model(model="azure/gpt-6", api_base=wire.url, api_key=_AZURE_KEY)
def _post(gateway: Gateway, path: str, body: Mapping[str, JsonValue], stream: tuple[bytes, ...]) -> _Served:
with wire_server(serve(stream, AZURE_TARGET)) as wire, gateway.scenario() as scenario:
model: Final = _azure_model(scenario, wire)
response: Final = gateway.request("POST", path, {**body, "model": model}, headers=_ANTHROPIC_HEADERS)
return _Served(response, wire)
def _chat(gateway: Gateway, stream: tuple[bytes, ...]) -> tuple[httpx.Response, str]:
marker: Final = uuid.uuid4().hex
served: Final = _post(gateway, "/v1/chat/completions", chat_body("", marker), stream)
_assert_one_attempt(served.wire, marker)
return served.response, marker
def _assert_one_attempt(wire: Wire, marker: str) -> None:
received: Final = wire.drain()
assert len(received) == 1 and marker.encode() in received[0].body, [request.target for request in received]
def _assert_failure_logged(call_id: str, logged_code: str, provider_message: str) -> None:
row: Final = spend_row(call_id)
assert row["status"] == "failure", row
error: Final = object_value(object_value(row["metadata"])["error_information"])
assert str(error["error_code"]) == logged_code, error
assert provider_message in string_value(error["error_message"]), error
def _identity() -> str:
return "resp_" + uuid.uuid4().hex
def test_openai_sdk_chat_stream_top_level_rate_limit_is_a_throttling_error_with_the_provider_message(
gateway: Gateway,
) -> None:
marker: Final = uuid.uuid4().hex
stream: Final = openai_error_stream(_identity(), "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
with wire_server(serve(stream, AZURE_TARGET)) as wire, gateway.scenario() as scenario:
model: Final = _azure_model(scenario, wire)
client: Final = openai.OpenAI(base_url=f"{gateway.client.base_url}/v1", api_key=gateway.key, max_retries=0)
with pytest.raises(openai.RateLimitError) as raised:
client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": marker}],
tools=function_tools(),
stream=True,
extra_body={"num_retries": 0, "cache": {"no-cache": True}},
)
assert raised.value.status_code == 429
assert_provider_typed_rate_limit(object_value(raised.value.body))
_assert_one_attempt(wire, marker)
_assert_failure_logged(raised.value.response.headers["x-litellm-call-id"], "429", RATE_LIMIT_MESSAGE)
async def test_async_openai_sdk_codex_chat_stream_top_level_rate_limit_is_a_throttling_error(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
stream: Final = openai_error_stream(_identity(), "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
with wire_server(serve(stream, OPENAI_TARGET)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model="openai/gpt-5.3-codex", api_base=wire.url, api_key="synthetic-openai-key")
client: Final = openai.AsyncOpenAI(base_url=f"{gateway.client.base_url}/v1", api_key=gateway.key, max_retries=0)
with pytest.raises(openai.RateLimitError) as raised:
await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": marker}],
stream=True,
extra_body={"num_retries": 0, "cache": {"no-cache": True}},
)
assert raised.value.status_code == 429
assert_provider_typed_rate_limit(object_value(raised.value.body))
_assert_one_attempt(wire, marker)
@pytest.mark.parametrize(
("code", "message", "status"),
[
pytest.param("insufficient_quota", _QUOTA_MESSAGE, 429, id="insufficient_quota"),
pytest.param("server_error", _SERVER_MESSAGE, 500, id="server_error"),
pytest.param("invalid_prompt", "Invalid prompt: your prompt was flagged.", 400, id="invalid_prompt"),
pytest.param(
"context_length_exceeded",
"Your input exceeds the context window of this model.",
400,
id="context_length_exceeded",
),
pytest.param("503", "The engine is currently overloaded.", 503, id="decimal_503"),
pytest.param("brand_new_error_code", "Something new went wrong upstream.", 500, id="unknown_code"),
pytest.param("", "An error with an empty code.", 500, id="empty_code"),
],
)
def test_top_level_error_code_sets_the_chat_status_and_keeps_the_provider_message(
gateway: Gateway, code: str, message: str, status: int
) -> None:
response, _ = _chat(gateway, openai_error_stream(_identity(), code, message))
assert response.status_code == status, response.text
error: Final = error_body(response)
assert str(error["code"]) == str(status), error
client_message: Final = string_value(error["message"])
assert message in client_message and SENTINEL_PREFIX not in client_message, client_message
logged_code: Final = code if status == 400 else str(status)
_assert_failure_logged(response.headers["x-litellm-call-id"], logged_code, message)
def test_top_level_rate_limit_after_text_is_a_throttling_error_frame_behind_the_text(gateway: Gateway) -> None:
identity: Final = _identity()
stream: Final = (
frame(created(identity)),
frame(delta(identity, "Hello")),
frame(delta(identity, " there")),
frame(openai_error_event("rate_limit_exceeded", RATE_LIMIT_MESSAGE)),
)
response, _ = _chat(gateway, stream)
assert response.status_code == 200, response.text
frames: Final = data_frames(response.text)
assert chat_content(frames) == "Hello there", response.text
assert_provider_typed_rate_limit(object_value(frames[-1]["error"]))
def test_nested_error_object_wins_over_top_level_fields(gateway: Gateway) -> None:
event: Final = {
**openai_error_event("rate_limit_exceeded", "top-level says slow down"),
"error": {"type": "server_error", "code": "server_error", "message": "nested says the server broke"},
}
response, _ = _chat(gateway, (frame(created(_identity())), frame(event)))
assert response.status_code == 500, response.text
message: Final = string_value(error_body(response)["message"])
assert "nested says the server broke" in message and "top-level says" not in message, message
def _raw_event(body: str) -> bytes:
return f"event: error\ndata: {body}\n\n".encode()
_FIVE_KB_MESSAGE: Final = "Rate limit reached for gpt-6. " + "x" * 5120
@pytest.mark.parametrize(
("event", "provider_message"),
[
pytest.param(
frame({**openai_error_event("rate_limit_exceeded", RATE_LIMIT_MESSAGE), "error": None}),
RATE_LIMIT_MESSAGE,
id="error_null",
),
pytest.param(
frame({"type": "error", "sequence_number": 1, "code": 429, "message": RATE_LIMIT_MESSAGE, "param": None}),
RATE_LIMIT_MESSAGE,
id="integer_code",
),
pytest.param(frame(openai_error_event("rate_limit_exceeded", "")), "", id="empty_message"),
pytest.param(
frame({"type": "error", "sequence_number": 1, "code": "rate_limit_exceeded", "message": {"text": "x"}}),
"",
id="object_message",
),
pytest.param(frame(openai_error_event("rate_limit_exceeded", _FIVE_KB_MESSAGE)), _FIVE_KB_MESSAGE, id="5kb"),
pytest.param(
_raw_event(
'{"type": "error", "sequence_number": 1, "code": "rate_limit_exceeded", '
f'"code": "rate_limit_exceeded", "message": {json.dumps(RATE_LIMIT_MESSAGE)}, "param": null}}'
),
RATE_LIMIT_MESSAGE,
id="duplicated_code",
),
],
)
def test_malformed_top_level_fields_still_answer_a_throttling_error(
gateway: Gateway, event: bytes, provider_message: str
) -> None:
response, _ = _chat(gateway, (frame(created(_identity())), event))
assert response.status_code == 429, response.text
assert_provider_typed_rate_limit(error_body(response), provider_message)
healthy: Final = _post(
gateway, "/v1/chat/completions", chat_body("", "after"), healthy_stream(_identity(), "still serving")
)
assert chat_content(data_frames(healthy.response.text)) == "still serving", healthy.response.text
def _cached_chat(gateway: Gateway, stream: tuple[bytes, ...]) -> tuple[tuple[httpx.Response, ...], int]:
marker: Final = uuid.uuid4().hex
body: Final = {key: value for key, value in chat_body("", marker).items() if key != "cache"}
with wire_server(serve(stream, AZURE_TARGET)) as wire, gateway.scenario() as scenario:
model: Final = _azure_model(scenario, wire)
responses: Final = tuple(
gateway.request("POST", "/v1/chat/completions", {**body, "model": model}) for _ in range(2)
)
return responses, len(wire.drain())
def test_a_top_level_error_stream_is_never_served_from_the_response_cache(gateway: Gateway) -> None:
responses, attempts = _cached_chat(
gateway, openai_error_stream(_identity(), "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
)
assert attempts == 2, attempts
for response in responses:
assert response.status_code == 429, response.text
assert_provider_typed_rate_limit(error_body(response))
def _responses_frames(gateway: Gateway, stream: tuple[bytes, ...]) -> tuple[httpx.Response, Mapping[str, JsonValue]]:
marker: Final = uuid.uuid4().hex
served: Final = _post(
gateway, "/v1/responses", {"input": marker, "stream": True, "cache": {"no-cache": True}}, stream
)
_assert_one_attempt(served.wire, marker)
assert served.response.status_code == 200, served.response.text
assert served.response.text.rstrip().endswith("data: [DONE]"), served.response.text
frames: Final = data_frames(served.response.text)
assert frames[-1]["type"] == "response.failed", served.response.text
return served.response, object_value(object_value(frames[-1]["response"])["error"])
def _assert_rate_limit_failure(error: Mapping[str, JsonValue]) -> None:
assert error["code"] == "rate_limit_exceeded", error
assert RATE_LIMIT_MESSAGE in string_value(error["message"]), error
def test_native_responses_stream_fails_with_the_provider_code_and_message(gateway: Gateway) -> None:
identity: Final = _identity()
response, error = _responses_frames(
gateway, openai_error_stream(identity, "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
)
assert [frame["type"] for frame in data_frames(response.text)] == ["response.created", "response.failed"]
_assert_rate_limit_failure(error)
_assert_failure_logged(response.headers["x-litellm-call-id"], "429", RATE_LIMIT_MESSAGE)
def test_native_responses_stream_error_after_text_fails_with_the_provider_code(gateway: Gateway) -> None:
identity: Final = _identity()
stream: Final = (
frame(created(identity)),
frame(delta(identity, "partial")),
frame(openai_error_event("rate_limit_exceeded", RATE_LIMIT_MESSAGE)),
)
response, error = _responses_frames(gateway, stream)
kinds: Final = [frame["type"] for frame in data_frames(response.text)]
assert kinds == ["response.created", "response.output_text.delta", "response.failed"], kinds
_assert_rate_limit_failure(error)
def _failed_error(events: list[ResponseStreamEvent]) -> tuple[str, str]:
assert [event.type for event in events] == ["response.created", "response.failed"], events
failed: Final = events[-1]
assert isinstance(failed, ResponseFailedEvent) and failed.response.error is not None, failed
return failed.response.error.code, failed.response.error.message
def test_openai_sdk_responses_stream_fails_with_insufficient_quota(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
stream: Final = openai_error_stream(_identity(), "insufficient_quota", _QUOTA_MESSAGE)
with wire_server(serve(stream, OPENAI_TARGET)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model="openai/gpt-5.3-codex", api_base=wire.url, api_key="synthetic-openai-key")
client: Final = openai.OpenAI(base_url=f"{gateway.client.base_url}/v1", api_key=gateway.key, max_retries=0)
events: Final = list(
client.responses.create(model=model, input=marker, stream=True, extra_body={"cache": {"no-cache": True}})
)
code, message = _failed_error(events)
assert code == "insufficient_quota" and _QUOTA_MESSAGE in message, events
_assert_one_attempt(wire, marker)
async def test_async_openai_sdk_responses_stream_fails_with_the_server_error_message(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
stream: Final = openai_error_stream(_identity(), "server_error", _SERVER_MESSAGE)
with wire_server(serve(stream, AZURE_TARGET)) as wire, gateway.scenario() as scenario:
model: Final = _azure_model(scenario, wire)
client: Final = openai.AsyncOpenAI(base_url=f"{gateway.client.base_url}/v1", api_key=gateway.key, max_retries=0)
response: Final = await client.responses.create(
model=model, input=marker, stream=True, extra_body={"cache": {"no-cache": True}}
)
events: Final = [event async for event in response]
code, message = _failed_error(events)
assert code == "server_error" and _SERVER_MESSAGE in message, events
_assert_one_attempt(wire, marker)
def _assert_messages_error(error: Mapping[str, JsonValue], error_type: str, provider_message: str) -> None:
assert error["type"] == error_type, error
message: Final = string_value(error["message"])
assert provider_message in message and SENTINEL_PREFIX not in message, message
def test_messages_stream_top_level_rate_limit_is_a_rate_limit_errorframe(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
stream: Final = openai_error_stream(_identity(), "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
served: Final = _post(gateway, "/v1/messages", messages_body("", marker), stream)
assert served.response.status_code == 200, served.response.text
events: Final = sse_events(served.response.text)
assert events[0][0] == "message_start" and events[-1][0] == "error", events
error: Final = object_value(events[-1][1]["error"])
_assert_messages_error(error, "rate_limit_error", RATE_LIMIT_MESSAGE)
assert string_value(error["message"]).count(RATE_LIMIT_PREFIX) == 1, error
_assert_one_attempt(served.wire, marker)
def _anthropic_tools() -> list[anthropic.types.ToolParam]: # mutable-ok: the Anthropic SDK tools parameter is a list
return [
{
"name": "get_weather",
"description": "Weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}},
}
]
async def _drain_async_messages_stream(client: anthropic.AsyncAnthropic, model: str, marker: str) -> None:
async with client.messages.stream(
model=model,
max_tokens=64,
messages=[{"role": "user", "content": marker}],
tools=_anthropic_tools(),
extra_body={"num_retries": 0, "cache": {"no-cache": True}},
) as events:
async for _ in events:
pass
def _drain_messages_stream(client: anthropic.Anthropic, model: str, marker: str) -> None:
with client.messages.stream(
model=model,
max_tokens=64,
messages=[{"role": "user", "content": marker}],
tools=_anthropic_tools(),
extra_body={"num_retries": 0, "cache": {"no-cache": True}},
) as events:
for _ in events:
pass
async def test_async_anthropic_sdk_stream_raises_the_top_level_rate_limit(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
stream: Final = openai_error_stream(_identity(), "rate_limit_exceeded", RATE_LIMIT_MESSAGE)
with wire_server(serve(stream, AZURE_TARGET)) as wire, gateway.scenario() as scenario:
model: Final = _azure_model(scenario, wire)
client: Final = anthropic.AsyncAnthropic(
base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0
)
with pytest.raises(anthropic.APIStatusError) as raised:
await _drain_async_messages_stream(client, model, marker)
_assert_messages_error(
object_value(object_value(raised.value.body)["error"]), "rate_limit_error", RATE_LIMIT_MESSAGE
)
_assert_one_attempt(wire, marker)
def test_sync_anthropic_sdk_stream_raises_the_top_level_server_error_message(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
stream: Final = openai_error_stream(_identity(), "server_error", _SERVER_MESSAGE)
with wire_server(serve(stream, AZURE_TARGET)) as wire, gateway.scenario() as scenario:
model: Final = _azure_model(scenario, wire)
client: Final = anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0)
with pytest.raises(anthropic.APIStatusError) as raised:
_drain_messages_stream(client, model, marker)
_assert_messages_error(object_value(object_value(raised.value.body)["error"]), "api_error", _SERVER_MESSAGE)
_assert_one_attempt(wire, marker)

View file

@ -371,6 +371,7 @@ class TestChatGPTResponsesAPITransformation:
"type": "error",
"error": {"message": "ChatGPT upstream failed"},
},
{"type": "error", "code": "server_error", "message": "ChatGPT upstream failed"},
],
)
def test_chatgpt_non_stream_sse_response_raises_openai_error(self, error_chunk):
@ -392,5 +393,5 @@ class TestChatGPTResponsesAPITransformation:
logging_obj=logging_obj,
)
assert "ChatGPT upstream failed" in str(exc_info.value)
assert exc_info.value.message == "ChatGPT upstream failed"
assert exc_info.value.status_code == 502

View file

@ -3119,7 +3119,7 @@ class TestNativeWebSocketEncryptedContentAffinity:
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failure_frame, expected_status",
"failure_frame, expected_status, expected_message",
[
(
{
@ -3131,6 +3131,7 @@ class TestNativeWebSocketEncryptedContentAffinity:
},
},
400,
"The encrypted content for item rs_1 could not be verified.",
),
(
{
@ -3142,11 +3143,17 @@ class TestNativeWebSocketEncryptedContentAffinity:
},
},
500,
"upstream blew up",
),
(
{"type": "error", "code": "rate_limit_exceeded", "message": "Rate limit reached for gpt-5.6"},
429,
"Rate limit reached for gpt-5.6",
),
],
)
async def test_backend_to_client_books_failure_frames_as_failures(
self, failure_frame: dict[str, object], expected_status: int
self, failure_frame: dict[str, object], expected_status: int, expected_message: str
):
import asyncio
from unittest.mock import AsyncMock
@ -3183,7 +3190,7 @@ class TestNativeWebSocketEncryptedContentAffinity:
logging_obj.dispatch_failure_handlers.assert_awaited_once()
exception = logging_obj.dispatch_failure_handlers.await_args[0][0]
assert exception.status_code == expected_status
assert failure_frame.get("error", failure_frame.get("response", {}).get("error"))["message"] in str(exception)
assert expected_message in str(exception)
@pytest.mark.asyncio
async def test_backend_to_client_bills_completed_turns_before_a_failure(self):

View file

@ -587,3 +587,24 @@ def test_maybe_raise_for_error_event_maps_vector_store_timeout_to_retriable_504(
with pytest.raises(MidStreamFallbackError) as exc_info:
iterator._maybe_raise_for_error_event(chunk)
assert exc_info.value.status_code == 504
@pytest.mark.parametrize(
("code", "status_code", "exception_type"),
[
("rate_limit_exceeded", 429, litellm.RateLimitError),
("server_error", 500, litellm.InternalServerError),
(429, 429, litellm.RateLimitError),
],
)
def test_openai_top_level_error_event_keeps_the_provider_code_and_message(
code: str | int, status_code: int, exception_type: type[Exception]
):
chunk = ErrorEvent(
type=ResponsesAPIStreamEvents.ERROR, sequence_number=3, code=code, message=f"upstream said {code}"
)
with pytest.raises(MidStreamFallbackError) as exc_info:
_make_iterator()._maybe_raise_for_error_event(chunk)
assert exc_info.value.status_code == status_code
assert type(exc_info.value.original_exception) is exception_type
assert f"upstream said {code}" in str(exc_info.value.original_exception)