Toute API qui déplace de l'argent, envoie un message ou crée quelque chose fait une promesse qu'elle écrit rarement : si vous n'êtes pas sûr que votre requête est passée, renvoyez-la, il n'arrivera rien de grave. Cet article explique comment cette promesse est tenue, où elle casse, et ce qui a changé en 2025 et en 2026.
English version: Retry forever, charge once: idempotency in 2026.
Deux incidents à garder en tête
Le jour du Black Friday 2024, Voyado, une plateforme suédoise de relation client, n'arrive plus à envoyer correctement les campagnes SMS de ses clients. Sa base SMS est surchargée et ne monte pas en charge, l'API renvoie des erreurs de 10 h 22 à 10 h 51 (heure de Paris), et environ 5 % des destinataires prévus ne reçoivent rien. D'autres reçoivent le même message plusieurs fois. Voyado l'écrit ainsi : « les relances automatiques conçues pour garantir que tous les messages soient toujours livrés ont conduit à envoyer des lots de messages plusieurs fois, en contournant d'une manière ou d'une autre les contrôles prévus pour empêcher ces doublons ». L'équipe a ensuite dû renoncer à renvoyer les messages qui avaient échoué, « pour éviter le risque d'envoyer d'autres doublons » (page de statut de Voyado, post-mortem du 6 décembre 2024).
Le 27 août 2026, un attaquant prend deux messages de retrait signés sur le réseau blockchain ICON et les soumet 1 492 fois en vingt minutes. 1 490 de ces appels réussissent et libèrent 119 866 000 ICX et 531 600 bnUSD depuis des comptes détenus par la fondation ICON ; la perte nette confirmée est d'environ 150,2 ETH et 31 204 USDC. Le contrat avait pourtant une protection contre le double traitement. Le post-mortem explique pourquoi elle a échoué : « le contrôle d'unicité du contrat (la garde censée empêcher qu'un message soit traité deux fois) ne validait que les bits hauts du numéro de série, alors que la partie réellement couverte par la signature cryptographique (les 256 bits bas) ne changeait jamais d'un appel à l'autre » (post-mortem de la fondation ICON, 30 août 2026).
Les deux systèmes avaient une protection contre les doublons, et les deux ont quand même fait la même chose deux fois. L'un l'a perdue sous la charge, l'autre vérifiait les mauvais octets. Gardez-les en tête : à la fin, vous saurez quelle partie d'une architecture aurait attrapé chacun d'eux.
Le problème en une phrase
Un timeout ne dit pas si l'opération a eu lieu. La documentation de Stripe le dit sans détour : il faut traiter le résultat d'une requête qui renvoie une 500 « comme indéterminé » (Stripe, gestion des erreurs de bas niveau). Le client sait qu'il a envoyé quelque chose et qu'il n'a pas reçu de réponse. Il ne sait pas lequel de ces trois cas s'est produit.
- La requête s'est perdue. Rien ne s'est passé côté serveur. Réessayer est correct.
- La requête a été traitée et la réponse s'est perdue. Le paiement existe. Réessayer sans protection paie deux fois.
- La même requête est arrivée deux fois en même temps, parce qu'un utilisateur a double-cliqué, qu'un load balancer a relancé ou qu'une application mobile a renvoyé à la reconnexion. Les deux copies sont en cours avant que l'une ait fini.
Le client ne peut pas distinguer le cas 1 du cas 2, et il lui reste deux choix. Abandonner, et certaines opérations qui auraient dû avoir lieu n'auront jamais lieu. Ou réessayer, et certaines auront lieu deux fois. L'idempotence est ce qui permet de réessayer sans danger : le serveur reconnaît la deuxième tentative et lui répond avec le résultat de la première.
Ce que HTTP promet déjà
HTTP définit le mot. Le RFC 9110, la norme actuelle de la sémantique HTTP, dit qu'une méthode est « idempotente » si l'effet voulu sur le serveur de plusieurs requêtes identiques est le même que celui d'une seule, et que parmi les méthodes qu'il définit, « PUT, DELETE et les méthodes sûres sont idempotentes » (RFC 9110, §9.2.2). Les méthodes sûres sont GET, HEAD, OPTIONS et TRACE.
POST n'est pas dans la liste, PATCH non plus. Tout le problème est là : créer un paiement, envoyer un e-mail ou passer une commande, c'est un POST. La norme dit aussi aux clients quoi en faire : un client « NE DEVRAIT PAS relancer automatiquement une requête dont la méthode n'est pas idempotente, sauf s'il a un moyen de savoir que la sémantique de la requête est en réalité idempotente ».
Une clé d'idempotence est ce « moyen de savoir ». Elle transforme un POST non idempotent en opération que le client peut répéter.
La clé d'idempotence
Le brouillon de l'IETF présenté plus bas attribue le motif à PayPal et à Stripe. Le client génère une valeur unique, en général un UUID aléatoire, l'envoie avec la requête, et renvoie la même valeur à chaque nouvelle tentative de cette requête. Le serveur stocke la clé avec le résultat de la première tentative. Quand la clé revient, il ne refait pas l'opération : il renvoie le résultat stocké.
Trois questions décident si une implémentation fonctionne :
- La clé est-elle comparée à la requête ? Si un client réutilise une clé pour une autre requête, un bug de son côté, le serveur doit refuser plutôt que renvoyer un résultat sans rapport.
- Que se passe-t-il pendant que la première tentative tourne encore ? Un doublon qui arrive en cours de route ne doit pas lancer une deuxième exécution.
- Combien de temps la clé est-elle gardée ? Une relance qui arrive après la suppression de la clé est une nouvelle requête.
Le brouillon de l'IETF, relancé ce mois-ci
Une norme est en cours d'écriture : The Idempotency-Key HTTP Header Field. Elle a atteint le dernier appel du groupe de travail en janvier 2024, y est retournée en juin 2024, et sa version 07 a expiré en avril 2026. La version 08 a été publiée le 22 octobre 2026, avec un nouveau co-auteur et un nouveau texte pour le cas de la relance, que les auteurs marquent « encore ouvert à la discussion ». C'est toujours un Internet-Draft, pas un RFC.
Ses réponses aux trois questions sont celles que la plupart des API utilisent déjà :
- clé absente sur un endpoint qui l'exige :
400; - clé réutilisée avec un contenu différent :
422; - relance pendant que la requête d'origine est encore en traitement :
409; - une politique d'expiration que le serveur « DEVRAIT définir » et « publier dans sa documentation ».
Ce que font les grandes API en 2026
| API | Où va la clé | Durée de conservation | Même clé, autre requête |
|---|---|---|---|
| Stripe API v1 | En-tête Idempotency-Key, sur POST | Au moins 24 heures | Erreur. Sinon la réponse enregistrée est rejouée, y compris une 500 |
| Stripe API v2 | Même en-tête, sur POST et DELETE ; générée automatiquement si absente | 30 jours | Une première tentative en échec est réexécutée |
| Adyen | En-tête idempotency-key, 64 caractères au plus, POST | 7 à 14 jours | Non documenté ; 409 ou 422 code 704 pendant l'exécution |
| Shopify GraphQL | Directive @idempotent(key:) ; obligatoire sur refundCreate et les mutations d'inventaire depuis la version 2026-04 | 24 heures | IDEMPOTENCY_KEY_PARAMETER_MISMATCH |
| Resend | En-tête Idempotency-Key, 256 caractères au plus | 24 heures | 409 invalid_idempotent_request |
| PayPal | En-tête PayPal-Request-Id, sur les API qui le prennent en charge | Variable selon l'API | Renvoie le dernier statut de la requête précédente |
| AWS EC2 | Paramètre ClientToken, 64 caractères ASCII au plus | Durée de vie de la ressource plus une marge | IdempotentParameterMismatch |
| MTN MoMo | En-tête X-Reference-Id, un UUID v4, sur les POST financiers | Non précisé | RESOURCE_ALREADY_EXIST : référence dupliquée |
Sources : les pages de documentation listées à la fin, lues le 31 octobre 2026.
Trois changements ressortent.
L'API v2 de Stripe a repensé la sémantique. En v1, les relances avec la même clé renvoient la réponse enregistrée, « y compris les erreurs 500 », et les clés peuvent être supprimées après 24 heures. En v2, les clés vivent 30 jours, et « si la première requête a échoué (ou partiellement échoué), l'API réexécute les requêtes en échec et renvoie la nouvelle réponse ». Si vous n'envoyez pas de clé, « Stripe génère automatiquement un UUID pour vous » (Stripe, présentation de l'API v2). Le comportement de la v2 est plus proche de ce que veut l'appelant : le résultat enregistré d'un succès, et une vraie deuxième chance après un échec.
Shopify l'a rendue obligatoire. En décembre 2025, Shopify a ajouté la directive @idempotent à son API GraphQL Admin et annoncé qu'elle devient obligatoire pour les remboursements et les ajustements de stock dans la version 2026-04. Le changelog donne la raison sans euphémisme : « les ajustements de stock et les remboursements en double sont depuis longtemps un point douloureux pour les marchands et pour Shopify, avec des incohérences de stock et des pertes financières » (changelog Shopify, 12 décembre 2025).
L'e-mail a rejoint le paiement. Resend a ajouté les clés d'idempotence à son API d'envoi en mai 2025, puis aux envois par lots en juin 2025. Un e-mail envoyé deux fois ne coûte pas d'argent, mais il coûte de la confiance, et les SMS de Voyado montrent que les systèmes de messagerie relancent aussi fort que les systèmes de paiement.
Pourquoi une clé et pas une empreinte de la requête
Un raccourci tentant consiste à se passer de clé et à dédoublonner sur une empreinte du corps de la requête. AWS explique pourquoi il ne le fait pas, avec un exemple tiré d'EC2 : si ces requêtes servaient à lancer une instance EC2, l'hypothèse pourrait ne pas tenir, car « il est possible que l'appelant veuille réellement deux instances EC2 identiques » (AWS Builders' Library, Making retries safe with idempotent APIs). Deux paiements identiques de 5 000 au même marchand peuvent être deux cafés. Seul l'appelant sait si une requête est une relance ou une nouvelle intention, donc c'est l'appelant qui fournit la clé.
L'empreinte garde un rôle : stockée à côté de la clé, elle détecte une clé réutilisée pour une autre requête. AWS fait exactement cela : « nous stockons aussi les paramètres de la requête initiale avec l'identifiant de requête du client ».
Ce que fait le serveur avec la clé
Côté serveur, une clé a une vie courte, en quatre états.
- Absente. Première fois que cette clé est vue. La requête la réserve et s'exécute.
- Réservée. La première tentative tourne. Un doublon reçoit
409et réessaiera plus tard. - Terminée. Le résultat est stocké. Un doublon avec la même requête reçoit ce résultat, marqué comme rejoué ; Stripe ajoute l'en-tête
Idempotent-Replayed: true. Un doublon avec une autre requête reçoit422. - Expirée. Supprimée à la fin de la période de conservation. La requête suivante avec cette clé est traitée comme nouvelle.
Deux règles de ceux qui ont construit cela à grande échelle rendent ces états fiables.
AWS : le processus qui enregistre le jeton d'idempotence et toutes les opérations qui modifient l'état pour servir la requête doit être une opération ACID, atomique, cohérente, isolée et durable. La clé et l'effet s'écrivent ensemble, ou aucun des deux.
Brandur Leach, qui a travaillé sur l'API de Stripe, a décrit en 2017 le cas plus difficile, celui où l'opération appelle un autre système : « dès que nous faisons notre première modification d'état chez un tiers, nous sommes engagés, d'une manière ou d'une autre » (Implementing Stripe-like Idempotency Keys in Postgres). Sa réponse découpe la requête en phases atomiques, des transactions locales entre les appels externes, et enregistre un point de reprise après chacune, pour qu'une relance avec la même clé puisse revenir au point du cycle situé juste avant l'échec de la dernière tentative.
AWS ajoute une troisième règle, facile à rater : ne répondez pas à une relance par « existe déjà ». Ça paraît inoffensif, puisque rien n'a été créé deux fois, mais renvoyer ResourceAlreadyExists « a un effet de bord du point de vue du client ». Le client a demandé un paiement et on lui dit que quelque chose s'est mal passé. La relance doit recevoir la réponse que la première tentative aurait reçue.
Une implémentation dans Postgres
Voici une implémentation complète pour un service NestJS ou Express sur Postgres. Je l'ai exécutée sur PostgreSQL 18.6 et sur 19 RC 1, avec dix tests décrits plus bas.
CREATE TABLE idempotency_keys (
account_id bigint NOT NULL,
key text NOT NULL CHECK (char_length(key) BETWEEN 1 AND 255),
request_hash bytea NOT NULL,
locked_until timestamptz NOT NULL DEFAULT now(),
response_code integer,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, key)
);
CREATE TABLE payments (
id uuid PRIMARY KEY DEFAULT uuidv7(),
account_id bigint NOT NULL,
amount integer NOT NULL CHECK (amount > 0),
currency text NOT NULL,
provider_id text
);La clé est rattachée au compte, comme chez Stripe, chez Adyen et dans la spécification ACP : deux clients qui génèrent par hasard la même clé ne doivent pas voir les résultats l'un de l'autre. uuidv7(), nouveau dans PostgreSQL 18, donne des identifiants ordonnés dans le temps, qui s'indexent mieux que des UUID aléatoires.
Quand tout l'effet est dans votre base
Si créer le paiement n'écrit que dans votre propre base, une seule transaction suffit. La réservation, le paiement et la réponse stockée sont validés ensemble, ou pas du tout.
const fingerprint = (r: PaymentRequest) =>
createHash('sha256').update(JSON.stringify([r.amount, r.currency])).digest();
// The key, the payment and the stored reply commit together, or not at all.
export async function createPayment(db: Pool, accountId: number, key: string, req: PaymentRequest): Promise<Reply> {
const hash = fingerprint(req);
const tx = await db.connect();
try {
await tx.query('BEGIN');
// A second request with the same key waits here until the first one commits or rolls back.
const claim = await tx.query(
`INSERT INTO idempotency_keys (account_id, key, request_hash) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING`,
[accountId, key, hash],
);
if (claim.rowCount === 0) {
await tx.query('ROLLBACK');
return replay(db, accountId, key, hash);
}
const { rows: [payment] } = await tx.query(
`INSERT INTO payments (account_id, amount, currency) VALUES ($1, $2, $3)
RETURNING id, amount, currency`,
[accountId, req.amount, req.currency],
);
await tx.query(
`UPDATE idempotency_keys SET response_code = 201, response_body = $3
WHERE account_id = $1 AND key = $2`,
[accountId, key, payment],
);
await tx.query('COMMIT');
return { status: 201, body: payment };
} catch (err) {
await tx.query('ROLLBACK');
throw err;
} finally {
tx.release();
}
}
async function replay(db: Pool, accountId: number, key: string, hash: Buffer): Promise<Reply> {
const { rows: [saved] } = await db.query(
`SELECT request_hash, response_code, response_body FROM idempotency_keys
WHERE account_id = $1 AND key = $2`,
[accountId, key],
);
if (!saved.request_hash.equals(hash)) return { status: 422, body: { error: 'key_reused_with_another_request' } };
if (saved.response_code === null) return { status: 409, body: { error: 'request_in_progress' } };
return { status: saved.response_code, body: saved.response_body, replayed: true };
}La concurrence est gérée par la clé primaire, pas par le code applicatif. Quand deux transactions insèrent la même clé, la deuxième attend sur l'index unique que la première valide ou annule. Si la première valide, ON CONFLICT DO NOTHING n'insère rien, et la deuxième requête lit la réponse stockée. Si la première annule, la deuxième insère la clé et fait le travail elle-même. La documentation de PostgreSQL le dit de la variante ON CONFLICT DO UPDATE : l'une des deux issues « est garantie, même sous forte concurrence » (INSERT).
Cinquante requêtes simultanées avec la même clé produisent un paiement et cinquante réponses identiques, une originale et quarante-neuf marquées comme rejouées. Les mêmes cinquante requêtes sans clé produisent cinquante paiements. Un crash entre le paiement et la validation ne laisse aucune clé derrière lui, et la relance paie une fois. Validez plutôt la clé seule avant le paiement, laissez le processus mourir entre les deux, et la clé reste réservée sans réponse : chaque relance reçoit 409, et aucun paiement n'est jamais fait.
Un détail mérite d'être vérifié dans votre propre pile : qu'un COMMIT a réellement validé. Quand une instruction échoue dans une transaction et que l'erreur est attrapée, PostgreSQL répond au COMMIT suivant par une étiquette ROLLBACK plutôt que par une erreur, et un client qui ignore l'étiquette annonce un succès. J'ai trouvé ce bug dans le pilote PGlite de MikroORM en octobre 2026 ; le mainteneur l'a corrigé dans #8552, en reprenant mon test. Dans un handler d'idempotence, cela revient à dire à un client « 201, c'est fait » pour un paiement qui n'existe pas.
Quand l'effet est ailleurs
La plupart des vrais paiements appellent un prestataire : Stripe, Adyen, un opérateur de mobile money. Cet appel ne peut pas tenir dans votre transaction, donc la requête se déroule en trois étapes.
// 1. Claim the key, or take it back from a request that died holding it.
const claim = await db.query(
`INSERT INTO idempotency_keys (account_id, key, request_hash, locked_until)
VALUES ($1, $2, $3, now() + interval '30 seconds')
ON CONFLICT (account_id, key) DO UPDATE SET locked_until = EXCLUDED.locked_until
WHERE idempotency_keys.response_code IS NULL
AND idempotency_keys.locked_until < now()
AND idempotency_keys.request_hash = EXCLUDED.request_hash`,
[accountId, key, hash],
);
if (claim.rowCount === 0) return replay(db, accountId, key, hash);
// 2. Same key downstream: if we crash after this line, the retry charges nothing new.
const charge = await provider.charge(`${accountId}:${key}`, req);
// 3. Record the payment and the reply together, unless a retry that took the key back got there first.
// In one transaction:
// UPDATE idempotency_keys SET response_code = 201
// WHERE account_id = $1 AND key = $2 AND response_code IS NULL
// -- zero rows: roll back and replay; otherwise insert the payment and store the reply.L'étape 1 valide la réservation avec un verrou qui expire au bout de trente secondes. Un doublon qui arrive pendant que le verrou tient reçoit 409. Une relance qui arrive après l'expiration du verrou, parce que la première tentative a planté, reprend la clé, mais seulement si la requête correspond et qu'aucune réponse n'a été stockée.
L'étape 2 contient la règle qui rend l'ensemble sûr : transmettre la clé. Si le processus meurt après que le prestataire a débité la carte et avant l'étape 3, la relance rappelle le prestataire avec la même clé, et le prestataire renvoie le premier débit au lieu d'en faire un deuxième. Sans cela, aucun soin apporté à votre propre base n'aide, parce que l'argent est déjà parti.
L'étape 3 traite la dernière course : la première tentative était seulement lente, pas morte, et termine après qu'une relance a repris sa clé et fini. La condition WHERE response_code IS NULL fait voir au retardataire que quelqu'un d'autre a écrit la réponse ; il annule et rejoue.
Les tests couvrent chacun de ces cas : vingt requêtes simultanées appellent le prestataire une seule fois ; un crash après le débit, suivi d'une relance après l'expiration du verrou, débite une fois ; une première tentative lente qui finit après une relance produit un seul paiement et renvoie la réponse de la relance. Retirer la clé transmise fait débiter deux fois au test du crash. Retirer la condition response_code IS NULL fait enregistrer deux paiements au test de la tentative lente.
Ce que PostgreSQL 19 va changer
PostgreSQL 19 a atteint sa première version candidate le 15 octobre 2026 et n'est pas encore final. Il ajoute ON CONFLICT DO SELECT, qui « renvoie la ligne existante en conflit avec la ligne proposée à l'insertion » (documentation devel de PostgreSQL). La réservation et le rejeu tiennent en une seule instruction :
INSERT INTO idempotency_keys (account_id, key, request_hash) VALUES ($1, $2, $3)
ON CONFLICT (account_id, key) DO SELECT
RETURNING old IS NULL AS claimed, request_hash, response_code, response_body;Sur la 19 RC 1, claimed vaut vrai quand la clé était nouvelle et faux quand la ligne existait déjà, auquel cas l'empreinte et la réponse stockées reviennent dans le même aller-retour. Tant que la 19 n'est pas sortie, c'est la version en deux temps ci-dessus qu'il faut faire tourner.
Ce que donnent les bibliothèques
Si vous tournez sur AWS Lambda, Powertools for AWS Lambda propose un utilitaire d'idempotence pour TypeScript (version 2.36.0, octobre 2026) qui implémente les mêmes états, INPROGRESS, COMPLETE et EXPIRED, dans DynamoDB ou un cache compatible Redis. Il fait expirer les enregistrements après une heure par défaut, stocke une empreinte du contenu pour lever IdempotencyValidationError en cas de différence, et enregistre le temps d'exécution restant pour qu'un enregistrement laissé INPROGRESS par une fonction arrivée au timeout puisse être repris (Powertools for AWS Lambda, Idempotency). C'est la même conception que ci-dessus, avec un autre stockage.
Les files livrent au moins une fois, encore
Tout ce qui précède concerne un client qui relance une requête HTTP. Le même problème existe entre vos propres services, et en 2026 aucun système de messagerie courant ne le supprime.
Amazon SQS. Les files standard livrent au moins une fois : vous pouvez recevoir de nouveau une copie d'un message, donc il faut « concevoir vos applications pour qu'elles soient idempotentes ». Les files FIFO dédoublonnent, mais pendant cinq minutes seulement : si un message est bien envoyé mais que l'accusé de réception se perd et que le message est renvoyé avec le même MessageDeduplicationId après ce délai, « Amazon SQS ne peut pas détecter les doublons » (SendMessage).
Google Cloud Pub/Sub propose une livraison « exactly-once », sous conditions. Elle ne vaut que pour les abonnements en pull, au sein d'une seule région : si votre application abonnée est répartie sur plusieurs régions, elle peut recevoir des doublons « même quand la livraison exactly-once est activée ». Elle ne couvre pas non plus le côté publication : un abonnement peut recevoir plusieurs copies du même message « à cause de doublons côté publication » (Pub/Sub, livraison exactly-once, mise à jour du 26 octobre 2026).
Apache Kafka a une sémantique exactly-once depuis 2017, et la version 4.4.0 est sortie le 22 octobre 2026. La garantie est réelle et étroite : elle couvre la lecture depuis Kafka, le traitement et l'écriture dans Kafka, parce que la position du consommateur est écrite dans la même transaction que la sortie. Pour tout ce qui est hors de Kafka, la documentation est explicite : la livraison exactly-once vers d'autres systèmes de destination « exige en général la coopération de ces systèmes » (conception de Kafka 4.4). La proposition qui permettrait à Kafka de participer à un commit en deux phases avec une base externe, KIP-939, a été acceptée, puis mise en pause, et son code client retiré de la branche de la version 4.2. En 4.4, elle n'est toujours pas livrée.
L'outbox transactionnelle est la manière standard de publier un événement quand une ligne change en base : écrire l'événement dans une table outbox dans la même transaction, et laisser un processus séparé l'envoyer. Elle garantit que le message est envoyé « si et seulement si la transaction en base est validée », et annonce aussitôt le prix : le relais peut publier un message plus d'une fois, donc « un consommateur de messages doit être idempotent » (microservices.io).
Les doublons ne sont pas des cas marginaux à grande échelle. En 2017, Segment mesurait qu'« environ 0,6 % des événements reçus dans une fenêtre de quatre semaines sont des doublons de messages déjà reçus », et construisait une couche de dédoublonnage d'environ 60 milliards de clés, 1,5 To sur disque, pour une fenêtre de quatre semaines (Delivering billions of messages exactly once). Et les doublons arrivent en masse précisément au moment où l'on est le plus occupé à se relever d'une panne. Pendant la panne Kafka de PagerDuty du 28 août 2025, causée par un bug qui créait un nouveau producteur Kafka à chaque requête API, « près de 4,2 millions de producteurs en trop par heure au pic », des clients ont reçu des webhooks et des messages de chat en double « pendant que le système traitait l'arriéré de messages périmés » (PagerDuty Engineering, 4 septembre 2025).
Côté consommateur, la table est la même que côté HTTP. L'identifiant du message joue le rôle de la clé, et son enregistrement est validé avec l'effet.
Exécution durable : chaque étape tourne au moins une fois
La grande tendance d'infrastructure de 2025 et 2026 est l'exécution durable : des moteurs de workflow qui enregistrent chaque étape et reprennent après un crash. Cloudflare Workflows est disponible en version finale depuis le 7 avril 2025. AWS a lancé les fonctions durables de Lambda le 2 décembre 2025, pour Node.js et Python. Temporal, Restate, DBOS et Inngest proposent le même modèle.
Ils diffèrent sur beaucoup de points et s'accordent sur un seul : une étape qui a planté avant que son résultat soit enregistré est rejouée.
- AWS : « les étapes ont par défaut une sémantique d'exécution au moins une fois » (fonctions durables Lambda, idempotence).
- Cloudflare : « comme une étape peut être relancée plusieurs fois, vos étapes devraient (idéalement) être idempotentes » (Rules of Workflows).
- Temporal : « Temporal recommande que les Activities soient idempotentes », et propose de combiner l'identifiant d'exécution du workflow et celui de l'Activity comme clé d'idempotence (Activity definition).
- DBOS : « les étapes sont tentées au moins une fois mais ne sont jamais réexécutées une fois terminées ».
- Inngest, sur la faille qu'ils partagent tous : « une requête peut réussir avant qu'Inngest enregistre le résultat de l'étape ».
L'exécution durable sort la boucle de relance de votre code. Elle ne dispense pas l'effet au bout de la boucle de reconnaître une répétition. Le moteur vous donne un identifiant stable pour chaque étape : transmettez-le au prestataire de paiement, à l'API d'e-mail ou à votre propre table, comme clé.
Les agents IA relancent aussi
Les appelants les plus récents de votre API sont des agents IA, et ils apportent des relances à chaque couche.
Les SDK relancent par défaut. Les SDK officiels d'Anthropic et d'OpenAI relancent tous deux les requêtes en échec deux fois par défaut : erreurs de connexion, 408, 409, 429 et erreurs serveur, et la documentation d'Anthropic précise que les requêtes arrivées au timeout sont relancées aussi (SDK Anthropic, SDK OpenAI). Cela relance l'appel au modèle. L'outil que le modèle décide ensuite d'appeler est une requête à part, et c'est votre API.
Le protocole d'outils prévoit les répétitions. Le Model Context Protocol, le protocole ouvert que beaucoup d'agents utilisent pour appeler des outils, permet à un outil de déclarer idempotentHint : « si vrai, appeler l'outil plusieurs fois avec les mêmes arguments n'aura aucun effet supplémentaire sur son environnement ». La valeur par défaut est false, et la spécification avertit que ces annotations sont des indications que les clients « DOIVENT considérer » comme non fiables, sauf si elles viennent de serveurs de confiance. La révision actuelle, datée du 28 juillet 2026, a aussi supprimé la reprise des flux : un flux de réponse coupé perd la requête en cours, et les clients « DOIVENT la renvoyer comme une nouvelle requête, avec un nouvel identifiant » (changelog MCP, 2026-07-28). Un appel d'outil dont le flux casse après l'exécution de l'outil sera envoyé de nouveau.
Les frameworks d'agents le disent dans leur propre documentation. Le SDK Agents d'OpenAI, dans son guide sur la validation humaine : « l'approbation autorise l'exécution d'un appel d'outil ; elle ne garantit pas une exécution unique. Si un outil effectue une action externe et que le processus s'arrête avant que l'application enregistre l'état mis à jour, reprendre depuis l'ancien instantané approuvé peut exécuter l'outil de nouveau. Par exemple, un paiement peut aboutir alors que l'instantané enregistré n'a aucune trace de son résultat » (openai-agents-python). LangGraph : une tâche commencée mais pas terminée peut être rejouée à la reprise, donc « concevez les effets de bord pour qu'ils soient idempotents ». Inngest recommande d'utiliser l'identifiant de l'appel d'outil du modèle comme clé d'idempotence chez le prestataire de paiement.
Le paiement par agent l'a rendue obligatoire. L'Agentic Commerce Protocol, lancé par Stripe et OpenAI le 29 septembre 2025 pour les achats dans ChatGPT, est sorti avec un en-tête Idempotency-Key optionnel. Dans la version 2026-04-17 de la spécification, il est obligatoire sur chaque POST, et le RFC du checkout ajoute : « les serveurs NE DOIVENT PAS réexécuter les effets de bord (par exemple la capture du paiement ou la réservation de stock) lors d'un rejeu » (spécification ACP). Le protocole x402 de Coinbase a ajouté en février 2026 une extension payment-identifier qui permet aux clients de fournir un id servant de clé d'idempotence.
Je n'ai trouvé aucun incident public et vérifié d'agent ayant payé deux fois. Le risque est documenté par ceux qui construisent les protocoles et les frameworks, et ils convergent tous vers la même réponse : une clé stable par action logique, transmise au système qui l'exécute.
Neuf façons de se tromper
1. Une nouvelle clé à chaque relance. Si le client génère la clé dans sa boucle de relance, chaque tentative est une nouvelle requête. La clé appartient à l'intention de l'utilisateur : générez-la quand il clique sur « Payer », stockez-la avec la commande en attente, et réutilisez-la jusqu'à une réponse définitive.
2. Une clé qui ne couvre pas la requête. La garde d'ICON vérifiait une partie d'un numéro de série alors que la signature en couvrait une autre : un message pouvait être rejoué sous un « nouveau » numéro, toujours validement signé. Le correctif du post-mortem énonce la règle générale : le nombre signé et le nombre utilisé pour le contrôle d'unicité sont désormais « garantis identiques ». Ce qui autorise l'action doit être ce sur quoi vous dédoublonnez.
3. La clé enregistrée après l'effet. Si le paiement est fait et que le processus meurt avant l'écriture de la clé, la relance ne trouve pas de clé et paie de nouveau. Enregistrez la clé avant l'effet : dans la même transaction quand l'effet est local, comme une réservation avec verrou quand il ne l'est pas.
4. Des erreurs stockées comme des réponses. Une 500 stockée contre une clé transforme une panne passagère en panne définitive : chaque relance reçoit la même erreur. ACP l'interdit (« les réponses avec un statut HTTP 5xx NE DOIVENT PAS être mises en cache contre la clé d'idempotence »), et la v2 de Stripe réexécute les requêtes en échec. Stockez les réponses définitives, y compris une carte refusée, et laissez les pannes passagères se relancer.
5. Une fenêtre plus courte que vos relances. SQS FIFO dédoublonne pendant cinq minutes, Resend et Shopify gardent les clés 24 heures, Stripe v2 30 jours. Un job qui relance avec un backoff exponentiel pendant six heures, une application mobile qui renvoie le lendemain matin une requête mise en attente, ou un workflow repris après une panne d'un week-end peuvent tous dépasser la fenêtre. Brandur suggérait environ 72 heures pour son propre nettoyage, pour qu'un bug déployé un vendredi puisse être corrigé le lundi.
6. La clé non transmise. Votre table peut être parfaite : un crash entre le débit chez le prestataire et votre validation débite quand même deux fois si le prestataire n'a reçu aucune clé. Chaque appel externe de la requête prend la clé, ou une clé dérivée.
7. Deux systèmes, aucun registre commun. En février 2025, HM Revenue and Customs, l'administration fiscale britannique, a reconnu que des remboursements d'impôt avaient été versés deux fois à certains contribuables : « il existe un scénario très particulier où nos systèmes CESA et PAYE peuvent tous deux régulariser une année fiscale, ce qui entraîne des remboursements en double » (Computer Weekly, 24 février 2025). Chaque système était idempotent de son côté. La clé métier, un remboursement par contribuable et par année fiscale, n'avait pas de propriétaire unique.
8. Les humains relancent aussi. En septembre 2024, un employé du bureau de poste de Taichung, à Taïwan, lance un virement de salaires, croit que le fichier a été supprimé, et le relance le lendemain ; deux superviseurs différents approuvent les deux virements. Plus de 10 000 salariés sont payés deux fois, 380 millions de dollars taïwanais au total, environ 12 millions de dollars américains (Taiwan News, 7 septembre 2024). Un fichier de lot a autant besoin d'une clé qu'un appel d'API : l'identité du fichier, vérifiée avant le deuxième lancement.
9. Facturer chaque relance. En juillet 2023, le Consumer Financial Protection Bureau américain a condamné Bank of America à verser au moins 80,4 millions de dollars de réparation et une amende de 60 millions, notamment parce que la banque facturait 35 dollars de frais pour provision insuffisante « à chaque fois » qu'un marchand présentait de nouveau le même paiement rejeté, « alors qu'elle avait déjà facturé ces frais sur la transaction initiale » (ordonnance du CFPB 2023-CFPB-0006). C'était une politique tarifaire, pas un bug. Elle montre la même erreur du côté métier : une relance traitée comme un nouvel événement.
Retour aux deux incidents
Voyado montre l'arbitrage au cœur du sujet. Un expéditeur qui ne peut pas savoir si un message est parti doit choisir entre le perdre et l'envoyer deux fois ; Voyado a choisi la livraison, et son contrôle des doublons n'a pas tenu sous la surcharge. La défense consiste à enregistrer chaque envoi contre une clé, par exemple campagne et destinataire, avant de le confier à la passerelle SMS, et à transmettre cette clé quand la passerelle en accepte une. Quand elle n'en accepte pas, un contrôle des doublons qui tient sous la charge est la seule protection qui reste. La deuxième leçon est dans leur dernière phrase : une fois les doublons possibles, ils ne pouvaient plus renvoyer sans risque ce qui avait échoué. Sans idempotence, relancer est dangereux, et se relever de l'incident aussi.
ICON avait un contrôle, et le contrôle portait sur les mauvais octets. Une clé d'unicité qui ne couvre pas exactement ce que la signature autorise n'est pas une clé : c'est un compteur que l'attaquant contrôle. La table de cet article stocke une empreinte de la requête à côté de la clé pour la même raison : la clé dit « ceci est une relance », l'empreinte prouve que c'est une relance de cette requête.
C'est la version honnête de « exactement une fois » : rien dans le réseau, la file ou le moteur de workflow ne la fournit. Chaque couche livre au moins une fois, et la dernière étape, celle qui déplace l'argent ou envoie le message, reconnaît la répétition parce qu'elle porte une clé choisie une seule fois, par l'appelant, et qui voyage avec la requête jusqu'en bas.
Sources
- Voyado, incident Voyado Engage du 29 novembre 2024, post-mortem publié le 6 décembre 2024.
- Fondation ICON, ICON Network replay exploit post-mortem, 30 août 2026.
- IETF, RFC 9110, HTTP Semantics, §9.2.
- Groupe de travail HTTPAPI de l'IETF, The Idempotency-Key HTTP Header Field, version 08, 22 octobre 2026.
- Stripe, Idempotent requests, Low-level error handling et API v2 overview.
- Brandur Leach, Implementing Stripe-like Idempotency Keys in Postgres, octobre 2017.
- AWS, Making retries safe with idempotent APIs, Amazon Builders' Library, et Ensuring idempotency in Amazon EC2 API requests.
- Adyen, API idempotency.
- Shopify, Implementing idempotency et changelog du 12 décembre 2025.
- Resend, Idempotency keys et changelog du 1er mai 2025.
- PayPal, Idempotency, mise à jour d'août 2026.
- MTN MoMo Developer, codes d'erreur.
- PostgreSQL, INSERT, fonctions UUID, annonce de PostgreSQL 18, PostgreSQL 19 RC 1 et INSERT, version devel.
- MikroORM, pull request #8552, octobre 2026.
- AWS, Powertools for AWS Lambda (TypeScript), Idempotency.
- AWS, Amazon SQS FIFO exactly-once processing, SendMessage et files standard.
- Google Cloud, Pub/Sub exactly-once delivery, mise à jour du 26 octobre 2026.
- Apache Kafka, Design, version 4.4, annonce de la 4.4.0, KIP-939 et KAFKA-19848.
- Chris Richardson, Pattern: Transactional outbox.
- Segment (Twilio), Delivering billions of messages exactly once, juin 2017.
- PagerDuty Engineering, August 28 Kafka outages: what happened and how we're improving, septembre 2025.
- AWS, lancement des fonctions durables Lambda, décembre 2025, et idempotence dans les fonctions durables.
- Cloudflare, Workflows GA, avril 2025, et Rules of Workflows.
- Temporal, Activity definition ; DBOS, Workflows ; Inngest, Handling idempotency et Durable agents.
- Model Context Protocol, spécification 2026-07-28 et changelog.
- Anthropic, SDK Python ; OpenAI, openai-node et Agents SDK, human in the loop ; LangChain, LangGraph functional API.
- Stripe, Stripe powers Instant Checkout in ChatGPT, septembre 2025 ; spécification Agentic Commerce Protocol ; Coinbase, extension x402 payment-identifier.
- Computer Weekly, HMRC working to resolve system glitch that doubles self-assessment tax rebates, 24 février 2025.
- Taiwan News, paiements de salaires en double à la poste de Taichung, 7 septembre 2024.
- Consumer Financial Protection Bureau, ordonnance Bank of America 2023-CFPB-0006, juillet 2023.
