Skip to content

Commit 7aff34b

Browse files
fix(redis): handle float redis_version from AWS ElastiCache Valkey
AWS ElastiCache Valkey returns redis_version as a float (7.0) instead of a string ('7.0.0'), causing AttributeError: 'float' object has no attribute 'split' in async_lpop when parsing version for LPOP count. Changes: - Extract version parsing into _parse_redis_major_version() helper - Add DEFAULT_REDIS_MAJOR_VERSION constant (replaces magic number) - Support multiple version formats: string, float, int, malformed - Add comprehensive test coverage for all version format edge cases Fixes: 'LiteLLM Redis Cache LPOP: - Got exception from REDIS' error during db_spend_update_job cronjobs
1 parent df6e084 commit 7aff34b

File tree

2 files changed

+113
-5
lines changed

2 files changed

+113
-5
lines changed

litellm/caching/redis_cache.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ def _get_call_stack_info(num_frames: int = 2) -> str:
8585

8686
class RedisCache(BaseCache):
8787
# if users don't provider one, use the default litellm cache
88+
89+
# Default Redis major version to assume when version cannot be determined
90+
# Using 7 as it's the modern version that supports LPOP with count parameter
91+
DEFAULT_REDIS_MAJOR_VERSION = 7
8892

8993
def __init__(
9094
self,
@@ -207,6 +211,35 @@ def check_and_fix_namespace(self, key: str) -> str:
207211

208212
return key
209213

214+
def _parse_redis_major_version(self) -> int:
215+
"""
216+
Parse Redis version to extract the major version number.
217+
218+
Handles multiple version formats:
219+
- Strings: "7.0.0", "6", "7.0.0-rc1", " 7.0.0 "
220+
- Floats: 7.0 (e.g., from AWS ElastiCache Valkey)
221+
- Integers: 7
222+
- Malformed: "latest", "", "Unknown" (defaults to DEFAULT_REDIS_MAJOR_VERSION)
223+
224+
Returns:
225+
int: The major version number (defaults to DEFAULT_REDIS_MAJOR_VERSION if unparseable)
226+
"""
227+
if self.redis_version == "Unknown":
228+
return self.DEFAULT_REDIS_MAJOR_VERSION
229+
230+
try:
231+
version_str = str(self.redis_version).strip()
232+
# Handle cases where there's no dot (e.g., "7" or 7)
233+
if "." in version_str:
234+
major_version = int(version_str.split(".")[0])
235+
else:
236+
# Direct integer or single-digit string
237+
major_version = int(float(version_str))
238+
return major_version
239+
except (ValueError, AttributeError):
240+
# Fallback for unparseable versions (e.g., "v7.0.0", "latest")
241+
return self.DEFAULT_REDIS_MAJOR_VERSION
242+
210243
def set_cache(self, key, value, **kwargs):
211244
ttl = self.get_ttl(**kwargs)
212245
print_verbose(
@@ -1259,11 +1292,7 @@ async def async_lpop(
12591292
start_time = time.time()
12601293
print_verbose(f"LPOP from Redis list: key: {key}, count: {count}")
12611294
try:
1262-
major_version: int = 7
1263-
# Check Redis version and use appropriate method
1264-
if self.redis_version != "Unknown":
1265-
# Parse version string like "6.0.0" to get major version
1266-
major_version = int(self.redis_version.split(".")[0])
1295+
major_version = self._parse_redis_major_version()
12671296

12681297
if count is not None and major_version < 7:
12691298
# For Redis < 7.0, use pipeline to execute multiple LPOP commands

tests/test_litellm/caching/test_redis_cache.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,82 @@ async def test_handle_lpop_count_for_older_redis_versions(monkeypatch):
120120
assert result == [b"value1", b"value2"]
121121
assert mock_pipeline.lpop.call_count == 2
122122
assert mock_pipeline.execute.call_count == 2
123+
124+
125+
@pytest.mark.asyncio
126+
@pytest.mark.parametrize(
127+
"redis_version",
128+
[
129+
# Standard cases
130+
"7.0.0", # Standard Redis string version
131+
7.0, # Valkey/ElastiCache float version (THE BUG this fix addresses)
132+
7, # Integer version (e.g., from some Redis forks)
133+
134+
# Version < 7
135+
"6", # String without dots, version < 7
136+
137+
# Malformed versions (fallback to 7)
138+
"latest", # Non-numeric version
139+
"", # Empty string
140+
-7.0, # Negative float
141+
142+
# Format variations
143+
" 7.0.0 ", # Whitespace (should be stripped)
144+
"7.0.0-rc1", # Version with suffix
145+
"10.0.0", # Double digit major version
146+
],
147+
)
148+
async def test_async_lpop_with_float_redis_version(
149+
monkeypatch, redis_no_ping, redis_version
150+
):
151+
"""
152+
Test async_lpop with various Redis version formats (especially float).
153+
154+
This test specifically addresses the issue where AWS ElastiCache Valkey
155+
returns redis_version as a float (e.g., 7.0) instead of a string (e.g., "7.0.0"),
156+
which caused a 'float' object has no attribute 'split' error when trying to
157+
use the Redis transaction buffer feature.
158+
159+
The fix converts the version to a string and handles edge cases like:
160+
- Floats (7.0) and integers (7)
161+
- Strings with/without dots ("7" vs "7.0.0")
162+
- Malformed versions ("v7.0.0", "latest") - fallback to version 7
163+
- Whitespace (" 7.0.0 ")
164+
- Negative versions (fallback to version 7)
165+
166+
Related: Database deadlock issues when use_redis_transaction_buffer is enabled.
167+
"""
168+
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
169+
170+
# Create RedisCache instance
171+
redis_cache = RedisCache()
172+
redis_cache.redis_version = redis_version # Set the version to test
173+
174+
# Create an AsyncMock for the Redis client
175+
mock_redis_instance = AsyncMock()
176+
mock_redis_instance.__aenter__.return_value = mock_redis_instance
177+
mock_redis_instance.__aexit__.return_value = None
178+
179+
# Mock lpop to return a test value (Redis >= 7.0 behavior)
180+
mock_redis_instance.lpop.return_value = [b"value1", b"value2"]
181+
182+
# Mock pipeline for Redis < 7.0 (used when major_version < 7)
183+
mock_pipeline = MagicMock()
184+
mock_pipeline.__aenter__ = AsyncMock(return_value=mock_pipeline)
185+
mock_pipeline.__aexit__ = AsyncMock(return_value=None)
186+
# Make pipeline() a regular method (not async) that returns the mock
187+
mock_redis_instance.pipeline = MagicMock(return_value=mock_pipeline)
188+
189+
# Mock handle_lpop_count_for_older_redis_versions for Redis < 7
190+
with patch.object(
191+
redis_cache, "handle_lpop_count_for_older_redis_versions",
192+
return_value=[b"value1", b"value2"]
193+
):
194+
with patch.object(
195+
redis_cache, "init_async_client", return_value=mock_redis_instance
196+
):
197+
# Call async_lpop with count - this should not raise AttributeError
198+
result = await redis_cache.async_lpop(key="test_key", count=2)
199+
200+
# Verify the method completed without error
201+
assert result is not None

0 commit comments

Comments
 (0)