Tous les articles
webhook designidempotencyretriesdistributed systems

Retries et idempotence webhook sans douleur

Tous les providers webhook majeurs — Stripe, GitHub, Shopify, Twilio, Slack — livrent le même événement plus d'une fois, intentionnellement. C'est l'at-least-once delivery. Si votre handler n'est pas conçu pour, vous aurez des doubles facturations, des labels GitHub dupliqués, des notifications Slack envoyées deux fois.

Pourquoi les doublons

Le provider retente quand il ne reçoit pas un 2xx à temps. Fenêtres typiques : Stripe 3 jours, GitHub 8 tentatives sur 8h, Shopify 48 heures. La plupart du temps la livraison originale a réussi — le réseau a juste perdu la réponse. Le provider ne le sait pas. Il retente. Votre handler tourne deux fois.

Ce n'est pas un bug à corriger. C'est une propriété du système. Concevez avec.

Le pattern d'idempotence qui marche

Chaque événement majeur a un ID unique. Stripe : evt_xxx. GitHub : X-GitHub-Delivery. Shopify : X-Shopify-Webhook-Id. Stockez l'ID au premier traitement. Au retry, vérifiez — si déjà vu, 200 sans relancer le handler.

async function handleWebhook(event) {
  const eventId = event.id;

  const inserted = await db.processedEvents.insertIfNew({
    eventId,
    receivedAt: new Date(),
  });

  if (!inserted) {
    return { status: 200, body: 'already processed' };
  }

  await actuallyHandleEvent(event);
  return { status: 200 };
}

Détail crucial : l'insert et la logique doivent être atomiques ensemble, ou l'insert d'abord. Sinon un crash en plein handler laisse aucune trace et le retry relance.

Où stocker la clé de dédup

  • Même transaction DB que l'écriture métier. Le mieux. Contrainte unique sur l'event ID.
  • Redis avec TTL long. OK pour événements à faible enjeu. TTL = fenêtre de retry la plus longue, 7 jours minimum.
  • Cache mémoire. Non. Le process redémarre, le cache disparaît, le retry réussit à relancer un handler déjà exécuté.

À quoi ressemble "non idempotent" en vrai

Cas réel : un handler Stripe invoice.payment_succeeded qui appelait account.credit(amount). Chaque paiement ajoutait du solde. La plupart du temps une fois. Occasionnellement deux quand un retry attrapait le même événement. Trouvé parce qu'un client a poliment demandé pourquoi sa facture de 50€ avait crédité son compte de 100€.

La correction : un index unique sur events_processed.stripe_event_id et un garde en haut du handler. Onze lignes.

Le timing du handler compte aussi

Timeouts durs : GitHub 10s, Discord interactions 3s, Stripe et Shopify plus permissifs (~30s) mais retries quand même si lent. Répondez vite, travaillez en async.

async function handleWebhook(event) {
  if (await alreadyProcessed(event.id)) {
    return { status: 200 };
  }

  await queue.enqueue('process-stripe-event', event);
  return { status: 200 };
}

Le worker fait le gros du travail. Le handler webhook est un dédoubleur + un producteur de queue.

Testez-le en local

Tester l'idempotence est exactement ce pour quoi le rejeu tunnel a été fait. Capturez une livraison dans l'historique du tunnel et rejouez-la dix fois. Le premier appel écrit en base. Les neuf suivants retournent 200 sans toucher la base.

Politiques de retry par provider

  • Stripe : 3 jours, backoff exponentiel, 16 tentatives max.
  • GitHub : 8 tentatives sur ~8h.
  • Shopify : 48h, backoff. Désactive après 19 échecs consécutifs sur 48h.
  • Twilio : 1 retry par défaut, jusqu'à 3 configurable.
  • Slack : 3 retries (1s, 30s, 5min).

Le bilan

La livraison dupliquée est un contrat, pas un défaut. Si vous avez livré un handler qui appelle increment, send_email ou charge_card sans clé de dédup, vous avez livré un bug. Corriger est petit. Découvrir après une plainte client est cher.

Voir aussi guide de rejeu webhook et guide vérification de signature. Rejoignez la waitlist PortPreview.

Questions fréquentes

Pourquoi les providers webhook envoient le même événement deux fois ?
Ils utilisent l'at-least-once delivery. S'ils ne reçoivent pas un 2xx à temps, ils retentent, même si le handler avait réussi et que c'est la réponse qui s'est perdue. Considérez la livraison dupliquée comme une garantie du système.
Comment rendre un handler webhook idempotent ?
Utilisez l'ID unique fourni par le provider (Stripe evt_xxx, GitHub X-GitHub-Delivery, Shopify X-Shopify-Webhook-Id) comme clé de dédup. Insérez-le avec contrainte unique en début de handler ; si l'insert échoue car déjà présent, renvoyez 200 sans relancer la logique métier.
Combien de temps garder la clé de dédup ?
Au moins la fenêtre de retry la plus longue. Stripe : 3 jours. Shopify : 48h. Un TTL de 7 jours couvre tous les providers majeurs avec marge. Si vous avez la place, gardez-la indéfiniment — utile aussi pour l'audit.