Files
litellm/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py
T
yuneng-jiang 12d29a38a7 tests(proxy_server): surface current behavior in tests (#29309)
* test(proxy/proxy_server): pin forwarding routes (PR2) (#28887)

* test(proxy): pin proxy_server.py forwarding-route behavior

PR2 of the proxy_server.py behavior-pinning project: fills the 12
forwarding-route test files added by the harness PR with happy + error
pins for all 52 LLM-facing routes (models, chat/completions, completions,
embeddings, moderations, audio, assistants, threads, utils, model-info,
model-metrics, queue). Every happy-path test asserts the full response
dict via normalize() so the gate enforces real shape pinning rather
than status codes.

* test(proxy): drop task-plumbing comments from PR2 test files

* test(proxy): tighten PR2 error-path status-code pins

Apply the same review feedback Greptile gave on PR1 (#28856) and PR3
(#28850) to PR2's forwarding-route tests:

- Replace permissive `>= 400` / `in (X, Y)` status assertions with the
  exact 500/405 the handler actually returns, so a regression that
  silently shifts the code now fails the pin.
- Add a body-presence check alongside each tightened status assertion
  to satisfy _pin_check.py's no-status-only rule.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* test(proxy): pin proxy_server.py non-route surface behavior (PR1) (#28856)

* test(proxy): pin proxy_server.py non-route surface behavior (PR1)

Fills the 7 PR1 placeholder files under tests/test_litellm/proxy/proxy_server/
with behavior pins for the non-route surface of proxy_server.py:
lifecycle/init/shutdown, ProxyConfig class methods, DB-overlay config scrubbers,
spend counters, background-health helpers, OpenAPI customization, exception
handlers, and streaming-generator helpers.

233 tests cover 101 pin-list symbols (1+ happy + 1+ error each). New-tests-only
coverage on litellm/proxy/proxy_server.py: 32.80% line / 20.91% branch (PR1
gate: 25% line / 18% branch). Full directory runs in ~22s with -n 4.

Plan: https://www.notion.so/Plan-Pin-proxy_server-py-behavior-2026-05-25-36c43b8acdab81ee845fd5365128a2fc

* test(proxy): address Greptile review comments on test_lifecycle.py

- test_initialize_signature_is_async_with_expected_params: hard-code
  expected_param_count so a signature change actually trips the gate
  (previously both sides of the comparison were len(sig.parameters)).
- test_check_request_disconnection_invalid_when_connected_times_out:
  patch asyncio.sleep so the test no longer spins for ~1.2 s of real
  wall-clock; timeout lowered to 0.05 s.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* test(proxy/proxy_server): pin control-plane routes (PR3) (#28850)

* test(proxy/proxy_server): pin misc routes (PR3, partial)

Adds happy + error tests for the misc control-plane routes:
GET /, /routes, /adaptive_router/state, /get_logo_url,
/get_image, /get_favicon.

Also gitignores .pin_list.txt (used by the pin gate).

* test(proxy/proxy_server): pin login/SSO routes (PR3, partial)

Adds happy + error tests for the 5 login/SSO control-plane routes:
GET /fallback/login, POST /login, POST /v2/login, POST /v3/login,
POST /v3/login/exchange. Mocks authenticate_user and
create_ui_token_object at their imported location.

* test(proxy/proxy_server): pin onboarding routes (PR3, partial)

Adds happy + error tests for the 2 onboarding control-plane routes:
GET /onboarding/get_token, POST /onboarding/claim_token. Wires a
MagicMock async context manager for prisma_client.db.tx() and
signs the onboarding JWT with the patched master_key.

* test(proxy/proxy_server): pin model_cost_map reload routes (PR3, partial)

Adds happy + error tests for the 5 model-cost-map control-plane routes:
POST /reload/model_cost_map, POST|DELETE|GET
/schedule/model_cost_map_reload(/status), GET /model/cost_map/source.
Attaches litellm_config to mock_prisma per-test (the table is not in
the default _PRISMA_TABLES fixture).

* test(proxy/proxy_server): pin anthropic_beta_headers reload routes (PR3, partial)

Adds happy + error tests for the 4 anthropic-beta-headers control-plane
routes: POST /reload/anthropic_beta_headers, POST|DELETE|GET
/schedule/anthropic_beta_headers_reload(/status). Stubs
db.litellm_config (not in default _PRISMA_TABLES) and monkeypatches
reload_beta_headers_config so no network calls fire.

* test(proxy/proxy_server): pin invitation routes (PR3, partial)

Adds happy + error tests for the 4 invitation control-plane routes:
POST /invitation/new, GET /invitation/info, POST /invitation/update,
POST /invitation/delete. Patches _user_has_admin_privileges /
_user_has_admin_view to avoid extensive get_user_object mocking.

* test(proxy/proxy_server): pin config CRUD routes (PR3, partial)

Adds happy + error tests for the 8 config-CRUD control-plane routes:
POST /config/update, POST|GET /config/field/update|info, GET /config/list,
POST /config/field/delete, POST /config/callback/delete,
GET /get/config/callbacks, GET /config/yaml. Attaches litellm_config
to mock_prisma per-test.

* test(proxy/proxy_server): tighten pin assertions per review

- test_routes_misc.py: `b"" in response.content` is trivially true;
  replace with `len(response.content) > 0` so an empty 405 body trips
  the gate.
- test_routes_login_sso.py: `len(response.content) >= 0` is trivially
  true; tighten to `> 0`.
- test_routes_anthropic_beta.py: replace brittle string-literal checks
  on the serialized JSON (`'"interval_hours": 12' in payload`) with
  `json.loads` + dict access so the assertion survives any serializer
  spacing.
- test_routes_config.py: `assert status_code in (404, 500)` was too
  permissive; the handler re-raises HTTPException(404) verbatim, so
  pin 404 strictly.

---------

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 23:17:24 -07:00

223 lines
7.3 KiB
Python

"""Behavior pins for the proxy_server exception handlers.
Pins covered:
- ``openai_exception_handler``
- ``_close_dangling_otel_server_span``
- ``otel_request_validation_exception_handler``
- ``otel_unhandled_exception_handler``
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from fastapi.exceptions import RequestValidationError
from litellm.proxy._types import ProxyException
from litellm.proxy.proxy_server import (
_close_dangling_otel_server_span,
openai_exception_handler,
otel_request_validation_exception_handler,
otel_unhandled_exception_handler,
)
from .conftest import normalize
def _make_request(parent_otel_span=None):
state = SimpleNamespace(parent_otel_span=parent_otel_span)
return SimpleNamespace(state=state)
# ---------------------------------------------------------------------------
# openai_exception_handler
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_openai_exception_handler_returns_mapped_payload():
exc = ProxyException(
message="bad input",
type="invalid_request_error",
param="model",
code=400,
)
request = _make_request()
response = await openai_exception_handler(request=request, exc=exc)
body = json.loads(response.body)
assert response.status_code == 400
assert normalize(body) == {
"error": {
"message": "bad input",
"type": "invalid_request_error",
"param": "model",
"code": "400",
}
}
@pytest.mark.asyncio
async def test_openai_exception_handler_invalid_empty_code_defaults_to_500():
"""openai_exception_handler falls back to 500 when ``code`` is falsy.
Constructing via __new__ bypasses __init__ — the production __init__ always
coerces None to the string "None", which is truthy. To exercise the falsy
fallback branch we hand-craft an exception with an empty code."""
exc = ProxyException.__new__(ProxyException)
exc.message = "boom"
exc.type = "server_error"
exc.param = None
exc.openai_code = None
exc.code = ""
exc.headers = {}
exc.provider_specific_fields = None
request = _make_request()
response = await openai_exception_handler(request=request, exc=exc)
body = json.loads(response.body)
assert response.status_code == 500
assert body == {
"error": {
"message": "boom",
"type": "server_error",
"param": None,
"code": "",
}
}
# ---------------------------------------------------------------------------
# _close_dangling_otel_server_span
# ---------------------------------------------------------------------------
def test_close_dangling_otel_server_span_records_status_and_ends(monkeypatch):
"""Happy path: with a logger and an active span, the handler sets the
response status, marks ERROR (>=400), ends the span, and clears state."""
import litellm.proxy.proxy_server as ps
span = MagicMock()
fake_logger = MagicMock()
monkeypatch.setattr(ps, "open_telemetry_logger", fake_logger, raising=False)
request = _make_request(parent_otel_span=span)
_close_dangling_otel_server_span(request=request, status_code=502)
observed = {
"status_attr_called": fake_logger.set_response_status_code_attribute.called,
"set_status_called": span.set_status.called,
"ended": span.end.called,
"state_cleared": request.state.parent_otel_span is None,
}
assert normalize(observed) == {
"status_attr_called": True,
"set_status_called": True,
"ended": True,
"state_cleared": True,
}
def test_close_dangling_otel_server_span_missing_span_is_noop_error():
"""When parent_otel_span is missing the call short-circuits — no error."""
request = _make_request(parent_otel_span=None)
result = _close_dangling_otel_server_span(request=request, status_code=200)
assert result is None
assert request.state.parent_otel_span is None
def test_close_dangling_otel_server_span_logger_raises_state_cleared_error(monkeypatch):
"""Logger raising is caught; state.parent_otel_span is cleared regardless."""
import litellm.proxy.proxy_server as ps
span = MagicMock()
fake_logger = MagicMock()
fake_logger.set_response_status_code_attribute.side_effect = RuntimeError("boom")
monkeypatch.setattr(ps, "open_telemetry_logger", fake_logger, raising=False)
request = _make_request(parent_otel_span=span)
_close_dangling_otel_server_span(request=request, status_code=500)
assert request.state.parent_otel_span is None
# ---------------------------------------------------------------------------
# otel_request_validation_exception_handler
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_otel_request_validation_exception_handler_returns_422_detail():
errors = [{"loc": ["body", "model"], "msg": "field required", "type": "missing"}]
exc = RequestValidationError(errors)
request = _make_request()
response = await otel_request_validation_exception_handler(request=request, exc=exc)
body = json.loads(response.body)
assert response.status_code == 422
assert normalize(body) == {"detail": exc.errors()}
@pytest.mark.asyncio
async def test_otel_request_validation_exception_handler_empty_errors_invalid_payload():
"""An empty error list still returns 422 — the validator emitted nothing
but the handler must not crash and the body must remain well-formed."""
exc = RequestValidationError([])
request = _make_request()
response = await otel_request_validation_exception_handler(request=request, exc=exc)
body = json.loads(response.body)
assert response.status_code == 422
assert body == {"detail": []}
# ---------------------------------------------------------------------------
# otel_unhandled_exception_handler
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_otel_unhandled_exception_handler_returns_500_generic_payload():
exc = RuntimeError("kaboom")
request = _make_request()
response = await otel_unhandled_exception_handler(request=request, exc=exc)
body = json.loads(response.body)
assert response.status_code == 500
assert normalize(body) == {
"error": {
"message": "Internal server error",
"type": "internal_server_error",
}
}
@pytest.mark.asyncio
async def test_otel_unhandled_exception_handler_reraises_proxy_exception_error():
"""ProxyException / HTTPException / RequestValidationError are re-raised
so the dedicated handler runs."""
exc = ProxyException(message="m", type="t", param="p", code=403)
request = _make_request()
with pytest.raises(ProxyException):
await otel_unhandled_exception_handler(request=request, exc=exc)
@pytest.mark.asyncio
async def test_otel_unhandled_exception_handler_reraises_http_exception_invalid():
request = _make_request()
with pytest.raises(HTTPException):
await otel_unhandled_exception_handler(
request=request, exc=HTTPException(status_code=418, detail="teapot")
)