fix(proxy): treat prisma as optional in the DB exception classifiers

Auth failures on a master-key-only proxy (no DATABASE_URL, so no prisma installed) returned 500 instead of 401 because the classifiers imported prisma unconditionally
This commit is contained in:
Devin AI 2026-08-01 17:20:00 +00:00
parent 2b3070890a
commit 2e50af856f
2 changed files with 109 additions and 29 deletions

View file

@ -1,4 +1,6 @@
from typing import Any, Awaitable, Callable, Optional, Union
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Awaitable, Callable, Optional, Tuple, Type, Union
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import (
@ -13,6 +15,49 @@ from litellm.secret_managers.main import str_to_bool
_MAX_EXCEPTION_CHAIN_DEPTH = 20
@dataclass(frozen=True, slots=True)
class _PrismaErrorTypes:
base: Type[Exception]
data_error: Type[Exception]
data_layer: Tuple[Type[Exception], ...]
transport: Tuple[Type[Exception], ...]
@lru_cache(maxsize=1)
def _prisma_error_types() -> Optional[_PrismaErrorTypes]:
"""Return the prisma exception classes these classifiers branch on, or None
when prisma is not installed.
``prisma`` is an optional dependency (the ``extra_proxy`` extra); a
master-key-only proxy started without a ``DATABASE_URL`` never installs it.
The classifiers below run on every auth failure, so importing it
unconditionally turned a plain 401 into a ``ModuleNotFoundError`` 500 on
such deployments. With prisma absent there is no prisma exception to
classify, so every prisma-specific branch is simply skipped.
"""
try:
from prisma import errors
except ImportError:
return None
return _PrismaErrorTypes(
base=errors.PrismaError,
data_error=errors.DataError,
data_layer=(
errors.DataError,
errors.UniqueViolationError,
errors.ForeignKeyViolationError,
errors.MissingRequiredValueError,
errors.RawQueryError,
errors.TableNotFoundError,
errors.RecordNotFoundError,
),
transport=(
errors.ClientNotConnectedError,
errors.HTTPClientClosedError,
),
)
class PrismaDBExceptionHandler:
"""
Class to handle DB Exceptions or Connection Errors
@ -47,24 +92,15 @@ class PrismaDBExceptionHandler:
to True so genuine outages that don't match a specific subclass
still trigger the fallback.
"""
import prisma
prisma_errors = _prisma_error_types()
# Explicit data-layer exclusion: DB IS reachable, fallback must
# NOT fire.
data_layer_errors = (
prisma.errors.DataError,
prisma.errors.UniqueViolationError,
prisma.errors.ForeignKeyViolationError,
prisma.errors.MissingRequiredValueError,
prisma.errors.RawQueryError,
prisma.errors.TableNotFoundError,
prisma.errors.RecordNotFoundError,
)
if isinstance(e, data_layer_errors):
if prisma_errors is not None and isinstance(e, prisma_errors.data_layer):
return False
if isinstance(e, DB_CONNECTION_ERROR_TYPES):
return True
if isinstance(e, prisma.errors.PrismaError):
if prisma_errors is not None and isinstance(e, prisma_errors.base):
return True
if isinstance(e, ProxyException) and e.type == ProxyErrorTypes.no_db_connection:
return True
@ -89,9 +125,10 @@ class PrismaDBExceptionHandler:
per-row data rejection has to additionally consult
``is_database_service_unavailable_error`` before acting on a True here.
"""
import prisma
return type(e) is prisma.errors.DataError
prisma_errors = _prisma_error_types()
if prisma_errors is None:
return False
return type(e) is prisma_errors.data_error
@staticmethod
def is_database_transport_error(e: Exception) -> bool:
@ -102,19 +139,13 @@ class PrismaDBExceptionHandler:
Use this for reconnect logic data-layer errors like UniqueViolationError
mean the DB IS reachable, so reconnecting would be pointless.
"""
import prisma
prisma_errors = _prisma_error_types()
if isinstance(e, DB_CONNECTION_ERROR_TYPES):
return True
if isinstance(
e,
(
prisma.errors.ClientNotConnectedError,
prisma.errors.HTTPClientClosedError,
),
):
if prisma_errors is not None and isinstance(e, prisma_errors.transport):
return True
if isinstance(e, prisma.errors.PrismaError):
if prisma_errors is not None and isinstance(e, prisma_errors.base):
error_message = str(e).lower()
connection_keywords = (
"can't reach database server",
@ -154,9 +185,10 @@ class PrismaDBExceptionHandler:
are already classified by type/keyword above, and data-layer ones
(the DB IS reachable) must stay 401.
"""
import prisma
if isinstance(e, prisma.errors.PrismaError):
prisma_errors = _prisma_error_types()
if prisma_errors is None:
return False
if isinstance(e, prisma_errors.base):
return False
tb = getattr(e, "__traceback__", None)
while tb is not None:

View file

@ -1,9 +1,11 @@
import asyncio
import builtins
import json
import os
import sys
from unittest.mock import MagicMock, patch
import httpx
import pytest
from fastapi import HTTPException, Request, status
from prisma import errors as prisma_errors
@ -27,7 +29,10 @@ sys.path.insert(
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import ProxyErrorTypes, ProxyException
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.db.exception_handler import (
PrismaDBExceptionHandler,
_prisma_error_types,
)
# Test is_database_connection_error method
@ -426,3 +431,46 @@ def test_handle_db_exception_with_non_db_error():
)
with pytest.raises(litellm.BudgetExceededError):
PrismaDBExceptionHandler.handle_db_exception(regular_error)
@pytest.fixture
def prisma_not_installed(monkeypatch):
"""Simulate a master-key-only deployment: `prisma` is an optional extra and
is absent when the proxy runs without a DATABASE_URL."""
real_import = builtins.__import__
def _import(name, *args, **kwargs):
if name == "prisma" or name.startswith("prisma."):
raise ImportError("No module named 'prisma'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _import)
_prisma_error_types.cache_clear()
yield
_prisma_error_types.cache_clear()
@pytest.mark.parametrize(
"classifier",
[
PrismaDBExceptionHandler.is_database_connection_error,
PrismaDBExceptionHandler.is_prisma_data_error,
PrismaDBExceptionHandler.is_database_transport_error,
PrismaDBExceptionHandler.is_prisma_engine_internal_error,
PrismaDBExceptionHandler.is_database_service_unavailable_error,
],
)
def test_classifiers_do_not_require_prisma(prisma_not_installed, classifier):
"""A plain auth failure on a proxy without prisma installed must classify as
"not a DB problem" instead of blowing up with ModuleNotFoundError, which the
auth layer surfaced as a 500 instead of a 401.
"""
assert classifier(Exception("No api key passed in.")) is False
def test_httpx_connect_error_still_classified_without_prisma(prisma_not_installed):
"""The non-prisma connectivity signals must keep working when prisma is absent."""
error = httpx.ConnectError("[Errno 111] Connection refused")
assert PrismaDBExceptionHandler.is_database_connection_error(error) is True
assert PrismaDBExceptionHandler.is_database_transport_error(error) is True
assert PrismaDBExceptionHandler.is_database_service_unavailable_error(error) is True