fix(streaming)!: map stream read timeouts to litellm.Timeout on chat, messages, and responses (#47198)

* fix(streaming): map stream read timeouts to litellm.Timeout

* revert(streaming): move the timeout mapping to a later commit

* fix(streaming): map stream read timeouts to litellm.Timeout on chat, messages, and responses

* fix(streaming): keep stream fallback and cooldown unchanged for transport timeouts

A chat stream served through the Responses bridge now keeps mid-stream
fallback on a stall, because the fallback exemption also covers a
litellm.Timeout whose cause is an httpx transport timeout. The Responses
stream hands the raw transport error to failure callbacks again, so
router cooldown sees a stall as it did before. The 408 deployment info
now lists stream_timeout.

* test(integration): cover a stalled hosted_vllm stream on chat, Responses, and Messages with its spend log row

* fix(router): keep a fallback deployment whose stream stalled out of cooldown

* test(integration): cover bridged chat, fallback and cooldown for stalled hosted_vllm streams
This commit is contained in:
Mateo Wang 2026-10-18 13:58:34 +00:00 • committed by GitHub
parent 5012083b72
commit 7e6fb09da3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 617 additions and 13 deletions

View file

@ -2551,7 +2551,8 @@ def exception_type(
################################################################################
if (
"Request Timeout Error" in error_str
isinstance(original_exception, httpx.TimeoutException)
or "Request Timeout Error" in error_str
or "Request timed out" in error_str
or "Timed out generating response" in error_str
or "The read operation timed out" in error_str
@ -2559,7 +2560,7 @@ def exception_type(
exception_mapping_worked = True
raise Timeout(
message=f"APITimeoutError - Request timed out. Error_str: {error_str}",
message=f"APITimeoutError - Request timed out. Error_str: {error_str or type(mappable_exception).__name__}",
model=model,
llm_provider=custom_llm_provider,
litellm_debug_info=extra_information,

View file

@ -45,7 +45,7 @@ from litellm.types.utils import (
)
from litellm.types.utils import GenericStreamingChunk as GChunk
from ..exceptions import OpenAIError
from ..exceptions import OpenAIError, Timeout
from .core_helpers import map_finish_reason, process_response_headers
from .exception_mapping_utils import exception_type, with_deployment_api_base
from .llm_response_utils.get_api_base import get_api_base
@ -88,6 +88,16 @@ def _apply_provider_fields(response: ModelResponseStream, fields: Mapping[str, o
setattr(response, key, value)
def _is_transport_timeout(error: BaseException) -> bool:
return isinstance(error, httpx.TimeoutException) or (
isinstance(error, Timeout) and isinstance(error.__cause__, httpx.TimeoutException)
)
def _skips_stream_fallback(status_code: int | None) -> bool:
return status_code is not None and 400 <= status_code < 500 and status_code != 429
def is_async_iterable(obj: object) -> bool:
"""
Check if an object is an async iterable (can be used with 'async for').
@ -2384,6 +2394,8 @@ class CustomStreamWrapper:
429 (rate-limit) is explicitly exempted from the 4xx filter because
it is transient and the Router should switch to another model group.
So is a transport read timeout, which maps to a 408 Timeout, including
one an inner Responses stream already mapped for the chat bridge.
An error an inner stream already wrapped (the chat-to-Responses bridge
consumes a Responses stream) is rebuilt around the provider exception
@ -2448,9 +2460,9 @@ class CustomStreamWrapper:
# Raise non-retriable client errors directly (skip fallback).
# Exception: 429 (rate-limit) IS retriable/transient — allow it
# through so the Router can switch to a different model group.
if mapped_status_code is not None and 400 <= mapped_status_code < 500 and mapped_status_code != 429:
raise mapped_exception
if original_status_code is not None and 400 <= original_status_code < 500 and original_status_code != 429:
if not _is_transport_timeout(e) and (
_skips_stream_fallback(mapped_status_code) or _skips_stream_fallback(original_status_code)
):
raise mapped_exception
raise MidStreamFallbackError(

View file

@ -293,6 +293,15 @@ def _map_stream_error_to_exception(
body=error_body,
headers=headers,
)
return _mapped_exception(provider_exception, model, custom_llm_provider, logging_obj)
def _mapped_exception(
original_exception: Exception,
model: str,
custom_llm_provider: str,
logging_obj: LiteLLMLoggingObj | None,
) -> Exception:
litellm_params: Final = (
None
if logging_obj is None
@ -302,7 +311,7 @@ def _map_stream_error_to_exception(
return litellm.exception_type(
model=model,
custom_llm_provider=custom_llm_provider,
original_exception=provider_exception,
original_exception=original_exception,
completion_kwargs={},
extra_kwargs=with_deployment_api_base(litellm_params),
)
@ -1044,6 +1053,11 @@ class BaseResponsesAPIStreamingIterator:
raise error
raise self._fallback_error(error) from error
def _raise_for_transport_timeout(self, error: httpx.TimeoutException) -> NoReturn:
self.finished = True
self._handle_failure(error)
raise _mapped_exception(error, self.model or "", self.custom_llm_provider or "", self.logging_obj) from error
def _raise_for_transport_error(self, error: httpx.ReadError | httpx.RemoteProtocolError) -> NoReturn:
self._handle_failure(error)
if self._output_started:
@ -1140,6 +1154,8 @@ class ResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator):
if self.completed_response is not None:
raise StopAsyncIteration from e
self._raise_for_transport_error(e)
except httpx.TimeoutException as e:
self._raise_for_transport_timeout(e)
except httpx.HTTPError as e:
# Handle HTTP errors
self.finished = True
@ -1223,6 +1239,8 @@ class SyncResponsesAPIStreamingIterator(BaseResponsesAPIStreamingIterator):
if self.completed_response is not None:
raise StopIteration from e
self._raise_for_transport_error(e)
except httpx.TimeoutException as e:
self._raise_for_transport_timeout(e)
except httpx.HTTPError as e:
# Handle HTTP errors
self.finished = True

View file

@ -4377,12 +4377,15 @@ class Router:
return response
except litellm.Timeout as e:
deployment_request_timeout_param: Final = _timeout_debug_deployment_dict.get("litellm_params", {}).get(
"request_timeout", None
deployment_litellm_params: Final[Mapping[str, object]] = _timeout_debug_deployment_dict.get(
"litellm_params", {}
)
deployment_timeout_param = _timeout_debug_deployment_dict.get("litellm_params", {}).get("timeout", None)
if litellm.expose_router_debug_in_errors:
e.message += f"\n\nDeployment Info: request_timeout: {deployment_request_timeout_param}\ntimeout: {deployment_timeout_param}"
e.message += (
f"\n\nDeployment Info: request_timeout: {deployment_litellm_params.get('request_timeout')}"
f"\ntimeout: {deployment_litellm_params.get('timeout')}"
f"\nstream_timeout: {deployment_litellm_params.get('stream_timeout')}"
)
# Set per-deployment num_retries on exception for retry logic
if deployment is not None:
self._set_deployment_num_retries_on_exception(e, deployment)

View file

@ -26,6 +26,7 @@ from litellm.constants import (
INTERNAL_CALL_ORIGIN_METADATA_KEY,
SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD,
)
from litellm.exceptions import MidStreamFallbackError
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.router_utils.cooldown_cache import ROUTER_COOLDOWNS_TARGET, CooldownCacheValue
from litellm.router_utils.cooldown_callbacks import router_cooldown_event_callback
@ -757,6 +758,10 @@ def is_caller_timeout_408(
return (finished - started).total_seconds() >= timeout
def is_stream_stall(exception: BaseException) -> bool:
return isinstance(exception, litellm.Timeout) and isinstance(exception.__cause__, MidStreamFallbackError)
def is_caller_scoped_auth_failure(deployment: Deployment | None, exception_status: str | int) -> bool:
if cast_exception_status_to_int(exception_status) not in (401, 403) or deployment is None:
return False

View file

@ -26,6 +26,7 @@ from litellm.router_utils.cooldown_handlers import (
is_advisor_orchestration_failure,
is_caller_scoped_auth_failure,
is_caller_timeout_408,
is_stream_stall,
set_cooldown_deployments,
)
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
@ -73,6 +74,13 @@ def _trigger_cooldown_for_failed_deployment(
)
return
if is_stream_stall(exception):
verbose_router_logger.debug(
"Not triggering cooldown for fallback deployment: its stream stalled on a read timeout, "
"which stays out of cooldown as it does for the primary deployment."
)
return
exception_status: Final[str | int] = getattr(exception, "status_code", "")
# Generic API calls (files, batches, threads, rerank, ...) take a caller-supplied

View file

@ -0,0 +1,364 @@
import threading
import uuid
from collections.abc import Generator, Iterator, Mapping
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass
from typing import Final, Literal
import httpx
import pytest
from integration._support.anthropic_sse import SseEvent, event_types, parse_sse
from integration._support.client import (
Gateway,
Scenario,
eventually,
gateway_from_environment,
object_value,
string_value,
)
from integration._support.database import read_rows
from integration._support.openai_wire import (
answering_model_discovery,
chat_stream,
openai_error,
posted_targets,
responses_stream,
)
from integration._support.process import graceful_stop_seconds, owned_proxy_process
from integration._support.redis_process import OwnedRedis, owned_redis
from integration._support.wire import Reply, Request, Wire, wire_server
from integration.routing.test_payment_required_cooldown import cooldown_config, cooled, router_settings
from integration.routing.test_payment_required_mapping import deployment
from pydantic import JsonValue
_BACKEND: Final = "glm-5-fp8"
_HOSTED: Final = f"hosted_vllm/{_BACKEND}"
_BRIDGED: Final = "openai/gpt-5.3-codex"
_PROVIDER_KEY: Final = "synthetic-hosted-vllm-key"
_ANSWER: Final = "scripted answer before the upstream stalls"
_FALLBACK_ANSWER: Final = "scripted answer from the fallback deployment"
_PROMPT: Final = "Keep streaming while the upstream thinks."
_CHAT_TARGET: Final = "/v1/chat/completions"
_RESPONSES_TARGET: Final = "/v1/responses"
_STREAM_TIMEOUT_SECONDS: Final = 1
_DEPLOYMENT_TIMEOUT_SECONDS: Final = 30
_PAUSE_SECONDS: Final = 3.0
_HOLD_SECONDS: Final = 30.0
_ROW_SECONDS: Final = 60
_EXTRA: Final[dict[str, JsonValue]] = {"num_retries": 0, "cache": {"no-cache": True}}
_ROWS: Final = 'SELECT status, metadata FROM "LiteLLM_SpendLogs" WHERE model_group=%s'
_EITHER_GROUP_ROWS: Final = 'SELECT status, metadata FROM "LiteLLM_SpendLogs" WHERE model_group IN (%s, %s)'
_OWNED_PROXY: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
Endpoint = Literal["chat", "responses", "messages"]
Shape = Literal["prompt", "pause_after_content", "pause_after_role", "stall_before_first", "hold_headers", "fail"]
def _stream_parts(target: str, answer: str) -> tuple[bytes, bytes, bytes]:
identity: Final = f"stall-{uuid.uuid4().hex}"
if target == _RESPONSES_TARGET:
return responses_stream(identity, _BACKEND, answer)
return chat_stream(identity, _BACKEND, answer)
def _sse(chunks: tuple[bytes, ...], *, pause: float = 0, gate: threading.Event | None = None) -> Reply:
return Reply(content_type="text/event-stream", chunks=chunks, pause_between_chunks=pause, gate_before_first=gate)
@dataclass(frozen=True, slots=True)
class _Peer:
shape: Shape
answer: str
release: threading.Event
def __call__(self, request: Request) -> Reply:
assert request.method == "POST", request
assert request.target in (_CHAT_TARGET, _RESPONSES_TARGET), request
opened, delta, closed = _stream_parts(request.target, self.answer)
match self.shape:
case "fail":
return openai_error(500)
case "hold_headers":
assert self.release.wait(_HOLD_SECONDS), request
return _sse((opened, delta, closed))
case "pause_after_content":
return _sse((opened + delta, closed), pause=_PAUSE_SECONDS)
case "pause_after_role":
return _sse((opened, delta + closed), pause=_PAUSE_SECONDS)
case "stall_before_first":
return _sse((opened, delta, closed), gate=self.release)
case "prompt":
return _sse((opened, delta, closed))
@contextmanager
def _peer(shape: Shape, answer: str = _ANSWER) -> Generator[Wire]:
release: Final = threading.Event()
with wire_server(answering_model_discovery(_Peer(shape, answer, release))) as wire:
try:
yield wire
finally:
release.set()
def _params(wire: Wire, backend: str) -> dict[str, JsonValue]:
return {
"model": backend,
"api_base": f"{wire.url}/v1",
"api_key": _PROVIDER_KEY,
"timeout": _DEPLOYMENT_TIMEOUT_SECONDS,
"stream_timeout": _STREAM_TIMEOUT_SECONDS,
}
def _deployment(scenario: Scenario, wire: Wire, backend: str = _HOSTED) -> tuple[str, str]:
group: Final = f"integration-stall-{uuid.uuid4().hex[:12]}"
return group, deployment(scenario, group, _params(wire, backend))
def _path(endpoint: Endpoint) -> str:
match endpoint:
case "chat":
return "/v1/chat/completions"
case "responses":
return "/v1/responses"
case "messages":
return "/v1/messages"
def _body(endpoint: Endpoint, model: str, extra: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
match endpoint:
case "chat":
return {"model": model, "messages": [{"role": "user", "content": _PROMPT}], "stream": True, **extra}
case "responses":
return {"model": model, "input": _PROMPT, "stream": True, **extra}
case "messages":
return {
"model": model,
"messages": [{"role": "user", "content": _PROMPT}],
"max_tokens": 16,
"stream": True,
**extra,
}
def _streamed(gateway: Gateway, endpoint: Endpoint, model: str, **extra: JsonValue) -> httpx.Response:
return gateway.request("POST", _path(endpoint), _body(endpoint, model, {**_EXTRA, **extra}))
def _upstream_targets(wire: Wire) -> tuple[str, ...]:
eventually(wire.received.qsize, lambda size: size >= 1, 10)
return posted_targets(wire)
def _chat_errors(events: tuple[SseEvent, ...]) -> tuple[dict[str, JsonValue], ...]:
return tuple(object_value(event.data["error"]) for event in events if "error" in event.data)
def _error_class(message: JsonValue) -> str:
return string_value(message).split(":", 1)[0]
def _failure_row(query: str, groups: tuple[str, ...]) -> dict[str, JsonValue]:
rows: Final = eventually(lambda: read_rows(query, groups), lambda found: len(found) >= 1, seconds=_ROW_SECONDS)
assert [row["status"] for row in rows] == ["failure"], rows
return object_value(object_value(rows[0]["metadata"])["error_information"])
def _logged_failure(model: str) -> tuple[JsonValue, JsonValue]:
error: Final = _failure_row(_ROWS, (model,))
return error["error_code"], error["error_class"]
@pytest.mark.parametrize(
("backend", "target"),
(
pytest.param(_HOSTED, _CHAT_TARGET, id="hosted-vllm"),
pytest.param(_BRIDGED, _RESPONSES_TARGET, id="bridged-to-responses"),
),
)
def test_a_chat_stream_that_stalls_past_its_stream_timeout_ends_with_a_408_timeout_and_logs_it(
gateway: Gateway, backend: str, target: str
) -> None:
with _peer("pause_after_content") as wire, gateway.scenario() as scenario:
model, _ = _deployment(scenario, wire, backend)
response: Final = _streamed(gateway, "chat", model)
assert response.status_code == 200, response.text
assert _ANSWER in response.text, response.text
events: Final = parse_sse(response.text)
assert _chat_errors(events[:-1]) == () and "error" in events[-1].data, response.text
error: Final = object_value(events[-1].data["error"])
observed: Final = (error["code"], _error_class(error["message"]), *_logged_failure(model))
assert observed == ("408", "litellm.Timeout", "408", "Timeout"), (error, observed)
assert _upstream_targets(wire) == (target,)
def test_a_native_responses_stream_that_stalls_past_its_stream_timeout_fails_with_request_timeout_and_logs_it(
gateway: Gateway,
) -> None:
with _peer("pause_after_content") as wire, gateway.scenario() as scenario:
model, _ = _deployment(scenario, wire)
response: Final = _streamed(gateway, "responses", model)
assert response.status_code == 200, response.text
events: Final = parse_sse(response.text)
types: Final = event_types(events)
assert "response.output_text.delta" in types, response.text
assert types[-1] == "response.failed" and "response.completed" not in types, response.text
failed: Final = object_value(object_value(events[-1].data["response"])["error"])
observed: Final = (failed["code"], *_logged_failure(model))
assert observed == ("request_timeout", "408", "Timeout"), (failed, observed)
assert _upstream_targets(wire) == (_RESPONSES_TARGET,)
def test_a_messages_stream_that_stalls_past_its_stream_timeout_ends_with_a_timeout_error_event_and_logs_it(
gateway: Gateway,
) -> None:
with _peer("pause_after_content") as wire, gateway.scenario() as scenario:
model, _ = _deployment(scenario, wire)
response: Final = _streamed(gateway, "messages", model)
assert response.status_code == 200, response.text
events: Final = parse_sse(response.text)
types: Final = event_types(events)
assert "content_block_delta" in types and "message_stop" not in types, response.text
assert types[-1] == "error", response.text
error: Final = object_value(events[-1].data["error"])
observed: Final = (_error_class(error["message"]), *_logged_failure(model))
assert observed == ("litellm.Timeout", "408", "Timeout"), (error, observed)
assert _upstream_targets(wire) == (_CHAT_TARGET,)
def test_a_chat_stream_that_never_pauses_is_answered_in_full_under_the_same_stream_timeout(gateway: Gateway) -> None:
with _peer("prompt") as wire, gateway.scenario() as scenario:
model, _ = _deployment(scenario, wire)
response: Final = _streamed(gateway, "chat", model)
assert response.status_code == 200, response.text
assert _ANSWER in response.text, response.text
assert response.text.rstrip().endswith("data: [DONE]"), response.text
events: Final = parse_sse(response.text)
assert _chat_errors(events) == (), response.text
assert _finish_reasons(events) == ("stop",), response.text
assert _upstream_targets(wire) == (_CHAT_TARGET,)
rows: Final = eventually(lambda: read_rows(_ROWS, (model,)), lambda found: len(found) >= 1, _ROW_SECONDS)
assert [row["status"] for row in rows] == ["success"], rows
@pytest.mark.parametrize(
("endpoint", "shape"),
(
pytest.param("chat", "pause_after_role", id="chat-after-the-role-chunk"),
pytest.param("messages", "stall_before_first", id="messages-before-the-first-chunk"),
),
)
def test_a_stream_that_stalls_before_any_content_falls_back_to_the_next_deployment(
gateway: Gateway, endpoint: Endpoint, shape: Shape
) -> None:
with _peer(shape) as stalled, _peer("prompt", _FALLBACK_ANSWER) as healthy, gateway.scenario() as scenario:
primary, _ = _deployment(scenario, stalled)
fallback, _ = _deployment(scenario, healthy)
response: Final = _streamed(gateway, endpoint, primary, fallbacks=[fallback])
assert response.status_code == 200, response.text
assert _FALLBACK_ANSWER in response.text and _ANSWER not in response.text, response.text
events: Final = parse_sse(response.text)
assert _chat_errors(events) == () and _completed(endpoint, events), response.text
assert (_upstream_targets(stalled), _upstream_targets(healthy)) == ((_CHAT_TARGET,), (_CHAT_TARGET,))
@dataclass(frozen=True, slots=True)
class _CooldownRig:
gateway: Gateway
cache: OwnedRedis
@pytest.fixture(scope="module")
def cooldown_rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_CooldownRig]:
directory: Final = tmp_path_factory.mktemp("stream-stall-cooldown")
with ExitStack() as stack:
upstream: Final = stack.enter_context(gateway_from_environment())
cache: Final = stack.enter_context(owned_redis(directory))
owned: Final = stack.enter_context(
owned_proxy_process(
upstream,
directory,
{"PROXY_CONFIG_RELOAD_INTERVAL_SECONDS": "2"},
config=cooldown_config(directory, router_settings(cache, allowed_fails=0), prometheus=False),
)
)
yield _CooldownRig(owned.gateway, cache)
@_OWNED_PROXY
@pytest.mark.parametrize(
("endpoint", "backend", "shape"),
(
pytest.param("chat", _HOSTED, "pause_after_content", id="chat"),
pytest.param("chat", _HOSTED, "stall_before_first", id="chat-before-the-first-chunk"),
pytest.param("responses", _HOSTED, "pause_after_content", id="responses"),
pytest.param("chat", _BRIDGED, "pause_after_content", id="chat-bridged-to-responses"),
),
)
def test_a_deployment_whose_stream_stalls_stays_out_of_cooldown(
cooldown_rig: _CooldownRig, endpoint: Endpoint, backend: str, shape: Shape
) -> None:
with _peer(shape) as wire, cooldown_rig.gateway.scenario() as scenario:
group, identity = _deployment(scenario, wire, backend)
response: Final = _streamed(cooldown_rig.gateway, endpoint, group)
error: Final = _failure_row(_ROWS, (group,))
assert not cooled(cooldown_rig.cache, identity), (error, response.text)
@_OWNED_PROXY
def test_a_deployment_whose_stream_times_out_before_it_opens_is_cooled_down(cooldown_rig: _CooldownRig) -> None:
with _peer("hold_headers") as wire, cooldown_rig.gateway.scenario() as scenario:
group, identity = _deployment(scenario, wire)
response: Final = _streamed(cooldown_rig.gateway, "chat", group)
assert response.status_code == 408, response.text
assert eventually(lambda: cooled(cooldown_rig.cache, identity), bool, _ROW_SECONDS)
def _fallback_cooled(cooldown_rig: _CooldownRig, fallback_shape: Shape) -> tuple[bool, dict[str, JsonValue], str]:
with (
_peer("fail") as failing,
_peer(fallback_shape) as fallback_wire,
cooldown_rig.gateway.scenario() as scenario,
):
primary, _ = _deployment(scenario, failing)
fallback, identity = _deployment(scenario, fallback_wire)
response: Final = _streamed(cooldown_rig.gateway, "chat", primary, fallbacks=[fallback])
error: Final = _failure_row(_EITHER_GROUP_ROWS, (primary, fallback))
assert _upstream_targets(fallback_wire) == (_CHAT_TARGET,), response.text
return cooled(cooldown_rig.cache, identity), error, response.text
@_OWNED_PROXY
def test_a_fallback_deployment_whose_stream_stalls_stays_out_of_cooldown(cooldown_rig: _CooldownRig) -> None:
is_cooled, error, text = _fallback_cooled(cooldown_rig, "stall_before_first")
assert not is_cooled, (error, text)
@_OWNED_PROXY
def test_a_fallback_deployment_whose_stream_times_out_before_it_opens_is_cooled_down(
cooldown_rig: _CooldownRig,
) -> None:
is_cooled, error, text = _fallback_cooled(cooldown_rig, "hold_headers")
assert is_cooled, (error, text)
def _completed(endpoint: Endpoint, events: tuple[SseEvent, ...]) -> bool:
match endpoint:
case "chat":
return _finish_reasons(events) == ("stop",)
case "messages":
return event_types(events)[-1:] == ("message_stop",)
case "responses":
return event_types(events)[-1:] == ("response.completed",)
def _finish_reasons(events: tuple[SseEvent, ...]) -> tuple[JsonValue, ...]:
choices: Final = tuple(_first_choice(event.data) for event in events if event.data.get("choices"))
return tuple(choice["finish_reason"] for choice in choices if choice.get("finish_reason"))
def _first_choice(data: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
choices: Final = data["choices"]
assert isinstance(choices, list), data
return object_value(choices[0])

View file

@ -2781,3 +2781,23 @@ def test_missing_aws_dependency_is_not_mapped_to_provider_failure(provider, depe
def test_non_aws_import_failure_keeps_provider_mapping(failure):
with pytest.raises(litellm.APIConnectionError):
exception_type(model="test-model", original_exception=failure, custom_llm_provider="openai")
@pytest.mark.parametrize(
"transport_timeout,reason",
[
(httpx.ReadTimeout(""), "ReadTimeout"),
(httpx.ReadTimeout("Timeout on reading data from socket"), "Timeout on reading data from socket"),
(httpx.TimeoutException(""), "TimeoutException"),
],
ids=["httpx_read_timeout", "aiohttp_read_timeout", "timeout_base_class"],
)
@pytest.mark.parametrize("provider", ["hosted_vllm", "openai", "anthropic"])
def test_transport_timeout_maps_to_timeout(
transport_timeout: httpx.TimeoutException, reason: str, provider: str
) -> None:
with pytest.raises(litellm.Timeout) as exc_info:
exception_type(model="glm", original_exception=transport_timeout, custom_llm_provider=provider)
assert exc_info.value.status_code == 408
assert f"Request timed out. Error_str: {reason}" in str(exc_info.value)

View file

@ -2,7 +2,7 @@ import asyncio
import json
import threading
import time
from typing import AsyncIterator, Final, Iterator, Literal, NoReturn, Optional
from typing import AsyncIterator, Callable, Final, Iterator, Literal, NoReturn, Optional
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
@ -1382,7 +1382,53 @@ async def test_async_streaming_read_timeout_triggers_midstream_fallback(
await response.__anext__()
assert excinfo.value.is_pre_first_chunk is True
assert isinstance(excinfo.value.original_exception, Exception)
assert isinstance(excinfo.value.original_exception, litellm.Timeout)
def _raw_read_timeout() -> NoReturn:
raise httpx.ReadTimeout("")
def _bridged_read_timeout() -> NoReturn:
raise litellm.Timeout(
message="APITimeoutError - Request timed out. Error_str: ReadTimeout", model="glm", llm_provider="hosted_vllm"
) from httpx.ReadTimeout("")
@pytest.mark.asyncio
@pytest.mark.parametrize("stall", [_raw_read_timeout, _bridged_read_timeout], ids=["raw", "responses_bridge"])
async def test_async_streaming_read_timeout_after_first_chunk_surfaces_timeout(
logging_obj: Logging, stall: Callable[[], NoReturn]
):
from litellm.exceptions import MidStreamFallbackError
class _OneChunkThenStall:
def __init__(self) -> None:
self.sent = False
def __aiter__(self) -> "_OneChunkThenStall":
return self
async def __anext__(self) -> ModelResponseStream:
if self.sent:
stall()
self.sent = True
return ModelResponseStream(choices=[{"index": 0, "delta": {"content": "hello"}}])
response: Final = CustomStreamWrapper(
completion_stream=_OneChunkThenStall(),
model="glm",
logging_obj=logging_obj,
custom_llm_provider="hosted_vllm",
)
with pytest.raises(MidStreamFallbackError) as excinfo:
async for _ in response:
pass
assert excinfo.value.is_pre_first_chunk is False
assert isinstance(excinfo.value.original_exception, litellm.Timeout)
assert excinfo.value.original_exception.status_code == 408
_AZURE_DEPLOYMENT_API_BASE: Final = "https://mid-stream-debug.openai.azure.com"

View file

@ -323,6 +323,43 @@ async def test_transport_error_after_output_started_is_not_fallback_eligible():
_assert_failure_logged_once(logging_obj, trailing_error)
_READ_TIMEOUT_PREFIXES: Final = [
pytest.param([], id="before-output"),
pytest.param(_PARTIAL_OUTPUT_EVENTS, id="after-output"),
]
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix", _READ_TIMEOUT_PREFIXES)
async def test_transport_read_timeout_raises_litellm_timeout(prefix):
logging_obj: Final = _failure_tracking_logging_obj()
stall: Final = httpx.ReadTimeout("")
iterator: Final = _make_iterator(sse_events=prefix, logging_obj=logging_obj, trailing_error=stall)
with pytest.raises(litellm.Timeout) as exc_info:
async for _ in iterator:
pass
assert exc_info.value.status_code == 408
assert "Request timed out. Error_str: ReadTimeout" in str(exc_info.value)
assert exc_info.value.__cause__ is stall
_assert_failure_logged_once(logging_obj, stall)
@pytest.mark.parametrize("prefix", _READ_TIMEOUT_PREFIXES)
def test_sync_transport_read_timeout_raises_litellm_timeout(prefix):
iterator: Final = _make_sync_iterator(
sse_events=prefix, logging_obj=_logging_obj_stub(), trailing_error=httpx.ReadTimeout("")
)
with pytest.raises(litellm.Timeout) as exc_info:
for _ in iterator:
pass
assert exc_info.value.status_code == 408
assert "Request timed out. Error_str: ReadTimeout" in str(exc_info.value)
@pytest.mark.asyncio
@pytest.mark.parametrize("trailer", [[], [_DONE_MARKER]], ids=["eof", "done-marker"])
async def test_stream_ending_after_partial_output_without_terminal_event_raises(trailer):

View file

@ -1,6 +1,7 @@
import asyncio
import importlib
import json
import time
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from types import MappingProxyType
@ -16,6 +17,8 @@ from litellm import Router
from litellm.integrations.custom_logger import CustomLogger
from litellm.llms.anthropic.pass_through.messages.fake_stream_iterator import FakeAnthropicMessagesStreamIterator
from litellm.litellm_core_utils import get_llm_provider_logic
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.router_utils.cooldown_handlers import mark_advisor_orchestration_failure
from litellm.router_utils.fallback_event_handlers import (
MID_STREAM_FALLBACK_CONTROLS_KEY,
@ -1083,6 +1086,68 @@ class TestTriggerCooldownForFailedDeployment:
mock_set_cooldown.assert_called_once()
async def _stalled_stream_error() -> Exception:
from litellm.router import _aread_first_chunk
async def _stall(**kwargs: object) -> NoReturn:
raise httpx.ReadTimeout("Timeout on reading data from socket")
stream: Final = CustomStreamWrapper(
completion_stream=None,
model="gpt-5.6",
logging_obj=Logging(
model="gpt-5.6",
messages=[{"role": "user", "content": "Hey"}],
stream=True,
call_type="acompletion",
start_time=time.time(),
litellm_call_id="stalled-fallback",
function_id="stalled-fallback",
),
custom_llm_provider="openai",
make_call=_stall,
)
try:
await _aread_first_chunk(stream)
except litellm.Timeout as stalled:
return stalled
raise AssertionError("a stream that stalls before its first chunk must raise litellm.Timeout")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("stream_stalled", "cooled"),
[(True, ()), (False, ("fallback-deployment",))],
ids=["stream-stall", "non-stream-timeout"],
)
async def test_a_fallback_deployment_whose_stream_stalled_stays_out_of_cooldown(
stream_stalled: bool, cooled: tuple[str, ...]
) -> None:
router: Final = litellm.Router(
model_list=[
{
"model_name": "fallback-model",
"litellm_params": {"model": "openai/gpt-5.6", "api_key": "sk-fake"},
"model_info": {"id": "fallback-deployment"},
}
],
allowed_fails=0,
cooldown_time=60,
num_retries=0,
)
error: Final = (
await _stalled_stream_error()
if stream_stalled
else litellm.Timeout(message="timeout", model="gpt-5.6", llm_provider="openai")
)
error.failed_deployment_id = "fallback-deployment"
_trigger_cooldown_for_failed_deployment(litellm_router=router, kwargs={}, exception=error)
active: Final = router.cooldown_cache.get_active_cooldowns(model_ids=["fallback-deployment"], parent_otel_span=None)
assert tuple(entry[0] for entry in active) == cooled
class TestRunAsyncFallbackTriggersCooldown:
class RouterWithLoggingKwarg:
fallback_access_check = None

View file

@ -37,6 +37,8 @@ non-content-policy error, regardless of whether `fallbacks` is set.
from __future__ import annotations
from typing import Final
import pytest
import litellm
@ -313,6 +315,29 @@ async def test_flag_on_shows_deployment_timeout_debug(monkeypatch: pytest.Monkey
assert "Deployment Info: request_timeout:" in msg, msg
@pytest.mark.asyncio
async def test_flag_on_deployment_timeout_debug_names_stream_timeout(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(litellm, "expose_router_debug_in_errors", True)
router: Final = Router(
model_list=[
{
"model_name": _INTERNAL_MODEL_GROUP_NAME,
"litellm_params": {"model": "gpt-4o", "api_key": "key", "stream_timeout": 7},
},
],
num_retries=0,
)
with pytest.raises(litellm.Timeout) as excinfo:
await router.acompletion(
model=_INTERNAL_MODEL_GROUP_NAME,
messages=[{"role": "user", "content": "hi"}],
stream=True,
mock_timeout=True,
timeout=0.001,
)
assert "\nstream_timeout: 7" in excinfo.value.message, excinfo.value.message
# --- Site 3: ContentPolicyViolationError fallback hint (no fallback set) ----