Redis Cluster and Failover Testing: Patterns for Production Resilience

Redis Cluster and Failover Testing: Patterns for Production Resilience

Redis Cluster is the native horizontal scaling solution for Redis. It partitions data across multiple nodes using consistent hashing (16384 slots), with each node responsible for a subset. When a primary fails, a replica is promoted. This guide covers how to test cluster behavior—slot assignment, redirects, failover, and reconnection—so you can verify your application handles these events correctly.

What Can Go Wrong in a Redis Cluster

Before writing tests, enumerate the failure modes your application must handle:

  1. MOVED redirect — A key's slot is owned by a different node. Client must retry against the correct node.
  2. ASK redirect — During slot migration, a key may temporarily be on either the old or new node.
  3. CLUSTERDOWN — The cluster rejects operations when too many nodes are unavailable.
  4. Replica failover — Primary node dies, replica is promoted. Brief unavailability window.
  5. Network partition — Nodes can't reach each other; split-brain risk.
  6. Hot slots — Uneven key distribution causes load imbalance.

Most client libraries (redis-py, ioredis, go-redis) handle MOVED/ASK automatically. Your job is to verify your application-level behavior: connection pooling, retry logic, timeout handling, and error propagation.

Testing MOVED Redirect Handling

# cluster_client.py
from redis.cluster import RedisCluster
from redis.exceptions import RedisClusterException, ClusterDownError
import json
import logging

logger = logging.getLogger(__name__)

class ClusterCacheClient:
    def __init__(self, startup_nodes: list[dict]):
        self.client = RedisCluster(
            startup_nodes=startup_nodes,
            decode_responses=True,
            skip_full_coverage_check=True,
            socket_timeout=2.0,
            retry_on_timeout=True,
            max_connections=20
        )
    
    def get(self, key: str) -> dict | None:
        try:
            value = self.client.get(key)
            return json.loads(value) if value else None
        except ClusterDownError as e:
            logger.error(f"Cluster is down: {e}")
            raise
        except RedisClusterException as e:
            logger.error(f"Cluster error on GET {key}: {e}")
            raise
    
    def set(self, key: str, value: dict, ttl: int = 300) -> bool:
        try:
            return self.client.setex(key, ttl, json.dumps(value))
        except ClusterDownError as e:
            logger.error(f"Cluster is down: {e}")
            raise
        except RedisClusterException as e:
            logger.error(f"Cluster error on SET {key}: {e}")
            raise
    
    def pipeline_get_multi(self, keys: list[str]) -> dict:
        """
        Get multiple keys. In cluster mode, keys on different slots
        can't be in the same pipeline unless using hash tags.
        """
        results = {}
        for key in keys:
            results[key] = self.get(key)
        return results
# test_cluster_client.py
import pytest
from unittest.mock import MagicMock, patch
from redis.exceptions import ClusterDownError, RedisClusterException
from cluster_client import ClusterCacheClient
import json

@pytest.fixture
def mock_cluster_client():
    with patch('cluster_client.RedisCluster') as mock_cls:
        mock_instance = MagicMock()
        mock_cls.return_value = mock_instance
        
        client = ClusterCacheClient([{'host': 'localhost', 'port': 6379}])
        yield client, mock_instance

def test_get_returns_deserialized_value(mock_cluster_client):
    client, mock_redis = mock_cluster_client
    mock_redis.get.return_value = json.dumps({'id': 1, 'name': 'Alice'})
    
    result = client.get('user:1')
    
    assert result == {'id': 1, 'name': 'Alice'}

def test_get_returns_none_for_missing_key(mock_cluster_client):
    client, mock_redis = mock_cluster_client
    mock_redis.get.return_value = None
    
    result = client.get('missing:key')
    assert result is None

def test_get_propagates_cluster_down_error(mock_cluster_client):
    client, mock_redis = mock_cluster_client
    mock_redis.get.side_effect = ClusterDownError("CLUSTERDOWN")
    
    with pytest.raises(ClusterDownError):
        client.get('any:key')

def test_set_propagates_cluster_exception(mock_cluster_client):
    client, mock_redis = mock_cluster_client
    mock_redis.setex.side_effect = RedisClusterException("MOVED 1234 127.0.0.1:6380")
    
    with pytest.raises(RedisClusterException):
        client.set('any:key', {'data': 'value'})

def test_pipeline_get_multi_handles_keys_on_different_slots(mock_cluster_client):
    client, mock_redis = mock_cluster_client
    
    call_count = 0
    def get_side_effect(key):
        nonlocal call_count
        call_count += 1
        return json.dumps({'key': key})
    
    mock_redis.get.side_effect = get_side_effect
    
    keys = ['user:1', 'product:1', 'order:1']
    results = client.pipeline_get_multi(keys)
    
    assert len(results) == 3
    assert call_count == 3  # One GET per key (no pipeline batching across slots)

Testing Hash Tags for Multi-Key Operations

Redis Cluster routes keys based on their hash slot. Multi-key operations (MGET, MSET, pipelines) require all keys to be on the same slot. Hash tags ({...}) force a group of keys to the same slot.

# hash_tag_operations.py
from redis.cluster import RedisCluster
import json

r = RedisCluster(startup_nodes=[{'host': 'localhost', 'port': 6379}], decode_responses=True)

def get_user_with_profile(user_id: int) -> dict:
    """
    Use hash tags to ensure user data and profile are on the same slot.
    Keys: {user:1}:data and {user:1}:profile — both hash to same slot.
    """
    pipe = r.pipeline()
    pipe.get(f'{{user:{user_id}}}:data')
    pipe.get(f'{{user:{user_id}}}:profile')
    data_str, profile_str = pipe.execute()
    
    return {
        'data': json.loads(data_str) if data_str else None,
        'profile': json.loads(profile_str) if profile_str else None
    }

def set_user_with_profile(user_id: int, data: dict, profile: dict):
    pipe = r.pipeline()
    pipe.set(f'{{user:{user_id}}}:data', json.dumps(data))
    pipe.set(f'{{user:{user_id}}}:profile', json.dumps(profile))
    pipe.execute()
# test_hash_tags.py
def test_hash_tagged_keys_are_on_same_slot():
    """Verify that hash-tagged keys hash to the same CRC16 slot."""
    from redis.cluster import get_node_name
    import binascii
    
    def crc16(data: str) -> int:
        return binascii.crc_hqx(data.encode(), 0) % 16384
    
    def get_hash_slot(key: str) -> int:
        # Redis uses the content between {} if present
        start = key.find('{')
        end = key.find('}', start + 1)
        if start != -1 and end != -1 and end > start + 1:
            hash_key = key[start + 1:end]
        else:
            hash_key = key
        return crc16(hash_key)
    
    user_id = 42
    data_slot = get_hash_slot(f'{{user:{user_id}}}:data')
    profile_slot = get_hash_slot(f'{{user:{user_id}}}:profile')
    orders_slot = get_hash_slot(f'{{user:{user_id}}}:orders')
    
    assert data_slot == profile_slot == orders_slot, \
        "All hash-tagged keys should map to the same slot"

def test_untagged_keys_likely_on_different_slots():
    """
    Without hash tags, keys for the same user land on different slots,
    preventing multi-key operations.
    """
    import binascii
    
    def crc16(data: str) -> int:
        return binascii.crc_hqx(data.encode(), 0) % 16384
    
    slot1 = crc16('user:1:data')
    slot2 = crc16('user:1:profile')
    
    # These will almost certainly be on different slots
    # (probability ~16383/16384 they differ)
    # Document this behavior for developers
    print(f"user:1:data → slot {slot1}")
    print(f"user:1:profile → slot {slot2}")
    # No hard assertion — this is a documentation test

Testing Failover with Testcontainers

Setting up a real Redis Cluster requires multiple containers. Here's a minimal 3-node cluster setup:

# conftest_cluster.py
import subprocess
import time
import redis
from redis.cluster import RedisCluster
import pytest

def create_redis_cluster_docker():
    """
    Use docker-compose or direct container management to create a 3-node cluster.
    This is a simplified example — in practice use docker-compose.
    """
    # Using the convenient redis/redis-stack or bitnami/redis-cluster image
    result = subprocess.run([
        'docker', 'run', '-d',
        '--name', 'redis-cluster-test',
        '-p', '7000-7005:7000-7005',
        '-e', 'ALLOW_EMPTY_PASSWORD=yes',
        '-e', 'REDIS_CLUSTER_ENABLED=yes',
        'bitnami/redis-cluster:latest'
    ], capture_output=True, text=True)
    
    return result.stdout.strip()

@pytest.fixture(scope='session')
def redis_cluster():
    container_id = create_redis_cluster_docker()
    
    # Wait for cluster to be ready
    deadline = time.time() + 30
    cluster_client = None
    
    while time.time() < deadline:
        try:
            cluster_client = RedisCluster(
                startup_nodes=[{'host': 'localhost', 'port': 7000}],
                decode_responses=True,
                skip_full_coverage_check=True
            )
            cluster_client.ping()
            break
        except Exception:
            time.sleep(0.5)
    
    if not cluster_client:
        pytest.skip("Redis cluster not available")
    
    yield cluster_client
    
    subprocess.run(['docker', 'rm', '-f', container_id])

Testing Application Behavior During Failover

The most important failover tests verify that your application retries correctly and doesn't fail the user request:

# test_failover_resilience.py
import pytest
from unittest.mock import MagicMock, patch, call
from redis.exceptions import ConnectionError, ClusterDownError, TimeoutError
import time

def test_client_retries_on_connection_error(mock_cluster_client):
    client, mock_redis = mock_cluster_client
    
    # Simulate: first call fails (node down), second succeeds (after failover)
    mock_redis.get.side_effect = [
        ConnectionError("Connection refused"),
        '{"id": 1}'
    ]
    
    # Application should retry internally
    # This tests that your retry wrapper works
    from cluster_client import ClusterCacheClient
    
    call_count = 0
    original_get = mock_redis.get
    
    def retry_get(key, max_retries=3, delay=0.01):
        for attempt in range(max_retries):
            try:
                return original_get(key)
            except ConnectionError:
                if attempt == max_retries - 1:
                    raise
                time.sleep(delay)
    
    result = retry_get('user:1')
    assert result == '{"id": 1}'
    assert mock_redis.get.call_count == 2

def test_circuit_breaker_opens_after_threshold(mock_cluster_client):
    """
    Test that a circuit breaker prevents cascading failures
    when Redis is down.
    """
    client, mock_redis = mock_cluster_client
    mock_redis.get.side_effect = ConnectionError("Redis down")
    
    failure_count = 0
    FAILURE_THRESHOLD = 5
    
    for _ in range(FAILURE_THRESHOLD + 2):
        try:
            client.get('any:key')
        except (ConnectionError, ClusterDownError):
            failure_count += 1
    
    # After threshold, should open circuit (stop hitting Redis)
    # This tests your circuit breaker implementation
    assert failure_count >= FAILURE_THRESHOLD

def test_timeout_handled_gracefully(mock_cluster_client):
    client, mock_redis = mock_cluster_client
    mock_redis.get.side_effect = TimeoutError("Timeout")
    
    with pytest.raises(TimeoutError):
        client.get('slow:key')

def test_fallback_to_database_when_cluster_down(monkeypatch):
    """
    Your application should fall back to the database when Redis is unavailable.
    This is the most critical resilience test.
    """
    db_result = {'id': 1, 'name': 'Alice'}
    db_fetch = MagicMock(return_value=db_result)
    
    with patch('cluster_client.RedisCluster') as mock_cls:
        mock_instance = MagicMock()
        mock_instance.get.side_effect = ClusterDownError("CLUSTERDOWN")
        mock_cls.return_value = mock_instance
        
        from resilient_cache import get_with_fallback
        monkeypatch.setattr('resilient_cache.fetch_from_db', db_fetch)
        
        result = get_with_fallback('user:1')
        
        assert result == db_result
        db_fetch.assert_called_once()

Testing Slot Distribution and Hot Key Detection

# test_slot_distribution.py
def test_key_distribution_across_slots():
    """
    Verify that your key naming convention distributes across slots.
    Poor distribution causes hot nodes and performance issues.
    """
    import binascii
    
    def crc16(data: str) -> int:
        return binascii.crc_hqx(data.encode(), 0) % 16384
    
    # Simulate 1000 keys
    import random
    keys = [f'user:{random.randint(1, 100000)}' for _ in range(1000)]
    
    slots = [crc16(k) for k in keys]
    
    # Divide into 3 equal ranges (3 nodes with ~5461 slots each)
    node_counts = [0, 0, 0]
    for slot in slots:
        if slot < 5461:
            node_counts[0] += 1
        elif slot < 10923:
            node_counts[1] += 1
        else:
            node_counts[2] += 1
    
    total = sum(node_counts)
    
    # Each node should handle roughly 33% of keys
    # Allow 20% deviation from ideal
    for count in node_counts:
        percentage = count / total
        assert 0.20 <= percentage <= 0.47, \
            f"Node handling {percentage:.1%} of keys — distribution is uneven"

def test_hot_key_pattern_detected():
    """
    Detect if a key pattern would create hot slots.
    Example: all keys sharing same hash tag land on same slot.
    """
    import binascii
    
    def crc16(data: str) -> int:
        return binascii.crc_hqx(data.encode(), 0) % 16384
    
    def get_hash_slot(key: str) -> int:
        start = key.find('{')
        end = key.find('}', start + 1)
        if start != -1 and end != -1 and end > start + 1:
            return crc16(key[start + 1:end])
        return crc16(key)
    
    # BAD pattern: all session keys use same hash tag
    bad_pattern_slots = set(
        get_hash_slot(f'{{sessions}}:user:{i}') for i in range(1000)
    )
    
    # All map to the same slot — this is a hot slot
    assert len(bad_pattern_slots) == 1, "Expected all to be on same slot (hot key risk)"
    
    # GOOD pattern: include user ID in hash tag
    good_pattern_slots = set(
        get_hash_slot(f'{{user:{i}}}:session') for i in range(1000)
    )
    
    # Spread across many slots
    assert len(good_pattern_slots) > 100, "Expected good distribution across slots"

Chaos Testing for Redis Cluster

Chaos tests deliberately introduce failures to verify system behavior:

# test_chaos_redis.py
import threading
import time
import random
import fakeredis
import pytest
from unittest.mock import patch, MagicMock
from redis.exceptions import ConnectionError, TimeoutError

class ChaosRedis:
    """Wraps a Redis client and randomly injects failures."""
    
    def __init__(self, real_client, failure_rate: float = 0.1):
        self._client = real_client
        self.failure_rate = failure_rate
        self.injected_failures = 0
    
    def __getattr__(self, name):
        attr = getattr(self._client, name)
        if callable(attr):
            def wrapper(*args, **kwargs):
                if random.random() < self.failure_rate:
                    self.injected_failures += 1
                    raise ConnectionError(f"Chaos: simulated failure on {name}")
                return attr(*args, **kwargs)
            return wrapper
        return attr

def test_application_survives_10_percent_failure_rate(monkeypatch):
    """
    Under 10% Redis failure rate, application should succeed
    on at least 80% of requests (accounting for retries).
    """
    fake_r = fakeredis.FakeRedis(decode_responses=True)
    chaos = ChaosRedis(fake_r, failure_rate=0.1)
    
    import cache_service
    monkeypatch.setattr(cache_service, 'r', chaos)
    
    db_fetch = MagicMock(return_value={'data': 'value'})
    monkeypatch.setattr(cache_service, 'fetch_from_db', db_fetch)
    
    successes = 0
    failures = 0
    
    for i in range(100):
        try:
            cache_service.get_user(i % 10)
            successes += 1
        except Exception:
            failures += 1
    
    success_rate = successes / 100
    assert success_rate >= 0.80, \
        f"Success rate {success_rate:.1%} below acceptable threshold"
    
    print(f"Injected {chaos.injected_failures} failures, "
          f"success rate: {success_rate:.1%}")

Key Takeaways

  1. Test MOVED/ASK handling by verifying your client library is configured with retry_on_error and watching that it doesn't surface these as user-visible errors.
  2. Test failover recovery by simulating ConnectionError from one node and verifying your application retries or falls back gracefully.
  3. Test hash tag correctness with pure slot calculation—no Redis needed. Verify that keys that must be co-located actually hash to the same slot.
  4. Test key distribution to detect hot spots before they hit production.
  5. Chaos tests with controlled failure injection rates give you confidence in your retry and fallback logic without requiring a full cluster teardown.

Redis Cluster adds operational complexity. Your tests should document and verify every assumption your application makes about cluster topology, failure modes, and recovery behavior.

Read more

Start now free