From b521aee43e549b1f5503e01b79dc98f307db4cc3 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:17:28 +0000 Subject: [PATCH] fix(proxy): read blocked_user_list per call and make cache eviction best-effort --- .../enterprise_hooks/blocked_user_list.py | 20 +++--- .../customer_endpoints.py | 7 ++- .../test_blocked_user_list.py | 63 +++++++++++++++++++ .../test_customer_endpoints.py | 19 ++++++ 4 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 tests/test_litellm/enterprise/enterprise_hooks/test_blocked_user_list.py diff --git a/enterprise/enterprise_hooks/blocked_user_list.py b/enterprise/enterprise_hooks/blocked_user_list.py index d34605b30ac..a435555121b 100644 --- a/enterprise/enterprise_hooks/blocked_user_list.py +++ b/enterprise/enterprise_hooks/blocked_user_list.py @@ -7,7 +7,7 @@ ## This accepts a list of user id's for whom calls will be rejected -from typing import Optional, Literal +from typing import List, Literal, Optional import litellm from litellm.proxy.utils import PrismaClient from litellm.caching.caching import DualCache @@ -23,18 +23,13 @@ class _ENTERPRISE_BlockedUserList(CustomLogger): self.prisma_client = prisma_client blocked_user_list = litellm.blocked_user_list - if blocked_user_list is None: - self.blocked_user_list = None - return - - if isinstance(blocked_user_list, list): - self.blocked_user_list = blocked_user_list + self.file_blocked_user_list: Optional[List[str]] = None if isinstance(blocked_user_list, str): # assume it's a filepath try: with open(blocked_user_list, "r") as file: data = file.read() - self.blocked_user_list = data.split("\n") + self.file_blocked_user_list = data.split("\n") except FileNotFoundError: raise Exception( f"File not found. blocked_user_list={blocked_user_list}" @@ -44,6 +39,15 @@ class _ENTERPRISE_BlockedUserList(CustomLogger): f"An error occurred: {str(e)}, blocked_user_list={blocked_user_list}" ) + @property + def blocked_user_list(self) -> Optional[List[str]]: + """Read the configured list on every call, so /customer/unblock takes effect immediately""" + if self.file_blocked_user_list is not None: + return self.file_blocked_user_list + if isinstance(litellm.blocked_user_list, list): + return litellm.blocked_user_list + return None + def print_verbose(self, print_statement, level: Literal["INFO", "DEBUG"] = "DEBUG"): if level == "INFO": verbose_proxy_logger.info(print_statement) diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index 43c0442920f..4722547c216 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -54,8 +54,11 @@ async def _invalidate_end_user_cache(user_ids: Sequence[str]) -> None: from litellm.proxy.proxy_server import user_api_key_cache for user_id in user_ids: - await user_api_key_cache.async_delete_cache(key=f"end_user_id:{user_id}") - await user_api_key_cache.async_delete_cache(key=f"litellm:end_user_id:{user_id}") + try: + await user_api_key_cache.async_delete_cache(key=f"end_user_id:{user_id}") + await user_api_key_cache.async_delete_cache(key=f"litellm:end_user_id:{user_id}") + except Exception as e: # noqa: BLE001 # the row is already written; a cached view expires on its own + verbose_proxy_logger.error(f"Failed to evict cached end-user {user_id} - {e!s}") def _to_customer_response(record: BaseModel) -> CustomerResponse: diff --git a/tests/test_litellm/enterprise/enterprise_hooks/test_blocked_user_list.py b/tests/test_litellm/enterprise/enterprise_hooks/test_blocked_user_list.py new file mode 100644 index 00000000000..4b26505e65c --- /dev/null +++ b/tests/test_litellm/enterprise/enterprise_hooks/test_blocked_user_list.py @@ -0,0 +1,63 @@ +import os +import sys + +import pytest +from fastapi import HTTPException + +sys.path.insert(0, os.path.abspath("../../../..")) + +import litellm +from litellm.caching.caching import DualCache +from litellm.proxy._types import UserAPIKeyAuth + + +@pytest.fixture +def hook(): + from enterprise.enterprise_hooks.blocked_user_list import ( + _ENTERPRISE_BlockedUserList, + ) + + return _ENTERPRISE_BlockedUserList(prisma_client=None) + + +async def _call(hook, user_id: str): + return await hook.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + cache=DualCache(), + data={"user": user_id}, + call_type="completion", + ) + + +@pytest.mark.asyncio +async def test_hook_reads_blocked_user_list_on_every_call(monkeypatch, hook): + """Regression: the hook used to keep the list object it read at init, so /customer/unblock + rebinding litellm.blocked_user_list left the hook rejecting an already unblocked customer. + """ + monkeypatch.setattr(litellm, "blocked_user_list", ["blocked-1"]) + + with pytest.raises(HTTPException) as exc_info: + await _call(hook, "blocked-1") + assert "blocked-1" in str(exc_info.value.detail) + + monkeypatch.setattr(litellm, "blocked_user_list", []) + + assert await _call(hook, "blocked-1") is None + + +@pytest.mark.asyncio +async def test_hook_keeps_filepath_backed_list(monkeypatch, tmp_path): + from enterprise.enterprise_hooks.blocked_user_list import ( + _ENTERPRISE_BlockedUserList, + ) + + blocked_users_file = tmp_path / "blocked_users.txt" + blocked_users_file.write_text("blocked-1") + monkeypatch.setattr(litellm, "blocked_user_list", str(blocked_users_file)) + + file_backed_hook = _ENTERPRISE_BlockedUserList(prisma_client=None) + + monkeypatch.setattr(litellm, "blocked_user_list", []) + + with pytest.raises(HTTPException): + await _call(file_backed_hook, "blocked-1") diff --git a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py index ad3a8d19a6c..4caa85efe3b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -870,6 +870,25 @@ def test_unblock_customer_without_enterprise_callback_still_persists( mock_prisma_client.db.litellm_endusertable.update_many.assert_awaited_once() +def test_unblock_customer_survives_cache_eviction_failure( + mock_prisma_client, mock_user_api_key_auth, mock_end_user_cache, monkeypatch +): + """An unreachable cache backend must not turn an already committed unblock into a 500.""" + monkeypatch.setattr(litellm, "blocked_user_list", ["blocked-1"]) + mock_prisma_client.db.litellm_endusertable.update_many = AsyncMock(return_value=1) + mock_end_user_cache.async_delete_cache = AsyncMock(side_effect=Exception("redis down")) + + response = client.post( + "/customer/unblock", + json={"user_ids": ["blocked-1"]}, + headers={"Authorization": "Bearer test-key"}, + ) + + assert response.status_code == 200 + assert response.json() == {"blocked_users": []} + mock_prisma_client.db.litellm_endusertable.update_many.assert_awaited_once() + + def test_unblock_customer_rejects_filepath_blocked_user_list( mock_prisma_client, mock_user_api_key_auth, mock_end_user_cache, monkeypatch ):