You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
redis-py version: 6.4.0 (also examined 7.4.0 — partially improved but not fully fixed)
Python: 3.10
Concurrency: 200+ threads sharing one RedisCluster client
Redis: 3-node cluster with replicas
Problem
When a single Redis cluster node becomes temporarily unreachable, the RedisCluster client's error recovery mechanism itself causes a cascading failure that makes the entire application unresponsive — even though the cluster majority is still healthy.
This is distinct from #3929 (permanent unrecoverable state after topology change). Here, the client's recovery behavior crashes the application within seconds of a transient failure, before the cluster has a chance to self-heal.
Failure Sequence (from code analysis)
Given: 200+ concurrent threads sharing one RedisCluster client, max_connections_per_node=50, socket_timeout=5
A single node becomes unreachable
Threads with active connections to that node block for socket_timeout seconds (5s)
Each thread that gets TimeoutError or ConnectionError hits this code path:
Thundering herd on initialize(): Every thread that fails independently calls initialize(). In 6.4.0, there's no deduplication — each call acquires self._lock (threading.RLock) sequentially. In 7.4.0, epoch-based dedup reduces but doesn't eliminate this.
initialize() tries dead nodes: Inside initialize(), it iterates startup_nodes and calls CLUSTER SLOTS — potentially trying the dead node again (another socket_timeout wait while holding the lock):
Pool exhaustion: While threads are queued on the lock or waiting on socket_timeout, they hold connections from the pool. With 200+ threads and only 50 connections per node, the pool saturates. New requests get MaxConnectionsError immediately (thanks to Fix ConnectionPool to raise MaxConnectionsError instead of Connection… #3698).
Application starvation: All thread pool slots are consumed by threads blocked on Redis. The application cannot serve any requests — including health checks — leading to container restarts.
Timeline: Single node failure → total application outage in ~5-10 seconds.
Root Causes
The core issue is that RedisCluster treats every failed request as a signal to do a full cluster topology refresh, with no:
Node health awareness — the client doesn't track which nodes are failing. It retries the dead node in every initialize() call, wasting socket_timeout seconds each time while holding the lock.
Deduplication — in 6.4.0, all threads call initialize() independently. 7.4.0 adds epoch-based dedup which helps but threads still queue on the lock.
Non-blocking refresh — initialize() is synchronous and holds a lock. All other threads queue behind it.
Fast-fail for known-dead nodes — no mechanism to skip nodes that have failed recently. Every attempt pays the full socket_timeout cost.
Impact
In production, this causes full application outages every time a single Redis node has even a brief connectivity issue. All pods in the deployment fail simultaneously (since they share the same Redis cluster), causing a complete service disruption.
Relation to existing issues/PRs
#3698 — I authored this fix to distinguish MaxConnectionsError from ConnectionError, preventing reinitialization loops when the pool is full. However, it doesn't prevent the pool from reaching saturation in the first place due to the thundering herd on initialize(). #3929 — Different trigger (permanent IP changes vs transient failure) but related: both expose the lack of per-node health tracking in the cluster client.
Environment
Problem
When a single Redis cluster node becomes temporarily unreachable, the
RedisClusterclient's error recovery mechanism itself causes a cascading failure that makes the entire application unresponsive — even though the cluster majority is still healthy.This is distinct from #3929 (permanent unrecoverable state after topology change). Here, the client's recovery behavior crashes the application within seconds of a transient failure, before the cluster has a chance to self-heal.
Failure Sequence (from code analysis)
Given: 200+ concurrent threads sharing one
RedisClusterclient,max_connections_per_node=50,socket_timeout=5A single node becomes unreachable
Threads with active connections to that node block for
socket_timeoutseconds (5s)Each thread that gets
TimeoutErrororConnectionErrorhits this code path:Thundering herd on initialize(): Every thread that fails independently calls initialize(). In 6.4.0, there's no deduplication — each call acquires self._lock (threading.RLock) sequentially. In 7.4.0, epoch-based dedup reduces but doesn't eliminate this.
initialize() tries dead nodes: Inside initialize(), it iterates startup_nodes and calls CLUSTER SLOTS — potentially trying the dead node again (another socket_timeout wait while holding the lock):
Pool exhaustion: While threads are queued on the lock or waiting on socket_timeout, they hold connections from the pool. With 200+ threads and only 50 connections per node, the pool saturates. New requests get MaxConnectionsError immediately (thanks to Fix ConnectionPool to raise MaxConnectionsError instead of Connection… #3698).
Application starvation: All thread pool slots are consumed by threads blocked on Redis. The application cannot serve any requests — including health checks — leading to container restarts.
Timeline: Single node failure → total application outage in ~5-10 seconds.
Root Causes
The core issue is that RedisCluster treats every failed request as a signal to do a full cluster topology refresh, with no:
Impact
In production, this causes full application outages every time a single Redis node has even a brief connectivity issue. All pods in the deployment fail simultaneously (since they share the same Redis cluster), causing a complete service disruption.
Relation to existing issues/PRs
#3698 — I authored this fix to distinguish MaxConnectionsError from ConnectionError, preventing reinitialization loops when the pool is full. However, it doesn't prevent the pool from reaching saturation in the first place due to the thundering herd on initialize().
#3929 — Different trigger (permanent IP changes vs transient failure) but related: both expose the lack of per-node health tracking in the cluster client.