Resilience: Circuit Breakers and Retries
Keep systems healthy under partial failure.
Resilience: Circuit Breakers and Retries is a free PHP Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Resilience Patterns
In a distributed system, partial failure is the normal state — some dependency is always slow, restarting, or overloaded. Resilience is about designing each service so one sick dependency doesn't take your service (and then the whole system) down with it.
This lesson covers the core toolkit: timeouts, retries with backoff, circuit breakers, bulkheads, and graceful degradation — all from PHP.
Timeouts First
The single most important resilience setting is the timeout. Without one, a slow downstream pins your PHP-FPM workers waiting; requests pile up; you run out of workers; your service goes down because someone else was slow. This is the textbook cascading failure.
Set both a connect timeout and a total request timeout on every outbound call. Always.
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$http = new Client([
'connect_timeout' => 0.5, // fail fast if we can't even connect
'timeout' => 2.0, // hard cap on the whole call
]);
// A hung dependency now fails in 2s instead of holding a worker forever.Retries — Carefully
Transient failures (a blip, a brief network drop, a 503) often succeed on a second try. But retries are dangerous: retry too eagerly and you amplify load on an already-struggling service. Rules:
- Only retry idempotent operations and retryable errors (timeouts, 5xx, 429).
- Cap the number of attempts.
- Never retry 4xx client errors — they'll just fail again.
Exponential Backoff + Jitter
Fixed-interval retries from many clients synchronize into a thundering herd that hammers the recovering service in waves. The fix is exponential backoff (double the delay each attempt) plus jitter (randomization) to spread the load out.
<?php
function backoffDelay(int $attempt, float $base = 0.1, float $cap = 5.0): float {
$exp = min($cap, $base * (2 ** $attempt)); // 0.1, 0.2, 0.4, ...
return mt_rand(0, (int)($exp * 1000)) / 1000; // full jitter: 0..exp
}
for ($i = 0; $i < 5; $i++) {
printf("attempt %d -> wait %.3fs\n", $i, backoffDelay($i));
}A Retry Loop
Combining the pieces: bounded attempts, retry only on retryable errors, sleep with jittered backoff between tries, and surface the last failure if all attempts fail.
<?php
function withRetry(callable $op, int $maxAttempts = 4): mixed {
$attempt = 0;
while (true) {
try {
return $op();
} catch (\Throwable $e) {
$attempt++;
if ($attempt >= $maxAttempts || !isRetryable($e)) {
throw $e; // give up
}
usleep((int)(backoffDelay($attempt) * 1_000_000));
}
}
}
function isRetryable(\Throwable $e): bool { return $e->getCode() === 0 || $e->getCode() >= 500; }
function backoffDelay(int $a): float { return min(5.0, 0.1 * (2 ** $a)) * (mt_rand(0, 100) / 100); }The Circuit Breaker
Retries help with blips, but if a dependency is truly down, retrying every request just wastes time and resources. A circuit breaker tracks failures and, once they exceed a threshold, opens — short-circuiting calls and failing instantly instead of waiting on a dead service.
It has three states: Closed (calls flow, failures counted), Open (calls rejected immediately), and Half-Open (a few trial calls test recovery).
Breaker State Machine
The transitions: Closed → Open when failures cross the threshold. Open → Half-Open after a cool-down. Half-Open → Closed on a successful probe, or back to Open on failure. State must be shared across PHP processes (Redis/APCu), since each request is a fresh process.
<?php
final class CircuitBreaker {
public function __construct(
private int $threshold = 5,
private int $coolDown = 30, // seconds
) {}
public function call(callable $op, array &$state): mixed {
if ($state['status'] === 'open') {
if (time() - $state['openedAt'] < $this->coolDown) {
throw new \RuntimeException('Circuit open - failing fast');
}
$state['status'] = 'half-open'; // time to probe
}
try {
$result = $op();
$state = ['status' => 'closed', 'failures' => 0]; // recovered
return $result;
} catch (\Throwable $e) {
$state['failures'] = ($state['failures'] ?? 0) + 1;
if ($state['failures'] >= $this->threshold || $state['status'] === 'half-open') {
$state['status'] = 'open';
$state['openedAt'] = time();
}
throw $e;
}
}
}Bulkheads
Named after ship compartments, the bulkhead pattern isolates resources so a failure in one area can't sink the whole vessel. If all your workers can call the slow Reports service, a Reports outage can consume every worker and starve Checkout.
Partition resources — separate connection pools, separate worker pools/queues per dependency, or concurrency limits per downstream — so one failing dependency exhausts only its own slice.
Graceful Degradation & Fallbacks
When a non-critical dependency is unavailable, degrade instead of erroring. Serve a cached value, a default, or a reduced experience. The circuit breaker's open state is the natural trigger for the fallback path.
<?php
function getRecommendations(callable $remoteCall, Redis $cache, string $userId): array {
try {
$recs = $remoteCall($userId);
$cache->setex("recs:$userId", 3600, json_encode($recs));
return $recs;
} catch (\Throwable $e) {
// Fallback 1: last-known-good from cache
if ($cached = $cache->get("recs:$userId")) {
return json_decode($cached, true);
}
// Fallback 2: generic popular items - never block the page
return ['popular-1', 'popular-2'];
}
}Idempotency Keys for Safe Retries
Retries are only safe on idempotent operations. For a non-idempotent action like "charge card", attach an idempotency key the server uses to dedupe: if a retry arrives after the first attempt already succeeded (but the response was lost), the server returns the original result instead of charging twice.
<?php
function chargeWithRetry(callable $http, string $orderId, int $cents): array {
// Same key across all retries of THIS logical charge
$key = 'charge-' . $orderId;
return withRetry(fn() => $http('POST', '/charges', [
'headers' => ['Idempotency-Key' => $key],
'json' => ['order' => $orderId, 'amount' => $cents],
]));
}
function withRetry(callable $op) { return $op(); } // see earlier scenePutting It Together
The patterns compose, and order matters. A robust outbound call typically nests like this:
- Timeout on each individual attempt (innermost).
- Retry with backoff wrapping the timed call for transient blips.
- Circuit breaker wrapping the retry so a sustained outage trips fast.
- Bulkhead limiting how much capacity this dependency can consume.
- Fallback outermost, catching whatever bubbles up.
Libraries like Resilience4PHP-style wrappers exist, but understanding the layering matters more than any specific package.
Quick Check
Choosing the right pattern.
Recap
Surviving partial failure:
- Timeouts on every call prevent worker exhaustion and cascading failure.
- Retries only for idempotent ops and retryable errors, with exponential backoff + jitter.
- Circuit breakers fail fast (Closed → Open → Half-Open) when a dependency is down.
- Bulkheads isolate resources so one failure can't starve everything.
- Graceful degradation / fallbacks keep core flows alive.
Layered together, these turn inevitable failures into contained, recoverable events.
Frequently asked questions
Is the “Resilience: Circuit Breakers and Retries” lesson free?
Yes — the full text of “Resilience: Circuit Breakers and Retries” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.
What will I learn in “Resilience: Circuit Breakers and Retries”?
Keep systems healthy under partial failure. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start PHP Academy?
No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Resilience: Circuit Breakers and Retries” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this PHP Academy lesson?
Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- From Monolith to Microservices
- Service Communication: REST and gRPC
- API Gateways and Service Discovery
- Resilience: Circuit Breakers and Retries