Aller au contenu
Lecture : 0 %

Le retry qui a tué la production

Comment trois couches qui rejouent chacune trois fois multiplient la charge par soixante-quatre, pourquoi le backoff n'y change rien, et comment un budget de retry borne l'amplification.

Par Elias Varen7 min de lecture

Soit une requête qui traverse trois couches : le navigateur appelle le front, le front appelle l'API, l'API appelle PostgreSQL. Chaque couche a été écrite par quelqu'un de prudent, qui a configuré trois retries, soit quatre tentatives par couche. Le jour où la base ralentit, un seul clic produit 4 × 4 × 4 = 64 requêtes SQL.

La base ne ralentissait que parce qu'elle était à 90 % de sa capacité. Elle reçoit maintenant soixante-quatre fois sa charge nominale, et le retry, censé masquer une panne transitoire, vient de la rendre permanente.

Un retry est une décision d'envoyer plus de charge à un système dont le seul signal connu est qu'il n'arrive pas à suivre. Une telle décision mérite un budget.

L'arithmétique de l'amplification

Le calcul général est court. Si une couche fait jusqu'à n tentatives et que la couche en dessous échoue systématiquement, elle multiplie le trafic par n. Avec k couches empilées, le facteur est n^k.

Couches qui rejouent2 tentatives3 tentatives4 tentatives
1234
24916
382764
41681256

Ce tableau oublie encore deux multiplicateurs. Le premier est l'utilisateur qui, devant un écran figé, rafraîchit, et chaque rafraîchissement relance l'arbre entier. Le second est le timeout, parce qu'une requête abandonnée par l'appelant continue souvent de s'exécuter chez l'appelé. La tentative n° 2 s'ajoute alors à la n° 1 au lieu de la remplacer, et la base exécute les deux avant de jeter le résultat de la première.

L'amplification n'est pas non plus proportionnelle au taux d'erreur de façon rassurante. Avec 10 % d'échecs et trois retries dans une seule couche, la charge ajoutée est d'environ 11 %, ce qui reste tolérable. Mais ces 11 % poussent un système déjà tendu vers 20 % d'échecs, qui en ajoutent 25 %. La boucle se referme, et c'est cette boucle qui fait tomber le service bien plus que le pic initial.

Pourquoi le backoff ne suffit pas

La réponse réflexe est « backoff exponentiel avec jitter ». Il en faut, mais ça ne règle pas ce problème-là.

Le backoff étale les tentatives dans le temps. Le jitter les désynchronise, ce qui évite que mille clients rejouent à la même milliseconde après un redémarrage. Ni l'un ni l'autre ne réduit le nombre de tentatives. Si la panne dure plus longtemps que la séquence de backoff (100 ms, 200 ms, 400 ms, donc moins d'une seconde), chaque requête entrante consomme quand même ses quatre tentatives. En régime établi, la charge offerte reste multipliée par quatre ; elle est seulement lissée.

Le backoff traite la panne de quelques centaines de millisecondes, le budget traite la panne de dix minutes, et ce second outil manque presque partout. Augmenter le nombre de retries après un incident « pour être plus résilient » revient d'ailleurs à augmenter l'exposant du prochain.

Le budget de retry

Les retries y deviennent une ressource partagée par tout le processus au lieu d'un droit attaché à chaque requête. Chaque requête normale crédite une fraction de token, chaque retry en débite un. Avec un ratio de 0,1, les retries ne peuvent pas dépasser 10 % du trafic nominal, quel que soit le taux d'échec.

PHP
final class RetryBudget
{
    private float $tokens;

    public function __construct(
        private readonly float $ratio = 0.1,  // un retry financé par dix requêtes
        private readonly float $max = 20.0,   // réserve pour les petits accidents
    ) {
        $this->tokens = $max;
    }

    public function onFirstAttempt(): void
    {
        $this->tokens = min($this->max, $this->tokens + $this->ratio);
    }

    public function allowRetry(): bool
    {
        if ($this->tokens < 1.0) {
            return false;
        }
        $this->tokens -= 1.0;

        return true;
    }
}

En temps normal, 1 % des appels échouent, le budget est plein et chaque échec est rejoué, comme avec un retry classique. Quand la dépendance tombe, la réserve se vide en vingt retries, puis le client plafonne à 1,1 fois le trafic nominal au lieu de 4. La dépendance reçoit une charge qu'elle peut absorber en redémarrant.

L'autre règle compte autant que le budget : une seule couche rejoue. Je choisis la plus proche de la panne, parce qu'elle sait distinguer une erreur transitoire d'une erreur définitive et qu'elle rejoue le moins de travail. Les couches du dessus propagent l'échec sans le rejouer. Pour que cela tienne, la couche qui a épuisé ses tentatives doit le signaler par un code d'erreur ou un header « déjà rejoué, n'insistez pas », que l'appelant respecte. Sans ce signal, chaque couche redécouvre l'échec et recommence l'arbre.

Ce qui se rejoue et ce qui ne se rejoue pas

Un budget borne le volume. Il ne dit pas si le retry est correct.

  • Une erreur de validation, un 404, un refus d'autorisation : jamais, la réponse sera la même.
  • Un refus de connexion, un 503 avec indication d'attente : oui, la requête n'a pas été exécutée.
  • Un timeout : c'est le cas dangereux, car on ne sait pas si l'opération a eu lieu. Rejouer une lecture est sans conséquence ; rejouer une écriture sans clé d'idempotence fabrique un doublon, que vous découvrirez par un client débité deux fois.
  • Une erreur de sérialisation ou un deadlock PostgreSQL : oui, mais en rejouant la transaction entière plutôt que la dernière instruction.

Le timeout cumule les deux risques. C'est l'erreur la plus fréquente en surcharge, celle où rejouer ajoute de la charge à un travail déjà en cours, et celle où l'effet a peut-être déjà eu lieu. S'il ne fallait poser un budget qu'à un endroit, ce serait sur les retries après timeout.

Ce que le budget fait perdre, et les appels sans retry

Le budget a un prix. Pendant une panne partielle, des requêtes qui auraient réussi à la deuxième tentative échouent parce que la réserve est vide. On échange du taux de succès individuel contre la survie de l'ensemble. L'échange est bon, mais il se voit dans les métriques, et il faudra l'expliquer à celui qui lit le tableau de bord le lendemain.

Il ajoute aussi un état à surveiller. Un budget épuisé est un signal d'alerte de premier ordre, alors qu'un budget que personne ne regarde échoue en silence. J'expose trois compteurs : tentatives initiales, retries accordés, retries refusés.

Dans certains cas, je ne mets aucun retry synchrone.

  • L'appelant est un humain devant un écran. Il rejouera lui-même, avec plus de discernement. Un message d'erreur rapide vaut mieux que douze secondes de tentatives invisibles.
  • Le travail peut partir en file. Un mail transactionnel, un webhook sortant, une génération de document : le retry asynchrone, espacé de minutes, avec une file de messages morts au bout, n'amplifie rien sur le chemin de la requête. Il obéit à ses propres règles.
  • L'opération n'est pas idempotente et ne peut pas le devenir. Mieux vaut un échec visible qu'un doublon silencieux.
  • Il reste trop peu de temps. Si l'appelant a déjà renoncé, la tentative suivante est du travail pour personne ; c'est le sujet des échéances propagées.

Avant d'ajouter un retry à un appel

Les questions se posent dans cet ordre.

  1. Quelqu'un rejoue-t-il déjà au-dessus ou en dessous ? Si oui, on n'en ajoute pas, on le déplace.
  2. L'opération est-elle idempotente, ou protégée par une clé ? Si non, pas de retry après timeout.
  3. Quelles erreurs sont transitoires ? La liste s'écrit ; tout le reste remonte immédiatement.
  4. Quel est le facteur d'amplification maximal du chemin complet ? Il suffit de faire la multiplication. Au-delà de 2, il faut un budget.
  5. Qui partage le budget ? Le processus, la machine ou la dépendance appelée, jamais la requête.
  6. Que voit l'appelant quand le budget est vide ? Une erreur rapide, marquée comme non rejouable.
  7. A-t-on testé avec la dépendance coupée pendant dix minutes ? Dix minutes et non dix secondes, sous charge réelle, en mesurant ce que la dépendance reçoit.

Une équipe qui ne sait pas répondre de tête à la quatrième question ne connaît pas le comportement de son système en panne, et c'est par cette multiplication que je commencerais.

Le retry qui a tué la production · Deepstack