Stratégies de nouvelle tentative : backoff exponentiel et gigue
Un appel réseau échoue. La solution évidente est « il suffit de réessayer », mais réessayer naïvement transforme un bref accroc en panne complète, car chaque client martèle le service en cours de rétablissement exactement au même moment. Le backoff exponentiel avec gigue (jitter) est la solution standard, et il est assez simple pour s'implémenter en une quinzaine de lignes une fois qu'on comprend la raison d'être de chaque élément.
Pourquoi les nouvelles tentatives naïves aggravent les choses
Supposons qu'un service en aval tombe pendant deux secondes sous la charge. Mille clients reçoivent une requête échouée, et tous réessaient immédiatement. Le service, tout juste en train de remonter, reçoit de nouveau les mêmes mille requêtes d'un coup, plus le nouveau trafic arrivé entre-temps. Il retombe. C'est une tempête de nouvelles tentatives (retry storm), et c'est une façon courante pour un petit accroc passager de devenir une panne prolongée.
Deux problèmes sont à corriger : des tentatives trop rapprochées, et des tentatives synchronisées entre clients.
Backoff exponentiel : espacer les tentatives
Au lieu de réessayer immédiatement, attendez plus longtemps après chaque échec, typiquement en doublant le délai à chaque fois.
async function withBackoff(fn, { maxRetries = 5, baseDelayMs = 200, maxDelayMs = 10000 } = {}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
if (attempt > maxRetries) throw err;
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
Avec baseDelayMs: 200, les délais sont d'environ 200 ms, 400 ms, 800 ms, 1600 ms, 3200 ms : ils croissent vite, plafonnés par maxDelayMs pour qu'une dépendance instable ne laisse pas une requête attendre des minutes. Cela règle à lui seul le problème des « tentatives trop rapprochées ». Cela ne fait rien pour les « tentatives synchronisées », car tous les clients calculent exactement la même suite de délais.
Gigue : briser la synchronisation
Si chaque client attend exactement 200 ms, puis exactement 400 ms, tous réessaient toujours au même instant les uns par rapport aux autres, simplement moins souvent. La gigue ajoute de l'aléa au délai pour que les tentatives s'étalent dans le temps au lieu d'arriver par vagues synchronisées.
function backoffWithFullJitter(attempt, baseDelayMs, maxDelayMs) {
const cap = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
return Math.random() * cap;
}
C'est la « gigue complète » (full jitter) : le délai est une valeur aléatoire entre 0 et le plafond exponentiel, plutôt que le plafond lui-même. Le blog d'architecture d'AWS a popularisé cette approche après avoir constaté qu'elle surpassait à la fois le backoff exponentiel simple et la « gigue égale » (plafond/2 plus une fraction aléatoire de plafond/2) en cas de forte contention, précisément parce qu'elle étale les tentatives sur la fenêtre la plus large.
Intégration dans la boucle de nouvelles tentatives :
async function withBackoffAndJitter(fn, { maxRetries = 5, baseDelayMs = 200, maxDelayMs = 10000 } = {}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
if (attempt > maxRetries) throw err;
const delay = backoffWithFullJitter(attempt - 1, baseDelayMs, maxDelayMs);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
Tous les échecs ne méritent pas une nouvelle tentative
Réessayer aveuglément à chaque erreur est un bug en soi. Une 400 Bad Request ou une 401 Unauthorized échouera de la même façon quel que soit le nombre de tentatives : vous ajoutez de la charge pour rien. Ne réessayez que les échecs plausiblement transitoires :
- Erreurs de niveau réseau (connexion réinitialisée, timeout)
429 Too Many Requests502,503,504: défaillances de passerelle ou de service amont
function isRetryable(err) {
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') return true;
if (err.status && [429, 502, 503, 504].includes(err.status)) return true;
return false;
}
Vérifiez cela avant d'attendre, et relancez immédiatement l'erreur pour tout le reste :
} catch (err) {
if (!isRetryable(err)) throw err;
// ...backoff logic
}
Respecter Retry-After
Une réponse 429 ou 503 s'accompagne souvent d'un en-tête Retry-After qui indique exactement combien de temps attendre. S'il est présent, utilisez-le à la place de votre délai calculé : le serveur connaît son propre temps de rétablissement mieux que votre estimation.
function delayFromResponse(err, fallbackDelayMs) {
const retryAfter = err.headers?.['retry-after'];
if (!retryAfter) return fallbackDelayMs;
const seconds = Number(retryAfter);
return Number.isFinite(seconds) ? seconds * 1000 : fallbackDelayMs;
}
Plafonner le budget total de tentatives, pas seulement le délai
maxRetries seul peut quand même laisser une même opération logique durer longtemps si les plafonds sont généreux : cinq tentatives avec un délai maximal de 10 secondes, c'est potentiellement plus de 30 secondes passées à réessayer un seul appel. Si l'appelant a son propre timeout (un utilisateur qui attend une requête, un consommateur de file avec un délai de visibilité), suivez le temps écoulé et abandonnez tôt :
async function withDeadline(fn, deadlineMs) {
const start = Date.now();
return withBackoffAndJitter(async () => {
if (Date.now() - start > deadlineMs) {
throw new Error('retry deadline exceeded');
}
return fn();
});
}
À retenir
Le backoff exponentiel règle les tentatives trop rapprochées. La gigue règle les tentatives synchronisées entre clients. Aucun des deux ne dispense de vérifier si un échec mérite seulement d'être réessayé, et aucun ne doit ignorer un en-tête Retry-After quand le serveur prend la peine d'en envoyer un. La plupart des bibliothèques de clients HTTP (intercepteurs axios, option de retry de got, bibliothèques de résilience comme cockatiel ou polly en .NET) implémentent déjà tout cela : utilisez-en une avant de réinventer la roue en production, mais sachez ce qu'elle fait en coulisses pour reconnaître les cas où ses valeurs par défaut ne conviennent pas à votre situation.