Files
litellm/tests/test_litellm/caching/test_redis_cluster_cache.py
T
michelligabriele 8dc0c97958 fix(caching): check REDIS_CLUSTER_NODES env var in Cache and Router class selection (#22790)
When Redis Cluster is configured via the REDIS_CLUSTER_NODES environment
variable, Cache.__init__() and Router._create_redis_cache() ignored the
env var and always created RedisCache instead of RedisClusterCache. This
caused the v3 rate limiter's cluster detection (_is_redis_cluster()) to
return False, skipping hash-slot key grouping. The resulting CROSSLOT
errors were silently caught, falling back to per-instance in-memory
counting — breaking RPM/TPM enforcement across multiple proxy instances.

Add REDIS_CLUSTER_NODES env var detection to both Cache.__init__() and
Router._create_redis_cache(), matching the existing pattern in
_redis.py:215-220. When the env var is set and no explicit startup_nodes
parameter is provided, parse it and create RedisClusterCache.

Fixes #22748
Related to #20836
2026-03-06 17:31:30 -08:00

182 lines
5.9 KiB
Python

import json
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
from litellm.caching.redis_cache import RedisCache
from litellm.caching.redis_cluster_cache import RedisClusterCache
@patch("litellm._redis.init_redis_cluster")
def test_redis_cluster_batch_get(mock_init_redis_cluster):
"""
Test that RedisClusterCache uses mget_nonatomic instead of mget for batch operations
"""
# Create a mock Redis client
mock_redis = MagicMock()
mock_redis.mget_nonatomic.return_value = [None, None] # Simulate no cache hits
mock_init_redis_cluster.return_value = mock_redis
# Create RedisClusterCache instance with mock client
cache = RedisClusterCache(
startup_nodes=[{"host": "localhost", "port": 6379}],
password="hello",
)
# Test batch_get_cache
keys = ["key1", "key2"]
cache.batch_get_cache(keys)
# Verify mget_nonatomic was called instead of mget
mock_redis.mget_nonatomic.assert_called_once()
assert not mock_redis.mget.called
@pytest.mark.asyncio
@patch("litellm._redis.init_redis_cluster")
async def test_redis_cluster_async_batch_get(mock_init_redis_cluster):
"""
Test that RedisClusterCache uses mget_nonatomic instead of mget for async batch operations
"""
# Create a mock Redis client
mock_redis = MagicMock()
mock_redis.mget_nonatomic.return_value = [None, None] # Simulate no cache hits
# Create RedisClusterCache instance with mock client
cache = RedisClusterCache(
startup_nodes=[{"host": "localhost", "port": 6379}],
password="hello",
)
# Mock the init_async_client to return our mock redis client
cache.init_async_client = MagicMock(return_value=mock_redis)
# Test async_batch_get_cache
keys = ["key1", "key2"]
await cache.async_batch_get_cache(keys)
# Verify mget_nonatomic was called instead of mget
mock_redis.mget_nonatomic.assert_called_once()
assert not mock_redis.mget.called
@patch("litellm._redis.get_redis_connection_pool")
@patch("litellm._redis.get_redis_client")
@patch("litellm.caching.redis_cache.RedisCache._setup_health_pings")
def test_cache_init_creates_cluster_cache_from_env_var(
mock_health, mock_get_client, mock_get_pool, monkeypatch
):
"""
Test that Cache() creates RedisClusterCache when REDIS_CLUSTER_NODES env var is set.
Regression test for https://github.com/BerriAI/litellm/issues/22748
"""
from litellm.caching.caching import Cache
startup_nodes = [{"host": "127.0.0.1", "port": "7001"}]
monkeypatch.setenv("REDIS_CLUSTER_NODES", json.dumps(startup_nodes))
monkeypatch.delenv("REDIS_HOST", raising=False)
monkeypatch.delenv("REDIS_PORT", raising=False)
monkeypatch.delenv("REDIS_PASSWORD", raising=False)
monkeypatch.delenv("REDIS_URL", raising=False)
mock_get_client.return_value = MagicMock()
mock_get_pool.return_value = MagicMock()
cache = Cache(type="redis")
assert isinstance(cache.cache, RedisClusterCache)
@patch("litellm._redis.get_redis_connection_pool")
@patch("litellm._redis.get_redis_client")
@patch("litellm.caching.redis_cache.RedisCache._setup_health_pings")
def test_cache_init_creates_redis_cache_without_cluster_config(
mock_health, mock_get_client, mock_get_pool, monkeypatch
):
"""
Test that Cache() creates RedisCache when no cluster config is present.
Ensures backward compatibility: without REDIS_CLUSTER_NODES or
redis_startup_nodes, the standard RedisCache is still used.
"""
from litellm.caching.caching import Cache
monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
monkeypatch.setenv("REDIS_HOST", "localhost")
monkeypatch.setenv("REDIS_PORT", "6379")
monkeypatch.delenv("REDIS_URL", raising=False)
mock_get_client.return_value = MagicMock()
mock_get_pool.return_value = MagicMock()
cache = Cache(type="redis")
assert isinstance(cache.cache, RedisCache)
assert not isinstance(cache.cache, RedisClusterCache)
@pytest.mark.parametrize(
"startup_nodes, env_var, expected_cache_type",
[
pytest.param(
[dict(host="node1.localhost", port=6379)],
None,
RedisClusterCache,
id="cluster-via-explicit-startup-nodes",
),
pytest.param(
None,
'[{"host": "node1.localhost", "port": 6379}]',
RedisClusterCache,
id="cluster-via-env-var",
),
pytest.param(
None,
None,
RedisCache,
id="standard-redis-when-no-cluster-config",
),
pytest.param(
[dict(host="explicit-node.localhost", port=6379)],
'[{"host": "env-node.localhost", "port": 6379}]',
RedisClusterCache,
id="explicit-startup-nodes-takes-precedence-over-env-var",
),
],
)
def test_router_create_redis_cache_cluster_detection(
startup_nodes, env_var, expected_cache_type, monkeypatch
):
"""
Test that Router._create_redis_cache() creates RedisClusterCache when
either startup_nodes is in config or REDIS_CLUSTER_NODES env var is set.
Also verifies that explicit startup_nodes take precedence over env var.
Regression test for https://github.com/BerriAI/litellm/issues/22748
"""
from litellm import Router
cache_config = dict(
host="mockhost",
port=6379,
password="mock-password",
startup_nodes=startup_nodes,
)
if env_var is not None:
monkeypatch.setenv("REDIS_CLUSTER_NODES", env_var)
else:
monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
def _mock_redis_cache_init(*args, **kwargs): ...
with patch.object(RedisCache, "__init__", _mock_redis_cache_init):
redis_cache = Router._create_redis_cache(cache_config)
assert isinstance(redis_cache, expected_cache_type)