fix(proxy): word database 503s by whether the fault is transient (#39256)

Permanent Prisma/query-engine faults keep the 503 status and no_db_connection type but stop claiming the database is temporarily unreachable. A permanent fault anywhere in the exception chain outranks the transport error that surfaced it. MCP bridge and DCR flows gain a faulted resolution state with matching wording. Resolves LIT-5208

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-02 15:05:55 -07:00 • committed by GitHub
parent 346813b374
commit 748c2026d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 449 additions and 27 deletions

View file

@ -1149,10 +1149,11 @@ class MCPRequestHandler:
would miss a real outage wrapped inside it."""
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
if PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(e):
outage: Final = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(e)
if outage is not None:
raise HTTPException(
status_code=503,
detail="Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly.",
detail=PrismaDBExceptionHandler.database_unavailable_message(outage),
) from None
@staticmethod

View file

@ -101,18 +101,29 @@ class _ResolvedKey:
key: "UserAPIKeyAuth"
_KeyResolutionFailure = Literal["no_active_key", "unavailable", "unresolvable"]
_KeyResolutionFailure = Literal["no_active_key", "unavailable", "faulted", "unresolvable"]
"""Why a token request yielded no active litellm key, kept distinct so a caller statuses each truthfully
instead of blaming the client for a gateway problem:
- ``no_active_key``: none was presented, or the presented key is unknown / blocked / expired (the
caller's request is at fault)
- ``unavailable``: the auth database was transiently unreachable while resolving (retryable)
- ``faulted``: the auth database's query engine reported a fault that retrying will not clear (still a
503, but the wording must not tell the operator to wait)
- ``unresolvable``: the gateway cannot resolve identity right now (no DB connection, or an unexpected
error) -- a gateway fault, not the caller's
The classification mirrors admission's ``_reload_admitted_key`` so the mint (ingress) and admission
(egress) never disagree on the status of the same outage."""
def _database_failure(exc: Exception) -> Literal["unavailable", "faulted"]:
from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import
PrismaDBExceptionHandler,
)
fault: Final = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(exc) or exc
return "faulted" if PrismaDBExceptionHandler.is_permanent_database_fault(fault) else "unavailable"
async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyResolutionFailure":
"""Resolve the presented litellm key to an active key record, or say precisely why not.
@ -170,7 +181,7 @@ async def _reload_active_key_by_hash(key_hash: str) -> "_ResolvedKey | _KeyResol
return "no_active_key"
except Exception as exc: # noqa: BLE001 # classify: a DB outage is retryable, anything else is an opaque gateway fault
if PrismaDBExceptionHandler.is_database_service_unavailable_error(exc):
return "unavailable"
return _database_failure(exc)
verbose_logger.debug(
"_reload_active_key_by_hash: unexpected key-resolution error (%s)",
type(exc).__name__,
@ -225,8 +236,9 @@ async def load_active_user_by_id(user_id: str) -> "LiteLLM_UserTable | _KeyResol
except (ProxyException, HTTPException):
return "no_active_key"
except Exception as exc: # noqa: BLE001 # a DB outage is retryable; a missing user (get_user_object's wrapped ValueError) or any other resolution failure fails closed as no_active_key, never a 500
if PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(exc):
return "unavailable"
outage: Final = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(exc)
if outage is not None:
return _database_failure(outage)
verbose_logger.debug("_reload_active_user_by_id: user-resolution error (%s)", type(exc).__name__)
return "no_active_key"
if user_object is None:
@ -383,6 +395,7 @@ _BridgeMintError = Literal[
"no_identity",
"invalid_refresh",
"identity_unavailable",
"identity_faulted",
"identity_unresolvable",
"not_configured",
"no_upstream_token",
@ -433,6 +446,13 @@ def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse:
"temporarily_unavailable",
"the authentication database is temporarily unreachable; retry shortly",
)
case "identity_faulted":
status, code, desc = (
503,
"temporarily_unavailable",
"the authentication database reported a fault that is not a transient outage; "
"retrying will not help until the gateway deployment is repaired",
)
case "identity_unresolvable":
status, code, desc = (
500,
@ -485,6 +505,8 @@ def _key_resolution_failure_to_mint_error(failure: _KeyResolutionFailure) -> _Br
return "no_identity"
case "unavailable":
return "identity_unavailable"
case "faulted":
return "identity_faulted"
case "unresolvable":
return "identity_unresolvable"
case _:
@ -569,6 +591,8 @@ def _refresh_key_failure_to_mint_error(failure: _KeyResolutionFailure) -> _Bridg
return "invalid_refresh"
case "unavailable":
return "identity_unavailable"
case "faulted":
return "identity_faulted"
case "unresolvable":
return "identity_unresolvable"
case _:

View file

@ -150,11 +150,18 @@ _CLIENT_RECORD_DEBUG_KEY: Final = "gateway_dcr_client"
_CONNECT_FLOW_DEBUG_KEY: Final = "gateway_connect_flow"
_AUTH_CODE_DEBUG_KEY: Final = "gateway_authorization_code"
ReloadUserFailure = Literal["unresolvable", "unavailable", "no_active_key"]
ReloadUserFailure = Literal["unresolvable", "unavailable", "faulted", "no_active_key"]
ReloadUser = Callable[[str], Awaitable[ReloadUserFailure | None]]
"""Injected live-user revalidation (the token endpoint's mirror of admission):
``None`` means the user is active; ``unavailable`` is a retryable DB outage; anything
else fails the grant closed."""
``None`` means the user is active; ``unavailable`` is a retryable DB outage; ``faulted`` is
a DB fault retrying will not clear (still 503, worded so nobody just waits); anything else
fails the grant closed."""
_DB_UNAVAILABLE_DESCRIPTION: Final = "the gateway database is unavailable; retry"
_DB_FAULTED_DESCRIPTION: Final = (
"the gateway database reported a fault that is not a transient outage; "
"retrying will not help until the gateway deployment is repaired"
)
PROXY_API_AUDIENCE: Final[SessionAudience] = "proxy_api"
"""The audience a native client (``lite login --pkce``, a Go CLI) asks for by sending the
@ -659,7 +666,9 @@ def _set_flow_cookie(response: Response, request: Request, handle: str, flow: _C
def _consent_lookup_failure_response(failure: ReloadUserFailure) -> Response:
match failure:
case "unavailable":
return _oauth_error(503, "temporarily_unavailable", "the gateway database is unavailable; retry")
return _oauth_error(503, "temporarily_unavailable", _DB_UNAVAILABLE_DESCRIPTION)
case "faulted":
return _oauth_error(503, "temporarily_unavailable", _DB_FAULTED_DESCRIPTION)
case "unresolvable":
return _oauth_error(500, "server_error", "the gateway is not configured to resolve users")
case "no_active_key":
@ -962,7 +971,9 @@ def _reload_failure_response(failure: ReloadUserFailure) -> Response:
``ReloadUserFailure`` member is a type error here rather than silently 400ing."""
match failure:
case "unavailable":
return _oauth_error(503, "temporarily_unavailable", "the gateway database is unavailable; retry")
return _oauth_error(503, "temporarily_unavailable", _DB_UNAVAILABLE_DESCRIPTION)
case "faulted":
return _oauth_error(503, "temporarily_unavailable", _DB_FAULTED_DESCRIPTION)
case "unresolvable":
return _oauth_error(500, "server_error", "the gateway is not configured to resolve users")
case "no_active_key":
@ -981,7 +992,7 @@ def _mint_failure_response(failure: ProxyCredentialMintFailure) -> Response:
return _oauth_error(
400, "invalid_grant", "this user belongs to a team; sign in again and pick the team for this credential"
)
case "unavailable" | "unresolvable" | "no_active_key":
case "unavailable" | "faulted" | "unresolvable" | "no_active_key":
return _reload_failure_response(failure)
case _:
assert_never(failure)
@ -1297,8 +1308,8 @@ async def introspect_gateway_token(
if peeked == "claimed":
return _inactive_introspection_response()
failure: Final = await reload_user(opened.principal.user_id)
if failure == "unavailable":
return _oauth_error(503, "temporarily_unavailable", "the gateway database is unavailable; retry")
if failure == "unavailable" or failure == "faulted":
return _reload_failure_response(failure)
if failure is not None:
return _inactive_introspection_response()
return _active_introspection_response(opened)

View file

@ -61,9 +61,7 @@ def _as_proxy_exception(e: Exception) -> ProxyException:
return e
if PrismaDBExceptionHandler.is_database_service_unavailable_error(e):
return ProxyException(
message=(
"Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly."
),
message=PrismaDBExceptionHandler.database_unavailable_message(e),
type=ProxyErrorTypes.no_db_connection,
param="None",
code=status.HTTP_503_SERVICE_UNAVAILABLE,

View file

@ -1,4 +1,4 @@
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Iterator
from typing import Any, Final, TypeVar
from litellm._logging import verbose_proxy_logger
@ -9,10 +9,32 @@ from litellm.proxy._types import (
)
from litellm.secret_managers.main import str_to_bool
# Bounds the __cause__/__context__ walk in is_database_service_unavailable_error_in_chain.
# Bounds the __cause__/__context__ walk in find_database_service_unavailable_error_in_chain.
# Real exception chains are a few links deep; the cap also makes the walk cycle-safe.
_MAX_EXCEPTION_CHAIN_DEPTH: Final = 20
_TRANSIENT_DB_UNAVAILABLE_MESSAGE: Final = (
"Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly."
)
def _exception_chain(e: BaseException) -> Iterator[BaseException]:
current = e # rebind-ok: advances one link per iteration of the bounded walk
for _ in range(_MAX_EXCEPTION_CHAIN_DEPTH):
yield current
following = current.__cause__ or current.__context__
if following is None:
return
current = following
def _database_service_unavailable_errors(e: BaseException) -> tuple[Exception, ...]:
return tuple(
link
for link in _exception_chain(e)
if isinstance(link, Exception) and PrismaDBExceptionHandler.is_database_service_unavailable_error(link)
)
def _exception_types(*candidates: object) -> tuple[type[BaseException], ...]:
"""Keep only the real exception classes among ``candidates``.
@ -279,6 +301,51 @@ class PrismaDBExceptionHandler:
),
)
@staticmethod
def is_permanent_database_fault(e: Exception) -> bool:
"""True for a service-unavailable failure that will not clear on its
own: an engine-layer ``PrismaError`` (missing or version-skewed engine
binary, engine error status, misused transaction) that is neither the
transient ``EngineConnectionError`` nor a reconnectable transport failure.
Picks only the wording of a 503, never whether one is sent;
``is_database_service_unavailable_error`` stays the status gate.
"""
if PrismaDBExceptionHandler.is_database_connection_error(e):
return False
if PrismaDBExceptionHandler.is_database_transport_error(e):
return False
return PrismaDBExceptionHandler.is_database_infrastructure_error(e)
@staticmethod
def database_unavailable_message(e: Exception) -> str:
"""The 503 detail for a service-unavailable database failure: retry
guidance for a transient outage, a pointer at the deployment for a
fault that retrying cannot fix. A permanent fault anywhere in the
exception chain wins, since the transport error that surfaced it is
not what blocks recovery."""
fault: Final = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(e) or e
if not PrismaDBExceptionHandler.is_permanent_database_fault(fault):
return _TRANSIENT_DB_UNAVAILABLE_MESSAGE
return (
"Service Unavailable, the authentication database query engine reported "
f"{type(fault).__name__}, which is not a transient outage and will not clear by retrying. "
"The proxy deployment needs attention."
)
@staticmethod
def find_database_service_unavailable_error_in_chain(e: BaseException) -> Exception | None:
"""The exception in the ``__cause__`` / ``__context__`` chain that
``is_database_service_unavailable_error`` accepts, or ``None``. Callers
that word a response by the kind of outage need the wrapped database
error itself, not just the fact that one is present. A permanent fault
outranks a transient one wherever it sits in the chain: a reconnect that
dies on a missing engine binary raises the transport error last, but the
binary is what keeps the database down."""
outages: Final = _database_service_unavailable_errors(e)
permanent: Final = next(filter(PrismaDBExceptionHandler.is_permanent_database_fault, outages), None)
return permanent if permanent is not None else next(iter(outages), None)
@staticmethod
def is_database_service_unavailable_error_in_chain(e: BaseException) -> bool:
"""Like ``is_database_service_unavailable_error`` but also walks the
@ -296,14 +363,7 @@ class PrismaDBExceptionHandler:
The walk is depth-bounded, which also makes it cycle-safe.
"""
current: BaseException | None = e
for _ in range(_MAX_EXCEPTION_CHAIN_DEPTH):
if not isinstance(current, Exception):
return False
if PrismaDBExceptionHandler.is_database_service_unavailable_error(current):
return True
current = current.__cause__ or current.__context__
return False
return PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(e) is not None
@staticmethod
def handle_db_exception(e: Exception):

View file

@ -6005,6 +6005,44 @@ class TestMCPDcrBridgeDelegateAdmission:
await MCPRequestHandler.process_mcp_request(scope)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == (
"Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly."
)
async def test_user_subject_envelope_permanent_db_fault_is_503_not_worded_as_transient(self):
"""A query engine fault that never heals (a missing engine binary) still fails admission with 503,
but the detail must not call the database "temporarily unreachable" or ask the client to retry: the
DCR client would loop on a retry that can never succeed. The fault reaches the handler wrapped in
get_user_object's bare ValueError, so the wording has to be picked off the wrapped cause."""
from prisma.engine.errors import BinaryNotFoundError
envelope = self._mint_bridge_envelope(user_id="sso-user-7")
scope = {
"type": "http",
"method": "POST",
"path": "/mcp/bridge_delegate_server",
"headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))],
}
with (
patch( # test-quality-ok: isolate the MCP registry, same seam as the sibling admission tests
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
) as mock_mgr,
patch( # test-quality-ok: the envelope opener reads master_key off the proxy module, no injection seam
"litellm.proxy.proxy_server.master_key", self._MASTER_KEY
),
self._patch_user_reload(
side_effect=self._wrapped_user_lookup_error(BinaryNotFoundError("query engine binary not found"))
),
):
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
with pytest.raises(HTTPException) as exc_info:
await MCPRequestHandler.process_mcp_request(scope)
assert exc_info.value.status_code == 503
assert "temporarily unreachable" not in exc_info.value.detail
assert "retry shortly" not in exc_info.value.detail.lower()
assert "BinaryNotFoundError" in exc_info.value.detail
assert "will not clear by retrying" in exc_info.value.detail
async def test_user_subject_envelope_scim_deactivated_user_fails_closed_401(self):
"""SCIM-deactivating the envelope's user revokes it immediately: the reloaded user carries

View file

@ -6432,6 +6432,23 @@ async def test_bridge_mint_db_outage_is_503_before_upstream():
response, post = await _prepare_only_bridge_exchange("unavailable")
assert response.status_code == 503
assert json.loads(response.body)["error"] == "temporarily_unavailable"
assert "retry shortly" in json.loads(response.body)["error_description"]
post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_mint_permanent_db_fault_is_503_without_retry_advice():
"""A query engine fault that never heals is still a 503 (the gateway is at fault, not the client), but
the description must not tell the client the database is temporarily unreachable and to retry: that
sends an operator to wait out an outage that is not one. The code stays temporarily_unavailable, the
only RFC 6749 error a client treats as a server-side 503."""
response, post = await _prepare_only_bridge_exchange("faulted")
assert response.status_code == 503
body = json.loads(response.body)
assert body["error"] == "temporarily_unavailable"
assert "temporarily unreachable" not in body["error_description"]
assert "retry shortly" not in body["error_description"]
assert "not a transient outage" in body["error_description"]
post.assert_not_called()
@ -7143,6 +7160,56 @@ async def test_resolve_active_litellm_key_db_outage_is_unavailable(proxy_globals
assert await _resolve_active_litellm_key(request) == "unavailable"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_permanent_engine_fault_is_faulted(proxy_globals):
"""A query engine that is missing or version-skewed cannot resolve any key until the deployment is
repaired, so the resolver reports "faulted" (still statused 503 by the mint) rather than "unavailable",
whose wording promises the outage is transient and asks the client to retry."""
from prisma.engine.errors import BinaryNotFoundError
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
class _FaultedPrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
raise BinaryNotFoundError("query engine binary not found")
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _FaultedPrisma()
request = _token_request({"x-litellm-api-key": "sk-during-engine-fault"})
assert await _resolve_active_litellm_key(request) == "faulted"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_transport_error_over_permanent_fault_is_faulted(proxy_globals):
"""A reconnect that dies on a missing engine binary raises the transport error last, with the
BinaryNotFoundError as __context__. The binary is what blocks recovery, so the key read is "faulted",
not the "unavailable" that the outer ConnectError alone would suggest."""
import httpx
from prisma.engine.errors import BinaryNotFoundError
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
)
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
class _ReconnectFailedPrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
try:
raise BinaryNotFoundError("query engine binary not found")
except BinaryNotFoundError:
raise httpx.ConnectError("All connection attempts failed")
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _ReconnectFailedPrisma()
request = _token_request({"x-litellm-api-key": "sk-during-failed-reconnect"})
assert await _resolve_active_litellm_key(request) == "faulted"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_no_database_is_unresolvable(proxy_globals):
"""With no database connection configured the gateway cannot verify the presented key at all, so
@ -7214,6 +7281,26 @@ async def test_reload_active_user_by_id_db_outage_is_unavailable(proxy_globals):
assert await _reload_active_user_by_id("sso-user-7") == "unavailable"
@pytest.mark.asyncio
async def test_reload_active_user_by_id_permanent_engine_fault_is_faulted(proxy_globals):
"""A permanent query engine fault while re-validating the user on refresh is "faulted", not
"unavailable": both are 503s, but only the transient one may tell the client to retry. get_user_object
wraps the fault in a bare ValueError, so the classification has to read the wrapped cause."""
from prisma.engine.errors import MismatchedVersionsError
from litellm.proxy._experimental.mcp_server.bridge_token_flow import _reload_active_user_by_id
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = object()
with patch( # test-quality-ok: get_user_object is the DB seam that wraps the fault; same patch as the outage sibling
"litellm.proxy.auth.auth_checks.get_user_object",
new=AsyncMock(side_effect=_wrapped_user_lookup_error(MismatchedVersionsError(expected="1", got="2"))),
):
assert await _reload_active_user_by_id("sso-user-7") == "faulted"
@pytest.mark.asyncio
async def test_token_endpoint_uses_client_secret_basic_when_configured():
"""LIT-4091: a server with token_endpoint_auth_method=client_secret_basic must send the

View file

@ -426,6 +426,7 @@ async def test_token_rejects_expired_code_and_missing_configuration():
[
("no_active_key", 400, "invalid_grant"),
("unavailable", 503, "temporarily_unavailable"),
("faulted", 503, "temporarily_unavailable"),
("unresolvable", 500, "server_error"),
],
)
@ -460,6 +461,25 @@ async def test_token_gates_on_live_user_revalidation(failure, expected_status, e
assert json.loads(response.body)["error"] == expected_error
def test_permanent_db_fault_503_does_not_promise_a_retry_will_help():
"""Both DB failures are 503 temporarily_unavailable (the only OAuth error a client reads as a
server-side outage), so the description is the one place the two are told apart: a transient outage
says retry, a fault that never heals must say retrying will not help and point at the deployment."""
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
_consent_lookup_failure_response,
_mint_failure_response,
_reload_failure_response,
)
for render in (_reload_failure_response, _consent_lookup_failure_response, _mint_failure_response):
transient = json.loads(render("unavailable").body)["error_description"]
faulted = json.loads(render("faulted").body)["error_description"]
assert transient == "the gateway database is unavailable; retry"
assert "retry" not in faulted.replace("retrying will not help", "")
assert "not a transient outage" in faulted
assert "retrying will not help" in faulted
@pytest.mark.asyncio
async def test_flow_is_single_use_shared_cache_rejects_second_complete():
"""A double-submit of the finish step mints only ONE code: the second complete over the
@ -1250,6 +1270,7 @@ async def test_native_authorize_refuses_a_hosted_redirect_for_the_proxy_api():
"failure, status, error",
[
("unavailable", 503, "temporarily_unavailable"),
("faulted", 503, "temporarily_unavailable"),
("unresolvable", 500, "server_error"),
("no_active_key", 403, "access_denied"),
],
@ -1424,6 +1445,7 @@ async def test_native_code_without_a_minter_is_refused_server_side():
("team_required", 400, "invalid_grant"),
("no_active_key", 400, "invalid_grant"),
("unavailable", 503, "temporarily_unavailable"),
("faulted", 503, "temporarily_unavailable"),
("unresolvable", 500, "server_error"),
],
)
@ -1805,5 +1827,12 @@ async def test_introspect_fails_closed_on_dead_user_and_503s_on_outage():
status, body = await _introspect(minted.token.get_secret_value(), reload_user=_reload_user_outage)
assert (status, body["error"]) == (503, "temporarily_unavailable")
async def _reload_user_faulted(user_id: str):
return "faulted"
status, body = await _introspect(minted.token.get_secret_value(), reload_user=_reload_user_faulted)
assert (status, body["error"]) == (503, "temporarily_unavailable")
assert "not a transient outage" in body["error_description"]
status, body = await _introspect(minted.token.get_secret_value(), master_key=None)
assert (status, body["error"]) == (500, "server_error")

View file

@ -9,6 +9,7 @@ from prisma import errors as prisma_errors
from prisma.engine.errors import (
BinaryNotFoundError,
EngineConnectionError,
EngineRequestError,
MismatchedVersionsError,
)
from prisma.errors import (
@ -32,6 +33,12 @@ from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler
class _EngineHttp500:
"""The response half of an EngineRequestError: the query engine answered a request with HTTP 500."""
status = 500
@pytest.mark.asyncio
@pytest.mark.parametrize(
"db_error",
@ -113,6 +120,90 @@ async def test_handle_authentication_error_permanent_fault_gets_no_fallback_iden
assert exc_info.value.code == str(status.HTTP_503_SERVICE_UNAVAILABLE)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"prisma_error",
[
pytest.param(BinaryNotFoundError("query engine binary not found"), id="BinaryNotFoundError"),
pytest.param(MismatchedVersionsError(expected="1", got="2"), id="MismatchedVersionsError"),
pytest.param(EngineRequestError(_EngineHttp500(), "query engine crashed"), id="EngineRequestError"),
pytest.param(PrismaError(), id="bare_PrismaError"),
],
)
async def test_handle_authentication_error_permanent_fault_503_is_not_worded_as_transient(prisma_error):
"""The 503 for a fault that never heals must not say the database is
"temporarily unreachable" and ask the caller to retry. The status is right
(the service is at fault) but that wording sends the operator to wait out an
outage that is not one, so the message has to say retrying will not help and
name the engine fault."""
handler = UserAPIKeyAuthExceptionHandler()
with patch( # test-quality-ok: the handler reads general_settings off the proxy module, no injection seam
"litellm.proxy.proxy_server.general_settings", {"allow_requests_on_db_unavailable": False}
):
with pytest.raises(ProxyException) as exc_info:
await handler._handle_authentication_error(prisma_error, MagicMock(), {}, "/test", None, "test-key")
assert exc_info.value.code == str(status.HTTP_503_SERVICE_UNAVAILABLE)
assert exc_info.value.type == ProxyErrorTypes.no_db_connection
assert "temporarily unreachable" not in exc_info.value.message
assert "retry shortly" not in exc_info.value.message.lower()
assert "will not clear by retrying" in exc_info.value.message
assert type(prisma_error).__name__ in exc_info.value.message
@pytest.mark.asyncio
async def test_handle_authentication_error_transport_error_raised_over_a_permanent_fault_names_the_fault():
"""A reconnect attempt that fails because the engine binary is missing surfaces as a transport
error with the BinaryNotFoundError as __context__. The response must describe the binary, which is
what keeps the database down, rather than promise the connection will come back."""
try:
raise BinaryNotFoundError("query engine binary not found")
except BinaryNotFoundError:
try:
raise httpx.ConnectError("All connection attempts failed")
except httpx.ConnectError as surfaced:
transport_over_fault = surfaced
handler = UserAPIKeyAuthExceptionHandler()
with patch( # test-quality-ok: the handler reads general_settings off the proxy module, no injection seam
"litellm.proxy.proxy_server.general_settings", {"allow_requests_on_db_unavailable": False}
):
with pytest.raises(ProxyException) as exc_info:
await handler._handle_authentication_error(transport_over_fault, MagicMock(), {}, "/test", None, "k")
assert exc_info.value.code == str(status.HTTP_503_SERVICE_UNAVAILABLE)
assert "temporarily unreachable" not in exc_info.value.message
assert "BinaryNotFoundError" in exc_info.value.message
assert "will not clear by retrying" in exc_info.value.message
@pytest.mark.asyncio
@pytest.mark.parametrize(
"db_error",
[
pytest.param(httpx.ConnectError("All connection attempts failed"), id="ConnectError"),
pytest.param(EngineConnectionError(), id="EngineConnectionError"),
pytest.param(PrismaError("can't reach database server"), id="P1001_text"),
],
)
async def test_handle_authentication_error_transient_outage_503_keeps_retry_wording(db_error):
"""A genuine outage is expected to come back, so its 503 keeps telling the
caller the database is temporarily unreachable and to retry."""
handler = UserAPIKeyAuthExceptionHandler()
with patch( # test-quality-ok: the handler reads general_settings off the proxy module, no injection seam
"litellm.proxy.proxy_server.general_settings", {"allow_requests_on_db_unavailable": False}
):
with pytest.raises(ProxyException) as exc_info:
await handler._handle_authentication_error(db_error, MagicMock(), {}, "/test", None, "test-key")
assert exc_info.value.code == str(status.HTTP_503_SERVICE_UNAVAILABLE)
assert exc_info.value.message == (
"Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly."
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"prisma_error",

View file

@ -8,6 +8,7 @@ import httpx
import pytest
from fastapi import HTTPException, Request
from prisma import errors as prisma_errors
from prisma.engine.errors import BinaryNotFoundError, EngineConnectionError
from prisma.errors import (
ClientNotConnectedError,
DataError,
@ -318,6 +319,43 @@ def test_is_database_service_unavailable_error_in_chain_sees_through_wrapping():
assert PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(ValueError("nope")) is False
def test_find_database_service_unavailable_error_in_chain_returns_the_wrapped_outage_itself():
"""Wording a 503 by the kind of outage needs the wrapped database error, not the ValueError
get_user_object wrapped it in, so the finder must hand back the inner exception."""
outage = _wrapped_like_get_user_object(ConnectionError("can't reach database server"))
found = PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(outage)
assert isinstance(found, ConnectionError)
assert found is outage.__context__
missing_user = _wrapped_like_get_user_object(Exception())
assert PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(missing_user) is None
def _raised_while_handling(inner, outer):
try:
raise inner
except BaseException:
try:
raise outer
except BaseException as surfaced:
return surfaced
def test_permanent_fault_outranks_the_transient_error_that_surfaced_it():
"""A reconnect that dies on a missing engine binary raises the transport error last, with the
BinaryNotFoundError left as __context__. The binary is what keeps the database down, so both the
finder and the 503 wording must pick it over the outer transient error, whichever way they nest."""
permanent = BinaryNotFoundError("query engine binary not found")
transient_over_permanent = _raised_while_handling(permanent, httpx.ConnectError("connection refused"))
permanent_over_transient = _raised_while_handling(httpx.ConnectError("connection refused"), permanent)
for chain in (transient_over_permanent, permanent_over_transient):
assert PrismaDBExceptionHandler.find_database_service_unavailable_error_in_chain(chain) is permanent
message = PrismaDBExceptionHandler.database_unavailable_message(chain)
assert "BinaryNotFoundError" in message
assert "will not clear by retrying" in message
assert "temporarily unreachable" not in message
def test_is_database_service_unavailable_error_in_chain_terminates_on_a_cause_cycle():
"""The walk must terminate on a pathological __cause__ cycle rather than hang. Neither link is an
outage, so the bounded walk returns False instead of looping forever."""
@ -509,6 +547,51 @@ def test_permanent_prisma_faults_are_still_reported_as_service_problems(prisma_e
assert PrismaDBExceptionHandler.is_database_service_unavailable_error(prisma_error) is True
RECONNECTABLE_CLIENT_STATE_FAULTS = (prisma_errors.ClientNotConnectedError, prisma_errors.HTTPClientClosedError)
@pytest.mark.parametrize("prisma_error", PERMANENT_PRISMA_FAULTS)
def test_permanent_prisma_faults_are_worded_as_not_retryable(prisma_error):
"""A 503 for a fault that never heals must not tell the operator to wait.
The status stays 503 (the service is at fault), but the message has to say
the outage is not transient and name the engine fault, or an operator
watching a version-skewed engine keeps retrying a request that can never
succeed. The two client-state faults a reconnect can repair keep the retry
wording."""
reconnectable = isinstance(prisma_error, RECONNECTABLE_CLIENT_STATE_FAULTS)
message = PrismaDBExceptionHandler.database_unavailable_message(prisma_error)
assert PrismaDBExceptionHandler.is_permanent_database_fault(prisma_error) is (not reconnectable)
assert message.startswith("Service Unavailable")
assert ("temporarily unreachable" in message) is reconnectable
assert ("Please retry shortly" in message) is reconnectable
assert ("will not clear by retrying" in message) is (not reconnectable)
assert (type(prisma_error).__name__ in message) is (not reconnectable)
@pytest.mark.parametrize(
"transient_error",
[
pytest.param(httpx.ConnectError("All connection attempts failed"), id="ConnectError"),
pytest.param(ConnectionError("connection refused"), id="ConnectionError"),
pytest.param(EngineConnectionError(), id="EngineConnectionError"),
pytest.param(prisma_errors.PrismaError("can't reach database server"), id="P1001_text"),
pytest.param(
ProxyException(message="no db", type=ProxyErrorTypes.no_db_connection, param=None, code=503),
id="ProxyException",
),
],
)
def test_transient_outages_keep_the_retry_wording(transient_error):
"""A genuine outage is expected to come back, so the retry guidance is the
right message and must not be replaced by the permanent-fault text."""
assert PrismaDBExceptionHandler.is_permanent_database_fault(transient_error) is False
assert PrismaDBExceptionHandler.database_unavailable_message(transient_error) == (
"Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly."
)
@pytest.mark.parametrize(
"transient_error",
[