litellm/tests/test_litellm_rust/responses/test_failures.py
yujonglee 323d3956d6
feat(rust): one typed decline from core up to each host (#47772)
* feat(rust): one typed decline from core up to each host

Rust core says it cannot serve a request with one Decline that travels
unchanged through llms and inference up to the host. The Python bridge
raises RustBridgeDeclined for it in route_failure before the public
exception mapping, so dispatch falls back to legacy Python instead of
failing the call, and the gateway answers 501. Every route collapses
errors after the send with Error::after_send, which keeps a provider
answer and a response fault as they are and nests anything else as the
source of an invalid response, so a billed call can never read as a
decline. Response-side cases that used to decline are now
ErrorDetail::Unhandled response faults.

The rust-error skill documents declined() as the first question both
boundaries ask, and the Python suites pin that a native decline reaches
the dispatcher before any provider IO.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(rust): keep post-provider hook failures terminal

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-28 09:22:11 -07:00

71 lines
2.7 KiB
Python

from typing import Final
import pytest
from pydantic import TypeAdapter
import litellm
from litellm import RateLimitError
from tests.test_litellm_rust.responses.data import RATE_LIMITED, call
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
pytestmark = pytest.mark.requires_rust_extension
_OBJECT: Final = TypeAdapter(dict[str, object])
def failure_signal(asynchronous: bool) -> str:
return "async_log_failure_event" if asynchronous else "log_failure_event"
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", (False, True))
async def test_sdk_policy_rejection_precedes_resource_setup_and_is_logged_once(
server: RecordingServer, monkeypatch: pytest.MonkeyPatch, asynchronous: bool
) -> None:
monkeypatch.setattr(litellm, "max_budget", 1.0)
monkeypatch.setattr(litellm, "_current_cost", 2.0)
monkeypatch.setattr(litellm, "ssl_verify", object())
recorder: Final = RecordingLogger()
server.expected_requests = 0
with pytest.raises(litellm.BudgetExceededError) as caught:
await call(server, asynchronous=asynchronous, callbacks=[recorder])
failure: Final = await recorder.wait_for_async(failure_signal(asynchronous))
assert len(failure) == 1
assert _OBJECT.validate_python(failure[0].kwargs)["exception"] is caught.value
assert not server.requests
assert not any("success" in name for name in recorder.names)
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", (False, True))
async def test_provider_failure_is_terminal_and_shared_with_callbacks(
server: RecordingServer, asynchronous: bool
) -> None:
recorder: Final = RecordingLogger()
server.enqueue(ResponseSpec(body=RATE_LIMITED, status=429))
with pytest.raises(RateLimitError) as caught:
await call(server, asynchronous=asynchronous, callbacks=[recorder])
assert caught.value.status_code == 429
assert len(server.requests) == 1
failure: Final = await recorder.wait_for_async(failure_signal(asynchronous))
assert len(failure) == 1
assert _OBJECT.validate_python(failure[0].kwargs)["exception"] is caught.value
assert not any("success" in name for name in recorder.names)
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", (False, True))
async def test_a_native_decline_reaches_the_dispatcher_before_provider_io(
server: RecordingServer, asynchronous: bool
) -> None:
server.expected_requests = 0
with pytest.raises(RuntimeError, match="declined the request: native Python responses streaming"):
await call(server, asynchronous=asynchronous, stream=True)
assert not server.requests