diff --git a/litellm/proxy/hooks/model_max_budget_limiter.py b/litellm/proxy/hooks/model_max_budget_limiter.py index a69240a457c..e360b97c3bd 100644 --- a/litellm/proxy/hooks/model_max_budget_limiter.py +++ b/litellm/proxy/hooks/model_max_budget_limiter.py @@ -152,13 +152,16 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting): A pod-local in-memory value can lag the shared total, so `async_get_cache` (which returns an in-memory hit before consulting Redis) must not be used - here. Local memory is only read as a fallback when Redis is not configured. - `redis_cache.async_get_cache` already handles its own connection errors and - returns None, so no additional error handling is needed. + here. Redis is read first; the pod-local value is only used as a fallback + when Redis is not configured or returns nothing (for example during a Redis + outage, since `redis_cache.async_get_cache` swallows connection errors and + returns None), which preserves per-pod enforcement instead of failing open. """ redis_cache = self.dual_cache.redis_cache if redis_cache is not None: - return await redis_cache.async_get_cache(key=cache_key) + redis_spend = await redis_cache.async_get_cache(key=cache_key) + if redis_spend is not None: + return redis_spend return await self.dual_cache.async_get_cache(key=cache_key, local_only=True) async def _get_end_user_spend_for_model( diff --git a/tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py b/tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py index 091562dbb69..82b568d160a 100644 --- a/tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py +++ b/tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py @@ -676,3 +676,37 @@ async def test_admission_falls_back_to_local_spend_when_redis_unavailable(): with pytest.raises(litellm.BudgetExceededError): await limiter.is_key_within_model_budget(user_api_key, model) + + +@pytest.mark.asyncio +async def test_admission_falls_back_to_local_spend_when_redis_returns_nothing(): + """ + Redis is configured but unreachable (its get swallows the error and returns + None). The limiter must fall back to the pod-local spend and keep enforcing + per-pod, rather than failing open and admitting every request. + """ + budget = 100.0 + local_spend = 150.0 + + model = "gpt-4" + budget_duration = "1d" + token = "test-key" + cache_key = f"{VIRTUAL_KEY_SPEND_CACHE_KEY_PREFIX}:{token}:{model}:{budget_duration}" + + unreachable_redis = _SharedRedisCache(store={}) + + dual_cache = DualCache() + dual_cache.redis_cache = unreachable_redis + await dual_cache.async_set_cache(key=cache_key, value=local_spend, local_only=True) + + limiter = _PROXY_VirtualKeyModelMaxBudgetLimiter(dual_cache=dual_cache) + user_api_key = UserAPIKeyAuth( + token=token, + key_alias="test-alias", + model_max_budget={model: {"budget_limit": budget, "time_period": budget_duration}}, + ) + + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await limiter.is_key_within_model_budget(user_api_key, model) + assert exc_info.value.current_cost == local_spend + assert unreachable_redis.get_count >= 1