mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
2b3070890a
commit
2e50af856f
2 changed files with 109 additions and 29 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue