La clé d’idempotence : ne pas créer deux fois la même opération
Rendre une création répétable sans doublon grâce à une clé d’idempotence insérée avant l’action et protégée par une contrainte d’unicité en base.
Où en êtes-vous avec cette notion ?
Une indication personnelle, enregistrée uniquement dans ce navigateur.
À lire ensuite
Le format café
L’essentiel à comprendre, le temps d’un café.
Envie de creuser ? Un cours plus complet vous attend juste après, à déplier sans quitter cette page.
Ce que vous allez comprendre
Comment un serveur reconnaît que deux requêtes portent la même intention métier, et pourquoi la protection réelle contre les doublons se trouve dans la base de données.
Le problème
Un utilisateur clique sur « Payer 49 € ». Le réseau ralentit, rien ne bouge à l’écran, il reclique. Le serveur reçoit :
POST /api/paiements
POST /api/paiements
Traitées naïvement, ces deux requêtes débitent deux fois. Et le client a raison de réessayer : une réponse peut se perdre alors que l’action a bel et bien eu lieu côté serveur.
POST crée généralement quelque chose de neuf à chaque appel. C’est sa sémantique, détaillée dans le cours sur l’idempotence HTTP. La question n’est donc pas de changer de méthode, mais de donner au serveur un moyen d’identifier l’intention.
La clé d’idempotence
Le client produit un identifiant unique pour cette tentative précise et le joint à la requête :
POST /api/paiements
Idempotency-Key: 7f4c91c2-3b0d-4f1e-9a77-2c8e51d0a6b3
La clé désigne une intention métier, pas une personne. user-42 serait un mauvais choix : le même utilisateur paiera d’autres fois. paiement-commande-8472, ou une valeur aléatoire tirée au moment du clic, conviennent.
Le client doit réutiliser exactement la même clé lorsqu’il réessaie. Une clé neuve à chaque tentative annule toute la mécanique.
La protection est en base, pas dans une condition
On imagine volontiers ce déroulé : chercher la clé, et si elle est absente, exécuter puis l’enregistrer. C’est insuffisant. Deux requêtes simultanées peuvent constater l’absence de la clé avant que l’une des deux ne l’ait écrite, et créer deux paiements.
La garantie vient d’une contrainte de la base :
CREATE UNIQUE INDEX ON cles_idempotence (cle);
On insère la clé d’abord. Si l’insertion échoue pour violation d’unicité, c’est le signal qu’une autre requête est déjà passée : il ne reste qu’à répondre comme elle. Ce n’est pas une vérification, c’est un verrou tenu par le moteur.
Fiche mémo
- Une clé = une intention métier, reprise à l’identique à chaque tentative.
- Insérer la clé avant d’agir, jamais après.
- La contrainte
UNIQUEarbitre la course à votre place. - Clé et effet métier dans la même transaction.
- Les clés se conservent un temps limité, puis s’effacent.
L’idée à emporter : l’idempotence d’une création ne s’écrit pas dans une condition if, elle s’appuie sur une contrainte que la base fait respecter.
Et si on allait plus loin ?
Le café vous a donné les repères. Prenez maintenant le temps de comprendre les mécanismes et de pratiquer, si vous le souhaitez.
Aller plus loinDu double clic à une création protégée par une contrainte d’unicitéDévelopper le coursReplier le cours
Ce que vous saurez faire
Concevoir une table de clés d’idempotence, écrire la séquence qui résiste à deux requêtes simultanées, décider quoi répondre pendant qu’une première tentative est encore en cours, et articuler tout cela avec une transaction.
1. La version naïve, et pourquoi elle échoue
Voici la séquence qui vient spontanément à l’esprit. Contexte d’exécution : code serveur, Node.js ou équivalent, avec un accès à la base. Ce fragment ne s’exécute pas seul.
// Version naïve : à ne pas reprendre en production.
async function creerPaiement(cle: string) {
const existant = await db.clesIdempotence.findUnique({ where: { cle } });
if (existant) return existant.resultat;
const paiement = await executerPaiement();
await db.clesIdempotence.create({ data: { cle, resultat: paiement } });
return paiement;
}
Elle fonctionne tant que les requêtes arrivent l’une après l’autre. Or c’est précisément le double clic qui pose problème, et il produit deux requêtes quasi simultanées.
Requête A Requête B
│ lecture : clé absente │
│ │ lecture : clé absente
│ paiement créé │
│ │ paiement créé
▼ ▼
Deux débits de 49 €
Entre la lecture et l’écriture existe une fenêtre qu’aucune relecture supplémentaire ne referme : le défaut est structurel.
2. La version robuste : insérer d’abord
On inverse l’ordre. La clé est insérée avant l’action, et c’est la base qui arbitre.
CREATE TABLE cles_idempotence (
cle text PRIMARY KEY,
etat text NOT NULL DEFAULT 'en_cours',
reponse jsonb,
creee_le timestamptz NOT NULL DEFAULT now()
);
Contexte d’exécution : psql, sur une base d’exercice, jamais votre base de production. La clé primaire suffit ici, puisqu’elle impose déjà l’unicité.
// Serveur. Une violation d’unicité signale qu’une autre requête est passée avant nous.
async function creerPaiement(cle: string) {
try {
await db.query('INSERT INTO cles_idempotence (cle) VALUES ($1)', [cle]);
} catch (erreur) {
if (estViolationUnicite(erreur)) return repondreSelonEtatExistant(cle);
throw erreur;
}
const paiement = await executerPaiement();
await db.query(
"UPDATE cles_idempotence SET etat = 'termine', reponse = $2 WHERE cle = $1",
[cle, JSON.stringify(paiement)],
);
return paiement;
}
Résultat attendu : une seule requête réussit l’insertion. La contrainte est évaluée par le moteur, à l’abri de la concurrence, ce qu’aucune séquence lecture-puis-écriture ne peut garantir depuis le code applicatif. La seconde requête reçoit une erreur, et cette erreur est une information utile, pas un incident.
Référence : RFC 9110, sémantique HTTP.
3. Que répondre à la requête arrivée deuxième ?
Deux situations, dont la seconde demande une décision de conception.
- La première tentative est terminée. La ligne contient la réponse enregistrée : renvoyez-la telle quelle. Le client obtient le même résultat qu’à la première tentative, sans nouvel effet.
- La première tentative est encore en cours. Il n’existe pas encore de réponse à rejouer. Deux comportements se défendent. Répondre immédiatement
409 Conflicten signalant qu’une opération identique est en cours, et laisser le client réessayer plus tard. Ou faire patienter la requête un court instant, puis relire l’état. Le premier choix est simple et prévisible ; le second épargne un aller-retour au client. C’est un arbitrage de conception, et il dépend de votre contexte.
Prévoyez également une durée de conservation des clés : passé un certain délai la ligne disparaît, et la même clé redeviendrait utilisable. Cette fenêtre fait partie du contrat de votre API et se documente.
4. Une seule transaction pour la clé et l’effet
Si l’enregistrement de la clé et l’effet métier ne sont pas atomiques, un incident survenu entre les deux laisse la base incohérente : une clé sans commande, ou une commande sans clé.
BEGIN
insérer la clé d’idempotence
créer la commande
enregistrer la réponse
COMMIT
Une transaction ne couvre en revanche que ce qu’elle contient. Un appel à un prestataire de paiement extérieur n’est pas annulé par un ROLLBACK. Pour ces effets-là, la clé doit être transmise au prestataire, qui applique la même logique de son côté.
Pièges et limites
- Générer une nouvelle clé à chaque tentative. Toute la mécanique repose sur la réutilisation de la même valeur. Produisez-la au moment du clic, pas au moment de l’envoi.
- Prendre un identifiant d’utilisateur comme clé.
user-42bloquerait tous les paiements suivants de cette personne. La clé identifie une intention, pas un compte. - Réutiliser une clé avec un contenu différent. Si le corps de la requête a changé, renvoyer l’ancienne réponse est trompeur. Enregistrez une empreinte du corps et répondez par une erreur en cas de divergence.
- Croire qu’une lecture avant écriture suffit. C’est la version naïve. Sans contrainte d’unicité, la fenêtre de concurrence reste ouverte.
- Figer les échecs. Faut-il rejouer une erreur
500mémorisée ? Le plus souvent non : une tentative échouée doit pouvoir être réessayée.
À vous de jouer
Deux requêtes portant la clé paiement-8472 arrivent à douze millisecondes d’intervalle. La table cles_idempotence a bien cle en clé primaire. La requête A insère la clé, puis met environ 800 ms à traiter le paiement chez le prestataire.
Décrivez étape par étape ce que fait la requête B, et dites ce qu’elle renvoie au client dans chacune des deux stratégies évoquées plus haut.
Correction commentée
La requête B tente son INSERT alors que A a déjà inséré la ligne. La base rejette l’insertion pour violation de la clé primaire. B n’exécute donc jamais le paiement : c’est exactement le résultat recherché, obtenu sans verrou applicatif ni fichier de synchronisation.
B relit ensuite la ligne. Elle y trouve etat = 'en_cours' et une réponse vide, puisque A n’a pas terminé.
- Stratégie « conflit ». B répond immédiatement
409 Conflict, en indiquant qu’une opération identique est en cours. Le client peut réessayer une seconde plus tard et obtiendra alors la réponse enregistrée. Simple à implémenter et facile à expliquer, mais le client doit gérer ce cas. - Stratégie « attente ». B relit l’état périodiquement pendant une durée bornée, puis renvoie la réponse enregistrée par A dès qu’elle apparaît, soit ici après environ 800 ms. Plus confortable pour le client, mais une connexion reste ouverte et la borne de temps est indispensable.
Dans les deux cas, le point essentiel est le même : un seul débit. Ce n’est pas la stratégie de réponse qui l’a garanti, c’est l’insertion préalable de la clé sous contrainte d’unicité. La stratégie ne décide que du confort du client.
Vous pouvez aussi vous arrêter ici. L’approfondissement est facultatif.
Les repères Cours Café
Pour aller à la source
Documentation de référence. Vérification éditoriale encore à effectuer.
Gardez une trace de cette idée.
Favoris, notes et progression seront disponibles après connexion du stockage distant.