mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Some checks are pending
GitHub Actions Security Analysis / zizmor (push) Waiting to run
* fix(redis): loop-scope async Lua script registration async_register_script registered the Lua script eagerly and returned a callable bound to the Redis client of the event loop running at registration time. The v3 parallel request limiter registers its three scripts once in __init__ at proxy startup and stores them, so a request or logging callback on another loop awaited a script bound to the startup loop and hit "got Future attached to a different loop". The limiter then fell back to a pipeline that reset the window TTL every increment, so counters never expired and an 80M TPM model rate-limited around 40M. Defer registration to call time and cache the per-loop executor in in_memory_llm_clients_cache (which already keys on the running loop), so each loop runs the script against its own client. Covers all five consumers of the primitive. Resolves LIT-3298 * fix(redis): await evalsha on the cluster Lua script path The cluster branch returned the evalsha coroutine without awaiting it, so callers received a coroutine instead of the script result. Await it, which also addresses the cluster path called out in review.
855 lines
32 KiB
Python
855 lines
32 KiB
Python
import asyncio
|
|
import os
|
|
import sys
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../../..")
|
|
) # Adds the parent directory to the system path
|
|
from unittest.mock import AsyncMock
|
|
|
|
from litellm.caching.redis_cache import RedisCache
|
|
|
|
|
|
@pytest.fixture
|
|
def redis_no_ping():
|
|
"""Patch RedisCache initialization to prevent async ping tasks from being created"""
|
|
with patch("asyncio.get_running_loop") as mock_get_loop:
|
|
# Either raise an exception or return a mock that will handle the task creation
|
|
mock_get_loop.side_effect = RuntimeError("No running event loop")
|
|
yield
|
|
|
|
|
|
@pytest.mark.parametrize("namespace", [None, "test"])
|
|
@pytest.mark.asyncio
|
|
async def test_redis_cache_async_increment(namespace, monkeypatch, redis_no_ping):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
# Create an AsyncMock for the Redis client
|
|
mock_redis_instance = AsyncMock()
|
|
|
|
# Make sure the mock can be used as an async context manager
|
|
mock_redis_instance.__aenter__.return_value = mock_redis_instance
|
|
mock_redis_instance.__aexit__.return_value = None
|
|
|
|
assert redis_cache is not None
|
|
|
|
expected_key = "test:test" if namespace else "test"
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
# Call async_set_cache
|
|
await redis_cache.async_increment(key=expected_key, value=1)
|
|
|
|
# Verify that the set method was called on the mock Redis instance
|
|
mock_redis_instance.incrbyfloat.assert_called_once_with(
|
|
name=expected_key, amount=1
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redis_cache_async_increment_refresh_ttl_true_bumps_existing_ttl(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""With refresh_ttl=True, every increment should call expire() to bump
|
|
the TTL, even when the key already has a TTL (counter-style use)."""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
mock_redis_instance = AsyncMock()
|
|
mock_redis_instance.__aenter__.return_value = mock_redis_instance
|
|
mock_redis_instance.__aexit__.return_value = None
|
|
mock_redis_instance.ttl.return_value = 42 # key already has ~42s left
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
await redis_cache.async_increment(
|
|
key="spend:team_member:u:t", value=0.05, refresh_ttl=True
|
|
)
|
|
|
|
mock_redis_instance.expire.assert_awaited_once_with("spend:team_member:u:t", 60)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redis_cache_async_increment_default_does_not_bump_existing_ttl(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""Default (refresh_ttl=False) preserves window-style semantics: TTL is
|
|
set only on first creation, never refreshed (used by rate-limit windows)."""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
mock_redis_instance = AsyncMock()
|
|
mock_redis_instance.__aenter__.return_value = mock_redis_instance
|
|
mock_redis_instance.__aexit__.return_value = None
|
|
mock_redis_instance.ttl.return_value = 42 # key already has ~42s left
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
await redis_cache.async_increment(key="rate_limit:window", value=1)
|
|
|
|
mock_redis_instance.expire.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.parametrize("namespace", [None, "litellm"])
|
|
@pytest.mark.asyncio
|
|
async def test_async_delete_cache_applies_namespace(
|
|
namespace, monkeypatch, redis_no_ping
|
|
):
|
|
"""async_delete_cache must prefix keys with the namespace, matching every
|
|
other cache operation. Without this, Redis NOPERM errors occur when an
|
|
ACL restricts DEL to the litellm:* pattern."""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
mock_redis_instance = AsyncMock()
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
await redis_cache.async_delete_cache(key="3997c4abcdef")
|
|
|
|
expected_key = "litellm:3997c4abcdef" if namespace else "3997c4abcdef"
|
|
mock_redis_instance.delete.assert_awaited_once_with(expected_key)
|
|
|
|
|
|
@pytest.mark.parametrize("namespace", [None, "litellm"])
|
|
def test_delete_cache_applies_namespace(namespace, monkeypatch, redis_no_ping):
|
|
"""delete_cache must prefix keys with the namespace, matching every other
|
|
cache operation."""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
mock_redis_client = MagicMock()
|
|
redis_cache.redis_client = mock_redis_client
|
|
|
|
redis_cache.delete_cache(key="3997c4abcdef")
|
|
|
|
expected_key = "litellm:3997c4abcdef" if namespace else "3997c4abcdef"
|
|
mock_redis_client.delete.assert_called_once_with(expected_key)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redis_client_init_with_socket_timeout(monkeypatch, redis_no_ping):
|
|
monkeypatch.setenv("REDIS_HOST", "my-fake-host")
|
|
redis_cache = RedisCache(socket_timeout=1.0)
|
|
assert redis_cache.redis_kwargs["socket_timeout"] == 1.0
|
|
client = redis_cache.init_async_client()
|
|
assert client is not None
|
|
assert client.connection_pool.connection_kwargs["socket_timeout"] == 1.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redis_cache_async_batch_get_cache(monkeypatch, redis_no_ping):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
|
|
# Create an AsyncMock for the Redis client
|
|
mock_redis_instance = AsyncMock()
|
|
|
|
# Make sure the mock can be used as an async context manager
|
|
mock_redis_instance.__aenter__.return_value = mock_redis_instance
|
|
mock_redis_instance.__aexit__.return_value = None
|
|
|
|
# Setup the return value for mget
|
|
mock_redis_instance.mget.return_value = [
|
|
b'{"key1": "value1"}',
|
|
None,
|
|
b'{"key3": "value3"}',
|
|
]
|
|
|
|
test_keys = ["key1", "key2", "key3"]
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
# Call async_batch_get_cache
|
|
result = await redis_cache.async_batch_get_cache(key_list=test_keys)
|
|
|
|
# Verify mget was called with the correct keys
|
|
mock_redis_instance.mget.assert_called_once()
|
|
|
|
# Check that results were properly decoded
|
|
assert result["key1"] == {"key1": "value1"}
|
|
assert result["key2"] is None
|
|
assert result["key3"] == {"key3": "value3"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_lpop_count_for_older_redis_versions(monkeypatch):
|
|
"""Test the helper method that handles LPOP with count for Redis versions < 7.0"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
# Create RedisCache instance
|
|
redis_cache = RedisCache()
|
|
|
|
# Create a mock pipeline
|
|
mock_pipeline = AsyncMock()
|
|
# Set up execute to return different values each time
|
|
mock_pipeline.execute.side_effect = [
|
|
[b"value1"], # First execute returns first value
|
|
[b"value2"], # Second execute returns second value
|
|
]
|
|
|
|
# Test the helper method
|
|
result = await redis_cache.handle_lpop_count_for_older_redis_versions(
|
|
pipe=mock_pipeline, key="test_key", count=2
|
|
)
|
|
|
|
# Verify results
|
|
assert result == [b"value1", b"value2"]
|
|
assert mock_pipeline.lpop.call_count == 2
|
|
assert mock_pipeline.execute.call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_rpush_pipeline_executes_all_operations(monkeypatch, redis_no_ping):
|
|
"""Verify that multiple rpush ops are batched into a single pipeline execute"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
|
|
mock_redis_instance = AsyncMock()
|
|
mock_pipeline = MagicMock()
|
|
mock_pipeline.__aenter__ = AsyncMock(return_value=mock_pipeline)
|
|
mock_pipeline.__aexit__ = AsyncMock(return_value=None)
|
|
mock_pipeline.rpush = MagicMock()
|
|
mock_pipeline.execute = AsyncMock(return_value=[3, 5, 1])
|
|
mock_redis_instance.pipeline = MagicMock(return_value=mock_pipeline)
|
|
|
|
from litellm.types.caching import RedisPipelineRpushOperation
|
|
|
|
rpush_list = [
|
|
RedisPipelineRpushOperation(key="key1", values=["a", "b"]),
|
|
RedisPipelineRpushOperation(key="key2", values=["c"]),
|
|
RedisPipelineRpushOperation(key="key3", values=["d", "e", "f"]),
|
|
]
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
result = await redis_cache.async_rpush_pipeline(rpush_list=rpush_list)
|
|
|
|
assert result == [3, 5, 1]
|
|
assert mock_pipeline.rpush.call_count == 3
|
|
mock_pipeline.rpush.assert_any_call("key1", "a", "b")
|
|
mock_pipeline.rpush.assert_any_call("key2", "c")
|
|
mock_pipeline.rpush.assert_any_call("key3", "d", "e", "f")
|
|
mock_pipeline.execute.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_rpush_pipeline_empty_list_returns_empty(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""Empty rpush_list should return empty list without touching Redis"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
|
|
mock_redis_instance = AsyncMock()
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
result = await redis_cache.async_rpush_pipeline(rpush_list=[])
|
|
|
|
assert result == []
|
|
mock_redis_instance.pipeline.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_rpush_pipeline_raises_on_redis_error(monkeypatch, redis_no_ping):
|
|
"""Pipeline errors should propagate"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
|
|
mock_redis_instance = AsyncMock()
|
|
mock_pipeline = MagicMock()
|
|
mock_pipeline.__aenter__ = AsyncMock(return_value=mock_pipeline)
|
|
mock_pipeline.__aexit__ = AsyncMock(return_value=None)
|
|
mock_pipeline.rpush = MagicMock()
|
|
mock_pipeline.execute = AsyncMock(side_effect=ConnectionError("Redis down"))
|
|
mock_redis_instance.pipeline = MagicMock(return_value=mock_pipeline)
|
|
|
|
from litellm.types.caching import RedisPipelineRpushOperation
|
|
|
|
rpush_list = [RedisPipelineRpushOperation(key="key1", values=["a"])]
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
with pytest.raises(ConnectionError, match="Redis down"):
|
|
await redis_cache.async_rpush_pipeline(rpush_list=rpush_list)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_lpop_pipeline_single_round_trip(monkeypatch, redis_no_ping):
|
|
"""Verify that multiple lpop ops are batched into a single pipeline execute"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
redis_cache.redis_version = "7.0.0"
|
|
|
|
mock_redis_instance = AsyncMock()
|
|
mock_pipeline = MagicMock()
|
|
mock_pipeline.__aenter__ = AsyncMock(return_value=mock_pipeline)
|
|
mock_pipeline.__aexit__ = AsyncMock(return_value=None)
|
|
mock_pipeline.lpop = MagicMock()
|
|
mock_pipeline.execute = AsyncMock(
|
|
return_value=[
|
|
[b"val1", b"val2"], # key1 results
|
|
None, # key2 empty
|
|
[b"val3"], # key3 results
|
|
]
|
|
)
|
|
mock_redis_instance.pipeline = MagicMock(return_value=mock_pipeline)
|
|
|
|
from litellm.types.caching import RedisPipelineLpopOperation
|
|
|
|
lpop_list = [
|
|
RedisPipelineLpopOperation(key="key1", count=10),
|
|
RedisPipelineLpopOperation(key="key2", count=10),
|
|
RedisPipelineLpopOperation(key="key3", count=5),
|
|
]
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
results = await redis_cache.async_lpop_pipeline(lpop_list=lpop_list)
|
|
|
|
assert len(results) == 3
|
|
assert results[0] == ["val1", "val2"]
|
|
assert results[1] is None
|
|
assert results[2] == ["val3"]
|
|
mock_pipeline.execute.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_lpop_pipeline_redis_lt7_regroups_flat_results(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""Verify Redis < 7 fallback issues individual LPOPs and regroups correctly"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
redis_cache.redis_version = "6.2.0"
|
|
|
|
mock_redis_instance = AsyncMock()
|
|
mock_pipeline = MagicMock()
|
|
mock_pipeline.__aenter__ = AsyncMock(return_value=mock_pipeline)
|
|
mock_pipeline.__aexit__ = AsyncMock(return_value=None)
|
|
mock_pipeline.lpop = MagicMock()
|
|
|
|
# With count=3 for key1 and count=2 for key2, we get 5 individual LPOP commands
|
|
# Simulate: key1 has 2 values then None, key2 has 1 value then None
|
|
mock_pipeline.execute = AsyncMock(
|
|
return_value=[
|
|
b"val1",
|
|
b"val2",
|
|
None, # 3 LPOPs for key1
|
|
b"val3",
|
|
None, # 2 LPOPs for key2
|
|
]
|
|
)
|
|
mock_redis_instance.pipeline = MagicMock(return_value=mock_pipeline)
|
|
|
|
from litellm.types.caching import RedisPipelineLpopOperation
|
|
|
|
lpop_list = [
|
|
RedisPipelineLpopOperation(key="key1", count=3),
|
|
RedisPipelineLpopOperation(key="key2", count=2),
|
|
]
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
results = await redis_cache.async_lpop_pipeline(lpop_list=lpop_list)
|
|
|
|
assert len(results) == 2
|
|
assert results[0] == ["val1", "val2"] # 2 values, None filtered out
|
|
assert results[1] == ["val3"] # 1 value, None filtered out
|
|
# All 5 individual LPOPs should be queued, but only 1 execute() call
|
|
assert mock_pipeline.lpop.call_count == 5
|
|
mock_pipeline.execute.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_rpush_pipeline_raises_on_per_command_error(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""Verify that per-command errors in pipeline results are raised, not silently dropped"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
|
|
mock_redis_instance = AsyncMock()
|
|
mock_pipeline = MagicMock()
|
|
mock_pipeline.__aenter__ = AsyncMock(return_value=mock_pipeline)
|
|
mock_pipeline.__aexit__ = AsyncMock(return_value=None)
|
|
mock_pipeline.rpush = MagicMock()
|
|
# Simulate: first RPUSH succeeds, second returns a per-command error
|
|
mock_pipeline.execute = AsyncMock(return_value=[3, Exception("WRONGTYPE")])
|
|
mock_redis_instance.pipeline = MagicMock(return_value=mock_pipeline)
|
|
|
|
from litellm.types.caching import RedisPipelineRpushOperation
|
|
|
|
rpush_list = [
|
|
RedisPipelineRpushOperation(key="key1", values=["a"]),
|
|
RedisPipelineRpushOperation(key="key2", values=["b"]),
|
|
]
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
with pytest.raises(Exception, match="WRONGTYPE"):
|
|
await redis_cache.async_rpush_pipeline(rpush_list=rpush_list)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_lpop_pipeline_raises_on_per_command_error(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""Verify that per-command errors in LPOP pipeline results are raised, not silently dropped"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
redis_cache.redis_version = "7.0.0"
|
|
|
|
mock_redis_instance = AsyncMock()
|
|
mock_pipeline = MagicMock()
|
|
mock_pipeline.__aenter__ = AsyncMock(return_value=mock_pipeline)
|
|
mock_pipeline.__aexit__ = AsyncMock(return_value=None)
|
|
mock_pipeline.lpop = MagicMock()
|
|
# Simulate: first LPOP succeeds, second returns a per-command error
|
|
mock_pipeline.execute = AsyncMock(return_value=[[b"val1"], Exception("WRONGTYPE")])
|
|
mock_redis_instance.pipeline = MagicMock(return_value=mock_pipeline)
|
|
|
|
from litellm.types.caching import RedisPipelineLpopOperation
|
|
|
|
lpop_list = [
|
|
RedisPipelineLpopOperation(key="key1", count=10),
|
|
RedisPipelineLpopOperation(key="key2", count=10),
|
|
]
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
with pytest.raises(Exception, match="WRONGTYPE"):
|
|
await redis_cache.async_lpop_pipeline(lpop_list=lpop_list)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_lpop_pipeline_empty_list(monkeypatch, redis_no_ping):
|
|
"""Empty lpop_list should return empty list without touching Redis"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
|
|
mock_redis_instance = AsyncMock()
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
result = await redis_cache.async_lpop_pipeline(lpop_list=[])
|
|
|
|
assert result == []
|
|
mock_redis_instance.pipeline.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_lpop_pipeline_propagates_redis_exception(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""Pipeline errors should propagate"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
redis_cache.redis_version = "7.0.0"
|
|
|
|
mock_redis_instance = AsyncMock()
|
|
mock_pipeline = MagicMock()
|
|
mock_pipeline.__aenter__ = AsyncMock(return_value=mock_pipeline)
|
|
mock_pipeline.__aexit__ = AsyncMock(return_value=None)
|
|
mock_pipeline.lpop = MagicMock()
|
|
mock_pipeline.execute = AsyncMock(side_effect=ConnectionError("Redis down"))
|
|
mock_redis_instance.pipeline = MagicMock(return_value=mock_pipeline)
|
|
|
|
from litellm.types.caching import RedisPipelineLpopOperation
|
|
|
|
lpop_list = [RedisPipelineLpopOperation(key="key1", count=10)]
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
with pytest.raises(ConnectionError, match="Redis down"):
|
|
await redis_cache.async_lpop_pipeline(lpop_list=lpop_list)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"redis_version",
|
|
[
|
|
# Standard cases
|
|
"7.0.0", # Standard Redis string version
|
|
7.0, # Valkey/ElastiCache float version (THE BUG this fix addresses)
|
|
7, # Integer version (e.g., from some Redis forks)
|
|
# Version < 7
|
|
"6", # String without dots, version < 7
|
|
# Malformed versions (fallback to 7)
|
|
"latest", # Non-numeric version
|
|
"", # Empty string
|
|
-7.0, # Negative float
|
|
# Format variations
|
|
" 7.0.0 ", # Whitespace (should be stripped)
|
|
"7.0.0-rc1", # Version with suffix
|
|
"10.0.0", # Double digit major version
|
|
],
|
|
)
|
|
async def test_async_lpop_with_float_redis_version(
|
|
monkeypatch, redis_no_ping, redis_version
|
|
):
|
|
"""
|
|
Test async_lpop with various Redis version formats (especially float).
|
|
|
|
This test specifically addresses the issue where AWS ElastiCache Valkey
|
|
returns redis_version as a float (e.g., 7.0) instead of a string (e.g., "7.0.0"),
|
|
which caused a 'float' object has no attribute 'split' error when trying to
|
|
use the Redis transaction buffer feature.
|
|
|
|
The fix converts the version to a string and handles edge cases like:
|
|
- Floats (7.0) and integers (7)
|
|
- Strings with/without dots ("7" vs "7.0.0")
|
|
- Malformed versions ("v7.0.0", "latest") - fallback to version 7
|
|
- Whitespace (" 7.0.0 ")
|
|
- Negative versions (fallback to version 7)
|
|
|
|
Related: Database deadlock issues when use_redis_transaction_buffer is enabled.
|
|
"""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
|
|
# Create RedisCache instance
|
|
redis_cache = RedisCache()
|
|
redis_cache.redis_version = redis_version # Set the version to test
|
|
|
|
# Create an AsyncMock for the Redis client
|
|
mock_redis_instance = AsyncMock()
|
|
mock_redis_instance.__aenter__.return_value = mock_redis_instance
|
|
mock_redis_instance.__aexit__.return_value = None
|
|
|
|
# Mock lpop to return a test value (Redis >= 7.0 behavior)
|
|
mock_redis_instance.lpop.return_value = [b"value1", b"value2"]
|
|
|
|
# Mock pipeline for Redis < 7.0 (used when major_version < 7)
|
|
mock_pipeline = MagicMock()
|
|
mock_pipeline.__aenter__ = AsyncMock(return_value=mock_pipeline)
|
|
mock_pipeline.__aexit__ = AsyncMock(return_value=None)
|
|
# Make pipeline() a regular method (not async) that returns the mock
|
|
mock_redis_instance.pipeline = MagicMock(return_value=mock_pipeline)
|
|
|
|
# Mock handle_lpop_count_for_older_redis_versions for Redis < 7
|
|
with patch.object(
|
|
redis_cache,
|
|
"handle_lpop_count_for_older_redis_versions",
|
|
return_value=[b"value1", b"value2"],
|
|
):
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
# Call async_lpop with count - this should not raise AttributeError
|
|
result = await redis_cache.async_lpop(key="test_key", count=2)
|
|
|
|
# Verify the method completed without error
|
|
assert result is not None
|
|
|
|
|
|
# LIT-3374: the namespace must be applied uniformly across every key-taking
|
|
# Redis operation, not just get/set/increment. Before the fix these paths wrote
|
|
# or read raw keys, so with a namespace configured the prefixed keys other
|
|
# operations created were silently missed.
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"namespace, raw_keys, expected_keys",
|
|
[
|
|
(None, ["{k:v}:tokens", "{k:v}:requests"], ["{k:v}:tokens", "{k:v}:requests"]),
|
|
(
|
|
"litellm_sandbox",
|
|
["{k:v}:tokens", "{k:v}:requests"],
|
|
["litellm_sandbox:{k:v}:tokens", "litellm_sandbox:{k:v}:requests"],
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_async_register_script_namespaces_keys(
|
|
namespace, raw_keys, expected_keys, monkeypatch, redis_no_ping
|
|
):
|
|
"""The callable returned by async_register_script (used by the rate limiter
|
|
Lua scripts, pod-lock release, and budget limiters) must namespace every key
|
|
it is invoked with. The hash tag is preserved so cluster slotting is intact."""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
|
|
registered_script = AsyncMock(return_value="ok")
|
|
mock_redis_instance = MagicMock()
|
|
mock_redis_instance.register_script = MagicMock(return_value=registered_script)
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
script = redis_cache.async_register_script("return 1")
|
|
result = await script(keys=raw_keys, args=[60])
|
|
|
|
assert result == "ok"
|
|
registered_script.assert_awaited_once_with(
|
|
keys=tuple(expected_keys), args=[60], client=None
|
|
)
|
|
|
|
|
|
# LIT-3298: rate limits tripped at ~40M instead of 80M. async_register_script
|
|
# registered the Lua script once at startup and stored the object on the
|
|
# limiter, so a request running on a different event loop awaited a script bound
|
|
# to the startup loop's connection -> "got Future attached to a different loop".
|
|
# The limiter then fell back to a pipeline that reset the window TTL, so two
|
|
# minutes of tokens piled into one window. The script must instead be registered
|
|
# lazily against the calling loop's client and cached per loop.
|
|
|
|
|
|
@pytest.mark.parametrize("namespace", [None, "litellm_sandbox"])
|
|
def test_async_register_script_binds_per_event_loop(namespace, monkeypatch):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
|
|
clients_built = []
|
|
|
|
def make_client():
|
|
client = MagicMock()
|
|
client.register_script = MagicMock(return_value=AsyncMock(return_value="ok"))
|
|
clients_built.append(client)
|
|
return client
|
|
|
|
unique_script = "return 'lit3298'"
|
|
|
|
with patch.object(redis_cache, "init_async_client", side_effect=make_client):
|
|
script = redis_cache.async_register_script(unique_script)
|
|
|
|
# Registration is deferred: no client is touched until the script runs.
|
|
assert clients_built == []
|
|
|
|
# Two loops kept alive at once so their ids can't be recycled into one
|
|
# cache key. The buggy version reuses the first loop's bound object.
|
|
loop_a = asyncio.new_event_loop()
|
|
loop_b = asyncio.new_event_loop()
|
|
try:
|
|
result_a = loop_a.run_until_complete(
|
|
script(keys=["{k:v}:tokens"], args=[60])
|
|
)
|
|
result_b = loop_b.run_until_complete(
|
|
script(keys=["{k:v}:tokens"], args=[60])
|
|
)
|
|
finally:
|
|
loop_a.close()
|
|
loop_b.close()
|
|
|
|
assert result_a == "ok"
|
|
assert result_b == "ok"
|
|
assert len(clients_built) == 2
|
|
for client in clients_built:
|
|
client.register_script.assert_called_once_with(unique_script)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_register_script_not_shared_across_namespaces(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""Two caches with different namespaces registering the SAME script must
|
|
each run against their own client and key prefix. A content-only executor
|
|
cache would let the second cache reuse the first's executor and namespace."""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
cache_a = RedisCache(namespace="ns_a")
|
|
cache_b = RedisCache(namespace="ns_b")
|
|
|
|
reg_a = AsyncMock(return_value="a")
|
|
client_a = MagicMock()
|
|
client_a.register_script = MagicMock(return_value=reg_a)
|
|
reg_b = AsyncMock(return_value="b")
|
|
client_b = MagicMock()
|
|
client_b.register_script = MagicMock(return_value=reg_b)
|
|
|
|
same_script = "return redis.call('GET', KEYS[1])"
|
|
with patch.object(
|
|
cache_a, "init_async_client", return_value=client_a
|
|
), patch.object(cache_b, "init_async_client", return_value=client_b):
|
|
script_a = cache_a.async_register_script(same_script)
|
|
script_b = cache_b.async_register_script(same_script)
|
|
result_a = await script_a(keys=["k"], args=[])
|
|
result_b = await script_b(keys=["k"], args=[])
|
|
|
|
assert (result_a, result_b) == ("a", "b")
|
|
reg_a.assert_awaited_once_with(keys=("ns_a:k",), args=[], client=None)
|
|
reg_b.assert_awaited_once_with(keys=("ns_b:k",), args=[], client=None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_register_script_cluster_path_uses_evalsha(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""Redis Cluster exposes script_load/evalsha rather than register_script.
|
|
The script is loaded once and invoked via evalsha with namespaced keys."""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace="ns")
|
|
|
|
cluster_client = MagicMock(spec=["script_load", "evalsha"])
|
|
cluster_client.script_load = MagicMock(return_value="sha123")
|
|
cluster_client.evalsha = AsyncMock(return_value="cluster-ok")
|
|
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=cluster_client
|
|
):
|
|
script = redis_cache.async_register_script("return 'cluster'")
|
|
result = await script(keys=["{k:v}:tokens"], args=[5, 60])
|
|
|
|
assert result == "cluster-ok"
|
|
cluster_client.script_load.assert_called_once_with("return 'cluster'")
|
|
cluster_client.evalsha.assert_awaited_once_with(
|
|
"sha123", 1, "ns:{k:v}:tokens", 5, 60
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_register_script_raises_for_unsupported_client(
|
|
monkeypatch, redis_no_ping
|
|
):
|
|
"""A client exposing neither register_script nor script_load fails loudly
|
|
rather than silently returning a no-op callable."""
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache()
|
|
bad_client = MagicMock(spec=[])
|
|
|
|
with patch.object(redis_cache, "init_async_client", return_value=bad_client):
|
|
script = redis_cache.async_register_script("return 'x'")
|
|
with pytest.raises(ValueError, match="does not support Lua script"):
|
|
await script(keys=["k"], args=[1])
|
|
|
|
|
|
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
|
|
@pytest.mark.asyncio
|
|
async def test_async_delete_cache_namespaces_key(
|
|
namespace, expected, monkeypatch, redis_no_ping
|
|
):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
mock_redis_instance = AsyncMock()
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
await redis_cache.async_delete_cache("k")
|
|
mock_redis_instance.delete.assert_awaited_once_with(expected)
|
|
|
|
|
|
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
|
|
@pytest.mark.asyncio
|
|
async def test_delete_cache_keys_namespaces_keys(
|
|
namespace, expected, monkeypatch, redis_no_ping
|
|
):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
mock_redis_instance = AsyncMock()
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
await redis_cache.delete_cache_keys(["k"])
|
|
mock_redis_instance.delete.assert_awaited_once_with(expected)
|
|
|
|
|
|
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
|
|
@pytest.mark.asyncio
|
|
async def test_async_get_ttl_namespaces_key(
|
|
namespace, expected, monkeypatch, redis_no_ping
|
|
):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
mock_redis_instance = AsyncMock()
|
|
mock_redis_instance.ttl = AsyncMock(return_value=42)
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
ttl = await redis_cache.async_get_ttl("k")
|
|
assert ttl == 42
|
|
mock_redis_instance.ttl.assert_awaited_once_with(expected)
|
|
|
|
|
|
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
|
|
@pytest.mark.asyncio
|
|
async def test_async_lpop_namespaces_key(
|
|
namespace, expected, monkeypatch, redis_no_ping
|
|
):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
mock_redis_instance = AsyncMock()
|
|
mock_redis_instance.lpop = AsyncMock(return_value=b"value")
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
await redis_cache.async_lpop(key="k")
|
|
mock_redis_instance.lpop.assert_awaited_once_with(expected, None)
|
|
|
|
|
|
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
|
|
@pytest.mark.asyncio
|
|
async def test_async_rpush_namespaces_key(
|
|
namespace, expected, monkeypatch, redis_no_ping
|
|
):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
mock_redis_instance = AsyncMock()
|
|
mock_redis_instance.rpush = AsyncMock(return_value=1)
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
await redis_cache.async_rpush("k", ["v"])
|
|
mock_redis_instance.rpush.assert_awaited_once_with(expected, "v")
|
|
|
|
|
|
@pytest.mark.parametrize("namespace, expected_match", [(None, "k*"), ("ns", "ns:k*")])
|
|
@pytest.mark.asyncio
|
|
async def test_async_scan_iter_namespaces_pattern(
|
|
namespace, expected_match, monkeypatch, redis_no_ping
|
|
):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
|
|
captured = {}
|
|
|
|
def scan_iter(match, count):
|
|
captured["match"] = match
|
|
|
|
async def gen():
|
|
for _ in ():
|
|
yield _
|
|
|
|
return gen()
|
|
|
|
mock_redis_instance = MagicMock()
|
|
mock_redis_instance.scan_iter = scan_iter
|
|
with patch.object(
|
|
redis_cache, "init_async_client", return_value=mock_redis_instance
|
|
):
|
|
await redis_cache.async_scan_iter(pattern="k")
|
|
assert captured["match"] == expected_match
|
|
|
|
|
|
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
|
|
def test_increment_cache_namespaces_key(
|
|
namespace, expected, monkeypatch, redis_no_ping
|
|
):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
mock_client = MagicMock()
|
|
mock_client.incr.return_value = 5
|
|
mock_client.ttl.return_value = 100
|
|
redis_cache.redis_client = mock_client
|
|
redis_cache.increment_cache(key="k", value=1)
|
|
mock_client.incr.assert_called_once_with(name=expected, amount=1)
|
|
|
|
|
|
@pytest.mark.parametrize("namespace, expected", [(None, "k"), ("ns", "ns:k")])
|
|
def test_delete_cache_namespaces_key(namespace, expected, monkeypatch, redis_no_ping):
|
|
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
|
|
redis_cache = RedisCache(namespace=namespace)
|
|
mock_client = MagicMock()
|
|
redis_cache.redis_client = mock_client
|
|
redis_cache.delete_cache(key="k")
|
|
mock_client.delete.assert_called_once_with(expected)
|