Retry a renvoyé 49 e-mails qui étaient déjà partis (et le nœud « Retry On Fail » les a marqués comme réussis)

Bonjour à tous,

J’ai testé le comportement de n8n quand un workflow échoue en cours d’exécution, en utilisant une fausse API locale pour pouvoir compter exactement ce qui s’est passé de l’autre côté. n8n auto-hébergé, lancé avec npx, novembre 2026.

Voici ce que j’ai trouvé :

  1. Un nœud HTTP Request envoie 50 emails. La requête #33 reçoit un 503. Le nœud échoue, mais 49 emails ont déjà été envoyés. « Retry with original workflow » (Réessayer avec le workflow original) réexécute tout le nœud, donc 49 clients reçoivent l’email deux fois (99 envoyés au total).

  2. Même workflow avec « Retry On Fail » (3 tentatives) activé : à nouveau 99 envoyés, 49 doublons, et l’exécution s’affiche comme réussie. Rien dans l’interface ne vous dit que quelque chose s’est mal passé.

  3. Une demande d’expédition a un délai d’attente de 5s. L’API la traite réellement mais répond après 10s. n8n enregistre un échec, et Retry l’envoie à nouveau (2 expéditions). Avec « Retry On Fail », elle a été traitée 3 fois et s’est quand même terminée en erreur.

  4. Pour être juste : quand l’API retourne simplement un 503 et ne traite rien, Retry reprend à partir du nœud défaillant et il n’y a pas de doublons. Ce cas fonctionne bien.

J’ai écrit un petit script qui, avant de renvoyer, demande à l’API cible « cet élément a-t-il déjà été traité ? » et ne renvoie que les éléments manquants. Dans le cas 1, il n’a envoyé que l’1 email manquant (0 doublons). Mais cela dépend de l’API ayant un moyen de le vérifier.

Mes questions :

  • Avez-vous rencontré des doublons comme celui-ci en production ? Quels nœuds / services ?
  • Comment le gérez-vous aujourd’hui : clés d’idempotence, une table de checkpoint DB, une exécution par élément, ou simplement l’accepter ?
  • Pour les services sans support de lookup ou d’idempotence, existe-t-il un modèle qui fonctionne réellement ?

Je serais ravi de partager la configuration de test si quelqu’un veut le reproduire.

Vos deux exemples exposent des risques de nouvelle tentative différents. Dans n8n 2.46.6, Retry On Fail appelle le nœud à nouveau avec la même entrée d’exécution. Le nœud HTTP Request attend les résultats de la requête du lot avant de traiter les erreurs. Une nouvelle tentative réussie vous indique que la dernière tentative a réussi, non que les éléments antérieurs n’ont eu aucun effet.

Un élément par exécution limite le rayon d’explosion, mais il ne peut toujours pas déterminer si l’expédition s’est produite après un délai d’expiration. Un tableau de points de contrôle peut préserver cette incertitude : enregistrer l’ID d’opération avant l’envoi, maintenir un résultat inconnu en dehors de la nouvelle tentative automatique, et le réconcilier avec un reçu de destination s’il en existe un. Sans recherche faisant autorité ni garantie d’idempotence de destination, il n’existe aucune règle automatique sûre qui évite à la fois les doublons et garantit la livraison. Je ne compterais pas un délai d’expiration, un « non trouvé » en retard ou un simple 503 comme preuve d’aucune écriture.

Si vous partagez la configuration synthétique et la version exacte de n8n, je serais intéressé par vérifier un cas supplémentaire : utiliser la sortie d’erreur par élément, puis vérifier que seul l’élément confirmé sans écriture est admissible à la relecture et que l’élément validé mais expiré reste non résolu.

Merci, c’est vraiment utile. Tu l’as mieux formulé que moi : un retry vert dit seulement que la dernière tentative a réussi.

La configuration est ici : GitHub - parkseyoung1215-netizen/agent-recovery · GitHub
Version n8n : 2.46.6, auto-hébergée via npx (la même que celle que tu as mentionnée). L’API factice est experiments/fake_shop.py, et /scenario/N bascule entre les cas.

Ton point sur un « not found » retardataire est une vraie lacune dans mon script. Actuellement recover.py traite une recherche négative comme sûre à rejouer, ce qui est exactement le cas que tu décris. Je vais le changer pour qu’un article qui a échoué avec un timeout reste non résolu à moins que la recherche soit fiable, et j’ajouterai ton cas de sortie d’erreur par élément. Je publierai les résultats ici.

Suivi rapide avec les résultats.

J’ai ajouté votre cas : 50 emails, customer10 reçoit un 503 (non écrit), customer20 est écrit mais la réponse arrive après le délai d’expiration. Les éléments défaillants sont gérés par élément, comme la sortie d’erreur de n8n.

  • La recherche reflète les écritures immédiatement : les deux versions renvoient uniquement customer10. 0 doublons.
  • La recherche accuse un retard de 8 s par rapport aux écritures : ma ancienne version faisait confiance au « non trouvé » précoce et renvoyait les deux, donc 1 doublon. La nouvelle version garde les deux éléments « en attente », vérifie à nouveau après une fenêtre de stabilisation, puis renvoie uniquement customer10 et marque customer20 comme déjà traité. 0 doublons.

La fenêtre de stabilisation reste une hypothèse concernant le service, donc si une recherche accuse un retard plus long que cela, un doublon est toujours possible. Sans aucune recherche, les éléments restent non résolus et vont à un humain.

Merci encore, cela a détecté un bug réel. Les détails sont dans le repo (experiments/exp7_ambiguous_demo.py).

Merci d’avoir partagé la fixture et les résultats. J’ai exécuté recover.py à 391324a contre votre faux magasin en boucle locale. Avec une recherche immédiate, l’e-mail d’expiration original a été trouvé et il y avait un envoi stocké. Avec un délai de recherche plus long que la fenêtre de règlement, la véritable CLI a planifié un renvoi, le faux magasin a enregistré deux envois, et la CLI a toujours imprimé « 0 doublons ». Ce nombre semble être imprimé sans condition, donc je signalerais les demandes de nouvelle tentative acceptées séparément des nombres de livraison vérifiés.

J’ai aussi essayé deux réponses de recherche malformées. HTTP 200 avec {} est allé dans l’ensemble de renvoi ; {"exists":"false"} est allé dans l’ensemble déjà livré. lookup() peut préserver le troisième état en retournant value if type(value) is bool else None. Un false explicite d’une recherche éventuellement cohérente devrait toujours signifier « pas encore visible », même après le délai limite. Utilisez le délai limite pour transférer en révision ; seule une confirmation de non-écriture au niveau de la destination ou une clé d’idempotence honorée de la tentative originale devrait autoriser la relecture automatique. La régression que je conserverais est : une non-écriture confirmée peut être rejouée, l’écriture expirée avec une recherche tardive ou malformée ne peut pas l’être.

Ce que tu as rencontré, c’est le fonctionnement de Retry On Fail. Il rejoue le nœud entier avec le même lot, et n8n ne peut pas savoir quels éléments l’API a déjà traités, donc ceux qui sont partis la première fois repartent à nouveau.

Si l’API supporte une clé d’idempotence, utilise-la, alors une tentative est sûre. Sinon, une petite table de points de contrôle aide. Écris l’id de chaque élément dans la DB juste avant le nœud d’envoi et ignore les ids déjà présents lors d’une tentative. Pour les emails, j’aime aussi un élément par exécution, un échec ne rejoue alors qu’un seul email au lieu du lot entier.

Une chose qui m’a posé problème au début : j’écrivais le point de contrôle après l’envoi. Quand l’exécution s’arrêtait entre l’envoi et l’écriture, je n’avais aucune idée si c’était parti. Écris avant l’envoi, donc un crash en cours d’exécution signifie « peut-être envoyé, vérifie l’API » au lieu de « définitivement envoyé deux fois ». Pour les envois sans enjeu comme les rappels, j’accepte juste les petits doublons et je ne construis le point de contrôle que pour tout ce qui coûte la confiance, les factures, les paiements, ce genre de choses.

Un autre test utile serait celui de deux agents de récupération traitant la même opération non résolue simultanément. Les deux pourraient passer une vérification de recherche avant envoi avant que l’un d’eux n’enregistre son renvoi. Une revendication atomique sur un ID d’opération stable peut prévenir cette course entre vos propres agents, mais cela ne résout pas un plantage après l’écriture distante et avant le point de contrôle local.

Pour ce cas de plantage, le résultat doit rester inconnu jusqu’à ce que la destination puisse le confirmer. Je testerais également un retard de recherche plus long que la fenêtre de règlement configurée, de sorte que la suite de tests rend cette hypothèse visible plutôt que de traiter un résultat négatif retardé comme la preuve que rien ne s’est produit. Je n’ai pas exécuté votre dépôt ; ce sont des cas de test supplémentaires suggérés par les résultats que vous avez affichés.

Merci @sameday, @kartike et Clearwork Desk. sameday, tu avais raison sur les deux bugs, et la ligne « 0 duplicates » était la pire : elle était affichée sans vérifier quoi que ce soit.

Corrigé dans e620685 :

  • L’outil signale maintenant seulement le nombre de requêtes qu’il a envoyées. La vérification de la présence de doublons doit se faire à la destination.
  • Une réponse de recherche qui n’est pas un vrai booléen ({} ou « false ») est traitée comme inconnue et est transmise à un humain.
  • Renvoi automatique uniquement lorsqu’une recherche qui reflète les écritures dit immédiatement que l’élément n’existe pas, ou lorsque la première tentative avait déjà la même Idempotency-Key. Une « non trouvée » avec latence n’est jamais approuvée, même après la fenêtre de règlement.
  • Chaque élément est revendiqué (création de fichier atomique) et marqué « envoi en cours » avant la requête, donc deux workers ne peuvent pas envoyer le même élément, et un crash après l’envoi laisse un enregistrement pour un humain.

Ajout de tests de régression pour ceux-ci, y compris une recherche qui a une latence plus longue que la fenêtre de règlement et un test qui montre l’hypothèse échouant intentionnellement : un service avec latence mal configuré comme « strong » produit toujours un doublon.

Compromis : avec une recherche avec latence et pas de clé d’idempotence, l’élément 503 n’est plus renvoyé automatiquement. Il est laissé pour un humain.

Merci pour la mise à jour. Un détail qui vaut la peine d’être documenté est l’endroit où les fichiers de réclamation se trouvent. Si les travailleurs s’exécutent dans des conteneurs séparés avec des systèmes de fichiers distincts, chacun pourrait réclamer la même opération localement. Les tests de régression exécutent-ils les deux travailleurs contre le même répertoire de réclamation?

Un cas supplémentaire utile serait de redémarrer un travailleur après qu’il enregistre « envoi » mais avant qu’il n’enregistre le résultat, et de vérifier que l’opération reste non résolue plutôt que d’être relancée. Je n’ai pas exécuté e620685 ; ce sont des questions basées sur votre description.

Bonne observation. Oui, les tests exécutent les deux workers contre le même répertoire de réclamation sur une machine, donc la réclamation ne protège que les workers qui partagent ce système de fichiers. Les workers dans des conteneurs séparés revendiqueraient chacun localement. J’ai noté cela comme une limitation dans le README : sur plusieurs hôtes, il faut un magasin partagé, comme une ligne de base de données avec une contrainte unique.

Le cas de redémarrage se trouve dans b36097f : si une récupération précédente a laissé un élément à « sending », le plan l’affiche maintenant comme inconnu pour un humain au lieu de quelque chose à renvoyer, et il y a un test de régression pour cela.

En poursuivant le point de sameday - la table de checkpoint vous protège à la source, mais la partie la plus importante est de faire en sorte que la destination rejette les rejeux. Lorsque vous contrôlez l’API de réception (ou pouvez ajouter un petit wrapper devant), acceptez une clé d’idempotence par opération et faites qu’une clé répétée soit une mise à jour ou une absence d’opération plutôt qu’une deuxième écriture. Ensuite, une nouvelle tentative est sûre par construction, et votre comptabilité côté source n’a plus besoin d’être parfaite.

Où nous avons abouti en production après un incident presque identique (une écriture de réservation a expiré, l’agent a annoncé le succès de toute façon, et une nouvelle tentative a créé un deuxième enregistrement) :

  1. Timeout = état inconnu. Il ne compte jamais comme un succès et ne se réessaye jamais automatiquement ; il va dans une file d’attente d’examen pour une vérification humaine.
  2. Clé d’idempotence côté destination sur chaque écriture. Une écriture réessayée avec la même clé ne change rien.
  3. Un test d’acceptation d’injection de défaillance - 503 forcé au milieu du lot plus une réponse tardive forcée - qui se réexécute à chaque changement de workflow. Ce test est comment vous savez réellement que la correction tient, et il a continué à attraper des régressions non liées, ce qui m’a surpris.

Votre instinct de maintenir les éléments ayant échoué avec timeout non résolus jusqu’à ce qu’une vérification de confiance existe est le bon appel. Si votre faux API peut aussi injecter le cas « traite maintenant, répond après 10 secondes », publiez le avant et après - cette paire est toute la leçon en un seul graphique.