Jour 50 Day 50 · mardi 20 octobre 2026 Tuesday 20 October 2026 Backend Avancé
Robustesse : timeouts, retries & circuit breakers Robustness: timeouts, retries & circuit breakers
Timeouts, retries avec backoff, circuit breakers, graceful degradation : les patterns qui séparent un backend de démo d'un backend de production — et une mine de questions d'entretien système. Timeouts, retries with backoff, circuit breakers, graceful degradation: the patterns that separate a demo backend from a production one — and a goldmine of system design interview questions.
L’essentiel
Première des huit fallacies of distributed computing (Peter Deutsch, 1994) : « le réseau est fiable ». Il ne l’est pas. Dès que votre service appelle autre chose — une base, une API tierce, un microservice voisin — cet appel peut échouer, traîner, ou pire : réussir sans que vous receviez la réponse. Un backend de production n’est pas un backend où rien n’échoue ; c’est un backend qui échoue proprement.
La hiérarchie des défenses, dans l’ordre où on les pose :
- Timeout — ne jamais attendre indéfiniment. Un appel sans timeout est un thread (ou une connexion de pool) potentiellement bloqué pour toujours.
- Retry avec backoff + jitter — réessayer les échecs transitoires, en espaçant les tentatives et en les désynchronisant.
- Circuit breaker — arrêter d’appeler un service manifestement à terre, pour le laisser respirer et échouer vite.
- Graceful degradation — quand tout a échoué, servir quelque chose de dégradé (cache périmé, valeur par défaut) plutôt qu’une erreur 500.
L’erreur la plus dangereuse n’est pas d’oublier ces patterns : c’est d’en appliquer un seul naïvement. Un retry sans timeout ni limite est une machine à aggraver les pannes.
Comment ça marche
Timeouts et budget de latence : dans une chaîne A → B → C, les timeouts doivent décroître en cascade. Si A accorde 2 s à B, B ne peut pas accorder 3 s à C — sinon B répondra à A après que A a déjà abandonné : travail gaspillé et erreurs incohérentes. On raisonne en budget : le SLO de bout en bout se découpe entre les étages, chaque étage gardant une marge.
Retry, la version correcte : exponential backoff (1 s, 2 s, 4 s…) plafonné, avec jitter (aléa). Sans jitter, tous les clients qui ont échoué en même temps réessaient en même temps — vagues synchronisées qui re-écrasent le service. Et surtout : on ne retry que ce qui est idempotent ou sûr. Rejouer un POST /payments qui a en réalité réussi (la réponse s’est perdue) = paiement en double. D’où les idempotency keys : le client envoie un identifiant unique avec la requête, le serveur détecte le doublon et renvoie la première réponse (voir la fiche API design du 10 septembre).
// Retry avec exponential backoff + full jitter (AWS-style)
async function withRetry(fn, { retries = 3, baseMs = 500 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn(); // fn contient son propre timeout !
} catch (err) {
// Ne retry que le transitoire : 503, timeout, reset.
// Jamais un 400/404 (rejouer ne changera rien),
// jamais un POST non idempotent.
if (!isTransient(err) || attempt >= retries) throw err;
// Backoff exponentiel plafonné : 500, 1000, 2000 ms…
const cap = Math.min(baseMs * 2 ** attempt, 10_000);
// Full jitter : tirage uniforme dans [0, cap]
// → désynchronise les clients, évite les vagues.
const delay = Math.random() * cap;
await new Promise((r) => setTimeout(r, delay));
}
}
}
Circuit breaker : un compteur d’échecs par dépendance, trois états.
échecs > seuil
┌────────┐ ─────────────────▶ ┌────────┐
│ CLOSED │ │ OPEN │
│ (appels│ ◀───────────────── │ échec │
│ passent)│ succès │immédiat│
└────────┘ │ └────────┘
▲ │ │ après un délai
│ │ ▼
│ ┌───────────┐ laisse passer
└────── │ HALF-OPEN │◀─ quelques appels
succès des │ (test) │ de test
essais └───────────┘
│ échec → retour OPEN
Fermé (closed) : tout passe, on compte les échecs. Trop d’échecs : ouvert (open) — on échoue immédiatement sans appeler, le service en face souffle. Après un délai, semi-ouvert (half-open) : quelques appels de test ; succès → fermé, échec → ré-ouvert. Deux bénéfices : le service malade récupère, et vos propres threads ne s’entassent plus à attendre un mort.
| Problème | Pattern |
|---|---|
| Un appel qui ne répond jamais | Timeout |
| Échec transitoire (blip réseau, 503) | Retry + backoff + jitter |
| Dépendance durablement à terre | Circuit breaker |
| Une dépendance lente épuise tous les threads | Bulkhead (pools isolés) |
| L’échec total plutôt que le dégradé | Fallback, cache stale |
| Rejouer un POST non sûr | Idempotency key |
| Tous les clients reviennent en même temps | Jitter, ouverture progressive |
Concepts clés à maîtriser
- Bulkhead : cloisonner les ressources par dépendance (pools de connexions/threads séparés, quotas). Si l’API de recommandations devient lente, elle sature son pool de 10 connexions — pas les 200 du service entier. Nom emprunté aux cloisons étanches des navires.
- Graceful degradation : prévoir la version dégradée de chaque feature. Le service de reco est mort ? Afficher les meilleures ventes (statique). Le taux de change est indisponible ? Servir la dernière valeur connue avec son âge (cache stale, souvent acceptable). Une page à 90 % fonctionnelle vaut infiniment mieux qu’un 500.
- Health checks : distinguer liveness (« le process tourne-t-il ? » — sinon on le redémarre) et readiness (« peut-il servir du trafic ? » — sinon on le sort du load balancer sans le tuer). Piège classique : un health check qui teste aussi les dépendances peut sortir toutes les instances du LB quand la base a un blip — la panne locale devient totale.
- Thundering herd : mille clients (ou mille entrées de cache expirant ensemble) frappent l’origine au même instant — au redémarrage d’un service, à l’expiration d’un cache populaire. Parades : jitter sur les TTL et les reconnexions, request coalescing (une seule requête régénère le cache, les autres attendent), warm-up progressif.
💡 Échouer vite — un service qui répond « erreur » en 5 ms est un bien meilleur voisin qu’un service qui répond « erreur » en 30 s : il ne retient ni threads, ni connexions, ni l’utilisateur. Le circuit breaker est avant tout une machine à échouer vite.
En entretien
« Que se passe-t-il si cette API tierce ne répond pas ? » — LA question de system design. Dérouler la hiérarchie : timeout (avec budget cohérent dans la chaîne), retry backoff + jitter si transitoire et idempotent, circuit breaker si l’échec persiste, fallback dégradé (cache stale, valeur par défaut) en dernier ressort. Mentionner le monitoring : un breaker qui s’ouvre doit alerter.
« Pourquoi du jitter dans le backoff ? » — Sans jitter, tous les clients tombés en même temps réessaient aux mêmes instants : des vagues synchronisées frappent le service au moment où il tente de se relever. Le jitter étale les tentatives uniformément. Bonus : citer le papier AWS « Exponential Backoff and Jitter » et le full jitter.
« Explique les états d’un circuit breaker. » — Closed : trafic normal, comptage des échecs. Open : échec immédiat sans appel, pendant un délai de repos. Half-open : quelques requêtes de sonde ; succès → closed, échec → open. Ajouter le pourquoi : protéger le service aval ET libérer ses propres ressources.
« Quand un retry est-il dangereux ? » — Deux cas. 1) Opération non idempotente : la requête a pu réussir sans que la réponse arrive ; rejouer duplique (paiement, envoi d’email) → idempotency keys. 2) Service surchargé : les retries multiplient le trafic exactement quand il faudrait le réduire → retry storm. Réponse complète : limiter les tentatives, backoff + jitter, retry budget, et ne pas empiler les retries à chaque étage.
« Liveness vs readiness ? » — Liveness : le process est-il vivant (sinon restart). Readiness : est-il prêt à servir (sinon retiré du LB, sans restart). Les confondre = redémarrages en boucle pendant qu’une dépendance est lente.
Pièges & idées reçues
⚠️ Le retry storm qui achève le service — un service ralentit sous charge ; les timeouts clients expirent ; chaque client retry 3 fois → le trafic entrant est multiplié par 3-4 sur un service déjà à genoux ; il s’effondre ; les retries continuent, et empêchent tout redémarrage (chaque instance qui revient est instantanément saturée). C’est une cascading failure auto-entretenue — la moitié des grands incidents publics (AWS, Cloudflare) en contiennent une. Parades : budget de retries global (ex. max 10 % du trafic), circuit breakers, backoff + jitter, et load shedding (rejeter tôt l’excès plutôt que tout servir mal).
- Retries empilés : 3 tentatives au niveau HTTP client × 3 au niveau service × 3 au niveau gateway = jusqu’à 27 appels pour une requête. Décider d’UN étage propriétaire du retry.
- Timeout unique et généreux (« 30 s partout ») : trop long pour l’utilisateur, incohérent en cascade. Les timeouts se dimensionnent par appel, sur les percentiles observés (p99 + marge).
- Le circuit breaker n’est pas un retry : il ne réessaie rien, il empêche d’appeler. Les deux se combinent : retry pour les blips, breaker pour les pannes durables.
- Tester uniquement le chemin heureux : la robustesse se teste en injectant les pannes (timeouts simulés, chaos testing) — sinon vos fallbacks sont du code mort qui échouera le jour J.
Pour aller plus loin
- AWS Architecture Blog — Exponential Backoff and Jitter : le papier de référence, avec les simulations
- Amazon Builders’ Library — Timeouts, retries, and backoff with jitter : le guide appliqué
- Google SRE Book — Addressing Cascading Failures : le chapitre qui dissèque retry storms et load shedding
- Martin Fowler — CircuitBreaker et les libs resilience4j (Java) / Polly (.NET)
- Release It! (Michael Nygard) — le livre qui a nommé ces patterns, bourré de récits d’incidents réels
The essentials
First of the eight fallacies of distributed computing (Peter Deutsch, 1994): “the network is reliable”. It isn’t. As soon as your service calls anything else — a database, a third-party API, a neighboring microservice — that call can fail, drag on, or worse: succeed without you ever receiving the response. A production backend is not one where nothing fails; it’s one that fails cleanly.
The hierarchy of defenses, in the order you install them:
- Timeout — never wait forever. A call without a timeout is a thread (or pool connection) potentially blocked for good.
- Retry with backoff + jitter — retry transient failures, spacing attempts out and desynchronizing them.
- Circuit breaker — stop calling a service that is clearly down, let it breathe, and fail fast.
- Graceful degradation — when everything has failed, serve something degraded (stale cache, default value) rather than a 500.
The most dangerous mistake is not forgetting these patterns: it’s applying just one of them naively. A retry without a timeout or a limit is a machine for making outages worse.
How it works
Timeouts and the latency budget: in a chain A → B → C, timeouts must decrease down the cascade. If A gives B 2 s, B cannot give C 3 s — otherwise B will answer A after A has already given up: wasted work and inconsistent errors. Think in terms of a budget: the end-to-end SLO is split across the tiers, each tier keeping a margin.
Retry, the correct version: exponential backoff (1 s, 2 s, 4 s…) with a cap, plus jitter (randomness). Without jitter, all the clients that failed at the same moment retry at the same moment — synchronized waves that crush the service again. Above all: only retry what is idempotent or safe. Replaying a POST /payments that actually succeeded (the response got lost) = double charge. Hence idempotency keys: the client sends a unique identifier with the request, the server detects the duplicate and returns the first response (see the API design topic from September 10).
// Retry with exponential backoff + full jitter (AWS-style)
async function withRetry(fn, { retries = 3, baseMs = 500 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn(); // fn carries its own timeout!
} catch (err) {
// Only retry the transient: 503, timeout, reset.
// Never a 400/404 (replaying changes nothing),
// never a non-idempotent POST.
if (!isTransient(err) || attempt >= retries) throw err;
// Capped exponential backoff: 500, 1000, 2000 ms…
const cap = Math.min(baseMs * 2 ** attempt, 10_000);
// Full jitter: uniform draw in [0, cap]
// → desynchronizes clients, avoids waves.
const delay = Math.random() * cap;
await new Promise((r) => setTimeout(r, delay));
}
}
}
Circuit breaker: a failure counter per dependency, three states.
failures > threshold
┌────────┐ ─────────────────▶ ┌────────┐
│ CLOSED │ │ OPEN │
│ (calls │ ◀───────────────── │ instant│
│ pass) │ success │ fail │
└────────┘ │ └────────┘
▲ │ │ after a delay
│ │ ▼
│ ┌───────────┐ lets a few
└────── │ HALF-OPEN │◀─ probe calls
probes │ (test) │ through
succeed └───────────┘
│ failure → back to OPEN
Closed: everything passes, failures are counted. Too many failures: open — fail immediately without calling, the downstream service gets a breather. After a delay, half-open: a few probe calls; success → closed, failure → open again. Two benefits: the sick service recovers, and your own threads stop piling up waiting on a corpse.
| Problem | Pattern |
|---|---|
| A call that never answers | Timeout |
| Transient failure (network blip, 503) | Retry + backoff + jitter |
| Dependency down for good | Circuit breaker |
| One slow dependency exhausts all threads | Bulkhead (isolated pools) |
| Total failure instead of degraded | Fallback, stale cache |
| Replaying an unsafe POST | Idempotency key |
| All clients coming back at once | Jitter, gradual ramp-up |
Key concepts to master
- Bulkhead: partition resources per dependency (separate connection/thread pools, quotas). If the recommendations API gets slow, it saturates its pool of 10 connections — not the service’s 200. Named after a ship’s watertight compartments.
- Graceful degradation: plan the degraded version of every feature. Reco service dead? Show best-sellers (static). Exchange rate unavailable? Serve the last known value with its age (stale cache, often acceptable). A page that’s 90% functional beats a 500 every time.
- Health checks: distinguish liveness (“is the process running?” — if not, restart it) from readiness (“can it serve traffic?” — if not, pull it from the load balancer without killing it). Classic trap: a health check that also tests dependencies can pull all instances from the LB when the database blips — a local failure becomes a total one.
- Thundering herd: a thousand clients (or a thousand cache entries expiring together) hit the origin at the same instant — on a service restart, on a popular cache expiry. Countermeasures: jitter on TTLs and reconnections, request coalescing (one request regenerates the cache, the others wait), progressive warm-up.
💡 Fail fast — a service that answers “error” in 5 ms is a far better neighbor than one that answers “error” in 30 s: it holds no threads, no connections, and doesn’t hold the user hostage. A circuit breaker is above all a fail-fast machine.
In an interview
“What happens if this third-party API doesn’t respond?” — THE system design question. Walk the hierarchy: timeout (with a coherent budget down the chain), retry with backoff + jitter if transient and idempotent, circuit breaker if the failure persists, degraded fallback (stale cache, default value) as a last resort. Mention monitoring: a breaker opening must alert.
“Why jitter in the backoff?” — Without jitter, all the clients that failed together retry at the same instants: synchronized waves hit the service exactly as it tries to get back up. Jitter spreads attempts uniformly. Bonus: cite the AWS “Exponential Backoff and Jitter” post and full jitter.
“Explain the circuit breaker states.” — Closed: normal traffic, failures counted. Open: instant failure without calling, for a cool-down period. Half-open: a few probe requests; success → closed, failure → open. Add the why: protect the downstream service AND free your own resources.
“When is a retry dangerous?” — Two cases. 1) Non-idempotent operation: the request may have succeeded without the response arriving; replaying duplicates (payment, email send) → idempotency keys. 2) Overloaded service: retries multiply traffic exactly when it should shrink → retry storm. Complete answer: cap attempts, backoff + jitter, a retry budget, and don’t stack retries at every tier.
“Liveness vs readiness?” — Liveness: is the process alive (if not, restart). Readiness: is it ready to serve (if not, removed from the LB, no restart). Confusing them = restart loops while a dependency is merely slow.
Pitfalls & misconceptions
⚠️ The retry storm that finishes the service off — a service slows down under load; client timeouts expire; each client retries 3 times → incoming traffic is multiplied by 3-4 on a service already on its knees; it collapses; the retries keep coming and prevent any restart (every instance that comes back is instantly saturated). It’s a self-sustaining cascading failure — half the big public incidents (AWS, Cloudflare) contain one. Countermeasures: a global retry budget (e.g. max 10% of traffic), circuit breakers, backoff + jitter, and load shedding (reject the excess early rather than serving everything badly).
- Stacked retries: 3 attempts at the HTTP client × 3 at the service × 3 at the gateway = up to 27 calls for one request. Pick ONE tier that owns the retry.
- A single generous timeout (“30 s everywhere”): too long for the user, incoherent in cascade. Timeouts are sized per call, from observed percentiles (p99 + margin).
- A circuit breaker is not a retry: it retries nothing, it prevents calling. The two combine: retry for blips, breaker for lasting outages.
- Testing only the happy path: robustness is tested by injecting failures (simulated timeouts, chaos testing) — otherwise your fallbacks are dead code that will fail on the day it matters.
Going further
- AWS Architecture Blog — Exponential Backoff and Jitter: the reference post, with simulations
- Amazon Builders’ Library — Timeouts, retries, and backoff with jitter: the applied guide
- Google SRE Book — Addressing Cascading Failures: the chapter dissecting retry storms and load shedding
- Martin Fowler — CircuitBreaker and the resilience4j (Java) / Polly (.NET) libraries
- Release It! (Michael Nygard) — the book that named these patterns, packed with real incident stories