From d5ca88900c4ec558fdf9c7f9084484896221a366 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:37:09 +0000 Subject: [PATCH] fix(anthropic): answer an upstream Anthropic 529 as 529 overloaded_error on /v1/messages (#46063) * fix(anthropic): answer a 503 as 529 overloaded_error on /v1/messages Anthropic's error contract has no 503, so a client built against it (Claude Code) treats a 503 from /v1/messages as a plain 5xx and retries it ten times instead of taking its overload path. A Bedrock 503 now leaves /v1/messages and /v1/messages/count_tokens as 529 overloaded_error, for both a JSON answer and a stream that fails before its first byte. An Anthropic 529 overloaded_error now maps to litellm.ServiceUnavailableError (503) instead of InternalServerError (500), so OpenAI-shaped routes answer it as 503, the status OpenAI documents for an overloaded engine, and router retry and allowed-fails policies key it as ServiceUnavailableError. * test(proxy): expect overloaded_error on a 503 stream error after a keepalive ping * fix(proxy): record the 529 the Anthropic routes answer and type the chat stream error frame The messages route stamps the answered status on the exception before post_call_failure_hook runs, so the spend log row, the OTel server span, the failed-request metric, and alerting record the 529 the client saw instead of the deployment's 503, while the deployment failure metric and cooldown keep the deployment's own status. A chat completions stream error frame names the type its status stands for instead of the null .type ServiceUnavailableError carries. An Anthropic-shaped 503 envelope passes through as 529 overloaded_error through a validated TypedDict, and count_tokens answers every ProxyException as the Anthropic envelope. Five integration tests that pinned 503 on the messages route now expect 529 * test(proxy): expect the top-level Anthropic envelope from count_tokens errors count_tokens now answers a ProxyException as a JSONResponse carrying the Anthropic error envelope instead of raising HTTPException, which FastAPI wrapped in {"detail": ...}. The three legacy error-format tests now read the response body and check that no detail wrap is left. * test(proxy): cover the 529 overloaded_error answer across routes, cooldown, and retry budgets * fix(anthropic): keep Anthropic overloads as InternalServerError and answer 529 from the provider status An upstream Anthropic 529 stays litellm.InternalServerError (status 500) so InternalServerErrorRetries, InternalServerErrorAllowedFails, SDK handlers, and the OpenAI-shaped routes behave as on main. The error now carries provider_status_code, and the /v1/messages routes answer 529 overloaded_error from it. * refactor(proxy): unwrap a mid-stream fallback error once instead of recursing for the provider status * fix(anthropic): keep count_tokens' detail body, end keepalive streams with an Anthropic error event, and log any 529 as an overload * test(integration): cover the audit round 3 cells and expect 529 on the count_tokens 503 cases * fix(proxy): re-raise an unchanged stream HTTPException as itself and walk nested mid-stream fallbacks for the provider status * fix(proxy): walk nested mid-stream fallbacks without recursion * fix(anthropic): preserve 503s and answer upstream 529s on messages routes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(anthropic): answer an upstream Anthropic 529 as 529 on count_tokens Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(anthropic): use integration coverage for keepalive errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/exceptions.py | 3 + .../litellm_core_utils/error_normalization.py | 4 + .../exception_mapping_utils.py | 7 + .../anthropic_endpoints/answered_status.py | 30 + .../proxy/anthropic_endpoints/endpoints.py | 35 +- litellm/proxy/common_request_processing.py | 51 +- .../adaptive_router/signals.py | 2 +- ...test_anthropic_messages_overloaded_wire.py | 724 ++++++++++++++++++ ...hropic_messages_pre_content_retry_chaos.py | 78 +- ...thropic_messages_pre_content_retry_wire.py | 4 +- ...st_anthropic_overloaded_failure_signals.py | 404 ++++++++++ .../test_vertex_passthrough_stream_wire.py | 121 +++ ...thropic_overloaded_cooldown_owned_proxy.py | 247 ++++++ ...stream_retry_budget_sources_owned_proxy.py | 55 +- .../test_error_normalization.py | 16 + .../test_exception_mapping_utils.py | 50 +- .../anthropic/test_anthropic_common_utils.py | 1 + .../test_answered_status.py | 116 +++ .../anthropic_endpoints/test_endpoints.py | 133 +++- .../proxy/test_common_request_processing.py | 28 + .../adaptive_router/test_signals.py | 6 + .../components/errors/errorsData.test.ts | 1 + .../components/errors/errorsData.ts | 1 + 23 files changed, 2068 insertions(+), 49 deletions(-) create mode 100644 litellm/proxy/anthropic_endpoints/answered_status.py create mode 100644 tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_overloaded_wire.py create mode 100644 tests/integration/observability/test_anthropic_overloaded_failure_signals.py create mode 100644 tests/integration/providers/test_vertex_passthrough_stream_wire.py create mode 100644 tests/integration/routing/test_anthropic_overloaded_cooldown_owned_proxy.py create mode 100644 tests/unit/proxy/anthropic_endpoints/test_answered_status.py diff --git a/litellm/exceptions.py b/litellm/exceptions.py index 12425c45b01..6662cf4a2c2 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -867,6 +867,7 @@ class BadGatewayError(openai.APIStatusError): class InternalServerError(openai.InternalServerError): response: httpx.Response # pyright: ignore[reportIncompatibleVariableOverride] # LiteLLM constructs an HTTPX response request: httpx.Request # pyright: ignore[reportIncompatibleVariableOverride] # LiteLLM constructs an HTTPX request + provider_status_code: int | None def __init__( self, @@ -878,8 +879,10 @@ class InternalServerError(openai.InternalServerError): max_retries: int | None = None, num_retries: int | None = None, body: object | None = None, + provider_status_code: int | None = None, ): self.status_code = 500 + self.provider_status_code = provider_status_code self.message = f"litellm.InternalServerError: {message}" self.llm_provider = llm_provider self.model = model diff --git a/litellm/litellm_core_utils/error_normalization.py b/litellm/litellm_core_utils/error_normalization.py index 00317e502ec..717db34ede5 100644 --- a/litellm/litellm_core_utils/error_normalization.py +++ b/litellm/litellm_core_utils/error_normalization.py @@ -133,6 +133,8 @@ _STATUS_CODE_MAP: Final[Mapping[str, str]] = MappingProxyType( } ) +_OVERLOADED_STATUS_CODE: Final = "529" + _INTERNAL_STATE_EXCEPTIONS: Final[tuple[type[BaseException], ...]] = ( TypeError, KeyError, @@ -201,6 +203,8 @@ def normalize_error(exc: Exception | None, status_code: str, message: str) -> st ) if by_message is not None: return by_message + if status_code == _OVERLOADED_STATUS_CODE: + return PROVIDER_OVERLOADED by_class: Final = _classify_by_class(exc) if by_class is not None: return by_class diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index f1195802fc5..422a2ef1219 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -605,6 +605,11 @@ def _map_openai_exception( ) +def _anthropic_overload_status(original_exception: _ProviderHTTPException) -> int | None: + upstream_status: Final = getattr(original_exception, "status_code", None) + return 529 if isinstance(upstream_status, int) and upstream_status >= 500 else None + + def _map_anthropic_exception( *, model: str, @@ -630,6 +635,7 @@ def _map_anthropic_exception( message=f"AnthropicError - {error_str}", model=model, llm_provider="anthropic", + provider_status_code=_anthropic_overload_status(original_exception), ) if "Invalid API Key" in error_str: raise AuthenticationError( @@ -694,6 +700,7 @@ def _map_anthropic_exception( llm_provider="anthropic", model=model, response=getattr(original_exception, "response", None), + provider_status_code=original_exception.status_code, ) elif original_exception.status_code == 502: raise BadGatewayError( diff --git a/litellm/proxy/anthropic_endpoints/answered_status.py b/litellm/proxy/anthropic_endpoints/answered_status.py new file mode 100644 index 00000000000..8a3469f9119 --- /dev/null +++ b/litellm/proxy/anthropic_endpoints/answered_status.py @@ -0,0 +1,30 @@ +from typing import Final, TypeVar + +from litellm.exceptions import InternalServerError, MidStreamFallbackError + +_Raised = TypeVar("_Raised", bound=Exception) + + +def _provider_status_code(exc: Exception) -> int | None: + unwrapped = exc # rebind-ok: the recursion gate requires iterative wrapper traversal + while isinstance(unwrapped, MidStreamFallbackError) and unwrapped.original_exception is not None: + unwrapped = unwrapped.original_exception + match unwrapped: + case InternalServerError(provider_status_code=int() as provider_status): + return provider_status + case _: + return None + + +def _copy_carrying(exc: _Raised, attribute: str, value: int | str) -> _Raised: + answered: Final = type(exc).__new__(type(exc), *exc.args) + answered.__dict__.update({**exc.__dict__, attribute: value}) + answered.__cause__ = exc.__cause__ + answered.__context__ = exc.__context__ + answered.__suppress_context__ = exc.__suppress_context__ + return answered.with_traceback(exc.__traceback__) + + +def with_answered_status(exc: Exception) -> Exception: + provider_status_code: Final = _provider_status_code(exc) + return exc if provider_status_code is None else _copy_carrying(exc, "status_code", provider_status_code) diff --git a/litellm/proxy/anthropic_endpoints/endpoints.py b/litellm/proxy/anthropic_endpoints/endpoints.py index baa6602b042..a0c43b9a8c5 100644 --- a/litellm/proxy/anthropic_endpoints/endpoints.py +++ b/litellm/proxy/anthropic_endpoints/endpoints.py @@ -22,6 +22,7 @@ from litellm.llms.anthropic.messages.context_management import ( AnthropicContextManagementError, ) from litellm.proxy._types import * +from litellm.proxy.anthropic_endpoints.answered_status import with_answered_status from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_request_processing import ( ProxyBaseLLMRequestProcessing, @@ -225,15 +226,18 @@ async def anthropic_response( request_id=request.headers.get("x-request-id"), ) return JSONResponse(status_code=e.status_code, content=body) - except Exception as e: + except Exception as raised: + answered: Final = with_answered_status(raised) await proxy_logging_obj.post_call_failure_hook( - user_api_key_dict=user_api_key_dict, original_exception=e, request_data=base_llm_response_processor.data + user_api_key_dict=user_api_key_dict, + original_exception=answered, + request_data=base_llm_response_processor.data, ) - log_llm_api_exception(e, base_llm_response_processor.litellm_call_id) + log_llm_api_exception(answered, base_llm_response_processor.litellm_call_id) - if isinstance(e, ProxyException): + if isinstance(answered, ProxyException): return _anthropic_error_json_response( - with_litellm_call_id(e, base_llm_response_processor.litellm_call_id), request + with_litellm_call_id(answered, base_llm_response_processor.litellm_call_id), request ) # Extract model_id from request metadata (same as success path) @@ -250,20 +254,20 @@ async def anthropic_response( response_cost=0, model_region=getattr(user_api_key_dict, "allowed_model_region", ""), request_data=data, - timeout=getattr(e, "timeout", None), + timeout=getattr(answered, "timeout", None), litellm_logging_obj=None, ) - if isinstance(e, HTTPException): - return _anthropic_error_json_response(proxy_exception_from_http_exception(e, headers), request) + if isinstance(answered, HTTPException): + return _anthropic_error_json_response(proxy_exception_from_http_exception(answered, headers), request) - error_msg: Final = f"{e}" + error_msg: Final = f"{answered}" return _anthropic_error_json_response( ProxyException( - message=getattr(e, "message", error_msg), - type=openai_error_type(e, error_status_code(e, 500)), - param=openai_error_param(e), - code=error_status_code(e, 500), + message=getattr(answered, "message", error_msg), + type=openai_error_type(answered, error_status_code(answered, 500)), + param=openai_error_param(answered), + code=error_status_code(answered, 500), headers=headers, ), request, @@ -383,8 +387,9 @@ async def count_tokens( except ProxyException as e: return _anthropic_error_json_response(e, request) except Exception as e: - log_llm_api_exception(e, litellm_call_id) - return _anthropic_error_json_response(proxy_exception_from_route_error(e), request) + answered: Final = with_answered_status(e) + log_llm_api_exception(answered, litellm_call_id) + return _anthropic_error_json_response(proxy_exception_from_route_error(answered), request) else: return { "input_tokens": token_response.total_tokens, diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 4be614f1658..647b19d5597 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -272,6 +272,7 @@ if TYPE_CHECKING: ProxyConfig = _ProxyConfig else: ProxyConfig = Any +from litellm.proxy.anthropic_endpoints.answered_status import with_answered_status from litellm.proxy.anthropic_endpoints.streaming_model_restamp import ( AnthropicStreamModelRestamper, ) @@ -1272,6 +1273,21 @@ def _sse_error_frames(error_obj: Mapping[str, object]) -> tuple[str, str]: return f"data: {json.dumps({'error': error_obj}, default=str)}\n\n", "data: [DONE]\n\n" +def _openai_late_failure_frames(exc: Exception) -> tuple[str, ...]: + _, error_obj = sse_error_payload(exc) + return _sse_error_frames(error_obj) + + +def _anthropic_late_failure_frames(exc: Exception) -> tuple[str, ...]: + answered: Final = proxy_exception_from_route_error(with_answered_status(exc)) + return ( + anthropic_error_sse_frame( + status_code=error_status_code(answered, status.HTTP_500_INTERNAL_SERVER_ERROR), + raw_message=answered.message, + ), + ) + + def _sse_stream_headers(headers: Mapping[str, str]) -> Mapping[str, str]: """`headers` plus the two that stop reverse proxies from buffering SSE (issue #28384).""" return MappingProxyType({**headers, **_TTFT_KEEPALIVE_HEADERS}) @@ -1520,6 +1536,7 @@ async def open_sse_before_first_byte( ping_interval_seconds: float | str | None, media_type: str = "text/event-stream", on_late_failure: Callable[[Exception], Awaitable[HTTPException | None]] | None = None, + late_failure_frames: Callable[[Exception], tuple[str, ...]] = _openai_late_failure_frames, ) -> _LateResponseT | StreamingResponse: """Write SSE keepalive comments while the upstream LLM call is still in flight. @@ -1565,8 +1582,7 @@ async def open_sse_before_first_byte( # would never fire and the failure would go unaudited. The hook # also gets to sanitize what reaches the client, by returning or # raising a replacement, so its answer decides the frame. - _, error_obj = sse_error_payload(await _sanitized_late_failure(exc, on_late_failure)) - for frame in _sse_error_frames(error_obj): + for frame in late_failure_frames(await _sanitized_late_failure(exc, on_late_failure)): yield frame.encode() return async for chunk in _relay_late_response(produced): @@ -2982,13 +2998,14 @@ class ProxyBaseLLMRequestProcessing: idle. See ``open_sse_before_first_byte``; unwrapped unless an operator sets ``litellm_settings.sse_keepalive_ping_interval_seconds``. """ + answers_as_anthropic: Final = route_type == "anthropic_messages" async def _audit_late_failure(exc: Exception) -> HTTPException | None: # Once a keepalive is on the wire this can no longer raise, so the # caller's `except` never runs its own post_call_failure_hook. return await proxy_logging_obj.post_call_failure_hook( user_api_key_dict=user_api_key_dict, - original_exception=exc, + original_exception=with_answered_status(exc) if answers_as_anthropic else exc, request_data=self.data, dispatched_call_type=self.route_type, ) @@ -3017,6 +3034,7 @@ class ProxyBaseLLMRequestProcessing: ), ping_interval_seconds=ttft_keepalive_interval(self.data, llm_router), on_late_failure=_audit_late_failure, + late_failure_frames=_anthropic_late_failure_frames if answers_as_anthropic else _openai_late_failure_frames, ) async def _process_llm_request( @@ -4641,6 +4659,7 @@ class ProxyBaseLLMRequestProcessing: request: Request | None = None, flush_tail: Callable[[], bytes] | None = None, seal_open_frame: Callable[[bytes], str] | None = None, + answer_status: Callable[[Exception], Exception] | None = None, ) -> AsyncGenerator[str, None]: """ Shared streaming data generator: runs proxy iterator hook, per-chunk hook, @@ -4656,6 +4675,9 @@ class ProxyBaseLLMRequestProcessing: relays raw upstream bytes, so an upstream that hangs up mid-frame leaves the client inside an open frame, where an error frame would be swallowed or misparsed instead of raised. + + ``answer_status`` gives a failure the status the route answers it with + before the failure hook records it. """ verbose_proxy_logger.debug("inside generator") # Resolve per-stream (not per-chunk) whether the heavy per-chunk path @@ -4752,6 +4774,7 @@ class ProxyBaseLLMRequestProcessing: verbose_proxy_logger.exception( "litellm.proxy.proxy_server.async_data_generator(): Exception occured - %s", e ) + answered: Final = e if answer_status is None else answer_status(e) already_reported: Final = ( isinstance(stream_logging_obj, LiteLLMLoggingObj) and not stream_logging_obj.claim_stream_failure_report() @@ -4761,24 +4784,25 @@ class ProxyBaseLLMRequestProcessing: if already_reported else await proxy_logging_obj.post_call_failure_hook( user_api_key_dict=user_api_key_dict, - original_exception=e, + original_exception=answered, request_data=request_data, ) ) - if transformed_exception is not None: - e = transformed_exception + failure: Final = answered if transformed_exception is None else transformed_exception verbose_proxy_logger.debug( "An error occurred: %s", - e, + failure, ) - if isinstance(e, HTTPException): - raise e - stream_error_status: Final = error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR) + if isinstance(failure, HTTPException): + if failure is e: + raise + raise failure from e + stream_error_status: Final = error_status_code(failure, status.HTTP_500_INTERNAL_SERVER_ERROR) proxy_exception: Final = ProxyException( - message=redact_internal_details_from_client_message(getattr(e, "message", str(e))), - type=openai_error_type(e, stream_error_status), - param=openai_error_param(e), + message=redact_internal_details_from_client_message(getattr(failure, "message", str(failure))), + type=openai_error_type(failure, stream_error_status), + param=openai_error_param(failure), code=stream_error_status, ) stream_completed = True @@ -4834,6 +4858,7 @@ class ProxyBaseLLMRequestProcessing: request=request, flush_tail=None if restamper is None else restamper.flush, seal_open_frame=seal_open_sse_frame, + answer_status=with_answered_status, ) @overload diff --git a/litellm/router_strategy/adaptive_router/signals.py b/litellm/router_strategy/adaptive_router/signals.py index 72e8d27d2bf..c7fffd05632 100644 --- a/litellm/router_strategy/adaptive_router/signals.py +++ b/litellm/router_strategy/adaptive_router/signals.py @@ -198,7 +198,7 @@ def _detect_loop(history: list[str], new_calls: Sequence[Mapping[str, object]]) return False -_EXHAUSTION_STATUSES: Final = {408, 413, 429, 503, 504} +_EXHAUSTION_STATUSES: Final = {408, 413, 429, 503, 504, 529} _EXHAUSTION_KEYWORDS: Final = ( "context length", diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_overloaded_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_overloaded_wire.py new file mode 100644 index 00000000000..bae775164dd --- /dev/null +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_overloaded_wire.py @@ -0,0 +1,724 @@ +import json +import os +import threading +import time +import uuid +from collections.abc import Callable, Generator, Mapping +from contextlib import contextmanager +from typing import Final, Literal +from urllib.parse import unquote + +import anthropic +import httpx +import openai +import pytest +from integration._support.anthropic_sse import ( + LIFECYCLE, + error_frame, + event_type, + event_types, + message_id, + message_json, + message_stream, + parse_sse, + status_reply, + stream_reply, + user_prompt, +) +from integration._support.client import ( + JSON_OBJECT, + Gateway, + JsonValue, + Scenario, + eventually, + object_value, + string_value, +) +from integration._support.database import read_rows +from integration._support.openai_wire import openai_error +from integration._support.upstream import aws_event_stream_frame +from integration._support.wire import Reply, Request, Wire, wire_server +from redis import Redis + +_MODEL: Final = "claude-sonnet-4-5-20250929" +_API_KEY: Final = "synthetic-anthropic-key" +_BEDROCK_MODEL: Final = "us.anthropic.claude-sonnet-4-5-20250929-v1:0" +_BEDROCK_TOKEN: Final = "synthetic-bedrock-bearer" +_BEDROCK_503: Final = b'{"message": "Bedrock is unable to process your request."}' +_CONVERSE_MODEL: Final = f"bedrock/converse/{_BEDROCK_MODEL}" +_CONVERSE_STREAM_TARGET: Final = f"/model/{_BEDROCK_MODEL}/converse-stream" +_OPENAI_BACKEND: Final = "gpt-4o-mini" +_CHAT_ONLY_BACKEND: Final = "audit-chat-only" +_CODEX_BACKEND: Final = "gpt-5.3-codex" +_TEXT: Final = "Hello" +_KEEPALIVE_SECONDS: Final = 0.2 +_LATE_ANSWER_SECONDS: Final = 1.0 +_OVERLOAD_CLASS: Final = "503_PROVIDER_OVERLOADED" + +Shape = Literal["stream", "non-stream"] + + +def _prompt() -> str: + return "overloaded-" + uuid.uuid4().hex + + +def _always(reply: Reply) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + assert request.method == "POST", request + return reply + + return respond + + +def _late(reply: Reply, seconds: float) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + assert request.method == "POST", request + time.sleep(seconds) + return reply + + return respond + + +def _anthropic_sdk(gateway: Gateway) -> anthropic.Anthropic: + return anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0, timeout=60) + + +def _async_anthropic_sdk(gateway: Gateway) -> anthropic.AsyncAnthropic: + return anthropic.AsyncAnthropic( + base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0, timeout=60 + ) + + +def _openai_sdk(gateway: Gateway) -> openai.OpenAI: + return openai.OpenAI(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0, timeout=60) + + +def _async_openai_sdk(gateway: Gateway) -> openai.AsyncOpenAI: + return openai.AsyncOpenAI(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0, timeout=60) + + +def _messages_body(model: str, prompt: str, *, stream: bool) -> dict[str, JsonValue]: + return { + "model": model, + "max_tokens": 16, + "stream": stream, + "messages": [{"role": "user", "content": prompt}], + "num_retries": 0, + } + + +def _anthropic_error(response: httpx.Response) -> dict[str, JsonValue]: + assert response.headers["content-type"].startswith("application/json"), dict(response.headers) + payload: Final = object_value(json.loads(response.content)) + assert payload["type"] == "error", response.text + return object_value(payload["error"]) + + +def _openai_error(response: httpx.Response) -> dict[str, JsonValue]: + return object_value(object_value(json.loads(response.content))["error"]) + + +def _assert_overloaded(response: httpx.Response, *, message: str) -> None: + assert response.status_code == 529, response.text + error: Final = _anthropic_error(response) + assert error["type"] == "overloaded_error", response.text + assert message in string_value(error["message"]), response.text + assert "content_block_delta" not in response.text, response.text + + +def _assert_api_error(response: httpx.Response, *, status: int, message: str) -> None: + assert response.status_code == status, response.text + error: Final = _anthropic_error(response) + assert error["type"] == "api_error", response.text + assert message in string_value(error["message"]), response.text + + +def _assert_internal_server_error(response: httpx.Response) -> None: + assert response.status_code == 500, response.text + error: Final = _openai_error(response) + assert error["code"] == "500", response.text + assert "InternalServerError" in string_value(error["message"]), response.text + + +def _one_post(wire: Wire, target: str) -> None: + assert [(request.method, unquote(request.target)) for request in wire.drain()] == [("POST", target)] + + +def _spend_rows(model: str, expected: int) -> list[dict[str, JsonValue]]: + return eventually( + lambda: read_rows( + "SELECT status, metadata->'error_information'->>'error_code' AS code, " + "metadata->'error_information'->>'error_class' AS error_class, " + "metadata->'error_information'->>'normalized_error' AS normalized_error " + 'FROM "LiteLLM_SpendLogs" WHERE model_group=%s', + (model,), + ), + lambda rows: len(rows) >= expected, + seconds=70, + ) + + +def _assert_logged_failure(model: str, *, rows: int, error_class: str, code: str = "529") -> None: + assert ( + _spend_rows(model, rows) + == [{"status": "failure", "code": code, "error_class": error_class, "normalized_error": _OVERLOAD_CLASS}] * rows + ) + + +def test_messages_529_answers_overloaded_error_to_the_sync_sdk_and_logs_each_call_once(gateway: Gateway) -> None: + with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + sdk: Final = _anthropic_sdk(gateway) + prompt: Final = _prompt() + for _ in range(2): + with pytest.raises(anthropic.APIStatusError) as caught: + sdk.messages.create( + model=model, + max_tokens=16, + messages=[{"role": "user", "content": prompt}], + extra_body={"num_retries": 0}, + ) + assert caught.value.status_code == 529, caught.value.response.text + _assert_overloaded(caught.value.response, message="scripted 529") + assert [unquote(request.target) for request in wire.drain()] == ["/v1/messages"] * 2 + _assert_logged_failure(model, rows=2, error_class="InternalServerError") + + +async def test_messages_529_answers_overloaded_error_to_the_async_sdk(gateway: Gateway) -> None: + with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + with pytest.raises(anthropic.APIStatusError) as caught: + await _async_anthropic_sdk(gateway).messages.create( + model=model, + max_tokens=16, + messages=[{"role": "user", "content": _prompt()}], + extra_body={"num_retries": 0}, + ) + assert caught.value.status_code == 529, caught.value.response.text + _assert_overloaded(caught.value.response, message="scripted 529") + _one_post(wire, "/v1/messages") + + +@pytest.mark.parametrize( + ("upstream_status", "error_class", "error_type"), + ((529, "InternalServerError", "overloaded_error"), (503, "ServiceUnavailableError", "api_error")), +) +def test_messages_stream_rejected_before_its_first_byte_answers_the_provider_status_as_json( + gateway: Gateway, upstream_status: int, error_class: str, error_type: str +) -> None: + with wire_server(_always(status_reply(upstream_status))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request("POST", "/v1/messages", _messages_body(model, _prompt(), stream=True)) + assert response.status_code == upstream_status, response.text + error: Final = _anthropic_error(response) + assert error["type"] == error_type, response.text + assert f"scripted {upstream_status}" in string_value(error["message"]), response.text + _one_post(wire, "/v1/messages") + _assert_logged_failure(model, rows=1, error_class=error_class, code=str(upstream_status)) + + +def test_messages_stream_529_raises_529_in_the_sync_sdk(gateway: Gateway) -> None: + with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + with pytest.raises(anthropic.APIStatusError) as caught: + with _anthropic_sdk(gateway).messages.stream( + model=model, + max_tokens=16, + messages=[{"role": "user", "content": _prompt()}], + extra_body={"num_retries": 0}, + ) as stream: + stream.get_final_message() + assert caught.value.status_code == 529, caught.value.response.text + _assert_overloaded(caught.value.response, message="scripted 529") + _one_post(wire, "/v1/messages") + + +def _error_frames(text: str) -> tuple[dict[str, JsonValue], ...]: + return tuple( + object_value(json.loads(line.removeprefix("data:").strip())) + for line in text.splitlines() + if line.startswith("data:") and '"error"' in line + ) + + +def test_messages_529_after_the_keepalive_opened_the_response_ends_with_an_overloaded_error_event( + gateway: Gateway, +) -> None: + with ( + wire_server(_late(status_reply(529), _LATE_ANSWER_SECONDS)) as wire, + gateway.scenario() as scenario, + ): + model: Final = scenario.model( + model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY, keepalive_seconds=_KEEPALIVE_SECONDS + ) + response: Final = gateway.request("POST", "/v1/messages", _messages_body(model, _prompt(), stream=True)) + assert response.status_code == 200, response.text + assert response.headers["content-type"].startswith("text/event-stream"), dict(response.headers) + assert response.text.startswith(":"), response.text + (failure,) = (event for event in parse_sse(response.text) if event.data and event.event != "ping") + assert failure.event == "error", response.text + assert object_value(failure.data["error"])["type"] == "overloaded_error", response.text + assert "[DONE]" not in response.text, response.text + _one_post(wire, "/v1/messages") + + +def _late_bedrock_deployment(scenario: Scenario, wire: Wire) -> str: + return scenario.model( + model=f"bedrock/{_BEDROCK_MODEL}", + api_key=_BEDROCK_TOKEN, + aws_region_name="us-east-1", + api_base=wire.url, + keepalive_seconds=_KEEPALIVE_SECONDS, + ) + + +def _one_bedrock_post(wire: Wire) -> None: + (request,) = wire.drain() + assert unquote(request.target).startswith(f"/model/{_BEDROCK_MODEL}/"), request + + +def test_messages_bedrock_503_after_the_keepalive_opened_the_response_ends_with_an_api_error_event( + gateway: Gateway, +) -> None: + with ( + wire_server(_late(Reply(status=503, body=_BEDROCK_503), _LATE_ANSWER_SECONDS)) as wire, + gateway.scenario() as scenario, + ): + model: Final = _late_bedrock_deployment(scenario, wire) + response: Final = gateway.request("POST", "/v1/messages", _messages_body(model, _prompt(), stream=True)) + assert response.status_code == 200, response.text + assert response.text.startswith(":"), response.text + assert "[DONE]" not in response.text, response.text + (failure,) = (event for event in parse_sse(response.text) if event.data and event.event != "ping") + assert failure.event == "error", response.text + error: Final = object_value(failure.data["error"]) + assert error["type"] == "api_error", response.text + assert "Bedrock is unable to process your request." in string_value(error["message"]), response.text + _one_bedrock_post(wire) + _assert_logged_failure(model, rows=1, error_class="ServiceUnavailableError", code="503") + + +def test_chat_completions_bedrock_503_after_the_keepalive_opened_the_response_keeps_the_openai_error_frame( + gateway: Gateway, +) -> None: + with ( + wire_server(_late(Reply(status=503, body=_BEDROCK_503), _LATE_ANSWER_SECONDS)) as wire, + gateway.scenario() as scenario, + ): + model: Final = _late_bedrock_deployment(scenario, wire) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": _prompt()}], "stream": True, "num_retries": 0}, + ) + assert response.status_code == 200, response.text + assert response.text.startswith(":"), response.text + assert "event: error" not in response.text, response.text + (frame,) = _error_frames(response.text) + assert object_value(frame["error"])["code"] == "503", response.text + assert response.text.rstrip().endswith("data: [DONE]"), response.text + _one_bedrock_post(wire) + _assert_logged_failure(model, rows=1, error_class="ServiceUnavailableError", code="503") + + +def test_chat_completions_overloaded_frame_after_content_ends_with_a_500_error_frame(gateway: Gateway) -> None: + prompt: Final = _prompt() + upstream: Final = (*message_stream(prompt, _MODEL, _TEXT)[:3], error_frame(529, "Overloaded")) + with wire_server(_always(stream_reply(upstream))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": prompt}], "stream": True, "num_retries": 0}, + ) + assert response.status_code == 200, response.text + assert _TEXT in response.text, response.text + (frame,) = _error_frames(response.text) + error: Final = object_value(frame["error"]) + assert error["code"] == "500", response.text + _one_post(wire, "/v1/messages") + + +def _overloaded_before_content(prompt: str) -> Reply: + return stream_reply((*message_stream(prompt, _MODEL, _TEXT)[:1], error_frame(529, "Overloaded"))) + + +def test_messages_overloaded_frame_before_content_without_a_retry_budget_is_relayed_as_an_overloaded_event( + gateway: Gateway, +) -> None: + prompt: Final = _prompt() + with wire_server(_always(_overloaded_before_content(prompt))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request("POST", "/v1/messages", _messages_body(model, prompt, stream=True)) + assert response.status_code == 200, response.text + events: Final = parse_sse(response.text) + assert event_types(events) == ("message_start", "error"), response.text + assert message_id(events) == prompt, response.text + assert events[1].data == {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}} + _one_post(wire, "/v1/messages") + assert _spend_rows(model, 1) == [ + {"status": "success", "code": None, "error_class": None, "normalized_error": None} + ] + + +def test_chat_completions_overloaded_frame_before_content_answers_500(gateway: Gateway) -> None: + prompt: Final = _prompt() + with wire_server(_always(_overloaded_before_content(prompt))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": prompt}], "stream": True, "num_retries": 0}, + ) + _assert_internal_server_error(response) + _one_post(wire, "/v1/messages") + + +def test_chat_completions_529_answers_500_to_the_sync_sdk(gateway: Gateway) -> None: + with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + with pytest.raises(openai.APIStatusError) as caught: + _openai_sdk(gateway).chat.completions.create( + model=model, messages=[{"role": "user", "content": _prompt()}], extra_body={"num_retries": 0} + ) + assert caught.value.status_code == 500, caught.value.response.text + _assert_internal_server_error(caught.value.response) + _one_post(wire, "/v1/messages") + _assert_logged_failure(model, rows=1, error_class="InternalServerError", code="500") + + +async def test_chat_completions_stream_529_answers_500_to_the_async_sdk(gateway: Gateway) -> None: + with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + with pytest.raises(openai.APIStatusError) as caught: + await _async_openai_sdk(gateway).chat.completions.create( + model=model, + messages=[{"role": "user", "content": _prompt()}], + stream=True, + extra_body={"num_retries": 0}, + ) + assert caught.value.status_code == 500, caught.value.response.text + _assert_internal_server_error(caught.value.response) + _one_post(wire, "/v1/messages") + + +@pytest.mark.parametrize("shape", ("non-stream", "stream")) +def test_responses_529_answers_500(gateway: Gateway, shape: Shape) -> None: + with wire_server(_always(status_reply(529))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request( + "POST", "/v1/responses", {"model": model, "input": _prompt(), "stream": shape == "stream", "num_retries": 0} + ) + _assert_internal_server_error(response) + _one_post(wire, "/v1/messages") + + +@pytest.mark.parametrize("shape", ("non-stream", "stream")) +def test_messages_bedrock_503_answers_503_api_error(gateway: Gateway, shape: Shape) -> None: + with wire_server(_always(Reply(status=503, body=_BEDROCK_503))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"bedrock/{_BEDROCK_MODEL}", api_key=_BEDROCK_TOKEN, aws_region_name="us-east-1", api_base=wire.url + ) + response: Final = gateway.request( + "POST", "/v1/messages", _messages_body(model, _prompt(), stream=shape == "stream") + ) + _assert_api_error(response, status=503, message="Bedrock is unable to process your request.") + (request,) = wire.drain() + assert unquote(request.target).startswith(f"/model/{_BEDROCK_MODEL}/"), request + _assert_logged_failure(model, rows=1, error_class="ServiceUnavailableError", code="503") + + +def _converse_event(event: str, payload: Mapping[str, JsonValue]) -> bytes: + return aws_event_stream_frame( + {":event-type": event, ":content-type": "application/json", ":message-type": "event"}, + json.dumps(payload).encode(), + ) + + +def _converse_503_after_text() -> Reply: + unavailable: Final = aws_event_stream_frame( + { + ":exception-type": "serviceUnavailableException", + ":content-type": "application/json", + ":message-type": "exception", + }, + _BEDROCK_503, + ) + frames: Final = ( + _converse_event("messageStart", {"role": "assistant"}), + _converse_event("contentBlockDelta", {"delta": {"text": _TEXT}, "contentBlockIndex": 0}), + unavailable, + ) + return Reply(body=b"".join(frames), content_type="application/vnd.amazon.eventstream") + + +def _converse_deployment(scenario: Scenario, wire: Wire) -> str: + return scenario.model(model=_CONVERSE_MODEL, api_key=_BEDROCK_TOKEN, aws_region_name="us-east-1", api_base=wire.url) + + +def test_messages_bedrock_stream_503_after_text_ends_with_an_api_error_event(gateway: Gateway) -> None: + with wire_server(_always(_converse_503_after_text())) as wire, gateway.scenario() as scenario: + model: Final = _converse_deployment(scenario, wire) + response: Final = gateway.request("POST", "/v1/messages", _messages_body(model, _prompt(), stream=True)) + assert response.status_code == 200, response.text + events: Final = parse_sse(response.text) + types: Final = event_types(events) + assert "message_stop" not in types, response.text + assert types.index("content_block_delta") < types.index("error"), response.text + assert _TEXT in response.text, response.text + (failure,) = (event for event in events if event_type(event) == "error") + error: Final = object_value(failure.data["error"]) + assert error["type"] == "api_error", response.text + assert "Bedrock is unable to process your request." in string_value(error["message"]), response.text + _one_post(wire, _CONVERSE_STREAM_TARGET) + _assert_logged_failure(model, rows=1, error_class="ServiceUnavailableError", code="503") + + +def test_chat_completions_bedrock_stream_503_after_text_ends_with_a_503_error_frame(gateway: Gateway) -> None: + with wire_server(_always(_converse_503_after_text())) as wire, gateway.scenario() as scenario: + model: Final = _converse_deployment(scenario, wire) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": _prompt()}], "stream": True, "num_retries": 0}, + ) + assert response.status_code == 200, response.text + assert _TEXT in response.text, response.text + (frame,) = _error_frames(response.text) + error: Final = object_value(frame["error"]) + assert error["code"] == "503", response.text + _one_post(wire, _CONVERSE_STREAM_TARGET) + _assert_logged_failure(model, rows=1, error_class="ServiceUnavailableError", code="503") + + +@pytest.mark.parametrize("shape", ("non-stream", "stream")) +def test_chat_bridge_503_answers_503_api_error_on_messages(gateway: Gateway, shape: Shape) -> None: + with wire_server(_always(openai_error(503))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"hosted_vllm/{_CHAT_ONLY_BACKEND}", api_base=wire.url + "/v1", api_key="synthetic-vllm-key" + ) + response: Final = gateway.request( + "POST", "/v1/messages", _messages_body(model, _prompt(), stream=shape == "stream") + ) + _assert_api_error(response, status=503, message="scripted 503") + _one_post(wire, "/v1/chat/completions") + + +@pytest.mark.parametrize("shape", ("non-stream", "stream")) +def test_responses_bridge_503_answers_503_api_error_on_messages(gateway: Gateway, shape: Shape) -> None: + with wire_server(_always(openai_error(503))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"openai/{_CODEX_BACKEND}", api_base=wire.url + "/v1", api_key="synthetic-openai-key" + ) + response: Final = gateway.request( + "POST", "/v1/messages", _messages_body(model, _prompt(), stream=shape == "stream") + ) + _assert_api_error(response, status=503, message="scripted 503") + _one_post(wire, "/v1/responses") + + +_SETTLED_ANSWERS: Final = 4 + + +def _messages_to(gateway: Gateway, model: str) -> tuple[tuple[str, httpx.Response], ...]: + prompts: Final = tuple(_prompt() for _ in range(_SETTLED_ANSWERS)) + return tuple( + (prompt, gateway.request("POST", "/v1/messages", _messages_body(model, prompt, stream=False))) + for prompt in prompts + ) + + +def _failure_rows_of(models: tuple[str, str], expected: int) -> list[dict[str, JsonValue]]: + return eventually( + lambda: read_rows( + "SELECT metadata->'error_information'->>'error_code' AS code, " + "metadata->'error_information'->>'error_class' AS error_class, " + "metadata->'error_information'->>'normalized_error' AS normalized_error " + 'FROM "LiteLLM_SpendLogs" WHERE status = %s AND model_group IN (%s, %s)', + ("failure", *models), + ), + lambda rows: len(rows) >= expected, + seconds=70, + ) + + +@contextmanager +def _paused_deployment(gateway: Gateway) -> Generator[tuple[Wire, str, str]]: + unpaused: Final = Reply(body=message_json("msg_unpaused", _MODEL, _TEXT)) + with wire_server(_always(unpaused)) as wire, gateway.scenario() as scenario: + identity: Final = f"audit-paused-{uuid.uuid4().hex}" + name: Final = scenario.model( + model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY, model_info={"id": identity} + ) + paused: Final = gateway.request("PATCH", f"/model/{identity}/update", {"blocked": True}) + assert paused.status_code == 200, paused.text + yield wire, identity, name + + +def _assert_refused_without_an_upstream_call( + gateway: Gateway, wire: Wire, target: str, *, status: int, error_type: str, message: str +) -> None: + answers: Final = eventually( + lambda: _messages_to(gateway, target), + lambda settled: all(response.status_code != 200 for _, response in settled), + seconds=90, + ) + texts: Final = [response.text for _, response in answers] + assert [response.status_code for _, response in answers] == [status] * _SETTLED_ANSWERS, texts + errors: Final = [_anthropic_error(response) for _, response in answers] + assert [error["type"] for error in errors] == [error_type] * _SETTLED_ANSWERS, texts + assert all(message in string_value(error["message"]) for error in errors), texts + reached: Final = {user_prompt(JSON_OBJECT.validate_json(request.body)) for request in wire.drain()} + assert reached.isdisjoint(prompt for prompt, _ in answers), reached + + +def test_messages_to_a_paused_deployment_named_by_its_id_answer_503_api_error(gateway: Gateway) -> None: + with _paused_deployment(gateway) as (wire, identity, name): + _assert_refused_without_an_upstream_call( + gateway, wire, identity, status=503, error_type="api_error", message="is currently paused" + ) + rows: Final = _failure_rows_of((identity, name), _SETTLED_ANSWERS) + assert {(row["code"], row["error_class"], row["normalized_error"]) for row in rows} == { + ("503", "ServiceUnavailableError", _OVERLOAD_CLASS) + }, rows + + +def test_messages_to_a_paused_model_group_named_by_its_name_answer_403_permission_error(gateway: Gateway) -> None: + with _paused_deployment(gateway) as (wire, _, name): + _assert_refused_without_an_upstream_call( + gateway, wire, name, status=403, error_type="permission_error", message="Model is blocked" + ) + + +_MALFORMED_529_BODIES: Final = { + "empty": b"", + "text-5k": b"x" * 5000, + "html": b"

529

", + "api_error-type": b'{"type": "error", "error": {"type": "api_error", "message": "capacity"}}', +} + + +def _overloaded_for_sad_prompts(body: bytes) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + prompt: Final = user_prompt(object_value(json.loads(request.body))) + if prompt.startswith("sad:"): + return Reply(status=529, body=body, content_type="text/plain" if body[:1] != b"{" else "application/json") + return Reply(body=message_json(f"msg_{prompt}", _MODEL, _TEXT)) + + return respond + + +@pytest.mark.parametrize("body", _MALFORMED_529_BODIES.keys()) +def test_messages_529_with_a_malformed_body_still_answers_overloaded_error_and_unrelated_calls_keep_working( + gateway: Gateway, body: str +) -> None: + with wire_server(_overloaded_for_sad_prompts(_MALFORMED_529_BODIES[body])) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + sad: Final = gateway.request("POST", "/v1/messages", _messages_body(model, "sad:" + _prompt(), stream=False)) + assert sad.status_code == 529, sad.text + assert _anthropic_error(sad)["type"] == "overloaded_error", sad.text + prompt: Final = _prompt() + healthy: Final = gateway.request("POST", "/v1/messages", _messages_body(model, prompt, stream=False)) + assert healthy.status_code == 200, healthy.text + assert object_value(json.loads(healthy.content))["id"] == f"msg_{prompt}", healthy.text + assert [unquote(request.target) for request in wire.drain()] == ["/v1/messages"] * 2 + assert sorted(_spend_rows(model, 2), key=lambda row: str(row["status"])) == [ + { + "status": "failure", + "code": "529", + "error_class": "InternalServerError", + "normalized_error": _OVERLOAD_CLASS, + }, + {"status": "success", "code": None, "error_class": None, "normalized_error": None}, + ] + + +@pytest.mark.parametrize(("upstream_status", "error_type"), ((500, "api_error"), (429, "rate_limit_error"))) +def test_messages_other_upstream_statuses_keep_their_status_and_type( + gateway: Gateway, upstream_status: int, error_type: str +) -> None: + with wire_server(_always(status_reply(upstream_status))) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request("POST", "/v1/messages", _messages_body(model, _prompt(), stream=True)) + assert response.status_code == upstream_status, response.text + assert _anthropic_error(response)["type"] == error_type, response.text + _one_post(wire, "/v1/messages") + + +def _anthropic_rejection(status: int, error_type: str, message: str) -> Reply: + return Reply( + status=status, body=json.dumps({"type": "error", "error": {"type": error_type, "message": message}}).encode() + ) + + +_REJECTIONS_SAYING_OVERLOADED: Final = { + "400-tool-name": _anthropic_rejection( + 400, "invalid_request_error", "tools.0.name: 'Overloaded' is not a valid tool name" + ), + "404-model": _anthropic_rejection(404, "not_found_error", "model: claude-Overloaded"), +} + + +@pytest.mark.parametrize("shape", ("non-stream", "stream")) +@pytest.mark.parametrize("rejection", _REJECTIONS_SAYING_OVERLOADED.keys()) +def test_messages_4xx_whose_text_says_overloaded_is_not_answered_as_an_overload( + gateway: Gateway, rejection: str, shape: Shape +) -> None: + with wire_server(_always(_REJECTIONS_SAYING_OVERLOADED[rejection])) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + response: Final = gateway.request( + "POST", "/v1/messages", _messages_body(model, _prompt(), stream=shape == "stream") + ) + assert response.status_code == 500, response.text + error: Final = _anthropic_error(response) + assert error["type"] == "api_error", response.text + assert "Overloaded" in string_value(error["message"]), response.text + _one_post(wire, "/v1/messages") + assert _spend_rows(model, 1) == [ + { + "status": "failure", + "code": "500", + "error_class": "InternalServerError", + "normalized_error": _OVERLOAD_CLASS, + } + ] + + +def _string_values(cache: Redis) -> tuple[bytes, ...]: + keys: Final = tuple(key for key in cache.scan_iter(count=1000) if cache.type(key) == b"string") + return tuple(value for value in cache.mget(keys) if value is not None) if keys else () + + +def _cached_somewhere(served_id: str) -> bool: + with Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"])) as cache: + return any(served_id.encode() in value for value in _string_values(cache)) + + +def _healthy_until(overloaded: threading.Event) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + if overloaded.is_set(): + return status_reply(529) + prompt: Final = user_prompt(object_value(json.loads(request.body))) + return stream_reply(message_stream(f"msg_{prompt}", _MODEL, _TEXT)) + + return respond + + +def test_messages_cache_hit_is_served_while_the_upstream_is_overloaded(gateway: Gateway) -> None: + overloaded: Final = threading.Event() + with wire_server(_healthy_until(overloaded)) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY) + prompt: Final = _prompt() + body: Final = _messages_body(model, prompt, stream=True) + first: Final = gateway.request("POST", "/v1/messages", body) + assert first.status_code == 200, first.text + assert event_types(parse_sse(first.text)) == LIFECYCLE, first.text + assert message_id(parse_sse(first.text)) == f"msg_{prompt}", first.text + eventually(lambda: _cached_somewhere(f"msg_{prompt}"), bool, seconds=30) + overloaded.set() + replay: Final = gateway.request("POST", "/v1/messages", body) + assert replay.status_code == 200, replay.text + assert message_id(parse_sse(replay.text)) == f"msg_{prompt}", replay.text + fresh: Final = gateway.request("POST", "/v1/messages", _messages_body(model, _prompt(), stream=True)) + _assert_overloaded(fresh, message="scripted 529") + assert [unquote(request.target) for request in wire.drain()] == ["/v1/messages"] * 2 diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_pre_content_retry_chaos.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_pre_content_retry_chaos.py index 9dd1a2d2c18..99239efe5ee 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_pre_content_retry_chaos.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_pre_content_retry_chaos.py @@ -135,6 +135,13 @@ def _drop_or_500(request: Request, marker: str) -> Reply: return openai_error(500) +def _overloaded_while(outage: threading.Event) -> Callable[[Request, str], Reply]: + def first_attempt(request: Request, marker: str) -> Reply: + return status_reply(529) if outage.is_set() else _served(request, marker, 1) + + return first_attempt + + def _outage(statuses: Mapping[str, int]) -> Callable[[Request, str], Reply]: def first_attempt(request: Request, marker: str) -> Reply: if request.target == "/v1/messages": @@ -165,7 +172,7 @@ class _Upstream: return self.first_attempt(request, marker) -def _config(wire: Wire, directory: Path, models: _Models) -> Path: +def _config(wire: Wire, directory: Path, models: _Models, *, retries: int = 1) -> Path: config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) config["model_list"] = [ { @@ -174,7 +181,7 @@ def _config(wire: Wire, directory: Path, models: _Models) -> Path: "model": f"anthropic/{_ANTHROPIC_BACKEND}", "api_base": wire.url, "api_key": _ANTHROPIC_KEY, - "num_retries": 1, + "num_retries": retries, }, }, { @@ -183,7 +190,7 @@ def _config(wire: Wire, directory: Path, models: _Models) -> Path: "model": f"openai/{_OPENAI_BACKEND}", "api_base": wire.url + "/v1", "api_key": _OPENAI_KEY, - "num_retries": 1, + "num_retries": retries, }, }, ] @@ -408,3 +415,68 @@ async def test_outage_on_every_first_attempt_is_absorbed_by_the_deployment_budge _assert_completed_on_the_second_attempt(item) _assert_each_served_id_landed_exactly_once(models, served) assert all(upstream.attempts.count(call.marker) == 2 for call in calls) + + +def _messages_rows(model: str, expected: int) -> list[dict[str, JsonValue]]: + return eventually( + lambda: read_rows( + "SELECT request_id, status, metadata->'error_information'->>'error_code' AS code " + 'FROM "LiteLLM_SpendLogs" WHERE model_group=%s', + (model,), + ), + lambda found: len(found) >= expected, + seconds=90, + ) + + +@pytest.mark.timeout(300) +async def test_overloaded_outage_during_a_burst_with_a_worker_sigkill_answers_529_and_lands_every_call_once( + gateway: Gateway, tmp_path: Path +) -> None: + models: Final = _models() + calls: Final = _calls((("messages", True, 12), ("messages", False, 6))) + release: Final = threading.Event() + held: Final[SimpleQueue[str]] = SimpleQueue() + outage: Final = threading.Event() + outage.set() + upstream: Final = _Upstream(Attempts(), _overloaded_while(outage), held, release) + with wire_server(upstream) as wire: + config: Final = _config(wire, tmp_path, models, retries=0) + with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as owned: + candidate: Final = owned.gateway + base_url: Final = str(candidate.client.base_url) + workers: Final = eventually( + lambda: tuple(int(pid) for pid in _STARTED_WORKER.findall(owned.log.read_text())), + lambda pids: len(pids) == 2, + seconds=30, + ) + burst: Final = asyncio.create_task( + _burst(base_url, candidate.key, models, calls, tolerate_transport_errors=True) + ) + await asyncio.to_thread(eventually, held.qsize, lambda size: size == len(calls), 90) + held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, wire.url) for pid in workers}) + assert sum(held_by.values()) == len(calls), held_by + victim_pid, survivor_pid = sorted(workers, key=held_by.__getitem__) + with owned.replacing_worker(victim_pid) as kill: + kill.freeze() + await asyncio.to_thread(kill) + release.set() + served: Final = await burst + assert held_by[survivor_pid] >= 5, held_by + assert len(served) == held_by[survivor_pid], (held_by, len(served)) + for item in served: + assert item.status == 529, (item.call, item.text) + assert "overloaded_error" in item.text, (item.call, item.text) + assert "content_block_delta" not in item.text, (item.call, item.text) + outage.clear() + follow_up: Final = _Call(endpoint="messages", stream=True, marker=uuid.uuid4().hex) + (answered,) = await _burst(base_url, candidate.key, models, (follow_up,)) + assert answered.status == 200, answered.text + assert message_id(parse_sse(answered.text)) == f"msg_{follow_up.marker}_a1", answered.text + rows: Final = _messages_rows(models.messages, len(served) + 1) + assert sorted(string_value(row["status"]) for row in rows) == ["failure"] * len(served) + ["success"], rows + assert {row["code"] for row in rows if row["status"] == "failure"} == {"529"}, rows + assert [row["request_id"] for row in rows if row["status"] == "success"] == [ + f"msg_{follow_up.marker}_a1" + ], rows + assert all(upstream.attempts.count(item.call.marker) == 1 for item in (*served, answered)) diff --git a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_pre_content_retry_wire.py b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_pre_content_retry_wire.py index 041a0a72fdd..83a9ba3d14c 100644 --- a/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_pre_content_retry_wire.py +++ b/tests/integration/messages_endpoint/providers/anthropic/test_anthropic_messages_pre_content_retry_wire.py @@ -397,14 +397,14 @@ def test_retry_rejected_before_it_opens_counts_against_the_same_budget(gateway: with wire_server(respond) as wire, gateway.scenario() as scenario: model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=_API_KEY, num_retries=1) response: Final = gateway.request("POST", "/v1/messages", _body(model, prompt, "deployment")) - _assert_rejected_before_content(response, wire, status=500, attempts=2, error="error") + _assert_rejected_before_content(response, wire, status=529, attempts=2, error="overloaded_error") rows: Final = eventually( lambda: _request_rows(gateway, response.headers["x-litellm-call-id"]), lambda values: len(values) >= 1, seconds=70, ) rejected: Final = gateway.request("POST", "/v1/messages", _body(model, prompt, "request", budget=0)) - _assert_rejected_before_content(rejected, wire, status=500, attempts=1, error="error") + _assert_rejected_before_content(rejected, wire, status=529, attempts=1, error="overloaded_error") rejection_rows: Final = eventually( lambda: _request_rows(gateway, rejected.headers["x-litellm-call-id"]), lambda values: len(values) >= 1, diff --git a/tests/integration/observability/test_anthropic_overloaded_failure_signals.py b/tests/integration/observability/test_anthropic_overloaded_failure_signals.py new file mode 100644 index 00000000000..044c58e44cc --- /dev/null +++ b/tests/integration/observability/test_anthropic_overloaded_failure_signals.py @@ -0,0 +1,404 @@ +import gzip +import json +import time +import uuid +from collections.abc import Callable, Iterator, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Final +from urllib.parse import unquote + +import httpx +import pytest +import yaml +from integration._support.anthropic_sse import error_body +from integration._support.client import ( + JSON_OBJECT, + Gateway, + JsonValue, + eventually, + gateway_from_environment, + object_value, + string_value, +) +from integration._support.otlp_sink import Span, SpanSinks, recorded_spans, spans_for_trace +from integration._support.process import graceful_stop_seconds, owned_proxy +from integration._support.prometheus_series import Sample, scrape +from integration._support.wire import Reply, Request, Wire, wire_server + +pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120) + +_MODEL: Final = "claude-sonnet-4-5-20250929" +_BEDROCK_MODEL: Final = "us.anthropic.claude-sonnet-4-5-20250929-v1:0" +_BEDROCK_503: Final = b'{"message": "Bedrock is unable to process your request."}' +_MESSAGES_ANTHROPIC: Final = "audit-signals-messages-anthropic" +_MESSAGES_BEDROCK: Final = "audit-signals-messages-bedrock" +_CHAT_ANTHROPIC: Final = "audit-signals-chat-anthropic" +_TRACED_MESSAGES: Final = "audit-signals-traced-messages" +_TRACED_CHAT: Final = "audit-signals-traced-chat" +_DATADOG_MESSAGES_ANTHROPIC: Final = "audit-signals-datadog-messages-anthropic" +_DATADOG_MESSAGES_BEDROCK: Final = "audit-signals-datadog-messages-bedrock" +_DATADOG_CHAT_ANTHROPIC: Final = "audit-signals-datadog-chat-anthropic" +_GUARDED_MESSAGES: Final = "audit-signals-guarded-messages" +_GUARDED_CHAT: Final = "audit-signals-guarded-chat" +_AKTO_DOWN: Final = "audit-signals-akto-down" +_AKTO_UNREACHABLE: Final = "Akto guardrail service unreachable" +_LATE_PASS_THROUGH_BACKEND: Final = "claude-late-pass-through" +_KEEPALIVE_SECONDS: Final = 0.2 +_LATE_ANSWER_SECONDS: Final = 1.0 +_OVERLOADED: Final = Reply(status=529, body=error_body(529, "Overloaded")) +_SERVER: Final = 2 + +AuditConfigWriter = Callable[[Path, Mapping[str, JsonValue]], Path] + + +@dataclass(frozen=True, slots=True) +class _Rig: + proxy: Gateway + wire: Wire + spans: str + datadog: Wire + + +def _respond(request: Request) -> Reply: + assert request.method == "POST", request + if unquote(request.target).startswith("/model/"): + return Reply(status=503, body=_BEDROCK_503) + if json.loads(request.body).get("model") == _LATE_PASS_THROUGH_BACKEND: + time.sleep(_LATE_ANSWER_SECONDS) + return _OVERLOADED + + +def _datadog_intake(request: Request) -> Reply: + assert request.target == "/api/v2/logs", request.target + return Reply(status=202, body=b"{}") + + +def _config(directory: Path, tracing: Path, wire: Wire) -> Path: + configuration: Final = JSON_OBJECT.validate_python(yaml.safe_load(tracing.read_text())) + anthropic: Final = {"model": f"anthropic/{_MODEL}", "api_base": wire.url, "api_key": "synthetic-anthropic-key"} + bedrock: Final = { + "model": f"bedrock/{_BEDROCK_MODEL}", + "api_base": wire.url, + "api_key": "synthetic-bedrock-bearer", + "aws_region_name": "us-east-1", + } + deployments: Final = ( + (_MESSAGES_ANTHROPIC, anthropic), + (_MESSAGES_BEDROCK, bedrock), + (_CHAT_ANTHROPIC, anthropic), + (_TRACED_MESSAGES, anthropic), + (_TRACED_CHAT, anthropic), + (_DATADOG_MESSAGES_ANTHROPIC, anthropic), + (_DATADOG_MESSAGES_BEDROCK, bedrock), + (_DATADOG_CHAT_ANTHROPIC, anthropic), + (_GUARDED_MESSAGES, anthropic), + (_GUARDED_CHAT, anthropic), + ) + akto_down: Final = { + "guardrail_name": _AKTO_DOWN, + "litellm_params": { + "guardrail": "akto", + "mode": "pre_call", + "akto_base_url": "http://127.0.0.1:9", + "akto_api_key": "synthetic-akto-key", + "unreachable_fallback": "fail_closed", + }, + } + path: Final = directory / "anthropic-overloaded-failure-signals.yaml" + path.write_text( + yaml.safe_dump( + { + **configuration, + "model_list": [{"model_name": name, "litellm_params": params} for name, params in deployments], + "router_settings": {**object_value(configuration.get("router_settings", {})), "num_retries": 0}, + "litellm_settings": { + **object_value(configuration["litellm_settings"]), + "sse_keepalive_ping_interval_seconds": _KEEPALIVE_SECONDS, + }, + "guardrails": [akto_down], + } + ) + ) + return path + + +@pytest.fixture(scope="module") +def rig( + tmp_path_factory: pytest.TempPathFactory, audit_sinks: SpanSinks, otel_audit_config: AuditConfigWriter +) -> Iterator[_Rig]: + directory: Final = tmp_path_factory.mktemp("anthropic-overloaded-failure-signals") + multiprocess: Final = directory / "prometheus" + multiprocess.mkdir() + tracing: Final = otel_audit_config(directory, {"callbacks": ["otel", "prometheus", "datadog"]}) + with ( + wire_server(_respond) as wire, + wire_server(_datadog_intake) as datadog, + gateway_from_environment() as shared, + owned_proxy( + shared, + directory, + { + "OTEL_EXPORTER": "http/json", + "OTEL_ENDPOINT": audit_sinks.operator, + "PROMETHEUS_MULTIPROC_DIR": str(multiprocess), + "DD_API_KEY": "synthetic-datadog-key", + "DD_SITE": "datadog.invalid", + "DD_BASE_URL": datadog.url, + "DD_BATCH_SIZE": "1", + "ANTHROPIC_API_BASE": wire.url, + "ANTHROPIC_API_KEY": "synthetic-anthropic-key", + }, + config=_config(directory, tracing, wire), + workers=2, + ) as owned, + ): + yield _Rig(owned, wire, audit_sinks.operator, datadog) + + +@pytest.fixture(scope="module") +def v2_rig( + tmp_path_factory: pytest.TempPathFactory, audit_sinks: SpanSinks, otel_audit_config: AuditConfigWriter +) -> Iterator[_Rig]: + directory: Final = tmp_path_factory.mktemp("anthropic-overloaded-failure-signals-v2") + tracing: Final = otel_audit_config(directory, {"callbacks": ["otel"]}) + with ( + wire_server(_respond) as wire, + wire_server(_datadog_intake) as datadog, + gateway_from_environment() as shared, + owned_proxy( + shared, + directory, + {"OTEL_EXPORTER": "http/json", "OTEL_ENDPOINT": audit_sinks.operator, "LITELLM_OTEL_V2": "1"}, + config=_config(directory, tracing, wire), + workers=2, + ) as owned, + ): + yield _Rig(owned, wire, audit_sinks.operator, datadog) + + +def _send( + rig: _Rig, + route: str, + model: str, + trace_id: str | None = None, + *, + key: str | None = None, + extra: Mapping[str, JsonValue] | None = None, +) -> httpx.Response: + traceparent: Final = {} if trace_id is None else {"traceparent": f"00-{trace_id}-{uuid.uuid4().hex[:16]}-01"} + return rig.proxy.request( + "POST", + route, + { + "model": model, + "max_tokens": 16, + "messages": [{"role": "user", "content": f"signals-{uuid.uuid4().hex}"}], + "num_retries": 0, + **(extra or {}), + }, + key=key, + headers={"anthropic-version": "2023-06-01", **traceparent}, + ) + + +def _series(samples: Sequence[Sample], name: str, model: str) -> tuple[Sample, ...]: + return tuple(sample for sample in samples if sample.name == name and sample.labels.get("requested_model") == model) + + +def _until(rig: _Rig, name: str, model: str) -> tuple[Sample, ...]: + return eventually(lambda: _series(scrape(rig.proxy), name, model), bool, seconds=30) + + +@pytest.mark.parametrize( + ("route", "model", "answered", "deployment_status", "deployment_class"), + ( + pytest.param("/v1/messages", _MESSAGES_ANTHROPIC, 529, "500", "InternalServerError", id="messages-anthropic"), + pytest.param("/v1/messages", _MESSAGES_BEDROCK, 503, "503", "ServiceUnavailableError", id="messages-bedrock"), + pytest.param("/v1/chat/completions", _CHAT_ANTHROPIC, 500, "500", "InternalServerError", id="chat-anthropic"), + ), +) +def test_the_failed_request_metric_counts_the_answered_status_and_the_deployment_metric_the_deployment_status( + rig: _Rig, route: str, model: str, answered: int, deployment_status: str, deployment_class: str +) -> None: + rig.wire.drain() + response: Final = _send(rig, route, model) + assert response.status_code == answered, response.text + assert len(rig.wire.drain()) == 1 + failed: Final = _until(rig, "litellm_proxy_failed_requests_metric_total", model) + assert [(sample.labels["exception_status"], sample.labels["route"], sample.value) for sample in failed] == [ + (str(answered), route, 1.0) + ], failed + deployment: Final = _until(rig, "litellm_deployment_failure_responses_total", model) + assert [(sample.labels["exception_status"], sample.value) for sample in deployment] == [(deployment_status, 1.0)], ( + deployment + ) + assert all(deployment_class in sample.labels["exception_class"] for sample in deployment), deployment + + +def _server_spans(rig: _Rig, since: int, trace_id: str) -> tuple[Span, ...]: + traced: Final = spans_for_trace(recorded_spans(rig.spans, since)[1], trace_id) + return tuple(span for span in traced if span["kind"] == _SERVER) + + +@pytest.mark.parametrize( + ("route", "model", "answered"), + ( + pytest.param("/v1/messages", _TRACED_MESSAGES, 529, id="messages"), + pytest.param("/v1/chat/completions", _TRACED_CHAT, 500, id="chat"), + ), +) +def test_the_server_span_carries_the_status_the_route_answered( + rig: _Rig, route: str, model: str, answered: int +) -> None: + rig.wire.drain() + since, _ = recorded_spans(rig.spans) + trace_id: Final = uuid.uuid4().hex + response: Final = _send(rig, route, model, trace_id) + assert response.status_code == answered, response.text + assert len(rig.wire.drain()) == 1 + server: Final = eventually( + lambda: _server_spans(rig, since, trace_id), + lambda spans: any("error.code" in span["attributes"] for span in spans), + seconds=40, + return_last_on_timeout=True, + ) + assert [ + (str(span["attributes"].get("http.response.status_code")), str(span["attributes"].get("error.code"))) + for span in server + ] == [(str(answered), str(answered))], server + + +def _inflated(request: Request) -> bytes: + return gzip.decompress(request.body) if request.headers.get("content-encoding") == "gzip" else request.body + + +def _datadog_events(batches: Sequence[Request]) -> Iterator[JsonValue]: + for batch in batches: + yield from json.loads(_inflated(batch)) + + +def _datadog_messages(batches: Sequence[Request]) -> tuple[dict[str, JsonValue], ...]: + return tuple( + JSON_OBJECT.validate_json(string_value(object_value(event)["message"])) for event in _datadog_events(batches) + ) + + +def _keyed_by(message: Mapping[str, JsonValue], alias: str) -> bool: + proxy_side: Final = message.get("user_api_key_dict") + llm_side: Final = message.get("metadata") + return any(isinstance(side, dict) and side.get("user_api_key_alias") == alias for side in (proxy_side, llm_side)) + + +def _datadog_status_codes(rig: _Rig, alias: str, expected: int) -> tuple[tuple[str, str], ...]: + batches: Final[list[Request]] = [] # mutable-ok: drain() consumes the queue, later polls must keep earlier batches + + def delivered() -> tuple[tuple[str, str], ...]: + batches.extend(rig.datadog.drain()) + return tuple( + sorted( + ("proxy", str(message["status_code"])) + if "user_api_key_dict" in message + else ("llm", str(object_value(message["error_information"])["error_code"])) + for message in _datadog_messages(batches) + if _keyed_by(message, alias) + ) + ) + + return eventually(delivered, lambda codes: len(codes) >= expected, seconds=40, return_last_on_timeout=True) + + +@pytest.mark.parametrize( + ("route", "model", "answered", "deployment_status"), + ( + pytest.param("/v1/messages", _DATADOG_MESSAGES_ANTHROPIC, 529, "500", id="messages-anthropic"), + pytest.param("/v1/messages", _DATADOG_MESSAGES_BEDROCK, 503, "503", id="messages-bedrock"), + pytest.param("/v1/chat/completions", _DATADOG_CHAT_ANTHROPIC, 500, "500", id="chat-anthropic"), + ), +) +def test_the_datadog_proxy_failure_log_carries_the_answered_status_and_the_llm_failure_log_the_deployment_status( + rig: _Rig, route: str, model: str, answered: int, deployment_status: str +) -> None: + rig.wire.drain() + rig.datadog.drain() + alias: Final = f"audit-signals-datadog-{uuid.uuid4().hex}" + with rig.proxy.scenario() as scenario: + response: Final = _send(rig, route, model, key=scenario.key(key_alias=alias, models=[model])) + assert response.status_code == answered, response.text + assert len(rig.wire.drain()) == 1 + assert _datadog_status_codes(rig, alias, 2) == (("llm", deployment_status), ("proxy", str(answered))) + + +@pytest.mark.parametrize( + ("route", "model", "answered"), + ( + pytest.param("/v1/messages", _GUARDED_MESSAGES, 503, id="messages"), + pytest.param("/v1/chat/completions", _GUARDED_CHAT, 503, id="chat"), + ), +) +def test_a_fail_closed_guardrail_outage_is_answered_and_recorded_with_the_routes_status( + rig: _Rig, route: str, model: str, answered: int +) -> None: + rig.wire.drain() + rig.datadog.drain() + alias: Final = f"audit-signals-guarded-{uuid.uuid4().hex}" + with rig.proxy.scenario() as scenario: + response: Final = _send( + rig, route, model, key=scenario.key(key_alias=alias, models=[model]), extra={"guardrails": [_AKTO_DOWN]} + ) + assert response.status_code == answered, response.text + assert _AKTO_UNREACHABLE in response.text, response.text + assert rig.wire.drain() == () + failed: Final = _until(rig, "litellm_proxy_failed_requests_metric_total", model) + assert [(sample.labels["exception_status"], sample.labels["route"], sample.value) for sample in failed] == [ + (str(answered), route, 1.0) + ], failed + assert _datadog_status_codes(rig, alias, 2) == (("llm", str(answered)), ("proxy", str(answered))) + + +@pytest.mark.parametrize( + ("route", "model", "answered"), + ( + pytest.param("/v1/messages", _TRACED_MESSAGES, 529, id="messages-anthropic"), + pytest.param("/v1/messages", _MESSAGES_BEDROCK, 503, id="messages-bedrock"), + pytest.param("/v1/chat/completions", _TRACED_CHAT, 500, id="chat-anthropic"), + ), +) +def test_the_otel_v2_server_span_carries_the_status_the_route_answered( + v2_rig: _Rig, route: str, model: str, answered: int +) -> None: + v2_rig.wire.drain() + since, _ = recorded_spans(v2_rig.spans) + trace_id: Final = uuid.uuid4().hex + response: Final = _send(v2_rig, route, model, trace_id) + assert response.status_code == answered, response.text + assert len(v2_rig.wire.drain()) == 1 + server: Final = eventually( + lambda: _server_spans(v2_rig, since, trace_id), + lambda spans: any("litellm.provider.error.code" in span["attributes"] for span in spans), + seconds=40, + return_last_on_timeout=True, + ) + assert [str(span["attributes"].get("litellm.provider.error.code")) for span in server] == [str(answered)], server + + +def test_an_anthropic_pass_through_stream_that_fails_after_the_keepalive_opened_relays_the_upstream_body( + rig: _Rig, +) -> None: + rig.wire.drain() + response: Final = rig.proxy.request( + "POST", + "/anthropic/v1/messages", + { + "model": _LATE_PASS_THROUGH_BACKEND, + "max_tokens": 16, + "stream": True, + "messages": [{"role": "user", "content": f"signals-{uuid.uuid4().hex}"}], + }, + headers={"anthropic-version": "2023-06-01"}, + ) + assert response.status_code == 200, response.text + pings, _, relayed = response.text.rpartition("\n\n") + assert pings and set(pings.split("\n")) <= {"", ": ping"}, response.text + assert relayed == _OVERLOADED.body.decode(), response.text + assert [unquote(request.target) for request in rig.wire.drain()] == ["/v1/messages"] diff --git a/tests/integration/providers/test_vertex_passthrough_stream_wire.py b/tests/integration/providers/test_vertex_passthrough_stream_wire.py new file mode 100644 index 00000000000..1ec2f740480 --- /dev/null +++ b/tests/integration/providers/test_vertex_passthrough_stream_wire.py @@ -0,0 +1,121 @@ +import json +import os +import uuid +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Final + +import httpx +import pytest +from integration._support.client import Gateway, gateway_from_environment +from integration._support.forward_proxy import ForwardProxy, tunnelling_forward_proxy +from integration._support.process import owned_proxy +from integration._support.tls import server_context, write_self_signed_cert +from integration._support.wire import Reply, Request, Wire, wire_server + +_LOCATION: Final = "us-central1" +_HOST: Final = f"{_LOCATION}-aiplatform.googleapis.com" +_PROJECT: Final = "audit-vertex-pass-through" +_STREAMING_MODEL: Final = "gemini-3.8-flash" +_UNAVAILABLE_MODEL: Final = "gemini-3.8-flash-unavailable" +_VENDOR_TOKEN: Final = "Bearer synthetic-vertex-token" +_CHUNKS: Final = ( + b'data: {"candidates": [{"content": {"role": "model", "parts": [{"text": "Hel"}]}}]}\r\n\r\n', + b'data: {"candidates": [{"content": {"role": "model", "parts": [{"text": "lo"}]}, "finishReason": "STOP"}],' + b' "usageMetadata": {"promptTokenCount": 3, "candidatesTokenCount": 2, "totalTokenCount": 5}}\r\n\r\n', +) +_UNAVAILABLE_MESSAGE: Final = "The service is currently unavailable." +_UNAVAILABLE_BODY: Final = json.dumps( + {"error": {"code": 503, "message": _UNAVAILABLE_MESSAGE, "status": "UNAVAILABLE"}} +).encode() +_SCRUBBED: Final = frozenset( + { + "HTTPS_PROXY", + "HTTP_PROXY", + "ALL_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + "DEFAULT_VERTEXAI_PROJECT", + "DEFAULT_VERTEXAI_LOCATION", + "DEFAULT_GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_APPLICATION_CREDENTIALS", + } +) + + +@dataclass(frozen=True, slots=True) +class _Vertex: + wire: Wire + tunnel: ForwardProxy + proxy: Gateway + + def stream(self, model: str, marker: str) -> httpx.Response: + return self.proxy.request( + "POST", + f"/vertex_ai/v1/{_model_path(model)}:streamGenerateContent", + {"contents": [{"role": "user", "parts": [{"text": marker}]}]}, + headers={"Authorization": _VENDOR_TOKEN, "x-litellm-api-key": f"Bearer {self.proxy.key}"}, + ) + + +def _model_path(model: str) -> str: + return f"projects/{_PROJECT}/locations/{_LOCATION}/publishers/google/models/{model}" + + +def _respond(request: Request) -> Reply: + if request.target.startswith(f"/v1/{_model_path(_UNAVAILABLE_MODEL)}:"): + return Reply(status=503, body=_UNAVAILABLE_BODY) + if request.target.startswith(f"/v1/{_model_path(_STREAMING_MODEL)}:"): + return Reply(content_type="text/event-stream", chunks=_CHUNKS) + return Reply(status=404, body=b'{"error": "no scripted Vertex model"}') + + +@pytest.fixture(scope="module") +def vertex(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Vertex]: + directory: Final = tmp_path_factory.mktemp("vertex-pass-through-stream") + cert, key = write_self_signed_cert(directory, (_HOST,)) + with ( + wire_server(_respond, tls=server_context(cert, key)) as wire, + tunnelling_forward_proxy(int(wire.url.rsplit(":", 1)[1])) as tunnel, + gateway_from_environment() as rig, + owned_proxy( + rig, + directory, + {"HTTPS_PROXY": tunnel.url, "NO_PROXY": "127.0.0.1,localhost", "SSL_CERT_FILE": str(cert)}, + remove_environment=tuple(name for name in os.environ if name.upper() in _SCRUBBED), + workers=2, + ) as proxy, + ): + yield _Vertex(wire, tunnel, proxy) + + +@pytest.fixture(autouse=True) +def fresh_edges(vertex: _Vertex) -> None: + vertex.wire.drain() + vertex.tunnel.drain() + + +def test_vertex_pass_through_stream_relays_every_sse_chunk_byte_for_byte(vertex: _Vertex) -> None: + marker: Final = uuid.uuid4().hex + response: Final = vertex.stream(_STREAMING_MODEL, marker) + sent: Final = vertex.wire.drain() + + assert response.status_code == 200, response.text + assert response.headers["content-type"].startswith("text/event-stream"), response.headers + assert response.content == b"".join(_CHUNKS), response.text + assert [request.target for request in sent] == [ + f"/v1/{_model_path(_STREAMING_MODEL)}:streamGenerateContent?alt=sse" + ], sent + assert sent[0].headers["authorization"] == _VENDOR_TOKEN + assert json.loads(sent[0].body) == {"contents": [{"role": "user", "parts": [{"text": marker}]}]} + assert set(vertex.tunnel.targets()) == {f"{_HOST}:443"} + + +def test_vertex_pass_through_stream_relays_an_upstream_503_as_503(vertex: _Vertex) -> None: + response: Final = vertex.stream(_UNAVAILABLE_MODEL, uuid.uuid4().hex) + + assert response.status_code == 503, response.text + assert response.content == _UNAVAILABLE_BODY, response.text + assert [request.target for request in vertex.wire.drain()] == [ + f"/v1/{_model_path(_UNAVAILABLE_MODEL)}:streamGenerateContent?alt=sse" + ] diff --git a/tests/integration/routing/test_anthropic_overloaded_cooldown_owned_proxy.py b/tests/integration/routing/test_anthropic_overloaded_cooldown_owned_proxy.py new file mode 100644 index 00000000000..e6df49a73bd --- /dev/null +++ b/tests/integration/routing/test_anthropic_overloaded_cooldown_owned_proxy.py @@ -0,0 +1,247 @@ +import json +import os +import uuid +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Final +from urllib.parse import unquote + +import httpx +import pytest +import yaml +from integration._support.anthropic_sse import ( + LIFECYCLE, + error_body, + event_types, + message_id, + message_stream, + parse_sse, + status_reply, + stream_reply, + user_prompt, +) +from integration._support.client import ( + Gateway, + JsonValue, + gateway_from_environment, + object_value, + string_value, +) +from integration._support.process import graceful_stop_seconds, owned_proxy +from integration._support.wire import Reply, Request, Wire, wire_server + +_OVERLOADED: Final = "claude-overloaded" +_HEALTHY: Final = "claude-healthy" +_COUNTED: Final = "claude-counted" +_COUNTED_OVERLOADED: Final = "claude-counted-overloaded" +_COUNTED_RATE_LIMITED: Final = "claude-counted-rate-limited" +_CHAT_ONLY_BACKEND: Final = "audit-chat-only" +_API_KEY: Final = "synthetic-anthropic-key" +_TEXT: Final = "Hello" +_COOLDOWN_GROUP: Final = "audit-overloaded-cooldown" +_COUNT_DISABLED_GROUP: Final = "audit-count-tokens-disabled" +_COUNT_UPSTREAM_GROUP: Final = "audit-count-tokens-upstream-503" +_COUNT_OVERLOADED_GROUP: Final = "audit-count-tokens-upstream-529" +_COUNT_RATE_LIMITED_GROUP: Final = "audit-count-tokens-upstream-429" +_GATEWAY: Final = "/claude_code_gateway" +_GATEWAY_GROUPS: Final = { + "non-stream": "audit-gateway-overloaded-non-stream", + "stream": "audit-gateway-overloaded-stream", +} +_COUNT_STATUSES: Final = {_COUNTED: 503, _COUNTED_OVERLOADED: 529, _COUNTED_RATE_LIMITED: 429} +_COUNT_TOKENS_TARGET: Final = "/v1/messages/count_tokens" +_UPSTREAM_URL_PLACEHOLDER: Final = "upstream-url" + +pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 120) + + +def _served_id(marker: str) -> str: + return f"msg_{_HEALTHY}_{marker}" + + +def _respond(request: Request) -> Reply: + body: Final = object_value(json.loads(request.body)) + backend: Final = str(body["model"]) + if unquote(request.target) == _COUNT_TOKENS_TARGET: + status: Final = _COUNT_STATUSES[backend] + return Reply(status=status, body=error_body(status, f"scripted {status}")) + if backend == _OVERLOADED: + return status_reply(529) + if backend == _HEALTHY: + return stream_reply(message_stream(_served_id(user_prompt(body)), backend, _TEXT)) + return Reply(status=404, body=b'{"error": "no scripted backend"}') + + +def _deployment(name: str, model: str, api_base: str, api_key: str, **params: JsonValue) -> dict[str, JsonValue]: + return {"model_name": name, "litellm_params": {"model": model, "api_base": api_base, "api_key": api_key, **params}} + + +def _config(wire: Wire, directory: Path) -> Path: + config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + config["model_list"] = [ + _deployment(_COOLDOWN_GROUP, f"anthropic/{_OVERLOADED}", _UPSTREAM_URL_PLACEHOLDER, _API_KEY, order=1), + _deployment(_COOLDOWN_GROUP, f"anthropic/{_HEALTHY}", _UPSTREAM_URL_PLACEHOLDER, _API_KEY, order=2), + _deployment( + _COUNT_DISABLED_GROUP, + f"hosted_vllm/{_CHAT_ONLY_BACKEND}", + _UPSTREAM_URL_PLACEHOLDER + "/v1", + "synthetic-vllm-key", + ), + _deployment(_COUNT_UPSTREAM_GROUP, f"anthropic/{_COUNTED}", _UPSTREAM_URL_PLACEHOLDER, _API_KEY), + _deployment(_COUNT_OVERLOADED_GROUP, f"anthropic/{_COUNTED_OVERLOADED}", _UPSTREAM_URL_PLACEHOLDER, _API_KEY), + _deployment( + _COUNT_RATE_LIMITED_GROUP, f"anthropic/{_COUNTED_RATE_LIMITED}", _UPSTREAM_URL_PLACEHOLDER, _API_KEY + ), + *( + _deployment(group, f"anthropic/{_OVERLOADED}", _UPSTREAM_URL_PLACEHOLDER, _API_KEY) + for group in _GATEWAY_GROUPS.values() + ), + ] + config["router_settings"] = { + "num_retries": 0, + "allowed_fails_policy": {"InternalServerErrorAllowedFails": 0}, + "cooldown_time": 120, + "redis_host": os.environ["REDIS_HOST"], + "redis_port": int(os.environ["REDIS_PORT"]), + } + config["litellm_settings"]["disable_token_counter"] = True + config["general_settings"]["enable_claude_code_gateway"] = True + path: Final = directory / "anthropic-overloaded-cooldown.yaml" + path.write_text(yaml.safe_dump(config).replace(_UPSTREAM_URL_PLACEHOLDER, wire.url)) + return path + + +@dataclass(frozen=True, slots=True) +class _Rig: + proxy: Gateway + wire: Wire + + def messages(self, path: str, model: str, marker: str, **extra: JsonValue) -> httpx.Response: + return self.proxy.request( + "POST", + path, + { + "model": model, + "max_tokens": 16, + "stream": True, + "messages": [{"role": "user", "content": marker}], + **extra, + }, + headers={"anthropic-version": "2023-06-01"}, + ) + + def count_tokens(self, model: str, prefix: str = "") -> httpx.Response: + return self.proxy.request( + "POST", + prefix + _COUNT_TOKENS_TARGET, + {"model": model, "messages": [{"role": "user", "content": "count me"}]}, + headers={"anthropic-version": "2023-06-01"}, + ) + + def targets_called(self) -> tuple[str, ...]: + return tuple(unquote(request.target) for request in self.wire.drain()) + + def backends_called(self) -> tuple[str, ...]: + return tuple(str(object_value(json.loads(request.body))["model"]) for request in self.wire.drain()) + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]: + directory: Final = tmp_path_factory.mktemp("anthropic-overloaded-cooldown") + with gateway_from_environment() as gateway, wire_server(_respond) as wire: + overrides: Final = {"ANTHROPIC_API_BASE": wire.url, "ANTHROPIC_API_KEY": _API_KEY} + with owned_proxy(gateway, directory, overrides, config=_config(wire, directory), workers=2) as proxy: + yield _Rig(proxy, wire) + + +@pytest.fixture(autouse=True) +def _fresh_wire(rig: _Rig) -> None: + rig.wire.drain() + + +def _assert_overloaded_json(response: httpx.Response) -> None: + assert response.status_code == 529, response.text + payload: Final = object_value(json.loads(response.content)) + assert object_value(payload["error"])["type"] == "overloaded_error", response.text + + +def _count_tokens_error(response: httpx.Response, status: int) -> dict[str, JsonValue]: + assert response.status_code == status, response.text + envelope: Final = object_value(json.loads(response.content)) + assert envelope["type"] == "error", response.text + return object_value(envelope["error"]) + + +def test_an_overloaded_deployment_is_cooled_down_by_the_internal_server_error_allowed_fails_policy(rig: _Rig) -> None: + marker: Final = uuid.uuid4().hex + first: Final = rig.messages("/v1/messages", _COOLDOWN_GROUP, marker, num_retries=0, disable_fallbacks=True) + _assert_overloaded_json(first) + assert rig.backends_called() == (_OVERLOADED,) + second: Final = rig.messages("/v1/messages", _COOLDOWN_GROUP, marker, num_retries=0, disable_fallbacks=True) + assert second.status_code == 200, second.text + events: Final = parse_sse(second.text) + assert event_types(events) == LIFECYCLE, events + assert message_id(events) == _served_id(marker), events + assert rig.backends_called() == (_HEALTHY,) + + +def test_count_tokens_disabled_without_a_provider_counter_answers_503_api_error(rig: _Rig) -> None: + response: Final = rig.count_tokens(_COUNT_DISABLED_GROUP) + error: Final = _count_tokens_error(response, 503) + assert error["type"] == "api_error", response.text + assert "Token counting is disabled" in string_value(error["message"]), response.text + assert rig.targets_called() == () + + +def test_count_tokens_whose_upstream_answers_503_stays_503_api_error(rig: _Rig) -> None: + response: Final = rig.count_tokens(_COUNT_UPSTREAM_GROUP) + error: Final = _count_tokens_error(response, 503) + assert error["type"] == "api_error", response.text + assert "scripted 503" in string_value(error["message"]), response.text + assert rig.targets_called() == (_COUNT_TOKENS_TARGET,) +@pytest.mark.parametrize( + ("group", "status", "error_type"), + ( + pytest.param(_COUNT_OVERLOADED_GROUP, 529, "overloaded_error", id="529"), + pytest.param(_COUNT_RATE_LIMITED_GROUP, 429, "rate_limit_error", id="429"), + ), +) +def test_count_tokens_keeps_other_upstream_statuses_and_their_error_body( + rig: _Rig, group: str, status: int, error_type: str +) -> None: + response: Final = rig.count_tokens(group) + error: Final = _count_tokens_error(response, status) + assert error["type"] == error_type, response.text + assert f"scripted {status}" in string_value(error["message"]), response.text + assert rig.targets_called() == (_COUNT_TOKENS_TARGET,) + + +def test_pass_through_messages_stream_forwards_an_upstream_529_verbatim(rig: _Rig) -> None: + response: Final = rig.messages("/anthropic/v1/messages", _OVERLOADED, uuid.uuid4().hex) + assert response.status_code == 529, response.text + assert response.content == error_body(529, "scripted 529"), response.text + assert rig.backends_called() == (_OVERLOADED,) + + +@pytest.mark.parametrize("shape", _GATEWAY_GROUPS.keys()) +def test_the_claude_code_gateway_messages_route_answers_an_upstream_529_as_overloaded_error( + rig: _Rig, shape: str +) -> None: + response: Final = rig.messages( + f"{_GATEWAY}/v1/messages", + _GATEWAY_GROUPS[shape], + uuid.uuid4().hex, + stream=shape == "stream", + num_retries=0, + ) + _assert_overloaded_json(response) + assert rig.backends_called() == (_OVERLOADED,) + + +def test_the_claude_code_gateway_count_tokens_route_answers_an_upstream_503_as_503(rig: _Rig) -> None: + response: Final = rig.count_tokens(_COUNT_UPSTREAM_GROUP, prefix=_GATEWAY) + error: Final = _count_tokens_error(response, 503) + assert error["type"] == "api_error", response.text + assert "scripted 503" in string_value(error["message"]), response.text + assert rig.targets_called() == (_COUNT_TOKENS_TARGET,) diff --git a/tests/integration/routing/test_messages_stream_retry_budget_sources_owned_proxy.py b/tests/integration/routing/test_messages_stream_retry_budget_sources_owned_proxy.py index 0257b8372b0..e0900bc0926 100644 --- a/tests/integration/routing/test_messages_stream_retry_budget_sources_owned_proxy.py +++ b/tests/integration/routing/test_messages_stream_retry_budget_sources_owned_proxy.py @@ -45,10 +45,19 @@ _FALLBACK_GROUP: Final = "audit-fallback" _CW_FALLBACK_GROUP: Final = "audit-cw-fallback" _LONELY: Final = "audit-lonely" _POLICY: Final = "audit-policy" +_SERVICE_UNAVAILABLE_RETRY_POLICY: Final = "audit-service-unavailable-retry-policy" +_INTERNAL_RETRY_POLICY: Final = "audit-internal-retry-policy" _UPSTREAM_URL_PLACEHOLDER: Final = "upstream-url" Behavior = Literal[ - "drop-once", "drop-always", "hold-ping", "drop-then-too-long", "drop-then-401", "overloaded-frames-fallback-503" + "drop-once", + "drop-always", + "hold-ping", + "drop-then-too-long", + "drop-then-401", + "overloaded-frames-fallback-529", + "overloaded-frames", + "overloaded-once", ] pytestmark = pytest.mark.timeout(240) @@ -81,13 +90,15 @@ def _primary_reply( return Reply(status=400, body=error_body(400, "prompt is too long: 250000 tokens > 200000 maximum")) case "drop-then-401": return dropped if attempt == 1 else status_reply(401) - case "overloaded-frames-fallback-503": + case "overloaded-frames-fallback-529" | "overloaded-frames": return stream_reply((error_frame(529, "scripted overloaded"),)) + case "overloaded-once": + return status_reply(529) if attempt == 1 else stream_reply(full) raise AssertionError(behavior) def _fallback_reply(behavior: str, full: tuple[bytes, bytes, bytes, bytes]) -> Reply: - if behavior == "overloaded-frames-fallback-503": + if behavior == "overloaded-frames-fallback-529": return status_reply(503) return stream_reply(full) @@ -141,13 +152,19 @@ def _config(wire: Wire, directory: Path) -> Path: _deployment(_CW_FALLBACK_GROUP, _CONTEXT_WINDOW), _deployment(_LONELY, _PRIMARY, num_retries=1), _deployment(_POLICY, _PRIMARY), + _deployment(_SERVICE_UNAVAILABLE_RETRY_POLICY, _PRIMARY), + _deployment(_INTERNAL_RETRY_POLICY, _PRIMARY), ] config["router_settings"] = { "num_retries": 1, "disable_cooldowns": True, "fallbacks": [{_WITH_FALLBACKS: [_FALLBACK_GROUP]}], "context_window_fallbacks": [{_WITH_FALLBACKS: [_CW_FALLBACK_GROUP]}], - "model_group_retry_policy": {_POLICY: {"DefaultRetries": 2}}, + "model_group_retry_policy": { + _POLICY: {"DefaultRetries": 2}, + _SERVICE_UNAVAILABLE_RETRY_POLICY: {"ServiceUnavailableErrorRetries": 1, "InternalServerErrorRetries": 0}, + _INTERNAL_RETRY_POLICY: {"InternalServerErrorRetries": 1, "ServiceUnavailableErrorRetries": 0}, + }, } path: Final = directory / "messages-retry-budget-sources.yaml" path.write_text(yaml.safe_dump(config).replace(_UPSTREAM_URL_PLACEHOLDER, wire.url)) @@ -290,10 +307,34 @@ def test_a_retry_rejected_with_401_ends_the_retries_and_reaches_the_client_uncha assert rig.attempts_on(_PRIMARY, marker) == 2 -def test_overloaded_frames_whose_fallback_fails_answer_the_mapped_internal_server_error(rig: _Rig) -> None: +def test_overloaded_frames_whose_fallback_fails_answer_529_overloaded_error(rig: _Rig) -> None: marker: Final = uuid.uuid4().hex - response: Final = rig.stream(_WITH_FALLBACKS, "overloaded-frames-fallback-503", marker) - assert response.status_code == 500, response.text + response: Final = rig.stream(_WITH_FALLBACKS, "overloaded-frames-fallback-529", marker) + assert response.status_code == 529, response.text + assert "overloaded_error" in response.text, response.text assert "content_block_delta" not in response.text, response.text assert rig.attempts_on(_PRIMARY, marker) == 2 assert rig.attempts_on(_FALLBACK, marker) == 2 + + +def test_overloaded_frames_that_spend_the_budget_without_a_fallback_relay_the_last_attempts_frame(rig: _Rig) -> None: + marker: Final = uuid.uuid4().hex + response: Final = rig.stream(_LONELY, "overloaded-frames", marker) + assert response.status_code == 200, response.text + assert response.text == error_frame(529, "scripted overloaded").decode(), response.text + assert rig.attempts_on(_PRIMARY, marker) == 2 + + +def test_a_529_is_retried_under_the_internal_server_error_retry_policy(rig: _Rig) -> None: + marker: Final = uuid.uuid4().hex + _assert_completed(rig.stream(_INTERNAL_RETRY_POLICY, "overloaded-once", marker), _served_id(_PRIMARY, marker, 2)) + assert rig.attempts_on(_PRIMARY, marker) == 2 + + +def test_a_529_is_not_retried_under_the_service_unavailable_error_retry_policy(rig: _Rig) -> None: + marker: Final = uuid.uuid4().hex + response: Final = rig.stream(_SERVICE_UNAVAILABLE_RETRY_POLICY, "overloaded-once", marker) + assert response.status_code == 529, response.text + assert "overloaded_error" in response.text, response.text + assert "content_block_delta" not in response.text, response.text + assert rig.attempts_on(_PRIMARY, marker) == 1 diff --git a/tests/unit/litellm_core_utils/test_error_normalization.py b/tests/unit/litellm_core_utils/test_error_normalization.py index 87b9463cb01..524dda41218 100644 --- a/tests/unit/litellm_core_utils/test_error_normalization.py +++ b/tests/unit/litellm_core_utils/test_error_normalization.py @@ -217,6 +217,22 @@ def test_unknown_exception_falls_back_to_status_then_unclassified() -> None: assert normalize_error(Exception("x"), "", "x") == "UNCLASSIFIED" +def test_a_529_the_anthropic_routes_answered_is_an_overload() -> None: + paused = _proxy_exc("Model is paused", "service_unavailable_error", 529) + assert StandardLoggingPayloadSetup.get_error_information(paused)["normalized_error"] == "503_PROVIDER_OVERLOADED" + + +@pytest.mark.parametrize("body", ["", "bad gateway", '{"type":"error","error":{"type":"api_error"}}']) +def test_an_internal_server_error_answered_as_529_is_an_overload_whatever_its_body_says(body: str) -> None: + overloaded = litellm.InternalServerError(body, llm_provider="anthropic", model="claude", provider_status_code=529) + overloaded.status_code = 529 + internal = litellm.InternalServerError(body, llm_provider="anthropic", model="claude") + assert ( + StandardLoggingPayloadSetup.get_error_information(overloaded)["normalized_error"], + StandardLoggingPayloadSetup.get_error_information(internal)["normalized_error"], + ) == ("503_PROVIDER_OVERLOADED", "500_PROVIDER_INTERNAL_ERROR") + + def test_budget_exceeded_error_with_custom_wording_is_still_a_budget_error() -> None: exc = litellm.BudgetExceededError(current_cost=2.0, max_budget=1.0, message="Spending cap reached for key") assert StandardLoggingPayloadSetup.get_error_information(exc)["normalized_error"] == "429_BUDGET_EXCEEDED" diff --git a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py index 1b00a32d31d..32a99a37870 100644 --- a/tests/unit/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/unit/litellm_core_utils/test_exception_mapping_utils.py @@ -1230,12 +1230,12 @@ def test_openai_incorrect_api_key_keeps_the_proxy_authentication_error_type() -> class _UpstreamHTTPError(Exception): - def __init__(self, status_code: int) -> None: - super().__init__("upstream failure") - self.message = "upstream failure" + def __init__(self, status_code: int, message: str = "upstream failure") -> None: + super().__init__(message) + self.message = message self.status_code = status_code self.request = httpx.Request("POST", "https://api.example.com/v1/chat/completions") - self.response = httpx.Response(status_code=status_code, request=self.request, text="upstream failure") + self.response = httpx.Response(status_code=status_code, request=self.request, text=message) UPSTREAM_STATUS_CODES = (400, 401, 402, 403, 404, 408, 422, 429, 500, 503) @@ -1433,6 +1433,48 @@ def test_an_already_mapped_litellm_exception_passes_through_untouched(provider, assert returned is already_mapped +@pytest.mark.parametrize( + "message", + ['{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}', "upstream failure"], +) +def test_an_anthropic_529_stays_an_internal_server_error_that_carries_the_529(message, quiet_exception_mapping): + with pytest.raises(litellm.InternalServerError) as raised: + exception_type( + model="claude-opus-5-5", + original_exception=_UpstreamHTTPError(status_code=529, message=message), + custom_llm_provider="anthropic", + ) + + assert (type(raised.value), raised.value.status_code, raised.value.provider_status_code) == ( + litellm.InternalServerError, + 500, + 529, + ) + + +@pytest.mark.parametrize("status_code", [400, 404]) +def test_an_anthropic_client_error_naming_overloaded_carries_no_overload_status(status_code, quiet_exception_mapping): + with pytest.raises(litellm.InternalServerError) as raised: + exception_type( + model="claude-opus-5-5", + original_exception=_UpstreamHTTPError(status_code=status_code, message="model: claude-Overloaded"), + custom_llm_provider="anthropic", + ) + + assert raised.value.provider_status_code is None + + +def test_an_anthropic_500_carries_its_own_status_as_the_provider_status(quiet_exception_mapping): + with pytest.raises(litellm.InternalServerError) as raised: + exception_type( + model="claude-opus-5-5", + original_exception=_UpstreamHTTPError(status_code=500), + custom_llm_provider="anthropic", + ) + + assert (raised.value.status_code, raised.value.provider_status_code) == (500, 500) + + @pytest.mark.parametrize("status_code", UPSTREAM_STATUS_CODES) @pytest.mark.parametrize("provider", PROVIDERS_WITHOUT_A_HANDLER) def test_a_provider_without_a_handler_maps_by_the_upstream_status(provider, status_code, quiet_exception_mapping): diff --git a/tests/unit/llms/anthropic/test_anthropic_common_utils.py b/tests/unit/llms/anthropic/test_anthropic_common_utils.py index 0ee5d52d50d..99e6438c723 100644 --- a/tests/unit/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/unit/llms/anthropic/test_anthropic_common_utils.py @@ -4605,6 +4605,7 @@ def test_anthropic_error_frame_exception_classes_an_overloaded_frame_as_internal error: Final = anthropic_error_frame_exception("overloaded_error", "Overloaded", 503, "claude-sonnet-4-5") assert type(error) is litellm.InternalServerError + assert error.provider_status_code == 529 _NON_NUMERIC_BUDGETS: Final = (True, False, None, "", "lots", [4096]) diff --git a/tests/unit/proxy/anthropic_endpoints/test_answered_status.py b/tests/unit/proxy/anthropic_endpoints/test_answered_status.py new file mode 100644 index 00000000000..ff6c98e2282 --- /dev/null +++ b/tests/unit/proxy/anthropic_endpoints/test_answered_status.py @@ -0,0 +1,116 @@ +from typing import Final + +import openai +import pytest + +import litellm +from litellm.exceptions import MidStreamFallbackError +from litellm.proxy._types import ProxyException +from litellm.proxy.anthropic_endpoints.answered_status import with_answered_status + + +def _anthropic_internal_server_error(provider_status_code: int | None) -> litellm.InternalServerError: + return litellm.InternalServerError( + message="AnthropicError - Overloaded", + llm_provider="anthropic", + model="claude-opus-5-5", + provider_status_code=provider_status_code, + ) + + +@pytest.mark.parametrize("code", [400, 500, 503]) +def test_a_proxy_exception_passes_through_unchanged(code: int) -> None: + exc: Final = ProxyException(message="budget service unavailable", type="None", param=None, code=code) + + answered_exc: Final = with_answered_status(exc) + + assert answered_exc is exc + assert exc.code == str(code) + + +@pytest.mark.parametrize( + ("exc", "answered", "copy"), + [ + pytest.param( + litellm.ServiceUnavailableError(message="BedrockException", llm_provider="bedrock", model="m"), + 503, + False, + id="service-unavailable-503", + ), + pytest.param(_anthropic_internal_server_error(529), 529, True, id="anthropic-overload"), + pytest.param(_anthropic_internal_server_error(500), 500, True, id="anthropic-500"), + pytest.param(_anthropic_internal_server_error(None), 500, False, id="internal-server-error"), + pytest.param( + MidStreamFallbackError( + message="fallbacks failed", + model="m", + llm_provider="anthropic", + original_exception=_anthropic_internal_server_error(529), + ), + 529, + True, + id="fallback-over-an-anthropic-overload", + ), + pytest.param( + MidStreamFallbackError( + message="fallbacks failed", + model="m", + llm_provider="anthropic", + original_exception=MidStreamFallbackError( + message="retry failed before content", + model="m", + llm_provider="anthropic", + original_exception=_anthropic_internal_server_error(529), + ), + ), + 529, + True, + id="nested-fallback-over-an-anthropic-overload", + ), + pytest.param( + litellm.RateLimitError(message="slow down", llm_provider="anthropic", model="m"), + 429, + False, + id="rate-limit", + ), + ], +) +def test_a_litellm_exception_answers_the_status_anthropic_gives_its_failure( + exc: openai.APIStatusError, answered: int, copy: bool +) -> None: + raised_status: Final = exc.status_code + + answered_exc: Final = with_answered_status(exc) + + assert (answered_exc is not exc) is copy + assert (type(answered_exc), answered_exc.status_code, str(answered_exc)) == (type(exc), answered, str(exc)) + assert exc.status_code == raised_status + + +def _raise_a_bedrock_overload_caused_by_a_reset() -> None: + try: + raise ConnectionError("upstream reset") + except ConnectionError as upstream: + raise litellm.InternalServerError( + message="AnthropicError - Overloaded", + llm_provider="anthropic", + model="m", + provider_status_code=529, + ) from upstream + + +def test_the_answered_copy_keeps_the_raised_exceptions_cause_and_traceback() -> None: + with pytest.raises(litellm.InternalServerError) as raised: + _raise_a_bedrock_overload_caused_by_a_reset() + + answered_exc: Final = with_answered_status(raised.value) + + assert isinstance(raised.value.__cause__, ConnectionError) + assert (answered_exc.__cause__, answered_exc.__traceback__) == (raised.value.__cause__, raised.value.__traceback__) + + +def test_an_exception_without_a_status_passes_through_unchanged() -> None: + exc: Final = ValueError("no status") + + assert with_answered_status(exc) is exc + assert not hasattr(exc, "status_code") diff --git a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py index 553c3d4a9d7..dccd1e877fc 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_endpoints.py +++ b/tests/unit/proxy/anthropic_endpoints/test_endpoints.py @@ -17,6 +17,7 @@ from fastapi.responses import StreamingResponse from fastapi.testclient import TestClient from starlette.types import Message +import litellm from litellm import Router from litellm._logging import verbose_proxy_logger from litellm.caching.dual_cache import DualCache @@ -302,7 +303,7 @@ class TestProxyExceptionAnthropicEnvelope: import litellm.proxy.anthropic_endpoints.endpoints as ep from litellm.proxy._types import ProxyException - request = MagicMock() + request: Final = MagicMock() request.headers = {} response = ep._anthropic_error_json_response( @@ -319,10 +320,135 @@ class TestProxyExceptionAnthropicEnvelope: assert fallback.status_code == 500 assert json.loads(fallback.body)["error"]["type"] == "api_error" + @pytest.mark.asyncio + @pytest.mark.parametrize( + "unavailable, expected_status, expected_error_type, hook_receives_copy", + [ + pytest.param( + litellm.ServiceUnavailableError( + message='BedrockException - {"message": "Bedrock is unable to process your request."}', + llm_provider="bedrock", + model="global.anthropic.claude-opus-5-5", + ), + 503, + "api_error", + False, + id="bedrock-503", + ), + pytest.param( + litellm.InternalServerError( + message='AnthropicError - {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + llm_provider="anthropic", + model="claude-opus-5-5", + provider_status_code=529, + ), + 529, + "overloaded_error", + True, + id="anthropic-529", + ), + ], + ) + async def test_anthropic_response_records_the_answered_status( + self, + unavailable: litellm.ServiceUnavailableError | litellm.InternalServerError, + expected_status: int, + expected_error_type: str, + hook_receives_copy: bool, + ) -> None: + import litellm.proxy.anthropic_endpoints.endpoints as ep + import litellm.proxy.proxy_server as proxy_server + from litellm.proxy._types import UserAPIKeyAuth + + raised_status: Final = unavailable.status_code + request: Final = MagicMock() + request.headers = {} + + with ( + patch.object(ep, "read_request_body", new=AsyncMock(return_value={})), + patch.object( + ep.ProxyBaseLLMRequestProcessing, + "base_process_llm_request", + new=AsyncMock(side_effect=unavailable), + ), + patch.object(proxy_server, "proxy_logging_obj") as mock_logging, + ): + mock_logging.post_call_failure_hook = AsyncMock() + response = await ep.anthropic_response( + fastapi_response=MagicMock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(), + ) + + recorded: Final = mock_logging.post_call_failure_hook.await_args.kwargs["original_exception"] + assert recorded is not None + assert (recorded is not unavailable) is hook_receives_copy + assert recorded.status_code == expected_status + assert unavailable.status_code == raised_status + assert response.status_code == expected_status + assert json.loads(response.body)["error"]["type"] == expected_error_type + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "unavailable, expected_status, expected_error_type", + [ + pytest.param( + litellm.ServiceUnavailableError( + message="Bedrock is unable to process your request.", + llm_provider="bedrock", + model="global.anthropic.claude-opus-5-5", + ), + 503, + "api_error", + id="bedrock-503", + ), + pytest.param( + litellm.InternalServerError( + message='AnthropicError - {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + llm_provider="anthropic", + model="claude-opus-5-5", + provider_status_code=529, + ), + 529, + "overloaded_error", + id="anthropic-529", + ), + ], + ) + async def test_count_tokens_answers_provider_errors( + self, + unavailable: litellm.ServiceUnavailableError | litellm.InternalServerError, + expected_status: int, + expected_error_type: str, + ) -> None: + import litellm.proxy.anthropic_endpoints.endpoints as ep + import litellm.proxy.proxy_server as proxy_server + from litellm.proxy._types import UserAPIKeyAuth + + request = MagicMock() + request.headers = {} + + with ( + patch.object( + ep, + "read_request_body", + new=AsyncMock(return_value={"model": "claude-sonnet", "messages": [{"role": "user", "content": "hi"}]}), + ), + patch.object( + proxy_server, "count_request_tokens", new=AsyncMock(side_effect=unavailable) + ), + ): + response: Final = await ep.count_tokens(request=request, user_api_key_dict=UserAPIKeyAuth()) + + body: Final = json.loads(response.body) + assert response.status_code == expected_status + assert body["type"] == "error" + assert body["error"]["type"] == expected_error_type + @pytest.mark.parametrize("status_code", [402, 409, 422, 504]) def test_a_failure_inside_the_handler_keeps_the_api_error_type_for_a_status_outside_the_table( self, status_code: int - ): + ) -> None: import litellm.proxy.anthropic_endpoints.endpoints as ep from litellm.proxy._types import ProxyException @@ -334,7 +460,6 @@ class TestProxyExceptionAnthropicEnvelope: assert response.status_code == status_code assert json.loads(response.body)["error"] == {"type": "api_error", "message": "scripted"} - @staticmethod def _call_id_error_response(general_settings, provider_specific_fields=None): import litellm.proxy.anthropic_endpoints.endpoints as ep @@ -428,7 +553,7 @@ class TestHttpExceptionDictDetail: ) as mock_logging, # test-quality-ok: module global imported at call time; no injection seam ): mock_logging.post_call_failure_hook = AsyncMock() - response = await ep.anthropic_response( + response: Final = await ep.anthropic_response( fastapi_response=MagicMock(), request=request, user_api_key_dict=UserAPIKeyAuth(), diff --git a/tests/unit/proxy/test_common_request_processing.py b/tests/unit/proxy/test_common_request_processing.py index 4d1b67f9212..41e7ecc9428 100644 --- a/tests/unit/proxy/test_common_request_processing.py +++ b/tests/unit/proxy/test_common_request_processing.py @@ -4618,6 +4618,34 @@ class TestStreamCloseOnDisconnect: assert upstream.aclosed + async def test_async_streaming_data_generator_reraises_an_unchanged_http_exception_without_chaining_it_to_itself( + self, + ): + blocked = HTTPException(status_code=400, detail="Content blocked by guardrail") + + class FailingUpstream: + def __aiter__(self): + return self + + async def __anext__(self): + raise blocked + + ProxyLogging._callback_capabilities_cache.clear() + gen = ProxyBaseLLMRequestProcessing.async_streaming_data_generator( + response=FailingUpstream(), + user_api_key_dict=ProxyUserAPIKeyAuth(), + request_data={"model": "mock-model"}, + proxy_logging_obj=ProxyLogging(user_api_key_cache=MagicMock()), + serialize_chunk=lambda c: "data: x\n\n", + serialize_error=lambda e: "data: error\n\n", + ) + + with pytest.raises(HTTPException) as raised: + await gen.__anext__() + + assert raised.value is blocked + assert raised.value.__cause__ is not blocked + async def test_async_streaming_data_generator_closes_the_guardrail_chain_on_client_disconnect( self, ): diff --git a/tests/unit/router_strategy/adaptive_router/test_signals.py b/tests/unit/router_strategy/adaptive_router/test_signals.py index 2773c13a812..bd58b4c668d 100644 --- a/tests/unit/router_strategy/adaptive_router/test_signals.py +++ b/tests/unit/router_strategy/adaptive_router/test_signals.py @@ -81,6 +81,12 @@ def test_exhaustion_fires_on_infra_signal(fixture): assert state.exhaustion_count >= 1 +@pytest.mark.parametrize("status", [503, 529]) +def test_exhaustion_fires_on_an_overloaded_answer(status): + state, _ = _replay([{"user_content": "fix the build", "response_status": status}]) + assert state.exhaustion_count == 1 + + def test_no_signals_on_clean_session(): state, _ = _replay(_load("clean_no_signals")) assert state.misalignment_count == 0 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/errors/errorsData.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/errors/errorsData.test.ts index 73ffcd5a6a5..4a464760509 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/errors/errorsData.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/errors/errorsData.test.ts @@ -225,6 +225,7 @@ describe("statusCodeLabel", () => { [401, "Unauthorized"], [429, "Rate Limited"], [503, "Service Unavailable"], + [529, "Overloaded"], [418, "Client Error"], [599, "Server Error"], ] as const)("labels status %s", (code, label) => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/errors/errorsData.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/errors/errorsData.ts index 2374df0dd2c..9afd0707d06 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/errors/errorsData.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/errors/errorsData.ts @@ -51,6 +51,7 @@ const STATUS_LABELS: Record = { 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout", + 529: "Overloaded", }; export const statusCodeLabel = (code: number): string => {