From cae46735d78be7d24469689c62ddeb2008162a2c Mon Sep 17 00:00:00 2001 From: chjnett Date: Mon, 17 Aug 2026 18:52:40 +0900 Subject: [PATCH 1/2] fix(caching): guard Redis disconnect when pool is None and close cluster client --- litellm/caching/redis_cache.py | 3 ++- litellm/caching/redis_cluster_cache.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index f1c80eaacbe..90adda6b1cc 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -1370,7 +1370,8 @@ class RedisCache(BaseCache): self.redis_client.flushall() async def disconnect(self): - await self.async_redis_conn_pool.disconnect(inuse_connections=True) + if self.async_redis_conn_pool is not None: + await self.async_redis_conn_pool.disconnect(inuse_connections=True) try: self.redis_client.close() except Exception as e: diff --git a/litellm/caching/redis_cluster_cache.py b/litellm/caching/redis_cluster_cache.py index 12d285ca5a8..12903fd649d 100644 --- a/litellm/caching/redis_cluster_cache.py +++ b/litellm/caching/redis_cluster_cache.py @@ -56,6 +56,19 @@ class RedisClusterCache(RedisCache): async_redis_cluster_client: Final = self.init_async_client() return await async_redis_cluster_client.mget_nonatomic(keys=keys) + async def disconnect(self): + """ + Overrides `disconnect` in redis_cache.py. + + In cluster mode ``get_redis_connection_pool`` returns ``None`` (the + ``RedisCluster`` client builds its own per-node pools), so the base-class + implementation would dereference ``None``. Close the cluster client instead, + then let the base class tear down the sync client. + """ + if self.redis_async_redis_cluster_client is not None: + await self.redis_async_redis_cluster_client.aclose() + await super().disconnect() + async def test_connection(self) -> dict: """ Test the Redis Cluster connection. From 5fe80fd063a75093655441e20fe7e12537b794d3 Mon Sep 17 00:00:00 2001 From: chjnett Date: Mon, 17 Aug 2026 19:16:30 +0900 Subject: [PATCH 2/2] test(redis): cover cluster disconnect with and without a cluster client --- tests/test_litellm/test_redis.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 826beb74a27..6ed52d5a17d 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -1,3 +1,4 @@ +import asyncio import json from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -1478,3 +1479,39 @@ def test_async_sentinel_keeps_the_credential_provider_off_the_monitors(markers, master_kwargs = mock_sentinel_cls.return_value.master_for.call_args[1] assert isinstance(master_kwargs["credential_provider"], provider_cls) assert "password" not in master_kwargs +def _make_cluster_cache() -> RedisClusterCache: + """Build a RedisClusterCache without hitting a real Redis server.""" + cache = RedisClusterCache.__new__(RedisClusterCache) + cache.async_redis_conn_pool = None + cache.redis_async_redis_cluster_client = None + cache.redis_client = MagicMock() + return cache + + +def test_redis_cluster_cache_disconnect_without_pool(): + """ + In cluster mode ``get_redis_connection_pool`` returns ``None`` (the + RedisCluster client builds its own per-node pools), so the base-class + ``disconnect`` must not dereference the pool. Regression for #37137. + """ + cache = _make_cluster_cache() + + asyncio.run(cache.disconnect()) + + cache.redis_client.close.assert_called_once() + + +def test_redis_cluster_cache_disconnect_closes_cluster_client(): + """ + When a cluster client was created, ``disconnect`` must tear it down via + ``aclose`` (the same primitive ``test_connection`` uses) before delegating + to the base class. Regression for #37137. + """ + cache = _make_cluster_cache() + cluster_client = AsyncMock() + cache.redis_async_redis_cluster_client = cluster_client + + asyncio.run(cache.disconnect()) + + cluster_client.aclose.assert_awaited_once() + cache.redis_client.close.assert_called_once()