Aller au contenu

Retry et backoff

Retenter intelligemment sans amplifier la panne — exponential backoff, jitter, budget de retry et deadline propagation.


Pourquoi retenter

Retenter une requête qui a échoué est souvent la bonne réponse face à une erreur transitoire : réseau instable, redémarrage d'un pod, pic de charge bref. La majorité des erreurs en production sont transitoires — un retry suffit à les absorber sans que l'utilisateur ne percoive quoi que ce soit.

Mais retenter sans précaution peut amplifier une panne au lieu de l'absorber. Si un service est déjà surchargé et que chaque client retente 3 fois, le trafic triple instantanément. Le service croule sous une charge 3x supérieure à la normale, exactement au moment où il est le moins capable de la traiter.

Le retry est un outil puissant. Mal utilisé, c'est une arme de destruction massive.


Exponential backoff avec jitter

Formule

\[ \text{delay} = \min(\text{base} \times 2^{\text{attempt}} + \text{random\_jitter},\ \text{max\_delay}) \]
Tentative Délai de base Jitter (aléatoire) Délai effectif
1 100ms 0-50ms ~100-150ms
2 200ms 0-100ms ~200-300ms
3 400ms 0-200ms ~400-600ms
4 800ms 0-400ms ~800-1200ms
5 1600ms 0-800ms ~1600-2400ms

Le backoff exponentiel réduit la charge sur un service qui se remet d'une panne. Chaque tentative successive attend plus longtemps, laissant au service le temps de récupérer.

Le jitter est obligatoire

Sans jitter, si 1000 clients échouent au même instant et réessaient tous exactement a t+200ms, ils créent un nouveau pic de charge synchronisé qui réabîme le service en cours de récupération. C'est le "thundering herd" — un troupeau qui charge en même temps.

Avec du jitter, les retries sont étalées dans le temps. Le service reçoit un flux régulier au lieu d'un pic synchronisé.

Trois variantes de jitter :

  • Full jitter\(\text{delay} = \text{random}(0,\ \text{base} \times 2^{\text{attempt}})\). Distribution large, très efficace pour désynchroniser.
  • Equal jitter\(\text{delay} = \frac{\text{base} \times 2^{\text{attempt}}}{2} + \text{random}\!\left(0,\ \frac{\text{base} \times 2^{\text{attempt}}}{2}\right)\). Garantit un délai minimum.
  • Decorrelated jitter\(\text{delay} = \text{random}(\text{base},\ \text{previous\_delay} \times 3)\). Chaque tentative est décorrélée de la précédente.

Le full jitter est le plus simple et le plus efficace dans la majorité des cas. Ne pas chercher a optimiser le type de jitter — l'important est d'en avoir un.


Budget de retry

Le problème de l'amplification

Sans budget, le retry amplifie la charge de manière incontrole :

graph LR
    Normal["Trafic normal\n1000 req/s"] -->|30% echec| Retry["+ Retries\n3 tentatives max"]
    Retry --> Total["Trafic effectif\n~1900 req/s"]
    Total -->|surcharge| Service["Service deja\nen difficulte"]
    Service -->|plus d'echecs| Retry

    style Service fill:#c44,color:#fff
    style Total fill:#e76f51,color:#fff

Si 30% des requêtes échouent et chacune est retentee 3 fois, le trafic effectif augmente de 90%. Le service déjà en difficulté reçoit presque le double de sa charge normale.

Fonctionnement du budget

Le budget de retry fixe une limite globale : le système ne doit pas générer plus de X% de trafic supplémentaire via des retries. Un budget typique : 10% du trafic nominal.

Calcul : sur une fenêtre glissante (ex: 1 minute), compter le nombre de requêtes initiales et le nombre de retries. Si le ratio retries / requêtes initiales dépassé le budget, les nouvelles requêtes échouent directement sans retry.

\[ \text{budget\_restant} = \text{max\_retry\_ratio} - \frac{\text{retries\_en\_cours}}{\text{requetes\_totales}} \]

Si \(\text{budget\_restant} \leq 0\) : pas de retry, echec immediat.

Google recommande un budget de retry a 10% dans ses bonnes pratiques SRE. Cela signifie que pour 1000 requêtes initiales par seconde, le système n'emettra pas plus de 100 retries supplémentaires, quelle que soit la gravite de la panne.

Implémentation

Le budget de retry est généralement centralise au niveau du client HTTP ou du SDK de communication inter-services. Chaque service maintient un compteur glissant du ratio retries / requêtes et bloque les retries quand le budget est épuisé.


Hedged requests

Les hedged requests sont une variante du retry qui n'attend pas l'échec pour retenter. Le client envoie la même requête a plusieurs replicas simultanément (ou après un court délai) et prend la première réponse reçue.

Quand utiliser

Les hedged requests sont adaptées quand :

  • La latence est plus importante que le coût en ressources
  • Le service cible a plusieurs replicas
  • L'opération est en lecture seule (idempotente par nature)
  • La variance de latence est élevée (certains replicas sont parfois lents)

Stratégies

Immédiate hedging — envoyer la requête a 2 replicas simultanément. Prendre la première réponse et annuler l'autre. Double le trafic mais réduit significativement la latence p99.

Delayed hedging — envoyer a un replica. Si la réponse n'arrive pas dans le délai du p50 (ou p75), envoyer a un second replica. Prendre la première réponse. Augmente le trafic de seulement 5-25% en conditions normales.

Stratégie Surcout trafic Réduction p99 Cas d'usage
Immédiate 100% (double) Forte Requêtes critiques, faible volume
Delayed 5-25% Modérée Trafic général, bon compromis
Aucun 0% Aucune Écritures, budget serre

Warning

Ne JAMAIS utiliser de hedged requests sur des opérations d'écriture non-idempotentes. Envoyer deux fois un paiement, une commande ou une notification crée des doublons. Réserver aux lectures et aux écritures idempotentes avec clé d'idempotence vérifiée côté serveur.


Idempotence et retry

Retenter une requête suppose qu'elle peut être exécutée plusieurs fois sans effet de bord. C'est la définition de l'idempotence.

Opérations naturellement idempotentes

  • GET /orders/42 — lire une commande. Pas d'effet de bord.
  • PUT /users/42 {name: "Alice"} — remplacer l'état complet. Le résultat est le même quelle que soit le nombre d'executions.
  • DELETE /orders/42 — supprimer une commande. La deuxieme suppression retourné 404, mais l'état final est le même.

Opérations non-idempotentes

  • POST /orders — créer une commande. Chaque exécution crée une nouvelle commande.
  • POST /payments — debiter un compte. Chaque exécution debite à nouveau.
  • PATCH /users/42 {balance: +100} — incrementer un solde. Chaque exécution ajoute 100.

Idempotency keys

Pour rendre une opération non-idempotente retentable, le client généré une clé unique (UUID) et l'envoie dans un header :

POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{"amount": 100, "currency": "EUR", "to": "merchant-42"}

Le serveur stocke la clé avec le résultat de la première exécution. Les requêtes suivantes avec la même clé retournent le même résultat sans re-exécuter l'opération.


Quand ne pas retenter

Toutes les erreurs ne meritent pas un retry. Retenter au mauvais moment gaspille des ressources et retarde la réponse à l'utilisateur.

Situation Retenter ? Raison
Erreur 5xx transitoire Oui Le serveur a probablement un problème temporaire
Timeout Oui Avec backoff et si la deadline globale le permet
Erreur 4xx Non L'erreur vient du client, retenter ne changera rien
Circuit ouvert Non La dépendance est connue defaillante, aller au fallback
Budget de retry épuisé Non Respecter la limite globale
Deadline globale depassee Non La réponse arriverait trop tard
Erreur métier Non Stock insuffisant, solde negatif — pas transitoire

Timeout et deadline propagation

Sans timeout, une requête peut bloquer indéfiniment — occupant un thread, une connexion, une transaction ouverte. Avec des timeouts mal configures ou isoles, des cascades de retries aggravent le problème.

Le problème des timeouts isoles

Chaque service fixe un timeout local, sans coordination avec ses dependants. Le service A attend 10s, appelle le service B avec un timeout de 8s, qui appelle le service C avec un timeout de 6s. Si la requête vers C prend 9s, A timeout a 10s et informe le client que la requête a échoué. Mais B et C continuent à traiter inutilement jusqu'à leurs propres timeouts, consommant des ressources pour un travail dont le résultat ne sera jamais utilisé.

Deadline propagation

La solution est de propager un budget temps global depuis le premier appelant. Chaque service calcule le temps restant avant de faire son appel suivant.

sequenceDiagram
    participant C as Client
    participant A as Service A
    participant B as Service B
    participant DB as Base de donnees

    C->>A: Requete (deadline: 5000ms)
    Note over A: Traitement local: 200ms\nTemps restant: 4800ms
    A->>B: Appel (deadline: 4800ms)
    Note over B: Traitement local: 300ms\nTemps restant: 4500ms
    B->>DB: Query (deadline: 4500ms)
    DB-->>B: Reponse (500ms)
    Note over B: Temps restant: 4000ms
    B-->>A: Reponse
    Note over A: Temps restant: 3700ms
    A-->>C: Reponse finale

Si a n'importe quelle étape le temps restant est inférieur à un seuil minimal (ex: 50ms), le service retourné immédiatement une erreur "deadline exceeded" sans lancer un appel voue a échouer.

Implémenter la propagation

En gRPC, les deadlines sont natives et propagees automatiquement via les Context. En HTTP, on transmet le budget restant via un header (Grpc-Timeout, X-Request-Deadline, ou un header custom).

Chaque service intermédiaire :

  1. Lit le deadline entrant
  2. Soustrait son traitement local estime
  3. Passe le deadline réduit a ses propres appels sortants
  4. Abandonne le traitement si le deadline est dépassé avant même d'appeler

Intégration retry + circuit breaker + deadline

Les trois mécanismes s'articulent dans un ordre précis :

graph TD
    Req["Requete"]
    DL{"Deadline\nrestant > seuil ?"}
    CB{"Circuit\nferme ?"}
    Budget{"Budget retry\ndisponible ?"}
    Call["Appel"]
    Retry{"Echec\ntransitoire ?"}
    Backoff["Backoff + jitter"]
    Fallback["Fallback"]
    OK["Succes"]

    Req --> DL
    DL -->|non| Fallback
    DL -->|oui| CB
    CB -->|ouvert| Fallback
    CB -->|ferme| Call
    Call -->|succes| OK
    Call -->|echec| Retry
    Retry -->|non retentable| Fallback
    Retry -->|retentable| Budget
    Budget -->|epuise| Fallback
    Budget -->|disponible| Backoff
    Backoff --> DL

    style Fallback fill:#e76f51,color:#fff
    style OK fill:#2a9d8f,color:#fff

L'ordre est important. Vérifier le deadline en premier (inutile de retenter si le temps est ecoule). Vérifier le circuit breaker ensuite (inutile d'appeler une dépendance connue defaillante). Vérifier le budget de retry enfin (inutile d'amplifier la charge). Seulement alors, retenter avec backoff et jitter.


Chapitre suivant : Health checks — signaler l'état de sante des services à l'orchestrateur.