L'idempotence expliquée et ce qu'elle implique pour votre API
Les clés didempotence expliquées : évitez les requêtes API dupliquées, les races et les POST répétés grâce à des claims atomiques et transactions.
Une opération est idempotente lorsque son exécution répétée laisse le système dans le même état que son exécution unique. Envoyez deux fois la même requête et rien de supplémentaire ne se produit : pas de deuxième commande, pas de deuxième débit, pas de deuxième compte.
Le terme apparaît généralement dans deux situations : un débit en double en production, ou une API de paiement qui réclame un en-tête Idempotency-Key sans trop préciser ce que le serveur en fait. Cet article couvre les deux volets de ce contrat : ce que le client fait de la clé, et ce que le serveur met en œuvre pour qu’une requête répétée soit inoffensive, et non « généralement inoffensive ».
Points clés à retenir
- Une clé d’idempotence possède trois états, et non deux : absente, en cours de traitement (in flight) et terminée. Une requête qui trouve la clé en cours de traitement doit recevoir un 409 Conflict, et non déclencher une seconde exécution.
- Vérifier l’existence d’une clé puis l’écrire ensuite constitue précisément la situation de concurrence critique ; la seule revendication sûre est une insertion atomique unique avant tout début de traitement.
- Le résultat stocké doit être validé dans la même transaction de base de données que la modification métier ; les valider séparément ne fait que déplacer la situation de concurrence.
- Le client génère la clé avant la première tentative, la réutilise à chaque nouvelle tentative, et ne la dérive jamais d’un hachage du corps de la requête.
- Testez en déclenchant deux requêtes identiques au même instant. Un test de doublon qui les exécute l’une après l’autre réussit même lorsque le code présente une situation de concurrence.
Que prévient l’idempotence ?
L’idempotence vous protège des requêtes en double, et ces dernières sont banales, non exotiques. Un utilisateur double-clique sur « valider » avant que la page ne réagisse. Une bibliothèque cliente atteint son délai d’attente et renvoie la requête. Un proxy ou un service mesh réessaie après une connexion interrompue, à l’insu de l’application. Dans chaque cas, la première requête a pu aboutir ; la seconde, traitée naïvement, crée alors une deuxième commande ou transfère l’argent deux fois. Les session replays des bugs de double soumission montrent en général la version la plus banale : l’utilisateur qui appuie de nouveau sur « valider » alors que le spinner tourne encore, ce qui constitue le pendant côté client du problème exact que la clé résout côté serveur.
Pourquoi les nouvelles tentatives continuent d’affluer
Une nouvelle tentative n’est pas un dysfonctionnement. Les clients HTTP, les applications mobiles et l’infrastructure intermédiaire renvoient tous des requêtes après un délai d’attente, et ce par conception, car une réponse perdue est indiscernable d’une requête perdue. Vous ne pouvez pas empêcher les nouvelles tentatives d’arriver ; vous pouvez seulement rendre votre endpoint apte à recevoir deux fois la même requête sans dommage.
La RFC 9110 définit PUT et DELETE comme idempotents alors que POST ne l’est pas, et PATCH, défini dans la RFC 5789, ne l’est pas non plus. L’étiquette de la méthode ne rend jamais votre handler sûr à elle seule ; l’idempotence est une propriété de votre implémentation, pas du verbe.
Le volet client du contrat
Le client génère la clé d’idempotence avant la première tentative, envoie la clé identique à chaque nouvelle tentative de cette opération, et n’utilise une nouvelle clé que pour une opération réellement nouvelle. Une chaîne aléatoire à forte entropie telle qu’un UUID convient. Les recommandations de Stripe préconisent un UUID V4, un plafond de 255 caractères et aucune donnée sensible dans la clé elle-même — ni adresses e-mail ni autres identifiants personnels — car les clés apparaissent dans les logs.
Ne dérivez jamais la clé d’un hachage du corps de la requête. Deux commandes qui se trouvent être identiques, par exemple le même client achetant deux fois de suite le même article, produiraient le même hachage et seraient fusionnées en une seule. Un hachage vous indique si deux payloads correspondent ; une clé vous indique quelle opération l’appelant visait. Ce sont deux fonctions distinctes. Dériver la clé d’un élément stable sur lequel l’utilisateur agit déjà, comme un ID de panier, fonctionne très bien, car le panier représente l’opération.
Notez que l’en-tête Idempotency-Key relève d’une convention du secteur, et non d’une norme ratifiée. Le draft du groupe de travail httpapi de l’IETF a expiré à la révision 07 sans devenir une RFC ; chaque fournisseur définit donc sa propre sémantique.
Le volet serveur : revendiquer la clé de façon atomique
Une clé d’idempotence possède trois états, et non deux : absente, en cours de traitement et terminée. La plupart des implémentations défectueuses n’en modélisent que deux. Elles vérifient si la clé existe, exécutent le handler, puis enregistrent le résultat. Cela laisse une fenêtre pendant laquelle deux nouvelles tentatives concurrentes ne voient rien toutes les deux et s’exécutent toutes les deux. La solution consiste à revendiquer la clé par une insertion atomique unique avant tout début de traitement :
INSERT INTO idempotency_keys
(tenant_id, idem_key, fingerprint, state, locked_until)
VALUES
($1, $2, $3, 'in_flight', now() + interval '90 seconds')
ON CONFLICT (tenant_id, idem_key) DO NOTHING
RETURNING id;
Dans PostgreSQL, ON CONFLICT DO NOTHING ignore l’insertion et RETURNING ne renvoie aucune ligne en cas de clé conflictuelle ; zéro ligne renvoyée signifie donc qu’une autre requête en est propriétaire. Lisez la ligne existante : si son état est complete, rejouez le résultat stocké ; s’il est encore in_flight, renvoyez un 409 Conflict plutôt que d’exécuter une seconde fois. Cela correspond au comportement documenté des fournisseurs : Stripe renvoie un 409 Conflict lorsqu’une clé est réutilisée alors que la première requête est encore en cours, et n’enregistre pas ce conflit au compte de la clé, ce qui laisse le client libre de revenir plus tard. Stripe signale par ailleurs une réponse rejouée par un en-tête Idempotent-Replayed: true, une politesse peu coûteuse qui mérite d’être imitée.
Valider le résultat avec la modification métier
Le résultat stocké et la modification métier doivent être validés dans la même transaction de base de données. Les écrire séparément ne supprime pas la situation de concurrence, cela la déplace dans l’intervalle entre les deux commits. Si le processus meurt après le débit mais avant la mise à jour de la clé, l’argent a été transféré alors que la ligne indique toujours in_flight.
BEGIN;
INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3);
UPDATE idempotency_keys
SET state = 'complete', status_code = 201, response_body = $4
WHERE tenant_id = $1 AND idem_key = $5;
COMMIT;
Soit les deux lignes existent, soit aucune : c’est là tout l’enjeu.
Que faut-il stocker avec la clé, et pendant combien de temps ?
Stockez tout ce que le handler a produit, y compris ses échecs. Selon les règles d’idempotence de Stripe, le code de statut et le corps de la première tentative sont conservés et renvoyés à nouveau lors d’une réutilisation, réponses d’erreur et 500 compris. Rejouer une véritable erreur est plus honnête que d’exécuter discrètement l’opération une seconde fois. La véritable frontière se situe au niveau de tout ce qui est rejeté avant l’exécution du handler. La limitation de débit et l’authentification se placent en amont de la couche d’idempotence ; ces réponses ne sont donc jamais rattachées à la clé et restent réessayables.
Trois options de stockage, en bref :
- Réponse complète. La plus simple à rejouer à l’identique ; le stockage croît avec la taille du payload.
- Référence de ressource. Stockez l’ID de la commande créée et reconstruisez la réponse ; plus léger, mais nécessite une recherche supplémentaire.
- Marqueur plus empreinte de la requête. Stockage minimal ; viable uniquement lorsque la réponse est recalculable, et l’empreinte devient obligatoire plutôt qu’optionnelle.
Quatre règles s’appliquent quelle que soit l’option retenue. Placez la contrainte d’unicité sur (tenant_id, key) plutôt que sur la clé seule, afin qu’un tenant ne puisse pas entrer en collision avec les clés d’un autre tenant ni partir à leur pêche. Définissez une expiration : Stripe purge les clés une fois le seuil des 24 heures dépassé, le principe étant de survivre à la fenêtre de nouvelles tentatives sans laisser la table grossir indéfiniment. Posez un bail sur les lignes en cours de traitement (la colonne locked_until ci-dessus) afin qu’un processus mort en cours de requête ne puisse pas bloquer définitivement les nouvelles tentatives. Enfin, rejetez toute nouvelle tentative dont l’empreinte ne correspond pas à celle stockée. La même clé avec un corps différent trahit un bug côté client, et renvoyer une réponse sans rapport serait la pire des issues.
Comment tester correctement l’idempotence ?
Déclenchez deux requêtes identiques avec la même clé au même instant, puis vérifiez qu’il existe exactement une ressource. Exécuter les doublons l’un après l’autre ne prouve rien, car la première se termine avant que la seconde ne regarde : un code sujet aux situations de concurrence passera le test.
KEY=$(uuidgen)
for i in 1 2; do
curl -s -o "resp_$i.json" -w "%{http_code}\n" \
-X POST http://localhost:3000/orders \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"cart_id":"c_42","total_cents":1900}' &
done
wait
Vérifiez qu’il n’existe qu’une seule ligne dans orders pour ce panier, et que les deux codes de statut se composent d’un 201 accompagné soit d’un 201 rejoué, soit d’un 409. Si les deux réponses sont des 201 avec des ID de commande différents, vous êtes face à la situation de concurrence « vérifier puis écrire ».
Trois éléments à ne pas manquer
L’en-tête ne fait que donner à deux systèmes un nom commun pour une même opération. La sûreté, elle, repose sur trois éléments dans votre base de données : une contrainte d’unicité, une revendication atomique et une frontière transactionnelle. Maîtrisez-les et votre endpoint survivra à n’importe quel client qui réessaie — c’est-à-dire à tous. La même approche se transpose aux consommateurs de messages, où la livraison at-least-once implique une clé de déduplication qui remplit ce rôle sous un autre nom. Commencez par votre endpoint POST le plus dangereux, ajoutez la table de clés, et écrivez le test concurrent avant de lui accorder votre confiance.
FAQ
Les requêtes GET et PUT ont-elles besoin de clés d'idempotence ?
En général, non. La RFC 9110 définit GET comme sûre et PUT comme DELETE comme idempotentes : un PUT réessayé qui remplace intégralement une ressource laisse donc le même état sans clé. Les clés importent pour POST, où chaque requête crée quelque chose de nouveau. L'exception concerne un handler PUT ou DELETE assorti d'effets de bord, comme l'envoi d'un e-mail ou le déclenchement d'un webhook, qui nécessite malgré tout une déduplication côté serveur.
Quelle est la différence entre une clé d'idempotence et un ID de requête ?
Ils évoluent en sens inverse au fil des nouvelles tentatives. Un ID de requête ou ID de corrélation identifie une tentative HTTP unique à des fins de logging et de traçage : chaque nouvelle tentative en reçoit donc un nouveau. Une clé d'idempotence identifie une opération voulue : chaque nouvelle tentative réutilise donc la même. Un client qui génère une nouvelle clé d'idempotence à chaque tentative annule complètement la déduplication, et le serveur exécute l'opération deux fois.
Puis-je stocker les clés d'idempotence dans Redis plutôt que dans PostgreSQL ?
Oui, pour la revendication atomique : SET avec le flag NX revendique une clé en une seule étape atomique, ce qui correspond au motif insert-on-conflict. Ce que Redis ne peut pas vous offrir, c'est une transaction unique validant le résultat de la clé en même temps qu'une ligne métier stockée ailleurs. Un crash entre l'écriture Redis et le commit de la base rouvre la situation de concurrence ; conserver les clés dans la base métier est donc plus sûr.
Que se passe-t-il si un client réessaie après l'expiration de la clé d'idempotence ?
Le serveur traite la nouvelle tentative comme une requête entièrement nouvelle et l'exécute à nouveau, ce qui peut créer un doublon. Stripe, par exemple, purge les clés une fois les 24 heures dépassées : une clé réutilisée après cette fenêtre exécute donc l'opération une seconde fois. Fixez une durée de rétention supérieure au plus long délai de nouvelle tentative que peut raisonnablement produire un client, une file d'attente ou un job batch.