Aller au contenu

Retry Storms : comment de bons clients font tomber des serveurs en bonne santé

11 juillet 2026 · 11 min read · Read in English

Sommaire

Un retry est la ligne de code la plus raisonnable que vous écrirez jamais. La requête a échoué, le réseau est capricieux, alors on réessaie. Ça marche sur votre machine, ça marche dans la démo, ça part en prod.

Puis un jour une dépendance en aval a trente mauvaises secondes, et votre retry emmène tout le système avec elle. Pas la dépendance qui échoue, votre retry. Les clients étaient en bonne santé. Le serveur qu'ils ont martelé était en train de revenir à la santé. Les retries se sont assurés qu'il n'y arrive jamais.

C'est la moitié côté client de la résilience. Le rate limiting protège un service du trafic qu'il reçoit ; les retries concernent le trafic que vos clients génèrent, et sous stress une politique de retry naïve génère exactement la mauvaise quantité au pire moment. On part du retry en une ligne vers la poignée de patterns qui rendent les retries sûrs : backoff, jitter, budgets et circuit breakers. Du Go pour les exemples, mais c'est le raisonnement qui compte.

Le retry naïf, et pourquoi c'est un piège

Voici le retry que tout le monde écrit en premier. On essaie, et si ça échoue, on réessaie quelques fois.

func Call(ctx context.Context, do func() error) error {
    var err error
    for attempt := 0; attempt < 3; attempt++ {
        if err = do(); err == nil {
            return nil
        }
        time.Sleep(100 * time.Millisecond) // on attend un peu, puis on réessaie
    }
    return err
}

Sur un seul client qui tape un serveur globalement sain, c'est très bien. Le piège n'apparaît qu'à l'échelle, et il apparaît d'un coup.

Imaginez un service avec 10 000 clients. Une dépendance derrière lui a un hoquet de deux secondes : une pause GC, une bascule, un verrou bref. Les requêtes commencent à échouer. Maintenant chacun de ces 10 000 clients fait la même chose au même moment : il échoue, attend 100 ms, et réessaie. Vous venez de transformer une vague de trafic en quatre vagues (l'originale plus trois retries), et les trois vagues supplémentaires atterrissent en grappe serrée pile quand le serveur est le moins capable de les absorber.

Le serveur était sur le point de se rétablir. À la place il se prend 4× sa charge normale, prend encore plus de retard, échoue plus de requêtes, et déclenche encore plus de retries. C'est un retry storm : une boucle de rétroaction où l'échec crée de la charge et la charge crée plus d'échecs. Le blip de deux secondes de la dépendance devient une panne de vingt minutes, et rien dans vos logs ne pointe le vrai coupable, parce que le coupable, c'est votre propre politique de retry.

Deux choses clochent ici, et elles demandent deux corrections différentes :

  1. Les retries arrivent trop vite et en trop grand nombre, entassant de la charge sur un serveur en difficulté.
  2. Les retries arrivent en lockstep (synchronisés), parce que tous les clients ont échoué à peu près au même instant et attendent le même délai fixe.

Correction un : le backoff exponentiel

La première correction, c'est d'arrêter de réessayer à un intervalle fixe et agressif. Attendez plus longtemps après chaque échec, pour qu'un serveur en difficulté ait progressivement plus d'air.

func Call(ctx context.Context, do func() error) error {
    const base = 100 * time.Millisecond
    var err error
    for attempt := 0; attempt < 5; attempt++ {
        if err = do(); err == nil {
            return nil
        }
        // 100ms, 200ms, 400ms, 800ms, ...
        backoff := base * (1 << attempt)
        select {
        case <-time.After(backoff):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return err
}

Doubler l'attente après chaque tentative (base × 2^attempt) signifie qu'un client qui continue d'échouer recule vite : 100 ms, 200 ms, 400 ms, 800 ms. La pression totale des retries sur le serveur chute nettement par rapport à un matraquage fixe à 100 ms, et une dépendance vraiment tombée n'est pas harcelée toutes les 100 ms indéfiniment.

C'est une vraie amélioration. Ce n'est aussi pas suffisant, et la raison est subtile : le backoff corrige à quelle fréquence un client réessaie, mais il ne fait rien contre tous les clients qui réessaient ensemble. Dix mille clients qui ont échoué au même instant réessaieront toujours au même instant, à 100 ms, puis à nouveau à 200 ms, puis à 400 ms. Les vagues sont plus espacées maintenant, mais ce sont toujours des vagues. Le serveur voit un troupeau qui charge (thundering herd), juste un peu plus étalé.

Correction deux : le jitter (c'est le point important)

L'ingrédient manquant, c'est l'aléa. Si chaque client calcule le même backoff, le backoff seul ne fait que reprogrammer la ruée. Ajoutez une composante aléatoire et le troupeau s'étale sur l'intervalle au lieu d'arriver en pic.

La version qui marche le mieux en pratique, c'est le full jitter : n'attendez pas le backoff, attendez une durée aléatoire entre zéro et le backoff.

func Call(ctx context.Context, do func() error) error {
    const base = 100 * time.Millisecond
    var err error
    for attempt := 0; attempt < 5; attempt++ {
        if err = do(); err == nil {
            return nil
        }
        // full jitter : on dort un point aléatoire dans [0, base*2^attempt)
        window := base * (1 << attempt)
        wait := time.Duration(rand.Int63n(int64(window)))
        select {
        case <-time.After(wait):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return err
}

Ça ressemble à un petit changement, un seul appel à rand, mais c'est la différence entre un pic et un filet régulier. Avec dix mille clients dormant chacun un point aléatoire dans [0, window), leurs retries se répartissent uniformément sur la fenêtre au lieu d'atterrir sur la même milliseconde. Le serveur voit un flux doux et continu qu'il peut réellement servir.

Il vaut la peine d'être précis sur pourquoi le full jitter bat le tentant compromis « backoff plus un peu de bruit » (window/2 + rand(0, window/2), souvent appelé equal jitter). L'equal jitter garantit encore que chaque client attend au moins la moitié du backoff, donc la moitié de l'intervalle est vide et tous les retries s'entassent dans la seconde moitié. Le full jitter utilise tout l'intervalle. L'analyse bien connue d'AWS à ce sujet a trouvé que le full jitter à la fois terminait le travail plus vite et matraquait le serveur avec bien moins d'appels concurrents. Dans le doute, full jitter.

Le backoff plus full jitter corrige le problème de timing : les retries sont maintenant espacés et désynchronisés. Mais il y a un problème plus profond qu'aucun timing malin ne résout.

Le problème que le timing ne peut pas régler : les retries multiplient la charge

Prenez du recul et comptez. Chaque retry est, par définition, de la charge en plus. Une politique qui autorise jusqu'à 3 retries peut, dans le pire cas, tripler le trafic qu'un service reçoit. Et le pire cas n'est pas rare, c'est exactement le moment où une dépendance échoue, parce que c'est là que les retries se déclenchent. Les retries ajoutent le plus de charge précisément quand le système a le moins de capacité pour la servir.

C'est l'insight qui sépare les gens qui ont exploité de gros systèmes de ceux qui ne l'ont pas fait : en surcharge, réessayer empire les choses, ça ne les améliore pas. Un seul client qui réessaie un blip transitoire s'aide lui-même pour un coût négligeable. Dix mille clients qui réessaient une panne systémique montent collectivement une attaque par déni de service sur un service déjà à terre. Le backoff et le jitter façonnent quand la charge arrive ; ils ne font rien pour plafonner combien.

Il vous faut donc un mécanisme qui regarde l'agrégat et dit : on réessaie trop, arrête.

Correction trois : les retry budgets

Un retry budget plafonne les retries en fraction du trafic vivant. La règle, popularisée par la pratique SRE de Google, est simple : n'autoriser les retries que jusqu'à environ 10 % de votre taux de requêtes réussies. Quand les échecs sont rares, 10 % suffit largement et chaque requête échouée obtient son retry. Quand une dépendance fond et que tout échoue, le budget est épuisé presque immédiatement, donc la grande majorité des échecs ne sont pas réessayés. Les retries sont freinés le plus fort exactement quand ils feraient le plus de dégâts.

// Un retry budget : les retries ne peuvent pas dépasser `ratio` des appels réussis récents.
type Budget struct {
    mu        sync.Mutex
    ratio     float64 // ex. 0.1 → retries plafonnés à 10 % des succès
    successes float64 // compteur amorti des succès récents
    retries   float64 // compteur amorti des retries récents
}

func (b *Budget) allowRetry() bool {
    b.mu.Lock()
    defer b.mu.Unlock()
    if b.retries >= b.successes*b.ratio {
        return false // budget épuisé, ne pas réessayer, échouer vite
    }
    b.retries++
    return true
}

func (b *Budget) onSuccess() {
    b.mu.Lock()
    b.successes++
    b.mu.Unlock()
}

(En production les compteurs s'amortissent sur une fenêtre glissante au lieu de croître éternellement, mais la forme est celle-ci.) Le point clé, c'est que le budget transforme « dois-je réessayer ? » d'une décision par requête en une décision consciente de la flotte. Un client ne peut plus contribuer à un storm, parce que le storm épuise le budget partagé et les retries suivants sont simplement refusés. Une requête échouée qui perd à la loterie du budget échoue vite, ce qui est presque toujours mieux qu'un retry qui s'entasse sur une panne.

Correction quatre : les circuit breakers

Le backoff, le jitter et les budgets tentent tous encore l'appel. Mais si une dépendance est tombée depuis trente secondes, chaque tentative est une requête gaspillée : une connexion, un timeout, de la latence que votre appelant paie, et un paquet de plus jeté à un service qui en a besoin de moins, pas de plus. Un circuit breaker arrête complètement de tenter une fois que les échecs franchissent un seuil.

C'est une machine à trois états :

  • Fermé (closed) : normal. Les requêtes passent. On compte les échecs.
  • Ouvert (open) : trop d'échecs. Rejeter immédiatement sans appeler la dépendance du tout. C'est le geste clé : vous échouez vite localement et vous donnez à la dépendance un silence total pour se rétablir.
  • Demi-ouvert (half-open) : après un temps de refroidissement, on laisse passer une seule requête d'essai. Si elle réussit, on ferme le circuit et on reprend. Si elle échoue, on rouvre et on attend plus longtemps.
type Breaker struct {
    mu          sync.Mutex
    failures    int
    threshold   int           // échecs consécutifs avant l'ouverture
    state       string        // "closed" | "open" | "half-open"
    openedAt    time.Time
    cooldown    time.Duration
}

func (b *Breaker) Call(do func() error) error {
    b.mu.Lock()
    if b.state == "open" {
        if time.Since(b.openedAt) < b.cooldown {
            b.mu.Unlock()
            return errors.New("circuit ouvert : échec rapide")
        }
        b.state = "half-open" // refroidissement écoulé, on autorise un essai
    }
    b.mu.Unlock()

    err := do()

    b.mu.Lock()
    defer b.mu.Unlock()
    if err != nil {
        b.failures++
        if b.state == "half-open" || b.failures >= b.threshold {
            b.state = "open"
            b.openedAt = time.Now()
        }
        return err
    }
    b.failures = 0
    b.state = "closed"
    return nil
}

Le vrai rôle du breaker n'est pas de protéger l'appelant de la latence, même s'il le fait. C'est de protéger la dépendance d'un appelant qui, sinon, continuerait de frapper à une porte fermée. Un circuit ouvert est l'équivalent côté client du serveur qui déleste (drop load) : les deux côtés s'accordent pour envoyer moins de trafic afin que le système puisse guérir.

Et la correction la moins chère de toutes : réessayer moins

Toutes les techniques jusqu'ici supposent que la requête vaut la peine d'être réessayée. Souvent elle ne l'est pas, et les plus gros gains viennent de ne pas réessayer du tout.

  • Ne réessayez que les opérations idempotentes. Réessayer un GET est sûr. Réessayer un POST /charge peut débiter un client deux fois. Si l'opération n'est pas idempotente, soit rendez-la idempotente (clés d'idempotence), soit ne la réessayez pas.
  • Ne réessayez que les échecs réessayables. Un 503 ou un timeout de connexion peut réussir la prochaine fois. Un 400, un 404 ou un 401 échouera à l'identique à chaque fois ; les réessayer est du pur gaspillage qui ne fait que nourrir le storm.
  • Bornez le temps total, pas juste le nombre de tentatives. Respectez la deadline de l'appelant. Un retry qui se déclenche après que l'utilisateur a déjà abandonné n'aide personne et coûte quand même une requête au serveur.

La moitié de la sûreté des retries, c'est juste refuser de réessayer des choses qui ne devraient jamais l'être.

Les couches, côte à côte

CoucheProblème qu'elle règleCe qu'elle ne règle pas
Backoff exponentielRéessayer trop viteTout le monde réessaie encore en lockstep
Full jitterLe thundering herd synchroniséLe volume total de retries reste non borné
Retry budgetLes retries qui multiplient la charge en surchargeLes appels gaspillés à une dépendance totalement tombée
Circuit breakerMarteler une dépendance connue comme morteRien, si vous réessayez des appels non idempotents ou non réessayables
Réessayer sélectivementRéessayer ce qui ne devrait jamais l'êtren/a

Aucune de ces couches ne suffit seule. Le backoff sans jitter reprogramme la ruée. Le jitter sans budget lisse une charge non bornée. Un budget sans breaker jette encore des appels gaspillés à un service mort. Elles se composent en une politique, et un client mûr les utilise toutes.

À retenir

  • Un retry est de la charge qui arrive quand le système est le plus faible. Ce seul recadrage explique chaque pattern ici : tout le jeu consiste à contrôler combien de charge supplémentaire vous générez pendant une panne, et quand.
  • Le backoff contrôle à quelle fréquence un client réessaie ; le jitter contrôle si tous les clients réessaient ensemble. Il vous faut les deux, et le full jitter bat toutes les demi-mesures.
  • En surcharge, réessayer est une attaque contre votre propre infrastructure. Un retry budget borne le rayon de souffle en plafonnant les retries en fraction du trafic vivant, freinant le plus fort exactement quand ça compte.
  • Un circuit breaker laisse l'appelant échouer vite et donne à une dépendance morte le silence pour se rétablir. Échouer vite est une fonctionnalité, pas un pis-aller.
  • Le retry le moins cher est celui que vous n'envoyez jamais. Ne réessayez que les opérations idempotentes, uniquement sur des erreurs réessayables, et seulement dans la deadline.

Le rate limiting et les retries sont les deux faces d'une même pièce. Le serveur déleste la charge qu'il ne peut pas servir ; le client envoie moins de charge quand le serveur souffre. Réussissez les deux et trente mauvaises secondes restent trente mauvaises secondes, au lieu de devenir une panne dont tout le monde se souvient.

S'abonner aux prochains articles

Recevez les nouveaux articles par e-mail. Pas de spam, désinscription à tout moment.

Propulsé par Buttondown.

Related posts

© 2026 < Denis AKPAGNONITE /> | N1BBzerLZXT