Arrêter les effets secondaires dupliqués lors des nouvelles tentatives de webhook

un flux client a créé 3 notes HubSpot pour un seul envoi de formulaire parce que l’expéditeur a réessayé.

voici ce qui m’a permis de corriger le problème :

utiliser un identifiant stable (leur clé d’idempotence, ou un hash de external_id + type d’événement). avant tout nœud de création/mise à jour, vérifier un petit stockage pour cette clé. si elle y est, renvoyer 200 et quitter. sinon, écrire la clé d’abord, puis effectuer les effets secondaires.

aussi, étiqueter dans la note les nœuds qui écrivent ou lisent. le toi du futur oubliera.

ce n’est pas parfait pour tous les cas mais ça a tué les notes dupliquées.

@blessoftware - l’étape atomique est la bonne correction, et je l’étendrais sur trois arêtes qui posent problème en production.

Choix clé. « Leur clé d’idempotence » ne fonctionne que si l’expéditeur en fournit réellement une stable, donc vérifiez les en-têtes de livraison avant de leur faire confiance : certains fournisseurs envoient un ID d’événement stable qui survive aux tentatives de renvoi, tandis que d’autres le changent à chaque tentative (Slack change son timestamp et sa signature de requête à chaque renvoi, donc toute clé dérivée d’un en-tête devient une toute nouvelle clé à chaque fois). La solution de secours robuste est un hash du corps canonique : une charge utile identique renvoyée est un renvoi, tandis qu’une charge utile similaire mais nouvelle (l’utilisateur soumet réellement le formulaire deux fois) est un nouvel événement. Le hachage de external_id + event type confond ces deux cas et finira par abandonner un second envoi légitime.

TTL est une fenêtre de garantie, pas un paramètre de nettoyage. 86400 signifie réellement « après 24 heures, je ne peux plus distinguer un renvoi d’une première livraison. » Dimensionnez-le en fonction du calendrier de renvoi documenté de l’expéditeur plutôt que pour la propreté : si l’expéditeur effectue des renvois pendant jusqu’à 3 jours, tout TTL inférieur à 3 jours plus marge laisse un trou à la fin où un renvoi tardif passe comme nouveau. Assurance bon marché : TTL = fenêtre de renvoi maximale + un jour.

Vu n’est pas terminé. Ces deux extraits enregistrent tous deux « nous avons commencé à traiter cette clé » mais rien n’enregistre que la création HubSpot a réellement réussi. Si le workflow meurt après l’INCR/INSERT mais avant l’écriture de la note, chaque renvoi ultérieur est arrêté comme doublon - et maintenant vous n’avez pas un doublon, vous avez des données manquantes en silence, ce qui est pire. Le stockage a besoin d’un statut sur l’enregistrement (claimed → done/failed) et d’un petit nettoyeur qui remet en file d’attente tout ce qui est bloqué en claimed au-delà d’un certain âge.

Ce dernier point est exactement sur quoi mise « Respond to Immediately » : une fois que vous répondez 200 rapidement, l’expéditeur ne renverra jamais, donc votre enregistrement de dédup devient la seule preuve que l’événement est arrivé. Compromis raisonnable - mais cela transfère le besoin de durabilité de l’expéditeur à votre stockage, donc le stockage doit le justifier.

Je suis curieux de savoir ce que vous avez observé du côté du TTL en pratique : les expéditeurs dépassent-ils réellement leurs fenêtres de renvoi documentées, ou la fenêtre documentée plus marge tient-elle pour vous ?

@InvestigatorSuper216 Bonjour !
Les solutions fournies par @blessoftware et @InvestigatorSuper216 sont extrêmement précises, techniquement solides et s’alignent parfaitement avec les meilleures pratiques des systèmes distribués.

Vérification des faits et points techniques clés

Opérations atomiques @blessoftware a tout à fait raison. Un modèle standard « vérifier puis écrire » crée une condition de course (TOCTOU). L’utilisation de Redis INCR ou de PostgreSQL INSERT ... ON CONFLICT DO NOTHING garantit l’atomicité.

Le piège de la sortie : Dans n8n, désactiver « Always Output Data » sur un nœud de base de données garantit que lorsqu’un conflit retourne 0 lignes, la branche du workflow se termine naturellement à ce stade.

Sélection de la réponse webhook : Définir le mode de réponse du nœud webhook n8n sur « Respond Immediately » est la meilleure pratique pour éviter les dépassements de délai d’attente de l’expéditeur.

« Vu n’est pas fait » : @iwasinnam21 soulève avec justesse le défaut de la « double écriture ».
Verrouiller la clé avant d’appeler HubSpot risque une perte de données silencieuse si le workflow échoue en cours d’exécution.

Voici les alternatives natives à n8n…
Fonctionnalités natives de n8n pour simplifier la configuration :

Tableaux de données n8n (Pas de BD externe nécessaire) : au lieu d’héberger une instance Redis/PostgreSQL séparée, utilisez les tableaux de données n8n intégrés « Data tables | Build | n8n Docs ». Définissez une contrainte d’unicité sur votre colonne de clé personnalisée. Une insertion en doublon génère naturellement une erreur, échoue à ajouter et arrête la branche.

Nœud Remove Duplicates : Pour les doublons non concurrents (espacés de quelques minutes ou heures), utilisez le nœud natif n8n Remove Duplicates « Remove Duplicates | Nodes | n8n Docs ». Définissez l’opération pour comparer par rapport aux exécutions précédentes à l’aide d’une chaîne de charge utile hachée.

Routage des erreurs : Pour résoudre le problème « Vu n’est pas fait » de manière native, utilisez la gestion des erreurs n8n. Si HubSpot échoue, acheminez l’erreur vers un nœud qui supprime ou efface la clé verrouillée de votre magasin pour qu’elle puisse être réessayée ultérieurement.

Une nuance que j’ajouterais : « écrire la clé en premier » prévient l’effet secondaire en double, mais un simple drapeau vu/non-vu peut créer l’échec inverse si l’exécution s’arrête après la revendication de la clé et avant que l’écriture ne se produise réellement.

Je préfère une petite machine à états pour l’enregistrement d’idempotence :

  • in_flight — cet événement est actuellement revendiqué
  • completed — l’effet secondaire externe est confirmé
  • poison/review — le résultat est incertain ou la charge est dangereuse à réessayer aveuglément

Puis lors d’une nouvelle tentative :

  • completed → retourner 200 et ne rien faire
  • in_flight → attendre/court-circuiter sauf si la revendication est périmée
  • poison/review → vérifier le système externe avant de rejouer quoi que ce soit
  • missing — revendiquer, valider, puis effectuer l’écriture

Pour les écritures multi-étapes, je séparerais également l’ID d’événement stable du fournisseur de l’ID d’exécution de n8n. Un nouvel ID d’exécution à chaque nouvelle tentative n’est pas une clé d’idempotence.

Le test de régression que j’utiliserais consiste à rejouer intentionnellement le webhook exact deux fois, puis à simuler un échec après la revendication mais avant/après l’écriture externe. Si les deux cas se rétablissent sans dupliquer ni abandonner silencieusement l’action, la garde fait son travail.

Les étiquettes lecture-vs-écriture sont aussi une excellente habitude opérationnelle — surtout une fois qu’un flux de travail possède plusieurs nœuds d’effets secondaires.

@daniel_Samuel, une correction : les Data Tables ne supportent actuellement pas de contrainte UNIQUE sur une colonne personnalisée, donc cette partie ne fonctionnera pas. Conservez l’approche de clé unique Postgres pour la réclamation atomique.

Egalement, ne supprimez pas la clé simplement parce que le nœud HubSpot génère une erreur. Un délai d’expiration peut survenir après que HubSpot a créé la note. Supprimer la clé permet alors à la nouvelle tentative d’en créer une autre. C’est le cas pour l’état poison/review décrit ci-dessus, pas une relecture automatique.

Pour la réponse webhook, définissez Répondre en utilisant le nœud « Respond to Webhook » et envoyez 200 après que l’ID d’événement et la charge utile soient durables. Vous pouvez acquitter avant de créer la note, mais vous aurez alors besoin d’un processus de récupération pour les événements stockés qui n’ont pas été finalisés.

L’écriture durable avant de retourner 200 est la limite clé ici.

Dans mon expérience avec Inistate, je traite le webhook comme le début d’une opération de longue durée plutôt que de stocker un simple drapeau vu/non vu. n8n crée d’abord ou réclame un enregistrement Inistate en utilisant l’ID d’événement du fournisseur. L’enregistrement peut passer par les états Received, Processing, Completed et Needs reconciliation.

Une fois que cet enregistrement existe, le workflow peut retourner 200 et continuer avec l’écriture HubSpot. Si HubSpot expire, l’opération passe à Needs reconciliation au lieu de supprimer la clé ou de réessayer aveuglément. Un workflow de récupération ou une personne peut vérifier si la note existe avant de choisir l’action suivante.

L’ID d’événement empêche la livraison en double de créer une autre opération. L’état de l’opération gère le cas plus difficile où l’effet secondaire peut s’être produit mais n8n n’a pas reçu de confirmation.

Un cas limite que j’ajouterais ici : assurez-vous que la partie « vérifier la clé → écrire la clé » est atomique.

Si deux tentatives arrivent presque simultanément, les deux exécutions peuvent vérifier le magasin avant que l’une ou l’autre n’écrive la clé. Elles voient toutes les deux « non traité » et vous pouvez quand même vous retrouver avec deux notes HubSpot.

Pour tout ce qui est important, j’utiliserais un magasin où vous pouvez réclamer la clé d’idempotence de manière atomique, puis laisser uniquement l’exécution qui l’a réussi à réclamer continuer.

Je conserverais également la clé après succès pour une TTL raisonnable plutôt que de la supprimer immédiatement. Certains fournisseurs de webhooks réessaient bien plus tard que vous ne l’attendez.

Le modèle d’idempotence est définitivement la bonne direction — la concurrence est juste la partie qui peut encore vous mordre en production.