Tables de la boîte d’envoi et de la boîte de réception : conception de Webhooks résistants aux pannes
Découvrez comment la boîte aux lettres transactionnelle, une boîte aux lettres idempotente, le report conscient de l’état et les files de messages morts assurent une livraison fiable des webhooks sur AWS, Azure et GCP.
Les webhooks semblent être l’intégration la plus simple qui existe : une partie envoie une requête HTTP POST, l’autre la traite. En pratique, ils présentent tous les risques d’un système distribué, car le réseau entre deux services peut supprimer des requêtes, provoquer des temps d’attente intermédiaires, livrer deux fois le même chargement ou réordonner les événements. Si vous traitez un webhook comme une simple requête CRUD, vous finirez par manquer des notifications, déclencher deux fois des effets secondaires, et vous retrouverez avec deux systèmes en désaccord quant à ce qui s’est passé.
Ce guide présente un design capable de résister à de telles conditions. Vous comprendrez pourquoi l’approche naïve échoue, comment une file d’attente transactionnelle rend les webhooks sortants fiables, comment une boîte de réception idempotente permet de relancer en toute sécurité les webhooks entrants, comment gérer les événements arrivant dans le mauvais ordre, comment mettre en quarantaine les données qui ne peuvent jamais aboutir, ainsi quels services gérés sur AWS, Azure et Google Cloud conviennent à chaque composant.
Pourquoi l’implémentation évidente entraîne la perte de données
Imaginons un backend SaaS qui gère un changement d’état important, comme l’exécution d’une commande ou la mise en activité d’une abonnement. Un partenaire appelle votre API pour confirmer l’action, et votre service doit alors accomplir deux tâches :
- Permettre la persistance de l’état nouveau, par exemple en définissant le statut de l’entité sur
Active. - Informer un service intermédiaire que l’entité est prête, en lui envoyant un webhook.
Le code intuitif écrit dans la base de données, puis, sur la ligne suivante, envoie la requête HTTP. Il s’agit d’une écriture double : deux systèmes indépendants sont mis à jour l’un après l’autre, sans aucun lien entre eux.
Deux modes de défaillance en découlent directement :
- Le processus s’arrête entre les deux étapes. La base de données indique alors que l’entité est active, mais la requête n’a jamais été envoyée. Vos enregistrements sont corrects, le service suivant n’en sait rien, et personne ne s’en aperçoit avant qu’un client ne se plaigne.
- La requête est envoyée, puis la transaction échoue. Le service suivant a été informé que l’entité est active, mais votre base de données a annulé la transaction et considère toujours l’opération comme échouée.
Aucun ordre n’arrange le problème. Si vous placez l’appel HTTP en premier, vous obtenez la deuxième erreur ; si vous le mettez en dernier, c’est la première qui se produit. La cause profonde réside dans le fait qu’un commit de base de données et un appel réseau ne peuvent pas être effectués de manière atomique en même temps, de sorte que tout plantage ou erreur intermédiaire met les deux parties hors synchronisation.
Envoi fiable des webhooks grâce à un système de files d’attente transactionnelles
Le modèle de file d’attente élimine l’écriture double en ne faisant jamais appel à HTTP depuis le chemin de la requête. Au lieu de cela, l’intention d’envoyer le webhook devient des données, et ces données sont enregistrées dans la même transaction de base de données que le changement métier. Soit les deux sont confirmés, soit aucun ne l’est.
Les quatre étapes du flux de la file d’attente
- Ouvrir une transaction. L’opération métier démarre une transaction normale de base de données.
entities (par exemple, en définissant le statut sur Active) et insère une ligne dans outbox_events contenant le payload exact que le service suivant doit recevoir.outbox qui n’ont pas encore été envoyées. Pour chacune d’elles, il effectue une requête HTTP POST puis marque la ligne comme traitée.La table outbox
Le tableau ci-dessous contient une ligne par notification en attente. aggregate_type et aggregate_id identifient l’objet métier concerné par l’événement, event_type indique ce qui s’est produit, payload contient le corps à livrer, et processed_at reste vide jusqu’à ce que le relais confirme la livraison. La consultation des lignes où processed_at est nul fournit au relais sa liste de tâches. Notez que les commentaires en ligne utilisent un seul trait d’union ; dans PostgreSQL, un commentaire nécessite deux (--), il faut donc corriger cela avant d’exécuter l’instruction.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(50), - e.g., 'Order' or 'User'
aggregate_id UUID, - e.g., Entity ID
event_type VARCHAR(100), - e.g., 'order.activated'
payload JSONB NOT NULL, - The exact webhook payload
created_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP - Null until successfully sent
);
Ce que garantit la file d’envoi, et ce qu’elle ne garantit pas
Si le serveur plante après l’envoi du commit, rien n’est perdu : la ligne reste dans la table et le relais la trouvera lors de sa prochaine passe. Si l’endpoint cible n’est pas disponible, le relais réessaie simplement, de préférence avec un retard exponentiel afin de ne pas surcharger un récepteur en difficulté. Le changement d’état et l’intention de notification ne peuvent plus diverger.
Le compromis est que la livraison a lieu au moins une fois. Un relais peut envoyer la demande avec succès puis planter avant d’actualiser processed_at ; dans ce cas, le même événement est envoyé à nouveau lors du prochain exécution. Cela n’est acceptable que si les destinataires effectuent une déduplication, ce qui est précisément ce que propose le modèle de boîte de réception de l’autre côté. Inclure l’id de la ligne de la boîte d’envoi dans le payload ou une en-tête permet aux destinataires d’utiliser une clé fiable pour effectuer la déduplication. Si vous exécutez plusieurs instances de relais, assurez-vous que deux travailleurs ne puissent pas réclamer la même ligne en même temps ; dans PostgreSQL, sélectionner les lignes avec FOR UPDATE SKIP LOCKED est une méthode courante pour y parvenir.
Réception sécurisée des webhooks grâce à une boîte de réception idempotente
Passons maintenant à les webhooks que votre service reçoit de partenaires ou de systèmes en amont.
Supposons que votre gestionnaire mette cinq secondes à traiter la requête en raison de calculs intensifs ou d’une attente liée à un verrou détenu par un autre service. Le client HTTP de l’expéditeur pourrait abandonner avant que vous ne répondiez, en conclure que vous n’avez jamais reçu l’événement, et le renvoyer. Ainsi, le même événement arrive deux fois. Si votre gestionnaire envoie un e-mail ou crée une entrée dans la base de données à chaque exécution, le client reçoit deux e-mails et vous obtenez une ligne dupliquée.
Le modèle de boîte de réception sépare l’acceptation d’un webhook de son traitement.
Les quatre étapes du flux de la boîte de réception
- Réception et vérification. Dès l’arrivée de la requête, vérifiez sa signature HMAC afin de vous assurer qu’elle provient bien du partenaire et n’a pas été falsifiée ou modifiée.
webhook_inbox, identifié par l’identifiant d’événement unique du partenaire et protégé par une contrainte de unicité de la base de données.200 OK aussitôt, avant que toute logique métier ne s’exécute.La table de la boîte de réception
Ici, chaque ligne enregistre qui a envoyé l’événement (partner_name), son identifiant (partner_event_id), la charge utile, le résultat de la vérification de la signature, ainsi qu’un status qui peut être PENDING, PROCESSED ou QUARANTINED. La ligne importante est la contrainte composite UNIQUE(partner_name, partner_event_id) : c’est elle qui transforme les doublons en opérations sans effet. Comme pour la table des messages à envoyer, les commentaires composés d’un seul trait doivent être remplacés par -- pour que PostgreSQL accepte l’instruction.
CREATE TABLE webhook_inbox (
id UUID PRIMARY KEY,
partner_name VARCHAR(50), - e.g., 'Stripe' or 'GitHub'
partner_event_id VARCHAR(100), - The unique ID from the sender
payload JSONB NOT NULL,
signature_verified BOOLEAN,
status VARCHAR(20), - 'PENDING', 'PROCESSED', 'QUARANTINED'
received_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP,
UNIQUE(partner_name, partner_event_id) - Prevents duplicate inserts
);
Pourquoi la contrainte assure le travail principal
Puisque le gestionnaire se contente de vérifier, d’insérer et de renvoyer les données, il répond rapidement, ce qui fait que l’expéditeur rencontre rarement un délai d’attente. Lorsque l’expéditeur tente de nouveau, même dix fois de suite, la contrainte de unicité permet à une seule insertion de réussir. Votre gestionnaire doit considérer l’erreur de violation de l’unicité générée (ou le résultat ON CONFLICT DO NOTHING) comme un succès et renvoyer malgré tout 200 OK ; sinon, l’expéditeur continuera de tenter d’envoyer des événements que vous possédez déjà. Comme une seule ligne existe, le processus exécute les effets secondaires une seule fois.
Deux détails méritent d’être bien maîtrisés. Premièrement, la suppression des doublons dépend du fait que le partenaire fournisse un identifiant d’événement stable ; la plupart des fournisseurs de webhook en incluent un, mais il convient de le vérifier pour chaque intégration. Deuxièmement, un processus peut planter après avoir exécuté un effet secondaire mais avant d’indiquer que la ligne a été traitée, donc autant que possible, effectuez le changement métier et la mise à jour d’état dans une seule transaction, et assurez également que les effets secondaires externes soient idempotents. Pour en savoir plus sur la suppression des doublons à l’aide de clés, consultez les clés d’idempotence dans les endpoints POST de Node.js.
Gestion des événements arrivant dans le mauvais ordre
Même si les doublons sont maîtrisés, il n’y a aucune garantie que les événements arrivent dans l’ordre où ils ont été générés. Votre service peut recevoir entity.completed avant entity.started. Un gestionnaire qui applique aveuglément chaque événement tentera alors de passer une entité directement de l’état draft à completed, ce qui endommagera son état ou provoquera une erreur telle qu’un 409 Conflict.
Vérification de chaque transition par rapport à la machine d’états
La solution consiste à cesser de traiter les événements comme des commandes visant à modifier l’état et à commencer à les considérer comme des transitions proposées qui doivent être validées. Cela est parfois décrit comme un moteur de réconciliation d’états, dans l’esprit du sourcing d’événements : le processus compare l’événement reçu avec l’état actuel de l’entité et détermine si la transition est légale.
Le schéma ci-dessous illustre cette décision. Si un événement de finalisation arrive alors que l’entité est encore en version brouillon, la condition préalable n’a pas encore été remplie ; par conséquent, la fonction signale l’événement comme différé au lieu de l’appliquer. Les commentaires indiquent deux façons de gérer ce différé : laisser la ligne dans la boîte de réception pour tenter à nouveau plus tard, ou enregistrer un état projeté et attendre l’événement manquant. Un événement de démarrage sur une entité brouillon constitue une transition valide qui est appliquée. Considérez ceci comme du pseudocode : return status: 'DEFERRED'; n’est pas un JavaScript valide et devrait être return { status: 'DEFERRED' }; ; une implémentation réelle gérerait également les autres combinaisons d’événement et d’état.
function processWebhookEvent(event, currentEntityState) {
if (event.type === 'entity.completed' && currentEntityState === 'draft') {
// The 'started' event hasn't arrived yet!
// We cannot transition from 'draft' directly to 'completed'.
// Option A: Leave it in the inbox and retry in 5 minutes.
// Option B: Store a "Projected State" and wait for the missing piece.
return status: 'DEFERRED';
}
if (event.type === 'entity.started' && currentEntityState === 'draft') {
return transitionTo('started');
}
}
Le différé en tant que boucle auto-réparatrice
Prenez une commande où l’événement « expédié » vous parvient avant l’événement « payé ». Appliquer immédiatement l’état « expédié » placerait la commande dans un état que votre modèle ne permet pas. Avec un processeur conscient de l’état, la séquence devient la suivante :
- L’événement « expédié » arrive, l’évaluateur constate qu’il manque le paiement et l’événement est différé.
- L’événement « payé » arrive, est valide, et met à jour la commande.
- L’événement « expédié » différé est tenté à nouveau, trouve maintenant que ses prérequis sont remplis, et est appliqué.
Les événements différés peuvent être stockés dans une file d’attente dédiée aux tentatives de réexécution, comme Amazon SQS ou une file gérée par Redis, et un travailleur en arrière-plan les réessaie périodiquement. Le résultat est un flux de travail qui refuse les transitions invalides mais converge finalement dans l’état correct sans perdre aucun événement. Il convient cependant de fixer une limite sur la durée pendant laquelle un événement peut rester différé : si les prérequis ne sont jamais remplis, l’événement doit finalement être considéré comme une erreur plutôt que d’être réessayé indéfiniment, ce qui nous amène à la section suivante.
Isolement des éléments problématiques grâce aux tentatives de réexécution et à une file de lettres mortes
Certains événements ne réussiront jamais, peu importe le nombre de fois où vous les essayez : un en-tête mal formaté, ou une référence à un ID qui n’existe pas dans votre base de données. On les appelle des pilules empoisonnées. Un processus naïf les réessaiera indéfiniment, et si la file d’attente est traitée dans l’ordre, un message défectueux peut bloquer tous les événements valides qui suivent.
La défense standard consiste en une politique de réessai limitée avec des délais croissants, suivie d’une file de messages défectueux (DLQ). Un calendrier typique se présente comme suit :
- La première tentative échoue ; attendre une minute.
- La deuxième tentative échoue ; attendre cinq minutes.
- La troisième tentative échoue ; attendre quinze minutes.
- La quatrième tentative échoue ; déplacer l’événement dans la DLQ.
Le DLQ peut être une table dans votre propre base de données ou une fonctionnalité d’une file gérée. Ce qui importe, c’est ce qui se passe ensuite : les événements du DLQ doivent apparaître dans une vue d’administration interne et déclencher une alerte de haute priorité, car chacun d’eux représente des données que votre système n’a pas pu traiter. Un ingénieur enquête, corrige la erreur de mappage ou les données défectueuses, puis rejoue l’événement afin qu’il suive le parcours de traitement normal. Concevez cette action de rejouage dès le début ; sans elle, se remettre d’un problème dans le DLQ devient une modification manuelle de la base de données sous pression.
Mappage du concept sur AWS, Azure et Google Cloud
La boîte d’envoi et la boîte de réception se trouvent dans votre base de données relationnelle, mais l’ensemble du mécanisme associé (entrée des données, files d’attente, processus de traitement, DLQ) s’intègre bien aux services cloud gérés, ce qui réduit considérablement la charge opérationnelle. La structure reste identique chez chaque fournisseur ; seuls les noms des produits changent.
AWS
- Entrée : Amazon API Gateway accepte les webhooks entrants, un autoriseur Lambda vérifiant la signature HMAC avant que la demande n’atteigne le backend.
- Base de données : Amazon Aurora PostgreSQL contient les tables métier ainsi que
webhook_inboxetoutbox_events, ce qui permet d’appliquer les garanties transactionnelles. - Files d’attente et file de lettres mortes : Une file d’attente standard SQS gère le traitement asynchrone, tandis qu’une file de lettres mortes SQS configurée reçoit les messages une fois que le nombre maximal de réceptions est dépassé. Les files d’attente standard assurent au moins une livraison mais ne conservent pas l’ordre, ce qui est une raison supplémentaire pour laquelle les vérifications d’idempotence et d’état mentionnées ci-dessus sont importantes.
processed_at.Azure
- Entrée : Azure API Management reçoit les webhooks, valide les signatures et redirige les requêtes vers le backend.
- Base de données : Azure Database for PostgreSQL Flexible Server stocke l’état de l’application ainsi que les tables de la file d’entrée et de la file d’envoi.
- Files d’attente et DLQ : Azure Service Bus gère la distribution des messages et dispose d’un système intégré de messagerie défectueuse, qui déplace automatiquement un message de côté après un nombre prédéfini d’essais de livraison.
- Travailleurs : Les Azure Functions avec des déclencheurs Service Bus traitent les charges de travail reçues. Le relais de sortie fonctionne en boucle en arrière-plan dans Azure Container Apps, ou comme un Kubernetes CronJob si vous utilisez AKS ; il consulte PostgreSQL pour identifier les événements non envoyés et les transmet par HTTP.
Google Cloud
- Ingress : Google Cloud API Gateway gère les webhooks HTTP entrants ainsi que l’authentification.
- Base de données : Cloud SQL pour PostgreSQL stocke les données relationnelles, y compris les deux tables.
- Files d’attente et DLQ : Pub/Sub achemine les messages de manière asynchrone. La souscription principale traite les événements, tandis qu’un sujet de lettres mortes capture les messages qui n’ont pas encore été reconnus après le nombre maximal d’essais de livraison configuré.
- Employés : Les services Cloud Run, capables de réduire leur nombre d’instances à zéro entre deux pics d’utilisation, reçoivent des notifications Pub/Sub pour traiter les messages entrants. Le relais de messagerie sortante est soit une tâche Cloud Run, soit un service Cloud Run invoqué selon un calendrier par Cloud Scheduler, qui consulte Cloud SQL et envoie les événements en attente.
Pour découvrir d’autres modèles de connexion entre services, tels que OAuth et des appels API résilients, consultez six modèles d’intégration pour relier des services Node.js de manière fiable.
Points clés
- Un système webhook fiable est un pipeline de traitement d’événements, et non une paire d’extrémités HTTP.
- N’actualisez jamais la base de données et n’appeliez jamais un service distant comme deux étapes indépendantes ; écrivez une ligne dans la table de messagerie sortante au sein de la même transaction et laissez un relais s’en charger.
- L’arborescence des messages en attente assure une livraison au moins une fois, ce qui oblige chaque destinataire à supprimer les doublons.
- Du côté du destinataire, vérifier, stocker en appliquant une contrainte d’unicité sur l’ID de l’événement émis, confirmer immédiatement et effectuer le travail réel via un processus secondaire.
- Vérifier chaque événement par rapport à votre machine d’états et différer ceux dont les prérequis manquent, en imposant une limite sur le temps d’attente.
- Limiter les tentatives de réexpédition, diriger les échecs persistants vers une file de messages défectueux avec alertes, et faire en sorte que la réexécution soit une opération prioritaire.
- Les files gérées telles que SQS, Service Bus et Pub/Sub fournissent des mécanismes de tentatives répétées et de gestion des messages défectueux, tandis que les tables de base de données assurent les garanties essentielles.