From 6921e73e037913ddf61ec0245a8054d76aef3cd0 Mon Sep 17 00:00:00 2001 From: SIDDARTHA REDDY <75976672+SIDDARTHAREDDY8@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:09:49 -0400 Subject: [PATCH 1/4] fix(proxy): reject non-master keys when no database is configured Signed-off-by: SIDDARTHA REDDY <75976672+SIDDARTHAREDDY8@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 12 ++++ litellm/proxy/db/exception_handler.py | 26 +++++++ .../unit/proxy/auth/test_user_api_key_auth.py | 71 ++++++++++++++++++- tests/unit/proxy/db/test_exception_handler.py | 13 ++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index a1abed202a2..c44723a4bb9 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2737,6 +2737,18 @@ async def user_api_key_auth_builder( if ( prisma_client is None ): # if both master key + user key submitted, and user key != master key, and no db connected, raise an error + if not PrismaDBExceptionHandler.is_database_configured(): + # No database was ever configured, so there is no outage to + # ride out. ``allow_requests_on_db_unavailable`` must not fail + # open here: a non-master key cannot be verified and is + # rejected, instead of being admitted via the + # ``no_db_connection`` fallback token. See #47119. + raise ProxyException( + message="Authentication Error - No database configured. Virtual keys require a configured database; only the master key can be used without one.", + type=ProxyErrorTypes.auth_error, + code=status.HTTP_401_UNAUTHORIZED, + param=None, + ) raise ProxyException( message="No connected db.", type=ProxyErrorTypes.no_db_connection, diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index 379e0012ea8..0458af22b7e 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -1,3 +1,4 @@ +import os import re from collections.abc import Awaitable, Callable, Iterator from http import HTTPStatus @@ -90,6 +91,31 @@ class PrismaDBExceptionHandler: return True return False + @staticmethod + def is_database_configured() -> bool: + """True when a database was configured for this proxy. + + A database is configured when ``DATABASE_URL`` (or ``DIRECT_URL``) + is present in the environment, or a ``database_url`` is present in + ``general_settings``. The CLI / entrypoints assemble ``DATABASE_URL`` + into ``os.environ`` from discrete ``DATABASE_*`` settings before the + proxy starts, so the environment is the canonical signal by request + time. When neither is present there is no database to be + ``unavailable``: that state never resolves by itself and must not be + treated as a transient outage by + ``allow_requests_on_db_unavailable``. + """ + if os.getenv("DATABASE_URL") or os.getenv("DIRECT_URL"): + return True + try: + from litellm.proxy.proxy_server import general_settings + + if isinstance(general_settings, dict): + return bool(general_settings.get("database_url")) + except Exception: + return False + return False + @staticmethod def is_database_connection_error(e: Exception) -> bool: """True only for a database that is temporarily unreachable and is diff --git a/tests/unit/proxy/auth/test_user_api_key_auth.py b/tests/unit/proxy/auth/test_user_api_key_auth.py index 3660019cc2c..45da236b52d 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth.py @@ -476,9 +476,10 @@ def test_is_api_route_allowed(route, user_role, expected_result): @pytest.mark.asyncio -async def test_auth_not_connected_to_db(): +async def test_auth_not_connected_to_db(monkeypatch: pytest.MonkeyPatch): """ - ensure requests don't fail when `prisma_client` = None + ensure requests don't fail when `prisma_client` = None but a database + IS configured (configured-but-unreachable / transient outage) """ from fastapi import Request from starlette.datastructures import URL @@ -486,6 +487,7 @@ async def test_auth_not_connected_to_db(): from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import hash_token, user_api_key_cache + monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@localhost:5432/litellm") user_key = "sk-98765678" setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache) @@ -506,6 +508,71 @@ async def test_auth_not_connected_to_db(): assert valid_token.token == "failed-to-connect-to-db" +@pytest.mark.asyncio +async def test_auth_no_db_configured_rejects_non_master_key(monkeypatch: pytest.MonkeyPatch): + """Regression for #47119: with no database configured, + `allow_requests_on_db_unavailable` must not accept any API key. + + There is no outage to ride out, so a non-master key is rejected (401) + instead of receiving the DB-unavailable fallback token. + """ + from fastapi import Request + from starlette.datastructures import URL + + from litellm.proxy._types import ProxyException + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + from litellm.proxy.proxy_server import user_api_key_cache + + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.delenv("DIRECT_URL", raising=False) + + setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache) + setattr(litellm.proxy.proxy_server, "master_key", MASTER_KEY) + setattr(litellm.proxy.proxy_server, "prisma_client", None) + setattr( + litellm.proxy.proxy_server, + "general_settings", + {"allow_requests_on_db_unavailable": True}, + ) + + request = Request(scope={"type": "http", "method": "POST", "path": "/chat/completions", "headers": []}) + request._url = URL(url="/chat/completions") + + with pytest.raises(ProxyException) as exc_info: + await user_api_key_auth(request=request, api_key="Bearer sk-anything-1234") + + assert str(exc_info.value.code) == "401" + + +@pytest.mark.asyncio +async def test_auth_no_db_configured_master_key_still_works(monkeypatch: pytest.MonkeyPatch): + """With no database configured, the master key still authenticates.""" + from fastapi import Request + from starlette.datastructures import URL + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + from litellm.proxy.proxy_server import user_api_key_cache + + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.delenv("DIRECT_URL", raising=False) + + setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache) + setattr(litellm.proxy.proxy_server, "master_key", MASTER_KEY) + setattr(litellm.proxy.proxy_server, "prisma_client", None) + setattr( + litellm.proxy.proxy_server, + "general_settings", + {"allow_requests_on_db_unavailable": True}, + ) + + request = Request(scope={"type": "http", "method": "POST", "path": "/chat/completions", "headers": []}) + request._url = URL(url="/chat/completions") + + valid_token = await user_api_key_auth(request=request, api_key="Bearer " + MASTER_KEY) + assert valid_token is not None + assert valid_token.key_name != "failed-to-connect-to-db" + + def _assert_api_key_from_custom_header(headers, custom_header_name, expected_api_key): verbose_proxy_logger.setLevel(logging.DEBUG) request = MagicMock(spec=Request) diff --git a/tests/unit/proxy/db/test_exception_handler.py b/tests/unit/proxy/db/test_exception_handler.py index 75346ca08a6..f21f2e82748 100644 --- a/tests/unit/proxy/db/test_exception_handler.py +++ b/tests/unit/proxy/db/test_exception_handler.py @@ -951,3 +951,16 @@ def test_is_lock_timeout_error_recognises_a_statement_cancelled_under_lock_timeo ) def test_is_lock_timeout_error_excludes_other_failures(error: Exception) -> None: assert PrismaDBExceptionHandler.is_lock_timeout_error(error) is False + + +def test_is_database_configured(monkeypatch): + """No DATABASE_URL/DIRECT_URL and no general_settings database_url + means no database was configured (#47119); setting DATABASE_URL means + a database is configured even if it is currently unreachable.""" + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.delenv("DIRECT_URL", raising=False) + with patch("litellm.proxy.proxy_server.general_settings", {}): + assert PrismaDBExceptionHandler.is_database_configured() is False + monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@localhost:5432/litellm") + with patch("litellm.proxy.proxy_server.general_settings", {}): + assert PrismaDBExceptionHandler.is_database_configured() is True From fef9c7e5bfb56f53beac30ee5990516a95015344 Mon Sep 17 00:00:00 2001 From: SIDDARTHA REDDY <75976672+SIDDARTHAREDDY8@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:04:32 -0400 Subject: [PATCH 2/4] fix(proxy): gate no-db rejection on db-unavailable flag and drop blind except Signed-off-by: SIDDARTHA REDDY <75976672+SIDDARTHAREDDY8@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 9 +++++++-- litellm/proxy/db/exception_handler.py | 16 ++++++++-------- tests/unit/proxy/db/test_exception_handler.py | 6 ++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index c44723a4bb9..caa886c0a7c 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2737,12 +2737,17 @@ async def user_api_key_auth_builder( if ( prisma_client is None ): # if both master key + user key submitted, and user key != master key, and no db connected, raise an error - if not PrismaDBExceptionHandler.is_database_configured(): + if ( + not PrismaDBExceptionHandler.is_database_configured() + and PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() + ): # No database was ever configured, so there is no outage to # ride out. ``allow_requests_on_db_unavailable`` must not fail # open here: a non-master key cannot be verified and is # rejected, instead of being admitted via the - # ``no_db_connection`` fallback token. See #47119. + # ``no_db_connection`` fallback token. See #47119. When the + # flag is off no fallback token is issued anyway, so the + # established ``no_db_connection`` error below stands. raise ProxyException( message="Authentication Error - No database configured. Virtual keys require a configured database; only the master key can be used without one.", type=ProxyErrorTypes.auth_error, diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index 0458af22b7e..3f5d4558716 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -1,5 +1,6 @@ import os import re +import sys from collections.abc import Awaitable, Callable, Iterator from http import HTTPStatus from typing import Final, Protocol, TypeVar @@ -107,14 +108,13 @@ class PrismaDBExceptionHandler: """ if os.getenv("DATABASE_URL") or os.getenv("DIRECT_URL"): return True - try: - from litellm.proxy.proxy_server import general_settings - - if isinstance(general_settings, dict): - return bool(general_settings.get("database_url")) - except Exception: - return False - return False + # Read ``general_settings`` through ``sys.modules``: importing + # ``proxy_server`` here could fail or recurse while it is still + # being imported, and a proxy server that has not been imported + # yet has not configured a database through it either. + proxy_server_module = sys.modules.get("litellm.proxy.proxy_server") + general_settings = getattr(proxy_server_module, "general_settings", None) + return isinstance(general_settings, dict) and bool(general_settings.get("database_url")) @staticmethod def is_database_connection_error(e: Exception) -> bool: diff --git a/tests/unit/proxy/db/test_exception_handler.py b/tests/unit/proxy/db/test_exception_handler.py index f21f2e82748..a0391781cd5 100644 --- a/tests/unit/proxy/db/test_exception_handler.py +++ b/tests/unit/proxy/db/test_exception_handler.py @@ -964,3 +964,9 @@ def test_is_database_configured(monkeypatch): monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@localhost:5432/litellm") with patch("litellm.proxy.proxy_server.general_settings", {}): assert PrismaDBExceptionHandler.is_database_configured() is True + monkeypatch.delenv("DATABASE_URL", raising=False) + with patch( + "litellm.proxy.proxy_server.general_settings", + {"database_url": "postgresql://localhost:5432/litellm"}, + ): + assert PrismaDBExceptionHandler.is_database_configured() is True From 4016316c20dfd5193584ce31bd4f2cfed5b3232c Mon Sep 17 00:00:00 2001 From: SIDDARTHA REDDY <75976672+SIDDARTHAREDDY8@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:28:13 -0400 Subject: [PATCH 3/4] fix(proxy): declare is_database_configured locals Final for the type-discipline gate Signed-off-by: SIDDARTHA REDDY <75976672+SIDDARTHAREDDY8@users.noreply.github.com> --- litellm/proxy/db/exception_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index 3f5d4558716..0a2bf2aa279 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -112,8 +112,8 @@ class PrismaDBExceptionHandler: # ``proxy_server`` here could fail or recurse while it is still # being imported, and a proxy server that has not been imported # yet has not configured a database through it either. - proxy_server_module = sys.modules.get("litellm.proxy.proxy_server") - general_settings = getattr(proxy_server_module, "general_settings", None) + proxy_server_module: Final = sys.modules.get("litellm.proxy.proxy_server") + general_settings: Final = getattr(proxy_server_module, "general_settings", None) return isinstance(general_settings, dict) and bool(general_settings.get("database_url")) @staticmethod From a88e23eb5d14abd29f1466dbd57aae2932a47456 Mon Sep 17 00:00:00 2001 From: SIDDARTHA REDDY <75976672+SIDDARTHAREDDY8@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:04:30 -0400 Subject: [PATCH 4/4] fix(proxy): validate general_settings into a typed dict for the basedpyright gate Signed-off-by: SIDDARTHA REDDY <75976672+SIDDARTHAREDDY8@users.noreply.github.com> --- litellm/proxy/db/exception_handler.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index 0a2bf2aa279..378afea0d30 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -25,6 +25,7 @@ _TRANSIENT_DB_UNAVAILABLE_MESSAGE: Final = ( ) _DATABASE_ERROR_META: Final = TypeAdapter(dict[str, object]) +_GENERAL_SETTINGS_ADAPTER: Final = TypeAdapter(dict[str, object]) _BATCH_POSTGRES_ERROR_CODE: Final = re.compile(r'PostgresError \{ code: "([0-9A-Z]{5})"') _CONNECTION_CAPACITY_PHRASES: Final = ( "too many clients already", @@ -111,10 +112,19 @@ class PrismaDBExceptionHandler: # Read ``general_settings`` through ``sys.modules``: importing # ``proxy_server`` here could fail or recurse while it is still # being imported, and a proxy server that has not been imported - # yet has not configured a database through it either. + # yet has not configured a database through it either. The raw + # value is ``object`` until validated into a typed dict, because + # ``general_settings`` is declared as a bare ``dict`` and reading + # it directly surfaces as partially unknown to basedpyright. proxy_server_module: Final = sys.modules.get("litellm.proxy.proxy_server") - general_settings: Final = getattr(proxy_server_module, "general_settings", None) - return isinstance(general_settings, dict) and bool(general_settings.get("database_url")) + raw_settings: Final[object] = getattr(proxy_server_module, "general_settings", None) + if not isinstance(raw_settings, dict): + return False + try: + settings: Final = _GENERAL_SETTINGS_ADAPTER.validate_python(raw_settings) + except ValidationError: + return False + return bool(settings.get("database_url")) @staticmethod def is_database_connection_error(e: Exception) -> bool: