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_beforeCommon 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
endMeasuring 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.5Pattern 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 resultBuilding 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.