Patrons de résilience

La page Monolithe, modulaire, microservices énumère ce que le réseau facture : un appel qui peut expirer alors que le travail a été fait, une disponibilité qui se multiplie le long d’une chaîne, des pannes partielles permanentes. Voici ce qu’on paie avec.

Ces patrons forment le pendant du catalogue du GoF à l’échelle distribuée. Ils ont été catalogués par Michael Nygard dans Release It! (2007), sous le nom de stability patterns, et ils partent d’un renversement de perspective :

Dans un système distribué, la panne partielle n’est pas un incident : c’est l’état normal. La question n’est pas comment l’éviter, mais comment échouer sans propager.

La panne se propage par les ressources, pas par les erreurs

C’est l’idée centrale, et elle est contre-intuitive. Une erreur est inoffensive : elle revient immédiatement, l’appelant la traite, la ressource est libérée. Ce qui tue un système, c’est un appel qui ne revient pas.

   B ralentit (sans tomber)
        │
        ▼
   A garde ses connexions ouvertes en attendant B
        │
        ▼
   Le pool de connexions de A est épuisé
        │
        ▼
   A ne répond plus à personne — y compris pour les requêtes
   qui n'avaient aucun besoin de B
        │
        ▼
   Les appelants de A s'épuisent à leur tour…

C’est la défaillance en cascade, et elle explique deux choses. D’abord pourquoi un service qui ralentit est plus dangereux qu’un service qui tombe : un service tombé renvoie une erreur immédiate, un service lent immobilise. Ensuite pourquoi le délai d’attente est le patron numéro un, avant tous les autres — sans lui, aucun des suivants ne sert à rien.

Les anti-patrons

Nygard les catalogue avant les remèdes, parce qu’on les reconnaît dans son propre code.

Anti-patronCe qui se passe
Appel sans délai d’attenteLe défaut d’un client HTTP est souvent « infini ». Une seule dépendance lente suffit à figer l’application
Reprise immédiateTrois tentatives sans attente triplent la charge sur un service déjà en difficulté
Tempête de reprisesTous les clients réessayent à la même seconde, et achèvent le service au moment où il redémarre
File non bornéeLa file absorbe la surcharge, la latence explose, la mémoire part — l’échec est différé, pas évité
Ressource partagéeUn seul pool de connexions pour toutes les dépendances : la plus lente les prive toutes
Chaîne synchroneCinq appels en série multiplient les indisponibilités et additionnent les latences
Client trop confiantAucune gestion d’échec côté appelant : la panne du voisin devient la sienne

Les patrons, dans l’ordre où il faut les appliquer

L’ordre compte plus que la liste : chaque patron suppose les précédents.

PatronEmpêcheCoût
1Délai d’attenteL’immobilisation des ressourcesPresque nul
2IdempotenceLes doubles effets lors des reprisesUne clé et une table
3Reprise avec recul et gigueL’échec sur incident transitoireCharge supplémentaire à surveiller
4CloisonnementQu’une dépendance en prive les autresDes pools séparés à dimensionner
5DisjoncteurL’acharnement sur un service mortRéglage délicat, faux positifs
6Dégradation gracieuseLa panne totale sur une fonction secondaireUn mode dégradé à concevoir et tester
7Délestage et limitation de débitL’écroulement sous la surchargeDes refus assumés

1. Le délai d’attente

Tout appel distant en a un. Sans exception, et explicitement : les valeurs par défaut des clients HTTP sont fréquemment infinies ou de plusieurs minutes, ce qui revient au même.

La règle moins connue est celle du budget : le délai doit décroître le long de la chaîne. Si la passerelle accorde 3 secondes, le service appelé doit accorder moins à son propre appel, sinon il travaille encore alors que son appelant a déjà abandonné — le travail est perdu et les ressources restent occupées des deux côtés.

2. L’idempotence

Prérequis absolu de la reprise : réessayer une opération non idempotente, c’est choisir de facturer deux fois. Le mécanisme est toujours le même — une clé fournie par l’appelant, et une table des clés déjà traitées.

Le sujet est développé côté publication d’événements dans Événements de domaine ; c’est la même mécanique.

3. La reprise avec recul exponentiel et gigue

Trois conditions, toutes nécessaires :

  • Uniquement sur les erreurs reprenables — une expiration, une erreur 503, une connexion refusée. Jamais sur une 400 ou une 404 : réessayer une requête invalide ne la rendra pas valide.
  • Un nombre borné de tentatives, deux ou trois. Au-delà, on n’aide plus personne.
  • Un recul exponentiel avec gigue : attendre 100 ms, 200 ms, 400 ms — plus un aléa. La gigue est la partie que l’on oublie, et c’est elle qui évite la tempête : sans elle, mille clients qui reculent de la même façon réessayent tous à la même milliseconde.
Warning

La reprise est le patron le plus dangereux du catalogue, parce qu’il amplifie la charge exactement quand le système en a le moins besoin. Trois tentatives sur trois niveaux de la chaîne font vingt-sept appels pour une requête utilisateur. Si un seul patron devait être retiré de cette liste sur un système en difficulté, c’est celui-là.

4. Le cloisonnement

Un pool de ressources par dépendance, au lieu d’un pool global. Le nom vient des compartiments étanches d’un navire : une voie d’eau noie un compartiment, pas le bateau.

Concrètement : dix connexions réservées au service de paiement, dix au service de recommandation. Le service de recommandation devient lent ? Ses dix connexions se remplissent, et le paiement continue de fonctionner. Sans cloisonnement, la recommandation — fonction secondaire — aurait emporté l’encaissement.

5. Le disjoncteur

Trois états, et sa valeur tient à un seul mot : échouer vite.

stateDiagram-v2
    [*] --> Ferme
    Ferme --> Ouvert : seuil d'échecs atteint
    Ouvert --> SemiOuvert : après le délai de repos
    SemiOuvert --> Ferme : l'appel d'essai réussit
    SemiOuvert --> Ouvert : l'appel d'essai échoue
    Ferme : Fermé — les appels passent
    Ouvert : Ouvert — échec immédiat, aucun appel
    SemiOuvert : Semi-ouvert — un seul appel d'essai

Quand le disjoncteur est ouvert, l’appel échoue immédiatement au lieu d’attendre le délai d’attente. Le gain n’est pas de protéger le service en panne — il l’est accessoirement — mais de libérer l’appelant : ses ressources ne sont plus immobilisées à attendre quelque chose dont on sait qu’il ne répondra pas.

D’où une conséquence à retenir : un disjoncteur sans délai d’attente ne sert à rien, puisqu’il n’y a alors rien à raccourcir.

6. La dégradation gracieuse

Répondre partiellement plutôt que pas du tout : la page produit s’affiche sans le bloc de recommandations, le tarif utilise la dernière grille connue, la recherche retombe sur un tri par date.

Warning

Le repli silencieux est un piège classique. Renvoyer une liste vide, un zéro ou une valeur en cache sans que l’appelant sache qu’il s’agit d’un repli est parfois pire qu’une erreur : « aucune allergie connue » et « je n’ai pas pu vérifier les allergies » ne sont pas la même information. Un repli doit être signalé dans la réponse, et le choix de dégrader ou d’échouer est une décision métier, pas technique.

7. Le délestage et la limitation de débit

Refuser du travail est une fonctionnalité. Au-delà de sa capacité, un système qui accepte tout sert tout le monde mal ; un système qui refuse 20 % des requêtes sert correctement les 80 % restants.

La limitation de débit protège d’un client trop gourmand ; le délestage protège de la charge globale, en rejetant tôt et à bas coût. Les deux supposent d’avoir décidé à l’avance quoi sacrifier — et une file bornée avec contre-pression, plutôt qu’une file infinie qui transforme un problème de débit en problème de mémoire.

En code

Délai d’attente, reprise bornée, recul exponentiel et gigue — le socle minimal.

​
interface Politique {
  tentatives: number;      // Bornées : 3, jamais « jusqu'à ce que ça marche »
  delaiMs: number;         // Décroît le long de la chaîne
  baseMs: number;
}

const reprenable = (e: unknown): boolean =>
  e instanceof TimeoutError ||
  (e instanceof HttpError && (e.statut === 503 || e.statut === 429));

async function appelerAvecReprise<T>(
  operation: (signal: AbortSignal) => Promise<T>,
  p: Politique,
): Promise<T> {
  let derniere: unknown;

  for (let essai = 0; essai < p.tentatives; essai++) {
    const controleur = new AbortController();
    const minuteur = setTimeout(() => controleur.abort(), p.delaiMs);
    try {
      return await operation(controleur.signal);
    } catch (e) {
      derniere = e;
      if (!reprenable(e)) throw e;                 // 400, 404 : inutile d'insister
      if (essai === p.tentatives - 1) break;

      const recul = p.baseMs * 2 ** essai;         // 100, 200, 400 ms
      const gigue = Math.random() * recul;         // ← évite la tempête
      await new Promise((r) => setTimeout(r, recul + gigue));
    } finally {
      clearTimeout(minuteur);
    }
  }
  throw derniere;
}
import random
import time
from dataclasses import dataclass
from typing import Callable, TypeVar

T = TypeVar("T")


@dataclass(frozen=True)
class Politique:
    tentatives: int = 3        # Bornées, jamais « jusqu'à ce que ça marche »
    delai_s: float = 2.0       # Décroît le long de la chaîne
    base_s: float = 0.1


def reprenable(e: Exception) -> bool:
    return isinstance(e, TimeoutError) or (
        isinstance(e, HttpError) and e.statut in (503, 429)
    )


def appeler_avec_reprise(
    operation: Callable[[float], T],
    p: Politique = Politique(),
) -> T:
    derniere: Exception | None = None

    for essai in range(p.tentatives):
        try:
            return operation(p.delai_s)            # le délai est passé à l'appel
        except Exception as e:
            derniere = e
            if not reprenable(e):
                raise                              # 400, 404 : inutile d'insister
            if essai == p.tentatives - 1:
                break

            recul = p.base_s * 2**essai            # 0,1 · 0,2 · 0,4 s
            time.sleep(recul + random.uniform(0, recul))   # ← gigue
    raise derniere  # type: ignore[misc]
use std::time::Duration;

pub struct Politique {
    pub tentatives: u32,        // Bornées, jamais « jusqu'à ce que ça marche »
    pub delai: Duration,        // Décroît le long de la chaîne
    pub base: Duration,
}

fn reprenable(e: &Erreur) -> bool {
    matches!(e, Erreur::Expiration | Erreur::Http(503) | Erreur::Http(429))
}

pub async fn appeler_avec_reprise<T, F, Fut>(
    operation: F,
    p: &Politique,
) -> Result<T, Erreur>
where
    F: Fn() -> Fut,
    Fut: std::future::Future<Output = Result<T, Erreur>>,
{
    let mut derniere = Erreur::Expiration;

    for essai in 0..p.tentatives {
        match tokio::time::timeout(p.delai, operation()).await {
            Ok(Ok(valeur)) => return Ok(valeur),
            Ok(Err(e)) if !reprenable(&e) => return Err(e),  // 400, 404 : on sort
            Ok(Err(e)) => derniere = e,
            Err(_) => derniere = Erreur::Expiration,
        }
        if essai + 1 == p.tentatives {
            break;
        }

        let recul = p.base * 2u32.pow(essai);               // 100, 200, 400 ms
        let gigue = Duration::from_millis(
            rand::random::<u64>() % recul.as_millis() as u64,
        );
        tokio::time::sleep(recul + gigue).await;            // ← gigue
    }
    Err(derniere)
}

Trois détails portent tout le bénéfice : le filtre reprenable, sans lequel on s’acharne sur des requêtes invalides ; la borne sur le nombre de tentatives ; et la gigue, la ligne qu’on supprime en premier quand on simplifie et qui est précisément celle qui empêche la tempête de reprises.

Où placer tout cela

Ces patrons appartiennent à l’adaptateur sortant de l’hexagone, jamais au domaine. Un disjoncteur dans une règle métier est une fuite d’infrastructure ; le domaine voit uniquement un port qui réussit ou échoue.

Une partie du travail peut aussi être déportée hors du code applicatif : un maillage de services ou une passerelle gèrent délais, reprises et disjoncteurs pour tous les appels sortants. C’est séduisant et cela a une limite nette — l’infrastructure ne sait pas ce qui est idempotent, ni ce qui est dégradable. Le repli et l’idempotence restent des décisions applicatives.

Côté exploitation, les sondes de vivacité et de disponibilité participent du même sujet, et sont traitées dans Kubernetes — sondes : une instance qui se déclare indisponible pendant qu’elle se rétablit est une forme de délestage.

Ce que ça coûte

Le débogage devient plus difficile. Une reprise masque l’erreur d’origine ; un disjoncteur ouvert produit des échecs dont la cause est ailleurs et plus tôt. Sans traçabilité corrélée, on cherche longtemps.

Les faux positifs. Un disjoncteur mal réglé s’ouvre sur une dépendance lente mais saine, et provoque la panne qu’il devait éviter. Les seuils demandent des mesures réelles, pas des valeurs recopiées.

Des modes dégradés à tester. Un repli jamais exercé ne fonctionne pas le jour où il sert. C’est du code qui ne tourne qu’en cas de panne — donc jamais, sauf si on le provoque.

Du réglage permanent. Délais, seuils, tailles de pool évoluent avec le trafic et les dépendances. Ce n’est pas une configuration qu’on écrit une fois.

Vérifier que ça fonctionne

Un patron de résilience non éprouvé est une hypothèse. Trois pratiques, par ordre d’accessibilité :

  • L’injection de fautes en test : un adaptateur qui expire, renvoie 503, ou répond avec trois secondes de retard. C’est le plus rentable, et cela tient dans les tests existants.
  • Les journées de panne : provoquer une défaillance en préproduction, en équipe, et observer. On y découvre systématiquement un délai d’attente manquant.
  • L’ingénierie du chaos en production, avec un périmètre restreint et un moyen d’arrêt. À réserver aux systèmes qui ont déjà les deux points précédents.

Quand ne pas s’en occuper

Toute cette page est un coût de la distribution. Un monolithe avec une seule base de données n’a besoin ni de disjoncteur, ni de cloisonnement, ni de reprise : il n’y a pas de panne partielle à absorber.

Deux choses restent utiles même dans ce cas, et devraient être en place partout : un délai d’attente sur chaque appel sortant — y compris vers la base et vers le moindre service tiers — et un pool de connexions borné. Le reste attend le premier appel réseau vers un service dont vous n’êtes pas propriétaire.

Références