Votre logique de retry vous ment
Par Ulrich Dohou, Software Engineer
La semaine dernière, un client m’a envoyé un graphique de latence avec une question simple : « Pourquoi est-ce que notre p99 a triplé depuis qu’on a ajouté les retries ? » La réponse était dans la question. Les retries étaient la cause du problème qu’ils étaient censés résoudre.
C’est un pattern que je vois revenir trimestre après trimestre, quel que soit le fournisseur d’API ou le framework. Une équipe ajoute des retries pour gérer les erreurs transitoires. Les retries fonctionnent, pour le cas idéal. Puis les cas dégénérés arrivent, et le système se comporte d’une façon que personne n’avait prévue.
Le problème avec les retries naïfs
Un retry naïf, retry(3, delay=1s), est une bombe à retardement déguisée en résilience. Voici ce qui se passe vraiment quand votre fournisseur d’API ralentit :
- La requête initiale prend 5 secondes au lieu de 500 ms.
- Votre timeout se déclenche à 3 secondes.
- Vous retenez. La seconde requête prend aussi 5 secondes.
- Timeout. Troisième tentative.
- Vous avez maintenant consommé 9 secondes de budget réseau, envoyé trois requêtes à un service déjà surchargé, et votre utilisateur attend toujours.
Le service surchargé, lui, vient de recevoir trois fois plus de trafic qu’en temps normal, exactement au moment où il peut le moins se le permettre. Michael Nygard appelle ça un « amplificateur de cascade » dans Release It! : le mécanisme de protection amplifie la panne qu’il est censé contenir.
Les trois mensonges
Mensonge 1 : « Trois tentatives, c’est raisonnable »
Trois tentatives avec un délai fixe signifient que votre charge en cas d’erreur est 3× la charge normale. Si 10 % de vos requêtes échouent à un instant T, votre service d’amont reçoit 130 % du trafic attendu. Si vous n’êtes pas seul à le faire (et vous ne l’êtes jamais), le facteur réel est imprévisible.
Mensonge 2 : « L’exponential backoff résout le problème »
L’exponential backoff est mieux que le retry fixe, mais sans jitter il crée un problème différent : la synchronisation. Si cent clients ont tous échoué à la même seconde, ils vont tous retenter au même moment, 2s plus tard, puis 4s, puis 8s. Les pics deviennent périodiques au lieu de continus. La recommendation d’AWS insiste sur le jitter complet (sleep = random_between(0, base * 2^attempt)) pour cette raison exacte.
Mensonge 3 : « On retry les 5xx »
Tout ce qui commence par 5 n’est pas transitoire. Un 500 qui revient d’un service qui a crashé est probablement permanent jusqu’à un redéploiement. Un 503 avec un Retry-After header vous dit exactement quand retenter, et la plupart des implémentations l’ignorent. Un 429 vous dit d’arrêter, et le retry fait exactement le contraire.
Ce qu’il faut faire à la place
1. Budget de retry, pas compteur de retry
Au lieu de « 3 retries max », pensez « budget total de 5 secondes ». Si la première tentative prend 4 secondes et échoue, il vous reste 1 seconde, pas assez pour un retry complet. Vous échouez vite et proprement, au lieu de consumer le triple du temps.
async function withBudget<T>(
fn: () => Promise<T>,
budgetMs: number
): Promise<T> {
const deadline = Date.now() + budgetMs;
let attempt = 0;
while (Date.now() < deadline) {
try {
return await fn();
} catch (e) {
attempt++;
const jitter = Math.random() * Math.min(1000, 100 * 2 ** attempt);
const remaining = deadline - Date.now();
if (jitter > remaining) throw e;
await sleep(jitter);
}
}
throw new Error("Budget exhausted");
}
2. Distinguez les erreurs retryables des erreurs terminales
Un timeout réseau est probablement transitoire. Un 400 (requête invalide) ne changera pas au retry. Un 429 a un Retry-After, respectez-le. Un 500 sans plus de contexte ? Retryez une fois, puis échouez. Votre logique de retry devrait être un switch sur le type d’erreur, pas un catch aveugle.
3. Circuit breaker avant retry
Si votre service d’amont a échoué cinq fois en dix secondes, la sixième requête ne va probablement pas passer non plus. Un circuit breaker coupe les requêtes et renvoie une erreur immédiate pendant un cooldown. C’est contre-intuitif, échouer volontairement semble pire que retenter, mais c’est exactement ce qui empêche la cascade.
4. Faites que l’échec soit visible
Le plus dangereux avec les retries, c’est qu’ils cachent les problèmes. Votre requête a pris 8 secondes au lieu de 500 ms, mais elle a « réussi ». L’utilisateur a attendu, le graphique de succès est vert, et personne ne sait que le système est à un retry de la panne totale.
Métriques à exposer : taux de retry (retries/requêtes totales), temps perdu en retries, taux de succès au premier essai. Si votre taux de retry dépasse 5 % en régime normal, vous avez un problème que les retries masquent.
Quand ne pas retrier du tout
Certaines opérations ne doivent jamais être retried :
- Les écritures non-idempotentes. Si votre requête a peut-être réussi mais que vous n’avez pas reçu la réponse, retrier peut créer un doublon. Utilisez des clés d’idempotence.
- Les opérations longues. Une requête qui prend 30 secondes et timeout à 60 secondes, la retrier consume 2 minutes du budget de votre utilisateur.
- Les erreurs de validation. Un payload invalide sera invalide à chaque tentative.
La bonne posture par défaut n’est pas « retry tout », c’est « échoue vite, visiblement, avec un message utile ». Le retry est l’exception, pas la règle.
Si cela vous a parlé, vous aimerez Fallbacks, timeouts et l’art de se dégrader gracieusement et La mise à jour de modèle qui a discrètement cassé la production. Abonnez-vous ci-dessous pour recevoir le billet de vendredi prochain.
Abonnez-vous pour recevoir l'article de vendredi prochain ci-dessous.
Un e-mail · le vendredi · désabonnement à tout moment