From 2e50af856f99907241a02f90fd70dbdbdb9366da Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:20:00 +0000 Subject: [PATCH] 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 --- litellm/proxy/db/exception_handler.py | 88 +++++++++++++------ .../proxy/db/test_exception_handler.py | 50 ++++++++++- 2 files changed, 109 insertions(+), 29 deletions(-) diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index e4c565e4464..eade02534d9 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -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: diff --git a/tests/test_litellm/proxy/db/test_exception_handler.py b/tests/test_litellm/proxy/db/test_exception_handler.py index 23099177812..f994c2e6d8d 100644 --- a/tests/test_litellm/proxy/db/test_exception_handler.py +++ b/tests/test_litellm/proxy/db/test_exception_handler.py @@ -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