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 :

  1. Timeout — ne jamais attendre indéfiniment. Un appel sans timeout est un thread (ou une connexion de pool) potentiellement bloqué pour toujours.
  2. Retry avec backoff + jitter — réessayer les échecs transitoires, en espaçant les tentatives et en les désynchronisant.
  3. Circuit breaker — arrêter d’appeler un service manifestement à terre, pour le laisser respirer et échouer vite.
  4. 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èmePattern
Un appel qui ne répond jamaisTimeout
Échec transitoire (blip réseau, 503)Retry + backoff + jitter
Dépendance durablement à terreCircuit breaker
Une dépendance lente épuise tous les threadsBulkhead (pools isolés)
L’échec total plutôt que le dégradéFallback, cache stale
Rejouer un POST non sûrIdempotency key
Tous les clients reviennent en même tempsJitter, 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

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:

  1. Timeout — never wait forever. A call without a timeout is a thread (or pool connection) potentially blocked for good.
  2. Retry with backoff + jitter — retry transient failures, spacing attempts out and desynchronizing them.
  3. Circuit breaker — stop calling a service that is clearly down, let it breathe, and fail fast.
  4. 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.

ProblemPattern
A call that never answersTimeout
Transient failure (network blip, 503)Retry + backoff + jitter
Dependency down for goodCircuit breaker
One slow dependency exhausts all threadsBulkhead (isolated pools)
Total failure instead of degradedFallback, stale cache
Replaying an unsafe POSTIdempotency key
All clients coming back at onceJitter, 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

S'entraîner sur ce sujet → Practice this topic →