Fiabilité5 min de lecture

Retry non borné : risques d'amplification d'incident et stratégies de reprise

Pourquoi rejouer aveuglément des requêtes défaillantes entraîne l'effondrement en cascade des services et l'explosion de la facture API, et comment implémenter un retry borné avec backoff exponentiel.

Dans les architectures distribuées modernes, les pannes transitoires (micro-coupures réseau, latence temporaire d'un prestataire, indisponibilité momentanée d'une base de données) sont inévitables. La mise en place d'un mécanisme de réessai (retry) est la réaction intuitive pour rendre une application résiliente.

Cependant, un mécanisme de retry mal calibré ou dépourvu de limites strictes se transforme rapidement en vecteur d'auto-déni de service (self-inflicted DoS). Loin de résoudre l'incident, il amplifie la congestion, provoque des tempêtes de requêtes (retry storms) et peut générer des surcoûts d'infrastructure considérables.


Le piège du retry infini ou mal configuré

La forme la plus critique du retry non borné réside dans les boucles infinies ou les récursions aveugles au sein des blocs d'interception d'erreurs :

// ANTI-PATTERN REDOUTABLE : RECURSION INFINIE DANS LE BLOC CATCH
async function fetchSubscriptionData(customerId: string): Promise<Subscription> {
  try {
    const res = await fetch(`https://api.provider.com/customers/${customerId}`);
    if (!res.ok) throw new Error(`Erreur HTTP: ${res.status}`);
    return await res.json();
  } catch (error) {
    // Danger absolu : rappel récursif sans compteur de tentatives ni temporisation
    return fetchSubscriptionData(customerId);
  }
}

Si l'API distante subit une panne de 10 minutes ou renvoie une erreur persistante (par exemple un code 401 Unauthorized ou 404 Not Found), ce fragment de code va saturer la boucle d'événements Node.js, consommer les descripteurs de sockets disponibles et envoyer des dizaines de milliers de requêtes inutiles par minute.


Risques majeurs en production

flowchart TD
    Panne[Panne transitoire du service tiers] --> Erreur[Requête initiale en échec]
    Erreur --> Retry1[Retry immédiat sans backoff]
    Retry1 --> Conflit[Accroissement de la charge du serveur cible]
    Conflit --> Avalanche[Tempête de requêtes / Retry Storm]
    Avalanche --> PanneTotale[Effondrement complet et saturation des sockets locaux]
    Avalanche --> Surcout[Explosion de la facturation API à l'usage]

1. Tempêtes de requêtes (Retry Storms)

Lorsqu'un service commence à ralentir sous une forte charge, ses temps de réponse dépassent les seuils d'attente habituels. Si des centaines d'instances clientes réessaient immédiatement chaque appel manqué, le trafic entrant vers le service est multiplié par 3, 5 ou 10 précisément au moment où il a besoin de récupérer. Ce phénomène transforme une simple dégradation de performance en arrêt complet de service.

2. Explosion des coûts d'API

De nombreuses API modernes (fournisseurs de modèles d'IA, passerelles de messagerie, services financiers) facturent directement à la requête ou au jeton consommé. Une boucle de retry non bornée exécutée en tâche de fond sur des milliers d'utilisateurs peut multiplier la facture mensuelle par dix en l'espace de quelques heures.

3. Saturation des ressources locales

Chaque requête en attente ou en boucle mobilise un contexte d'exécution, de la mémoire vive et des descripteurs de fichiers dans l'infrastructure de votre serveur web (Next.js, conteneur Node, worker serverless). L'accumulation de ces requêtes finit par bloquer le traitement des requêtes des utilisateurs sains.


Tendances courantes selon la sémantique HTTP

La décision de réexécuter une requête dépend étroitement de la sémantique de l'API ciblée, du caractère idempotent de l'opération (lecture sûre vs mutation d'état), du contexte métier et de la présence éventuelle d'en-têtes de régulation :

Statut HTTPTendance couranteFacteurs déterminants (Sémantique, Idempotence & Métier)
400 Bad RequestEn règle générale, pas de retry immédiatIndique le plus souvent une charge utile invalide. Ne réessayer que si l'erreur résulte d'un état transitoire démontré côté serveur.
401 Unauthorized / 403 ForbiddenPas de retry simple sans action préalableExige le renouvellement d'un jeton de session expiré ou l'élévation des droits avant toute nouvelle soumission.
404 Not FoundGénéralement définitifÀ ne rejouer que dans les rares cas de cohérence à terme (eventual consistency) où la création de ressource est en cours de propagation.
429 Too Many RequestsRetry conditionné sous contrôle strictRespecter impérativement l'en-tête Retry-After s'il est fourni par le serveur distant, avec backoff et borne stricte.
500 Internal Server ErrorRetry très limité ou déconseilléStatut ambigu signalant un crash applicatif. À n'envisager qu'avec une clé d'idempotence pour éviter de rejouer des effets de bord partiels.
502, 503, 504Tendance favorable au retry bornéIndique souvent un incident d'infrastructure transitoire (passerelle, proxy, redémarrage). Appliquer un backoff exponentiel avec jitter.

Les composantes d'une stratégie de reprise saine

Une stratégie de résilience robuste repose sur l'association de quatre règles inviolables :

  1. Borner le nombre absolu de tentatives : limiter systématiquement entre 2 et 4 essais maximum.
  2. Backoff exponentiel : doubler le temps d'attente entre chaque essai (ex. 500ms, puis 1000ms, puis 2000ms) pour laisser au service distant le temps d'absorber le choc.
  3. Introduction d'aléa (Jitter) : ajouter une variation aléatoire (±20%) à l'intervalle d'attente afin d'éviter que tous les clients synchronisés ne réessaient exactement à la même milliseconde.
  4. Respect de l'idempotence : s'assurer que l'opération réessayée ne risque pas d'exécuter un double paiement ou une double création d'enregistrement (voir Gestion des webhooks Stripe).
// PATTERN RECOMMANDÉ : RETRY BORNÉ AVEC BACKOFF EXPONENTIEL ET JITTER
interface RetryOptions {
  maxAttempts?: number;
  initialDelayMs?: number;
  backoffFactor?: number;
}

export async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  options: RetryOptions = {}
): Promise<T> {
  const { maxAttempts = 3, initialDelayMs = 300, backoffFactor = 2 } = options;
  let attempt = 0;
  let delay = initialDelayMs;

  while (attempt < maxAttempts) {
    attempt++;
    try {
      return await fn();
    } catch (error) {
      if (attempt >= maxAttempts) {
        throw new Error(`Échec définitif après ${maxAttempts} tentatives. Dernière erreur: ${error}`);
      }

      // Calcul du délai avec jitter aléatoire (±20%)
      const jitter = delay * 0.2 * (Math.random() * 2 - 1);
      const sleepTime = Math.max(50, Math.round(delay + jitter));
      
      await new Promise((resolve) => setTimeout(resolve, sleepTime));
      delay *= backoffFactor;
    }
  }

  throw new Error("Condition de sortie inattendue");
}

Ce que GVO observe lors de l'audit

Le moteur d'analyse statique GVO traque les schémas de récursion et de boucles de relance non bornées dans le code source :

  • GVO-REL-UNBOUNDED-RETRY-V1 : Détecte les fonctions qui s'auto-invoquent au sein de leur propre bloc catch sans vérification d'un compteur d'itération (maxRetries, attempt, limit) ou sans temporisation contrôlée.
  • Le moteur signale la criticité de l'amplification d'incident et propose un refactoring vers une structure itérative bornée ou vers des utilitaires éprouvés (comme des bibliothèques de résilience reconnues).

Sources et références

  • AWS Architecture Blog : Exponential Backoff And Jitter (Marc Brooker).
  • Microsoft Azure Architecture Patterns : Retry Pattern & Transient Fault Handling.
  • Google Cloud Architecture Center : Truncated exponential backoff strategies.
  • Release It! (Michael T. Nygard) : Stability Patterns: Circuit Breaker, Timeouts, and Bulkheads.

Vérifier ce point sur votre projet

GVO confronte le code et les configurations de votre application aux règles de sécurité réelles.