Aller au contenu
Mohamed Tahiri
Miami --:--
← Tous les articles

Réessayer sans aggraver la panne : backoff et jitter

Quand un service tombe, tous ses clients réessaient au même moment. Pourquoi doubler les délais ne suffit pas, et comment un peu de hasard sauve la reprise. Avec une simulation de tempête de réessais.

Imaginez une API qui répond 503 pendant six cents millisecondes. Rien de grave : un déploiement, un serveur qui redémarre. Sauf que deux cents clients reçoivent l’erreur au même moment, et que chacun fait la chose raisonnable : réessayer. Deux cents décisions raisonnables prises dans la même milliseconde, vues du serveur, ressemblent beaucoup à une attaque.

J’ai dû écrire cette logique pour de vrai en construisant les SDK d’une API d’horaires : le client réessaie avec un backoff exponentiel et du jitter, respecte l’en-tête Retry-After jusqu’à 60 secondes, et ne réessaie jamais ce qui ne doit pas l’être. Aucune de ces idées n’est de moi. La plupart viennent d’un article publié sur le blog d’architecture d’AWS en 2015, Exponential Backoff And Jitter, qui reste la meilleure référence que je connaisse sur le sujet.

Ce qui m’a convaincu, en revanche, c’est de simuler la panne. Le graphique ci-dessous montre pourquoi le jitter compte plus que le backoff.

Le scénario

Deux cents clients envoient une requête dans la même fraction de seconde, par exemple parce qu’un déploiement vient de couper leurs connexions. Le serveur est indisponible pendant 600 ms, puis il revient, capable de traiter 24 requêtes toutes les 100 ms. Chaque client a droit à huit tentatives. Seule change la stratégie de réessai.

Réessayer tout de suite

La première stratégie est celle qu’on écrit quand on est pressé : une boucle, une pause fixe de 100 ms, huit essais. Pendant la panne, le serveur reçoit deux cents requêtes toutes les 100 ms, toutes refusées. Quand il revient, il en accepte 24 par tranche, les autres échouent et repartent aussitôt.

Les clients brûlent leurs tentatives pendant la panne, et la plupart abandonnent alors que le serveur fonctionne déjà. À peine un quart est servi. C’est le pire des deux mondes : beaucoup de charge, peu de résultats. Et dans un vrai système, cette charge tombe sur un service qui essaie justement de redémarrer. Elle peut suffire à le refaire tomber.

Doubler les délais

Le backoff exponentiel est la correction classique : on attend 100 ms, puis 200, 400, 800, et ainsi de suite jusqu’à un plafond. Les clients laissent enfin respirer le serveur. Mais regardez le graphique : la charge arrive en pics. Les deux cents clients ont échoué au même instant, ils ont tous attendu exactement la même durée, ils reviennent donc tous au même instant.

Chaque vague dépasse largement la capacité. Le serveur en sert 24, les autres repartent ensemble pour une attente deux fois plus longue. La reprise s’étire sur près de dix secondes, et une partie des clients abandonne quand même. Le backoff réduit le nombre d’appels. Il ne fait rien contre la synchronisation.

Ajouter du hasard

La solution tient en une ligne. Au lieu d’attendre exactement le délai calculé, chaque client tire une durée au hasard entre zéro et ce délai : c’est ce que l’article d’AWS appelle le full jitter. Les clients qui avaient échoué ensemble ne reviennent plus ensemble, les pics s’étalent en une charge à peu près continue, et cette charge passe sous la capacité du serveur.

Dans la simulation, tous les clients sont servis en quelques secondes, avec moins d’appels que les deux autres stratégies. Rejouez plusieurs fois : le tirage change, le résultat tient.

retry.py
import random
import time

RETRYABLE = {429, 502, 503, 504, 529}

def send_with_retries(send, attempts=6, base=0.25, cap=8.0, deadline=30.0):
    """Retry with full jitter, honour Retry-After, and give up at the deadline."""
    start = time.monotonic()
    for attempt in range(attempts):
        response = send()
        if response.status_code not in RETRYABLE:
            return response
        delay = random.uniform(0, min(cap, base * 2**attempt))
        hint = response.headers.get("retry-after", "")
        if hint.isdigit():
            # trust the server, but never wait more than a minute
            delay = max(delay, min(int(hint), 60))
        if time.monotonic() - start + delay > deadline:
            break
        time.sleep(delay)
    return response

Écouter ce que dit le serveur

Quand un serveur répond 429 ou 503, il ajoute parfois un en-tête Retry-After, avec un nombre de secondes ou une date. C’est la meilleure information disponible, puisqu’elle vient de celui qui connaît sa propre charge. Mon client la respecte, avec un plafond de 60 secondes : un en-tête mal configuré qui annonce une heure d’attente ne doit pas geler une application.

Les API de modèles de langage ont leurs propres codes. Celle d’Anthropic renvoie 529 quand elle est surchargée, et ses SDK officiels réessaient déjà deux fois par défaut, avec backoff. Avant d’écrire une boucle autour d’un client, il vaut la peine de vérifier ce qu’il fait déjà : sinon, on empile deux politiques de réessai sans le savoir.

Savoir quand abandonner

Un nombre de tentatives est une limite pratique, mais ce qui compte pour l’utilisateur, c’est une échéance. Dans le même projet, une skill Alexa doit diffuser des annonces sonores à l’heure exacte dans chaque lieu, et une annonce en retard est pire qu’une annonce manquée. Le worker abandonne donc tout message vieux de plus de cinq minutes. La visibilité de la file est réglée à 30 secondes pour que trois tentatives tiennent dans cette fenêtre, et la file des messages en échec déclenche une alarme, mais on ne la rejoue jamais.

Certaines requêtes ne doivent pas être réessayées du tout. Un 400 ou un 422 échouera de la même façon dans dix secondes. Et un POST qui crée une commande ne peut être rejoué que si le serveur sait reconnaître le doublon, en général grâce à une clé d’idempotence envoyée par le client.

Réessayer

429 et 503 en lisant Retry-After, 502 et 504, coupures réseau, délais dépassés sur une lecture.

Ne jamais réessayer

400, 401, 403, 404, 422 : la même requête donnera la même réponse.

Seulement avec une clé d’idempotence

Tout ce qui crée, paie ou envoie quelque chose.

Ne réessayer qu’à un seul endroit

Dernier piège, plus sournois : les réessais s’empilent. Si le navigateur réessaie trois fois, que le backend réessaie trois fois chaque appel au service suivant, et que ce service réessaie trois fois sa base de données, une seule erreur au fond de la pile devient 27 requêtes sur la base. Au moment précis où elle est en difficulté.

La règle que j’applique : un seul niveau réessaie, en général le plus proche de la ressource qui a échoué, et les autres propagent l’erreur. Les équipes qui gèrent de gros volumes vont plus loin avec un budget de réessais : au-delà d’une petite fraction du trafic, on arrête de réessayer. Un service qui échoue à ce point a besoin de calme, pas de renfort.

La liste que je garde sous la main

  • Backoff exponentiel plafonné, et toujours du jitter : un délai tiré entre zéro et min(plafond, base × 2ⁿ).
  • Retry-After quand il existe, avec un plafond raisonnable.
  • Une échéance plutôt qu’un simple nombre de tentatives.
  • Jamais de réessai sur une erreur client, et jamais d’écriture rejouée sans clé d’idempotence.
  • Un seul niveau de la pile réessaie.
Contact

Construisons quelque chose.

Ou laissez un message