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¶
| 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.
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 :
- Lit le deadline entrant
- Soustrait son traitement local estime
- Passe le deadline réduit a ses propres appels sortants
- 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.