mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 17:04:30 +00:00
358 lines
12 KiB
Python
358 lines
12 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException, Request, status
|
|
from prisma import errors as prisma_errors
|
|
from prisma.errors import (
|
|
ClientNotConnectedError,
|
|
DataError,
|
|
ForeignKeyViolationError,
|
|
HTTPClientClosedError,
|
|
MissingRequiredValueError,
|
|
PrismaError,
|
|
RawQueryError,
|
|
RecordNotFoundError,
|
|
TableNotFoundError,
|
|
UniqueViolationError,
|
|
)
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../../..")
|
|
) # Adds the parent directory to the system path
|
|
|
|
import litellm
|
|
from litellm._logging import verbose_proxy_logger
|
|
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
|
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
|
|
|
|
|
# Test is_database_connection_error method
|
|
@pytest.mark.parametrize(
|
|
"prisma_error",
|
|
[
|
|
HTTPClientClosedError(),
|
|
ClientNotConnectedError(),
|
|
PrismaError("can't reach database server"),
|
|
PrismaError("connection refused"),
|
|
PrismaError("timed out while connecting"),
|
|
],
|
|
)
|
|
def test_is_database_connection_error_prisma_connection_errors(prisma_error):
|
|
"""
|
|
Test that only Prisma connection-related errors are considered DB connection errors.
|
|
"""
|
|
assert PrismaDBExceptionHandler.is_database_connection_error(prisma_error) == True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"prisma_error",
|
|
[
|
|
PrismaError(),
|
|
PrismaError("validation failed on query"),
|
|
DataError(data={"user_facing_error": {"meta": {"table": "test_table"}}}),
|
|
UniqueViolationError(
|
|
data={"user_facing_error": {"meta": {"table": "test_table"}}}
|
|
),
|
|
ForeignKeyViolationError(
|
|
data={"user_facing_error": {"meta": {"table": "test_table"}}}
|
|
),
|
|
MissingRequiredValueError(
|
|
data={"user_facing_error": {"meta": {"table": "test_table"}}}
|
|
),
|
|
RawQueryError(data={"user_facing_error": {"meta": {"table": "test_table"}}}),
|
|
TableNotFoundError(
|
|
data={"user_facing_error": {"meta": {"table": "test_table"}}}
|
|
),
|
|
RecordNotFoundError(
|
|
data={"user_facing_error": {"meta": {"table": "test_table"}}}
|
|
),
|
|
],
|
|
)
|
|
def test_is_database_transport_error_non_connection_prisma_errors(prisma_error):
|
|
"""Data-layer errors should not trigger reconnect — DB is reachable when these occur."""
|
|
assert PrismaDBExceptionHandler.is_database_transport_error(prisma_error) == False
|
|
|
|
|
|
def test_is_database_connection_generic_errors():
|
|
"""
|
|
Test non-Prisma error cases for database connection checking
|
|
"""
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_connection_error(
|
|
Exception("Regular error")
|
|
)
|
|
== False
|
|
)
|
|
|
|
# Test with ProxyException (DB connection)
|
|
db_proxy_exception = ProxyException(
|
|
message="DB Connection Error",
|
|
type=ProxyErrorTypes.no_db_connection,
|
|
param="test-param",
|
|
)
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_connection_error(db_proxy_exception)
|
|
== True
|
|
)
|
|
|
|
# Test with non-DB error
|
|
regular_exception = Exception("Regular error")
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_connection_error(regular_exception)
|
|
== False
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
ConnectionError("connection refused"),
|
|
TimeoutError("timed out"),
|
|
OSError("network is unreachable"),
|
|
asyncio.TimeoutError(),
|
|
HTTPClientClosedError(),
|
|
ClientNotConnectedError(),
|
|
PrismaError("can't reach database server"),
|
|
PrismaError(),
|
|
],
|
|
)
|
|
def test_is_database_service_unavailable_error_infra_failures(error):
|
|
"""Infrastructure-level failures (socket/connection/timeout, prisma
|
|
transport, unknown PrismaError) mean the DB could not answer, so auth
|
|
must surface 503 instead of treating a valid key as invalid."""
|
|
assert PrismaDBExceptionHandler.is_database_service_unavailable_error(error) is True
|
|
|
|
|
|
def test_is_database_service_unavailable_error_prisma_p1001_masquerades_as_dataerror():
|
|
"""Real-world regression: prisma-client-py raises the P1001 "can't reach
|
|
database server" connectivity failure as a DataError (a data-layer type).
|
|
A type-only check would miss it and return 401 during a genuine outage;
|
|
the message keyword must still classify it as service-unavailable -> 503."""
|
|
p1001_as_dataerror = DataError(
|
|
data={
|
|
"user_facing_error": {
|
|
"message": "Can't reach database server at `127.0.0.1`:`5499`",
|
|
"meta": {"table": "t"},
|
|
}
|
|
}
|
|
)
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_service_unavailable_error(
|
|
p1001_as_dataerror
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_is_database_service_unavailable_error_cached_plan_escapes_as_503():
|
|
"""Composes with the cached-plan retry: when that recovery fails and the
|
|
Postgres "cached plan must not change result type" error escapes (raised by
|
|
prisma as a data-layer RawQueryError), it is a transient stale-DB-state
|
|
condition, not an invalid key, so it must classify as service-unavailable
|
|
-> 503 rather than fall through to 401."""
|
|
cached_plan_error = RawQueryError(
|
|
data={
|
|
"user_facing_error": {
|
|
"message": "cached plan must not change result type",
|
|
"meta": {"table": "t"},
|
|
}
|
|
}
|
|
)
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_service_unavailable_error(
|
|
cached_plan_error
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_is_database_service_unavailable_error_prisma_engine_malformed_payload():
|
|
"""Real-world regression: at the instant the DB socket drops, the prisma
|
|
query engine returns a malformed error payload (``user_facing_error.meta``
|
|
is ``null``). prisma-client-py's ``handle_response_errors`` then crashes
|
|
with ``AttributeError: 'NoneType' object has no attribute 'get'`` before it
|
|
can raise the proper P1001 error. That bare AttributeError has no
|
|
connection keyword, so without the prisma-engine-origin check it falls
|
|
through to 401 on the first request of an outage. Reproduce the exact
|
|
prisma crash and assert it classifies as service-unavailable -> 503."""
|
|
from prisma.engine import utils as prisma_engine_utils
|
|
|
|
malformed_payload = [
|
|
{
|
|
"error": "Can't reach database server",
|
|
"user_facing_error": {
|
|
"error_code": "P1001",
|
|
"message": "Can't reach database server at `localhost`:`5503`",
|
|
"meta": None,
|
|
},
|
|
}
|
|
]
|
|
with pytest.raises(AttributeError) as exc_info:
|
|
prisma_engine_utils.handle_response_errors(None, malformed_payload)
|
|
|
|
assert "no attribute 'get'" in str(exc_info.value)
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_service_unavailable_error(exc_info.value)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_is_prisma_engine_internal_error_excludes_application_attributeerror():
|
|
"""The prisma-engine-origin check must stay narrow: a genuine AttributeError
|
|
raised by application code (a real bug) must NOT be classified as
|
|
service-unavailable, otherwise real bugs would silently become 503s."""
|
|
|
|
def application_bug():
|
|
none_value = None
|
|
return none_value.get("oops")
|
|
|
|
with pytest.raises(AttributeError) as exc_info:
|
|
application_bug()
|
|
|
|
assert (
|
|
PrismaDBExceptionHandler.is_prisma_engine_internal_error(exc_info.value)
|
|
is False
|
|
)
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_service_unavailable_error(exc_info.value)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_is_prisma_engine_internal_error_excludes_data_layer_prisma_error():
|
|
"""A data-layer ``PrismaError`` (the DB IS reachable and rejected the data)
|
|
must stay 401. These are always raised from prisma internals, so the check
|
|
excludes any ``PrismaError`` by type before inspecting the traceback."""
|
|
data_layer_error = UniqueViolationError(
|
|
data={"user_facing_error": {"meta": {"table": "t"}}}
|
|
)
|
|
try:
|
|
raise data_layer_error
|
|
except UniqueViolationError as e:
|
|
assert PrismaDBExceptionHandler.is_prisma_engine_internal_error(e) is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
DataError(data={"user_facing_error": {"meta": {"table": "t"}}}),
|
|
UniqueViolationError(data={"user_facing_error": {"meta": {"table": "t"}}}),
|
|
RecordNotFoundError(data={"user_facing_error": {"meta": {"table": "t"}}}),
|
|
Exception("some unrelated error"),
|
|
ValueError("bad value"),
|
|
],
|
|
)
|
|
def test_is_database_service_unavailable_error_excludes_non_infra(error):
|
|
"""Data-layer errors (the DB IS reachable and answered) and generic
|
|
non-DB errors must NOT be classified as service-unavailable, otherwise a
|
|
genuine 401 would be masked as a transient 503."""
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_service_unavailable_error(error) is False
|
|
)
|
|
|
|
|
|
def test_is_database_service_unavailable_error_asyncpg(monkeypatch):
|
|
"""asyncpg connection/interface errors map to service-unavailable. asyncpg
|
|
is not a hard dependency, so inject a stand-in module to exercise the
|
|
branch deterministically regardless of the install environment."""
|
|
import sys
|
|
import types
|
|
|
|
fake_asyncpg = types.ModuleType("asyncpg")
|
|
fake_exceptions = types.ModuleType("asyncpg.exceptions")
|
|
|
|
class PostgresConnectionError(Exception):
|
|
pass
|
|
|
|
class InterfaceError(Exception):
|
|
pass
|
|
|
|
class UniqueViolationError(Exception): # data-layer, must stay False
|
|
pass
|
|
|
|
fake_exceptions.PostgresConnectionError = PostgresConnectionError
|
|
fake_exceptions.InterfaceError = InterfaceError
|
|
fake_exceptions.UniqueViolationError = UniqueViolationError
|
|
fake_asyncpg.exceptions = fake_exceptions
|
|
|
|
monkeypatch.setitem(sys.modules, "asyncpg", fake_asyncpg)
|
|
monkeypatch.setitem(sys.modules, "asyncpg.exceptions", fake_exceptions)
|
|
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_service_unavailable_error(
|
|
PostgresConnectionError("connection reset")
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_service_unavailable_error(
|
|
InterfaceError("connection was closed")
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
PrismaDBExceptionHandler.is_database_service_unavailable_error(
|
|
UniqueViolationError("duplicate key")
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
# Test should_allow_request_on_db_unavailable method
|
|
@patch(
|
|
"litellm.proxy.proxy_server.general_settings",
|
|
{"allow_requests_on_db_unavailable": True},
|
|
)
|
|
def test_should_allow_request_on_db_unavailable_true():
|
|
assert PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() == True
|
|
|
|
|
|
@patch(
|
|
"litellm.proxy.proxy_server.general_settings",
|
|
{"allow_requests_on_db_unavailable": False},
|
|
)
|
|
def test_should_allow_request_on_db_unavailable_false():
|
|
assert PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() == False
|
|
|
|
|
|
@patch(
|
|
"litellm.proxy.proxy_server.general_settings",
|
|
{"allow_requests_on_db_unavailable": True},
|
|
)
|
|
def test_handle_db_exception_with_connection_error():
|
|
"""
|
|
Test that DB connection errors are handled gracefully when allow_requests_on_db_unavailable is True
|
|
"""
|
|
db_error = ClientNotConnectedError()
|
|
result = PrismaDBExceptionHandler.handle_db_exception(db_error)
|
|
assert result is None
|
|
|
|
|
|
@patch(
|
|
"litellm.proxy.proxy_server.general_settings",
|
|
{"allow_requests_on_db_unavailable": False},
|
|
)
|
|
def test_handle_db_exception_raises_error():
|
|
"""
|
|
Test that DB connection errors are raised when allow_requests_on_db_unavailable is False
|
|
"""
|
|
db_error = ClientNotConnectedError()
|
|
with pytest.raises(ClientNotConnectedError):
|
|
PrismaDBExceptionHandler.handle_db_exception(db_error)
|
|
|
|
|
|
def test_handle_db_exception_with_non_db_error():
|
|
"""
|
|
Test that non-DB errors are always raised regardless of allow_requests_on_db_unavailable setting
|
|
"""
|
|
regular_error = litellm.BudgetExceededError(
|
|
current_cost=10,
|
|
max_budget=10,
|
|
)
|
|
with pytest.raises(litellm.BudgetExceededError):
|
|
PrismaDBExceptionHandler.handle_db_exception(regular_error)
|