Resilience Testing Patterns & Strategies for Modern Software

Resilience Testing Patterns & Strategies for Modern Software

A system that works perfectly under ideal conditions but falls apart under load, partial failure, or dependency degradation is not production-ready. Resilience testing is the practice of deliberately exercising failure modes to verify that your system handles them correctly — degrading gracefully rather than cascading into total unavailability.

This guide covers the core resilience patterns, how to test each one, and how to build a resilience testing strategy that keeps pace with your system's evolution.

Why Resilience Testing Is Hard

Traditional testing validates that the system does what it's supposed to do when everything works. Resilience testing validates behavior when things break — and the failure modes of distributed systems are vast and often surprising.

The challenges:

  • Failure modes are environment-dependent. A retry that works locally against a mock may time out in production against a real service with variable latency.
  • Recovery behavior is hard to observe. It's easy to verify that an error is returned; it's harder to verify that the system recovered correctly and resumed normal operation.
  • Blast radius is difficult to control. Injecting failures in production risks affecting real users. Injecting failures in staging may not reproduce production behavior.
  • Time-based conditions are flaky. Tests that depend on timeouts are inherently slow and sometimes non-deterministic.

Despite these challenges, untested resilience patterns are often worse than no resilience pattern at all — they create false confidence.

Pattern 1: Timeout

The most fundamental resilience pattern. Every network call should have a timeout. Without one, a slow dependency can hold threads indefinitely, exhausting your thread pool and causing cascading failure.

What to Test

  • The system returns an appropriate error when the timeout fires
  • Resources (connections, threads) are released after timeout
  • The caller can distinguish a timeout from other error types
  • Configured timeout values are appropriate (not too short, not too long)

Test Approach

def test_payment_service_times_out():
    # Use ToxiProxy or a mock server with artificial delay
    with slow_dependency(delay_ms=6000):
        with pytest.raises(PaymentTimeoutError):
            payment_service.charge(amount=100, timeout_ms=5000)

def test_timeout_releases_connection():
    pool_size_before = db_pool.available_connections()
    
    with slow_dependency(delay_ms=6000):
        try:
            db.query("SELECT 1", timeout=1)
        except TimeoutError:
            pass
    
    # Connection should be returned to pool
    assert db_pool.available_connections() == pool_size_before

Common Bugs Found

  • Timeout configured in one library but not another (SDK timeout vs. OS TCP timeout)
  • Timeout fires but connection isn't closed (resource leak)
  • Timeout error wrapped in a generic exception that's swallowed
  • Timeout set to 30 seconds when the dependent SLA is 5 seconds

Pattern 2: Retry

Retries handle transient failures — a database restart, a brief network blip, a rate-limited API response. The danger is retrying on non-transient failures (which amplifies load on an already-struggling system) or retrying without backoff (which creates thundering herd problems).

What to Test

  • Retries happen on transient errors (connection reset, 503, 429)
  • Retries do NOT happen on non-transient errors (400, 401, 404)
  • Backoff increases between retries
  • Maximum retry count is enforced
  • Jitter is applied so retries don't synchronize across clients
  • The correct response is returned when a retry succeeds
  • The original error is propagated when all retries fail

Test Approach

describe 'retry behavior' do
  it 'retries on connection reset and succeeds' do
    call_count = 0
    allow(http_client).to receive(:get) do
      call_count += 1
      raise Net::ConnectionRefused if call_count < 3
      { status: 200, body: '{"ok":true}' }
    end
    
    result = api_client.fetch_data
    expect(result['ok']).to be true
    expect(call_count).to eq(3)
  end

  it 'does not retry on 404' do
    allow(http_client).to receive(:get).and_return({ status: 404, body: '' })
    expect { api_client.fetch_data }.to raise_error(NotFoundError)
    expect(http_client).to have_received(:get).once
  end

  it 'enforces maximum retry count' do
    allow(http_client).to receive(:get).and_raise(Net::ConnectionRefused)
    expect { api_client.fetch_data }.to raise_error(MaxRetriesExceeded)
    expect(http_client).to have_received(:get).exactly(4).times  # initial + 3 retries
  end
end

Measuring Backoff

def test_exponential_backoff():
    call_times = []
    
    def failing_call():
        call_times.append(time.time())
        raise ConnectionError()
    
    with pytest.raises(MaxRetriesError):
        retry_with_backoff(failing_call, max_retries=3, base_delay=0.1)
    
    assert len(call_times) == 4
    delays = [call_times[i+1] - call_times[i] for i in range(3)]
    
    # Each delay should be roughly double the previous (within tolerance)
    assert delays[1] > delays[0] * 1.5
    assert delays[2] > delays[1] * 1.5

Pattern 3: Circuit Breaker

A circuit breaker monitors failure rates and "opens" when failures exceed a threshold, stopping requests to the failing dependency for a cooldown period. After cooldown, it enters "half-open" state, allows a test request, and either closes (resumes normal operation) or reopens (extends the outage).

States: Closed (normal) → Open (blocking requests) → Half-open (probing) → Closed or Open

What to Test

  • Circuit opens after threshold failures
  • Open circuit returns failure immediately without calling the dependency
  • Half-open state allows a probe request
  • Successful probe closes the circuit
  • Failed probe reopens the circuit and resets cooldown
  • Metrics are recorded correctly (success rate, latency, state transitions)

Test Approach

@Test
void circuitOpensAfterThresholdFailures() {
    CircuitBreaker cb = CircuitBreaker.ofDefaults("test");
    AtomicInteger callCount = new AtomicInteger(0);
    
    Supplier<String> decoratedCall = CircuitBreaker.decorateSupplier(cb, () -> {
        callCount.incrementAndGet();
        throw new RuntimeException("dependency down");
    });
    
    // Trigger failures to open circuit
    IntStream.range(0, 10).forEach(i -> {
        try { decoratedCall.get(); } catch (Exception ignored) {}
    });
    
    assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
    
    // Next call should fail immediately without calling the dependency
    int callsBeforeOpen = callCount.get();
    assertThrows(CallNotPermittedException.class, decoratedCall::get);
    assertThat(callCount.get()).isEqualTo(callsBeforeOpen);  // no new call
}

@Test
void circuitClosesAfterSuccessfulProbe() throws InterruptedException {
    CircuitBreaker cb = CircuitBreaker.of("test", CircuitBreakerConfig.custom()
        .waitDurationInOpenState(Duration.ofMillis(100))
        .build());
    
    // Open the circuit
    openCircuit(cb);
    
    // Wait for cooldown
    Thread.sleep(150);
    assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN);
    
    // Successful probe closes the circuit
    cb.executeSupplier(() -> "ok");
    assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
}

Pattern 4: Bulkhead

The bulkhead pattern limits concurrent usage of a resource, preventing one consumer from exhausting resources needed by others. It's named after ship compartments that limit flooding to one section.

Types: Thread pool isolation (each dependency gets its own thread pool) and Semaphore isolation (limits concurrent calls via a semaphore).

What to Test

  • Requests are rejected when the bulkhead is full
  • Rejected requests get a clear error, not a hang
  • One exhausted bulkhead doesn't affect others
  • Bulkhead capacity is appropriate for expected load
@Test
void bulkheadRejectsExcessiveConcurrency() throws InterruptedException {
    Bulkhead bulkhead = Bulkhead.of("test", BulkheadConfig.custom()
        .maxConcurrentCalls(3)
        .maxWaitDuration(Duration.ZERO)
        .build());
    
    CountDownLatch latch = new CountDownLatch(3);
    List<Thread> threads = IntStream.range(0, 3)
        .mapToObj(i -> new Thread(() -> {
            bulkhead.executeRunnable(() -> {
                latch.countDown();
                sleep(500);  // hold the bulkhead slot
            });
        }))
        .peek(Thread::start)
        .collect(Collectors.toList());
    
    latch.await();  // all 3 slots are now occupied
    
    // 4th call should be rejected
    assertThrows(BulkheadFullException.class, 
        () -> bulkhead.executeRunnable(() -> {}));
    
    threads.forEach(t -> t.join());
}

Pattern 5: Fallback

A fallback provides an alternative response when the primary call fails. Options include returning cached data, a default value, or calling a secondary service.

What to Test

  • Fallback is invoked on failure (not on success)
  • Fallback returns appropriate data
  • Fallback doesn't fail silently (errors in fallback should be visible)
  • Fallback behavior is acceptable to users (degraded but functional)
def test_fallback_serves_cached_data():
    cache.set('product:123', {'name': 'Widget', 'price': 9.99})
    
    with product_api.down():
        result = product_service.get(123)
    
    assert result['name'] == 'Widget'
    assert result['price'] == 9.99
    assert result['_from_cache'] is True

def test_fallback_not_invoked_on_success():
    with product_api.returning({'name': 'Widget', 'price': 12.99}):
        result = product_service.get(123)
    
    assert result['price'] == 12.99  # live price, not cached
    assert '_from_cache' not in result

Building a Resilience Test Suite

Layer Your Tests

Unit tests — test the resilience logic in isolation with mocks. Fast, deterministic, good for verifying retry counts and backoff math.

Integration tests with ToxiProxy — test actual network behavior with a real running dependency. Slower but catches real TCP-level issues unit tests miss.

Chaos tests in staging — run chaos experiments against the full system to catch emergent behavior that integration tests miss.

Continuous monitoring — run functional tests during chaos experiments or use HelpMeTest to monitor user-visible behavior continuously.

Define Steady State First

Before injecting failures, define what "healthy" means:

  • Error rate below 0.1%
  • p99 latency below 500ms
  • No queue depth growth above 1000 messages

Your hypothesis becomes: "When dependency X fails, steady state is maintained." If it's not, that's a finding — not a test failure.

Cover Recovery, Not Just Failure

The most commonly missed test: what happens when the dependency recovers? Some bugs only surface during the transition from degraded to healthy:

  • Circuit breaker doesn't close after recovery
  • Cache entries aren't invalidated when fresh data becomes available
  • Thread pool doesn't accept new work after being drained

Always test the full lifecycle: normal → degraded → recovery → normal.

Track Resilience Over Time

Resilience patterns erode through feature development. A retry wrapper that works today can be bypassed by a new code path next sprint. HelpMeTest's continuous monitoring catches this kind of regression — if a test that verifies retry behavior starts failing after a deployment, the alert fires before users notice.

Resilience testing isn't a one-time exercise. It's a continuous practice that keeps pace with your system's evolution.

Read more

Start now free