mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
fix(proxy)!: label shared-handler errors the way the route catch-alls do (#39555)
* fix(proxy): name shared-handler errors by the status they answer
handle_exception_on_proxy pinned type=internal_server_error on every
exception it wrapped, so a management route rejecting a caller's own
argument answered {"type":"internal_server_error","code":"400"} and an
SDK branching on the type read its own bad request as a gateway outage.
Derive the type from the status the response actually carries, keeping
a type the exception already names.
* test(proxy): pin a 500 for exceptions whose status is None
* fix(proxy): keep internal_server_error for timeouts and 5xx, redact hosts in shared handler errors
Provider 5xx and timeouts keep internal_server_error so retry loops still retry them,
the shared handler redacts internal hosts from raw exception messages, and /key/list
routes its HTTPException through the shared handler instead of its own copy of the
old label.
* fix(proxy): label shared-handler errors like the chat route, pick the status before the type, redact only gateway failures
* fix(proxy): pass shared-handler error messages through as on main
Redacting timeout and 5xx messages changed what every shared-handler route
returns, which relabeling the error type does not need, so messages go back
to the verbatim text main sends.
* fix(proxy): redact only the status-less failure main never sent
* fix(proxy): answer a generic 500 for a status-less failure and expect auth_error on the /user/new 403
A failure with no status now answers "Internal server error" instead of its own text, since that text can
name a guardrail's private host and pattern redaction misses cluster DNS names and IPv6 addresses. The
/user/new integration test now expects the auth_error type main's route catch-alls give a 403 the proxy raises
* fix(proxy): answer only a 4xx or 5xx carried status and fall back to the route default otherwise
A numeric-string status outside 400 to 599 made /v1/messages, images, rerank and the OpenAI pass-through answer HTTP 200 with an error body or drop the connection, and non-ASCII digits or a 5000-digit string slipped through isdecimal. error_status_code now accepts an int or a three-digit ASCII string in 400 to 599, the range the chat route already enforces
* refactor(proxy): parse a three-digit string status without recursing
This commit is contained in:
parent
b71491c6c9
commit
cbfb1abdb3
12 changed files with 311 additions and 53 deletions
|
|
@ -28,6 +28,8 @@ _OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType(
|
|||
|
||||
_AUTH_FAILURE_STATUS_CODES: Final = frozenset({status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN})
|
||||
|
||||
_HTTP_ERROR_STATUSES: Final = range(status.HTTP_400_BAD_REQUEST, 600)
|
||||
|
||||
_ERROR_BODY: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
_FAILOVER_SQLSTATES: Final = frozenset({"25006", "57P01", "57P02", "57P03"})
|
||||
|
|
@ -47,13 +49,26 @@ def attribute_of(value: object, name: str, default: object = None) -> object:
|
|||
|
||||
|
||||
def error_status_code(exc: object, default: int) -> int:
|
||||
"""The HTTP status an exception carries as ``status_code`` or, the way ``ProxyException``
|
||||
stores it, as a stringified ``code``; ``default`` when it carries neither."""
|
||||
carried: Final = attribute_of(exc, "status_code")
|
||||
if isinstance(carried, int) and not isinstance(carried, bool):
|
||||
"""The 4xx or 5xx HTTP status an exception carries as ``status_code`` (an int or a decimal string) or,
|
||||
the way ``ProxyException`` stores it, as a stringified ``code``; ``default`` when it carries neither."""
|
||||
carried: Final = _http_error_status(attribute_of(exc, "status_code"))
|
||||
if carried is not None:
|
||||
return carried
|
||||
stringified: Final = attribute_of(exc, "code")
|
||||
return int(stringified) if isinstance(stringified, str) and stringified.isdecimal() else default
|
||||
from_code: Final = _http_error_status(stringified) if isinstance(stringified, str) else None
|
||||
return default if from_code is None else from_code
|
||||
|
||||
|
||||
def _http_error_status(value: object) -> int | None:
|
||||
match value:
|
||||
case bool():
|
||||
return None
|
||||
case int() if value in _HTTP_ERROR_STATUSES:
|
||||
return value
|
||||
case str() if len(value) == 3 and value.isascii() and value.isdigit() and int(value) in _HTTP_ERROR_STATUSES:
|
||||
return int(value)
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def openai_error_type(exc: object, status_code: int) -> str:
|
||||
|
|
@ -105,9 +120,9 @@ def proxy_exception_for(exc: Exception, default_status_code: int) -> ProxyExcept
|
|||
code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
case HTTPException(status_code=status_code, detail=detail):
|
||||
return _openai_shaped_proxy_exception(exc, str(detail), status_code)
|
||||
return openai_shaped_proxy_exception(exc, str(detail), status_code)
|
||||
case _:
|
||||
return _openai_shaped_proxy_exception(
|
||||
return openai_shaped_proxy_exception(
|
||||
exc, str(exc) or type(exc).__name__, error_status_code(exc, default_status_code)
|
||||
)
|
||||
|
||||
|
|
@ -176,14 +191,19 @@ def _route_failure_error_type(exc: Exception, raised_status: int, answered_statu
|
|||
return openai_error_type(exc, answered_status)
|
||||
|
||||
|
||||
def _openai_shaped_proxy_exception(exc: Exception, message: str, status_code: int) -> ProxyException:
|
||||
def openai_shaped_proxy_exception(exc: Exception, message: str, status_code: int) -> ProxyException:
|
||||
answered_status: Final = answered_status_code(message, status_code)
|
||||
return ProxyException(
|
||||
message=message,
|
||||
type=_catch_all_error_type(exc, answered_status),
|
||||
param=openai_error_param(exc),
|
||||
code=answered_status,
|
||||
)
|
||||
return _proxy_exception(exc, message, answered_status, _catch_all_error_type(exc, answered_status))
|
||||
|
||||
|
||||
def status_typed_proxy_exception(exc: HTTPException) -> ProxyException:
|
||||
message: Final = str(exc.detail)
|
||||
answered_status: Final = answered_status_code(message, exc.status_code)
|
||||
return _proxy_exception(exc, message, answered_status, openai_error_type(exc, answered_status))
|
||||
|
||||
|
||||
def _proxy_exception(exc: Exception, message: str, answered_status: int, error_type: str) -> ProxyException:
|
||||
return ProxyException(message=message, type=error_type, param=openai_error_param(exc), code=answered_status)
|
||||
|
||||
|
||||
def _catch_all_error_type(exc: Exception, status_code: int) -> str:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
|||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.callback_utils import CALLBACK_VAR_ENCRYPTED_PREFIX
|
||||
from litellm.proxy.common_utils.openai_error_payload import status_typed_proxy_exception
|
||||
from litellm.proxy.common_utils.path_utils import is_within, safe_join
|
||||
from litellm.proxy.guardrails.content_filter_data import CATEGORIES_DIR, DATA_ROOTS, category_dirs, find_category_file
|
||||
from litellm.proxy.guardrails.guardrail_hooks.custom_code.bounded_execution import (
|
||||
|
|
@ -2533,7 +2534,7 @@ async def apply_guardrail(
|
|||
e = transformed_exception
|
||||
except Exception:
|
||||
verbose_proxy_logger.exception("apply_guardrail: post_call_failure_hook failed")
|
||||
raise handle_exception_on_proxy(e)
|
||||
raise handle_exception_on_proxy(status_typed_proxy_exception(e) if isinstance(e, HTTPException) else e)
|
||||
|
||||
# Success logging outside except so a hook error never triggers failure handlers.
|
||||
response = await _emit_guardrail_success_logs(
|
||||
|
|
|
|||
|
|
@ -85,8 +85,8 @@ from litellm.proxy._types import (
|
|||
from litellm.proxy.bug_report_config import build_proxy_bug_report
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
attribute_of,
|
||||
litellm_call_id_headers,
|
||||
openai_error_param,
|
||||
error_status_code,
|
||||
openai_shaped_proxy_exception,
|
||||
with_litellm_call_id,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
|
||||
|
|
@ -9186,39 +9186,30 @@ def _recreate_writer_on_read_only_transaction(prisma_client: "PrismaClient | Non
|
|||
asyncio.create_task(prisma_client.recreate_read_only_writer(reason="postgres_read_only_transaction"))
|
||||
|
||||
|
||||
def _client_message(e: Exception) -> str:
|
||||
status_is_none: Final = hasattr(e, "status_code") and attribute_of(e, "status_code") is None
|
||||
return "Internal server error" if status_is_none else strip_bug_report_notice(str(e))
|
||||
|
||||
|
||||
def handle_exception_on_proxy(e: Exception, litellm_call_id: str | None = None) -> ProxyException:
|
||||
"""
|
||||
Returns an Exception as ProxyException, this ensures all exceptions are OpenAI API compatible
|
||||
"""
|
||||
from fastapi import status
|
||||
|
||||
verbose_proxy_logger.exception("Exception: %s", e)
|
||||
if PrismaDBExceptionHandler.is_read_only_transaction_error(e):
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
_recreate_writer_on_read_only_transaction(prisma_client)
|
||||
|
||||
headers: Final = litellm_call_id_headers(litellm_call_id)
|
||||
if isinstance(e, HTTPException):
|
||||
return ProxyException(
|
||||
message=getattr(e, "detail", f"error({e})"),
|
||||
type=ProxyErrorTypes.internal_server_error,
|
||||
param=openai_error_param(e),
|
||||
headers=headers,
|
||||
code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR),
|
||||
)
|
||||
elif isinstance(e, ProxyException):
|
||||
if isinstance(e, ProxyException):
|
||||
return with_litellm_call_id(e, litellm_call_id)
|
||||
_status_code: Final = getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
if isinstance(e, HTTPException):
|
||||
return with_litellm_call_id(openai_shaped_proxy_exception(e, str(e.detail), e.status_code), litellm_call_id)
|
||||
if should_report_bug(e):
|
||||
verbose_proxy_logger.error(bug_report_notice(build_proxy_bug_report(e)))
|
||||
return ProxyException(
|
||||
message=strip_bug_report_notice(str(e)),
|
||||
type=ProxyErrorTypes.internal_server_error,
|
||||
param=openai_error_param(e),
|
||||
headers=headers,
|
||||
code=_status_code,
|
||||
)
|
||||
message: Final = _client_message(e)
|
||||
status_code: Final = error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return with_litellm_call_id(openai_shaped_proxy_exception(e, message, status_code), litellm_call_id)
|
||||
|
||||
|
||||
def require_enterprise_license(feature: str | None = None) -> None:
|
||||
|
|
|
|||
|
|
@ -432,7 +432,7 @@ def test_team_guardrail_submissions_require_admin_approval(
|
|||
f"Guardrail '{first_name}' not found. "
|
||||
"Please ensure the guardrail is configured in your LiteLLM proxy."
|
||||
),
|
||||
"type": "internal_server_error",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "404",
|
||||
}
|
||||
|
|
@ -481,7 +481,7 @@ def test_team_guardrail_submissions_require_admin_approval(
|
|||
f"Guardrail '{second_name}' not found. "
|
||||
"Please ensure the guardrail is configured in your LiteLLM proxy."
|
||||
),
|
||||
"type": "internal_server_error",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "404",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ def test_new_user_with_admin_role_requires_proxy_admin(gateway: Gateway, role: s
|
|||
assert refused.json() == {
|
||||
"error": {
|
||||
"message": f"Only proxy admins can create administrative users (proxy_admin, proxy_admin_viewer). Attempted to create user with role: {role}. Your role: internal_user",
|
||||
"type": "internal_server_error",
|
||||
"type": "auth_error",
|
||||
"param": None,
|
||||
"code": "403",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ def test_user_delete_revokes_keys_and_memberships(gateway: Gateway, peer: Gatewa
|
|||
assert info.json() == {
|
||||
"error": {
|
||||
"message": f"User {user_id} not found",
|
||||
"type": "internal_server_error",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "404",
|
||||
}
|
||||
|
|
@ -337,7 +337,7 @@ def test_scim_user_delete_revokes_keys(gateway: Gateway, peer: Gateway, monkeypa
|
|||
expected_info: Final = {
|
||||
"error": {
|
||||
"message": f"User {user_id} not found",
|
||||
"type": "internal_server_error",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "404",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ def test_apply_guardrail_returns_a_block_error_for_generic_guardrails(gateway: G
|
|||
assert error.model_dump() == {
|
||||
"error": {
|
||||
"message": _BLOCK_REASON,
|
||||
"type": "internal_server_error",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": 400,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,10 +111,13 @@ def test_the_default_status_stands_when_the_exception_carries_none(default: int)
|
|||
assert error_status_code(ValueError("boom"), default) == default
|
||||
|
||||
|
||||
@pytest.mark.parametrize("carried_status", [True, False, "429", None, 429.0])
|
||||
def test_a_non_int_carried_status_falls_back_to_the_default(carried_status: object):
|
||||
"""True is an int in Python but not an HTTP status, and a stringified one would break
|
||||
every caller that compares the code numerically."""
|
||||
@pytest.mark.parametrize(
|
||||
"carried_status",
|
||||
[True, False, "slow down", "", None, 429.0, 200, 1301, 0, "200", "1301", "0", "0429", " 429", "\u0664\u0662\u0669", "4" * 5000],
|
||||
)
|
||||
def test_a_carried_status_that_is_not_an_error_status_falls_back_to_the_default(carried_status: object):
|
||||
"""True is an int in Python but not an HTTP status, and answering a 200 or a 1301 either reads
|
||||
as success or makes uvicorn drop the connection."""
|
||||
|
||||
class _Carrier(Exception):
|
||||
status_code = carried_status
|
||||
|
|
@ -122,6 +125,22 @@ def test_a_non_int_carried_status_falls_back_to_the_default(carried_status: obje
|
|||
assert error_status_code(_Carrier("boom"), 500) == 500
|
||||
|
||||
|
||||
def test_a_status_carried_as_a_decimal_string_is_answered_as_that_status():
|
||||
"""Provider stream-error chunks pass the vendor's code through as a string ``status_code``,
|
||||
and the shared handler used to answer it as is, so dropping it turned a rate limit into a 500."""
|
||||
|
||||
class _Carrier(Exception):
|
||||
status_code = "429"
|
||||
|
||||
assert error_status_code(_Carrier("slow down"), 500) == 429
|
||||
assert proxy_exception_for(_Carrier("slow down"), default_status_code=500).to_dict() == {
|
||||
"message": "slow down",
|
||||
"type": "rate_limit_error",
|
||||
"param": None,
|
||||
"code": "429",
|
||||
}
|
||||
|
||||
|
||||
def test_a_proxy_exception_keeps_the_status_it_was_raised_with():
|
||||
"""ProxyException stores its status as the string ``code`` rather than ``status_code``,
|
||||
so a route tail that rewraps one used to answer a 4xx rejection as a 500."""
|
||||
|
|
@ -130,8 +149,8 @@ def test_a_proxy_exception_keeps_the_status_it_was_raised_with():
|
|||
assert error_status_code(rejection, 500) == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("carried_code", [None, "None", "", "rate_limited", "4xx", 404])
|
||||
def test_a_code_that_is_not_a_decimal_string_falls_back_to_the_default(carried_code: object):
|
||||
@pytest.mark.parametrize("carried_code", [None, "None", "", "rate_limited", "4xx", 404, "200", "1301"])
|
||||
def test_a_code_that_is_not_a_stringified_error_status_falls_back_to_the_default(carried_code: object):
|
||||
"""Only ProxyException's stringified status is a status; ``code`` on anything else
|
||||
(OpenAI's ``invalid_api_key``, a stray int) says nothing about the HTTP answer."""
|
||||
|
||||
|
|
|
|||
|
|
@ -3277,6 +3277,23 @@ class TestCredentialDisplayName:
|
|||
assert listed["openai-prod"]["credential_values"]["api_key"] != "sk-old"
|
||||
|
||||
|
||||
def test_looking_up_a_missing_credential_is_labelled_a_client_error_not_a_gateway_failure(
|
||||
restore_credential_list: None,
|
||||
):
|
||||
"""Naming a credential that does not exist is the caller's own mistake, and the shared
|
||||
handler used to label every one of these internal_server_error, so an SDK branching on
|
||||
the type read a 404 it can never retry past as a gateway outage and backed off forever."""
|
||||
response: Final = _call_as("GET", "/credentials/by_name/definitely-not-there")
|
||||
|
||||
assert response.status_code == 404, f"lookup answered {response.status_code}: {response.text}"
|
||||
assert response.json()["error"] == {
|
||||
"message": "Credential not found. Got credential name: definitely-not-there",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "404",
|
||||
}
|
||||
|
||||
|
||||
def test_get_credential_by_model_masks_the_deployment_credentials(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import yaml
|
|||
from fastapi import HTTPException
|
||||
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import (
|
||||
CreateGuardrailRequest,
|
||||
ListGuardrailsResponse,
|
||||
|
|
@ -2198,6 +2198,39 @@ def _patch_apply_guardrail_env(mocker, guardrail_result):
|
|||
return mock_guardrail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("vendor_status", "expected_type"),
|
||||
[(401, "authentication_error"), (403, "permission_error")],
|
||||
)
|
||||
async def test_a_guardrail_vendors_rejection_is_labelled_the_way_chat_labels_it(
|
||||
mocker, vendor_status: int, expected_type: str
|
||||
):
|
||||
"""A vendor refusing the guardrail's own credentials is not the caller's key being rejected,
|
||||
so it must not read as the proxy's auth_error the way a 401 the proxy raises does."""
|
||||
mock_guardrail: Final = _patch_apply_guardrail_env(mocker, None)
|
||||
mock_guardrail.apply_guardrail.side_effect = HTTPException(
|
||||
status_code=vendor_status, detail="vendor refused the request"
|
||||
)
|
||||
mocker.patch(
|
||||
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook", AsyncMock(return_value=None)
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as raised:
|
||||
await apply_guardrail(
|
||||
fastapi_request=mocker.Mock(),
|
||||
request=ApplyGuardrailRequest(guardrail_name="test-guardrail", text="hello"),
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
)
|
||||
|
||||
assert raised.value.to_dict() == {
|
||||
"message": "vendor refused the request",
|
||||
"type": expected_type,
|
||||
"param": None,
|
||||
"code": str(vendor_status),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_guardrail_forwards_metadata_to_guardrail(mocker):
|
||||
"""Client-supplied metadata must reach apply_guardrail via request_data so
|
||||
|
|
|
|||
|
|
@ -18750,7 +18750,7 @@ async def test_list_keys_rejects_invalid_expires():
|
|||
expires="expred",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "400"
|
||||
assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error")
|
||||
assert "Invalid expires value" in str(exc_info.value.message)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import asyncio
|
||||
import json
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from prisma.errors import DataError
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||||
from litellm.proxy.common_request_processing import proxy_exception_from_route_error
|
||||
from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for
|
||||
from litellm.proxy.utils import get_error_message_str, handle_exception_on_proxy
|
||||
from litellm.types.router import RouterErrors
|
||||
|
||||
|
||||
def normalize(value):
|
||||
|
|
@ -96,7 +103,7 @@ def test_handle_exception_on_proxy_happy_path_http_exception():
|
|||
assert snapshot == {
|
||||
"is_proxy_exception": True,
|
||||
"message": "forbidden",
|
||||
"type": ProxyErrorTypes.internal_server_error.value,
|
||||
"type": "auth_error",
|
||||
"code": "403",
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +163,7 @@ def test_handle_exception_on_proxy_uses_attached_status_code_when_present():
|
|||
assert snapshot == {
|
||||
"code": "418",
|
||||
"message": "teapot",
|
||||
"type": ProxyErrorTypes.internal_server_error.value,
|
||||
"type": "invalid_request_error",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -176,6 +183,176 @@ def test_handle_exception_on_proxy_error_path_none_input_wraps_as_500():
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected_type",
|
||||
[
|
||||
(400, "invalid_request_error"),
|
||||
(401, "auth_error"),
|
||||
(403, "auth_error"),
|
||||
(404, "invalid_request_error"),
|
||||
(408, "invalid_request_error"),
|
||||
(409, "invalid_request_error"),
|
||||
(422, "invalid_request_error"),
|
||||
(429, "rate_limit_error"),
|
||||
(500, ProxyErrorTypes.internal_server_error.value),
|
||||
(503, ProxyErrorTypes.internal_server_error.value),
|
||||
],
|
||||
)
|
||||
def test_handle_exception_on_proxy_labels_an_http_exception_by_the_status_it_answers(
|
||||
status_code: int, expected_type: str
|
||||
):
|
||||
"""A management route that rejects a caller's own argument answers 400, and calling that
|
||||
internal_server_error tells the caller's retry loop the gateway is down when nothing but
|
||||
their request is wrong."""
|
||||
result: Final = handle_exception_on_proxy(HTTPException(status_code=status_code, detail="boom"))
|
||||
|
||||
assert (result.code, result.type) == (str(status_code), expected_type)
|
||||
|
||||
|
||||
def _exception_carrying_status(carried: int | None, message: str = "boom") -> Exception:
|
||||
class _CarriedStatus(Exception):
|
||||
status_code = carried
|
||||
|
||||
return _CarriedStatus(message)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected_type",
|
||||
[
|
||||
(400, "invalid_request_error"),
|
||||
(404, "invalid_request_error"),
|
||||
(408, "invalid_request_error"),
|
||||
(429, "rate_limit_error"),
|
||||
(500, ProxyErrorTypes.internal_server_error.value),
|
||||
],
|
||||
)
|
||||
def test_handle_exception_on_proxy_labels_a_carried_status_the_same_way(status_code: int, expected_type: str):
|
||||
"""Routes also reach this handler with plain exceptions carrying their own status, and
|
||||
those must be named by that status rather than all landing on the 500 label."""
|
||||
result: Final = handle_exception_on_proxy(_exception_carrying_status(status_code))
|
||||
|
||||
assert (result.code, result.type) == (str(status_code), expected_type)
|
||||
|
||||
|
||||
def test_handle_exception_on_proxy_keeps_the_type_the_exception_already_names():
|
||||
"""A rate limit litellm raised already names itself, and overwriting that with the status
|
||||
map's guess would drop what the client branches on."""
|
||||
result: Final = handle_exception_on_proxy(
|
||||
litellm.RateLimitError(message="slow down", llm_provider="openai", model="gpt-4o")
|
||||
)
|
||||
|
||||
assert (result.code, result.type) == ("429", "throttling_error")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
openai.InternalServerError(
|
||||
message="upstream down",
|
||||
response=httpx.Response(500, request=httpx.Request("GET", "https://api.openai.com/v1/batches/b")),
|
||||
body={"type": "server_error"},
|
||||
),
|
||||
litellm.Timeout(message="took too long", model="gpt-6.1-sol", llm_provider="openai"),
|
||||
litellm.RateLimitError(message="slow down", llm_provider="openai", model="gpt-6.1-sol"),
|
||||
litellm.AuthenticationError(message="bad key", llm_provider="openai", model="gpt-6.1-sol"),
|
||||
litellm.BadRequestError(message="bad input", model="gpt-6.1-sol", llm_provider="openai"),
|
||||
HTTPException(status_code=404, detail="no such batch"),
|
||||
],
|
||||
ids=["provider_5xx_naming_its_own_type", "timeout", "rate_limit", "bad_key", "bad_request", "http_404"],
|
||||
)
|
||||
def test_handle_exception_on_proxy_labels_a_failure_the_way_the_chat_route_does(exc: Exception):
|
||||
"""Batch, file, and management routes answer through this handler while chat answers through the route
|
||||
error path, and one client calling both must read the same type for the same failure."""
|
||||
shared: Final = handle_exception_on_proxy(exc)
|
||||
chat: Final = proxy_exception_from_route_error(exc)
|
||||
|
||||
assert (shared.code, shared.type) == (chat.code, chat.type)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
HTTPException(status_code=400, detail="bad budget_duration"),
|
||||
HTTPException(status_code=401, detail="not allowed"),
|
||||
HTTPException(status_code=403, detail="admin only"),
|
||||
HTTPException(status_code=404, detail="no such team"),
|
||||
litellm.AuthenticationError(message="bad key", llm_provider="openai", model="gpt-6.1-sol"),
|
||||
ValueError(f"{RouterErrors.no_deployments_with_tag_routing.value}=['paid']"),
|
||||
_exception_carrying_status(409, "duplicate credential"),
|
||||
],
|
||||
ids=["http_400", "http_401", "http_403", "http_404", "provider_401", "tag_denial", "carried_409"],
|
||||
)
|
||||
def test_handle_exception_on_proxy_labels_a_failure_the_way_route_catch_alls_do(exc: Exception):
|
||||
"""Management routes answer some failures through their own catch-all and the rest through this handler,
|
||||
so a client reading /key/list and /key/generate must see the same type and status for the same failure."""
|
||||
shared: Final = handle_exception_on_proxy(exc)
|
||||
catch_all: Final = proxy_exception_for(exc, default_status_code=500)
|
||||
|
||||
assert (shared.code, shared.type, shared.message) == (catch_all.code, catch_all.type, catch_all.message)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message, expected",
|
||||
[
|
||||
(f"{RouterErrors.no_deployments_with_provider_budget_routing.value}, try later", ("429", "rate_limit_error")),
|
||||
(f"{RouterErrors.no_deployments_with_tag_routing.value}=['paid']", ("401", "auth_error")),
|
||||
],
|
||||
ids=["no_deployment_left", "no_deployment_for_tags"],
|
||||
)
|
||||
def test_handle_exception_on_proxy_names_a_router_failure_by_the_status_it_answers(
|
||||
message: str, expected: tuple[str, str]
|
||||
):
|
||||
"""A router failure that carries no status answers the 429 or 401 its message maps to, and a 429 labelled
|
||||
internal_server_error reads as an outage to a client that branches on the type."""
|
||||
result: Final = handle_exception_on_proxy(ValueError(message))
|
||||
|
||||
assert (result.code, result.type) == expected
|
||||
|
||||
|
||||
def test_handle_exception_on_proxy_keeps_the_path_a_rejected_request_names():
|
||||
"""A provider rejecting a batch input names the caller's own bucket path, and redacting the /tmp part of it
|
||||
leaves the caller nothing to fix their request with."""
|
||||
result: Final = handle_exception_on_proxy(
|
||||
litellm.BadRequestError(
|
||||
message="Invalid input_file_id gs://my-bucket/tmp/batch_input.jsonl",
|
||||
model="gemini-3.1-pro-preview",
|
||||
llm_provider="vertex_ai",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.code == "400"
|
||||
assert "gs://my-bucket/tmp/batch_input.jsonl" in result.message
|
||||
|
||||
|
||||
def test_handle_exception_on_proxy_keeps_the_message_an_exception_with_a_status_carries():
|
||||
"""Main sends a status-bearing failure's message verbatim, and relabeling its type must not change that text."""
|
||||
result: Final = handle_exception_on_proxy(_exception_carrying_status(503, "Cannot connect to host 10.12.4.7:8443"))
|
||||
|
||||
assert (result.code, result.message) == ("503", "Cannot connect to host 10.12.4.7:8443")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"boom",
|
||||
"Cannot connect to host 10.12.4.7:8443",
|
||||
"Cannot connect to host guardrail.internal.svc.cluster.local:443",
|
||||
"Cannot connect to host [fd00::12]:8443",
|
||||
],
|
||||
ids=["plain", "ipv4", "cluster_dns", "ipv6"],
|
||||
)
|
||||
def test_handle_exception_on_proxy_answers_a_generic_500_for_an_exception_whose_status_is_none(message: str):
|
||||
"""A guardrail whose upstream refuses the connection raises with status_code=None, which crashed the handler
|
||||
into a 503 blaming the auth database, and its text names private hosts no virtual key was ever sent."""
|
||||
result: Final = handle_exception_on_proxy(_exception_carrying_status(None, message))
|
||||
|
||||
assert (result.code, result.type, result.message) == (
|
||||
"500",
|
||||
ProxyErrorTypes.internal_server_error.value,
|
||||
"Internal server error",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue