Comprendre les clés d’idempotence dans les points de terminaison POST de Node.js
Explique pourquoi les requêtes POST échouent de manière imprévisible lors des tentatives répétées et comment les clés d’idempotence générées par le client permettent aux API Node.js de gérer en toute sécurité les requêtes dupliquées.
L’idempotence est un terme que l’on trouve çà et là dans la documentation des API de paiement, généralement accompagné d’une définition tirée du dictionnaire que tout le monde survole sans vraiment la comprendre. Ce qui suit est une tentative d’expliquer ce concept à travers les questions que se posent réellement les développeurs lorsqu’ils le rencontrent dans du code en production, plutôt que la version abstraite que l’on trouve dans un manuel scolaire.
Que signifie réellement « idempotent » lorsque l’on écrit du code, et non pas que l’on lit un glossaire ?
PUT /users/8/name avec un corps de { "name": "Jane" } : que vous l’appeliez une fois ou cinq fois, le nom de l’utilisateur restera « Jane » et rien ne s’accumulera. Comparez cela à POST /orders avec un en-tête destiné à créer une nouvelle commande — si vous l’appeliez cinq fois, vous obtiendriez probablement cinq commandes distinctes plutôt qu’une seule, car rien dans cette opération n’empêche leur accumulation.
Pourquoi cela devient-il un problème majeur surtout avec les requêtes POST ?
POST est généralement l’opération responsable de la création de contenus, et les réseaux présentent une manière particulière d’échouer qui rend cela dangereux : une demande peut être traitée avec succès du côté serveur sans que le client en soit jamais informé, car la réponse elle-même se perd quelque part au retour. Du point de vue du client, tout ce qu’il voit, c’est un délai d’attente expiré. Il n’a aucun moyen de savoir si la commande a réellement été traitée, alors il fait la seule chose sensée possible : il tente à nouveau.
// the client's perspective, roughly
async function submitOrder(payload) {
try {
return await fetch("/orders", { method: "POST", body: JSON.stringify(payload) });
} catch {
return submitOrder(payload); // did the first one actually fail, or just the response?
}
}
Si l’endpoint /orders n’est pas conçu pour supporter ce type de tentative répétée, le client se voit facturé deux fois pour une seule acquisition — et aucune des parties n’est clairement responsable. D’après le client, la demande a bien échoué. Du côté du serveur, elle a quant à elle réussi.
Alors, qu’il faut-il pour rendre un endpoint POST dans Node véritablement idempotent ?
La solution conventionnelle consiste pour le client à générer une clé unique par opération logique, à l’ajouter en en-tête, et à permettre au serveur d’utiliser cette clé pour reconnaître une demande réessayée comme étant la même que celle qu’il a déjà traitée, plutôt que de la considérer comme quelque chose de nouveau.
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
if (existing) {
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
const order = await createOrder(req.body);
await db.query(
"INSERT INTO idempotency_keys (key, response_body, status_code) VALUES ($1, $2, $3)",
[idempotencyKey, JSON.stringify(order), 201]
);
res.status(201).json(order);
});
Il incombe au client de réutiliser cette même clé chaque fois qu’il réessaie la même demande logique — généralement un UUID créé une seule fois, juste avant l’envoi de la première tentative. La tâche du serveur est plus simple : reconnaître une clé qu’il a déjà vue et retourner le résultat stocké au lieu de refaire le travail.
Qui doit générer la clé d’idempotence, le client ou le serveur ?
C’est le client qui doit s’en charger, et cela surprend beaucoup de gens car leur instinct leur souffle le contraire. Si c’était le serveur qui générait la clé, chaque tentative de réessai apporterait une clé nouvelle, ce qui rendrait tout le mécanisme inutile — le serveur n’aurait aucun moyen de distinguer une tentative de réessai d’une nouvelle demande. La clé doit exister avant même que la première tentative ne soit envoyée, afin que la même valeur puisse être réutilisée si cette tentative doit être renouvelée.
Que se passerait-il si deux demandes identiques arrivaient littéralement au même moment, plutôt qu’une après l’autre ?
C’est la partie que presque toutes les premières tentatives avec ce schéma ratent. L’approche simple « vérifier, puis insérer » présentée précédemment comporte une condition de concurrence intrinsèque : deux requêtes portant la même clé peuvent toutes deux exécuter leur SELECT, obtenir un résultat vide dans les deux cas, et procéder à la création d’une commande — ce qui annule complètement l’objectif initial de la clé.
// safer: let the database's own uniqueness constraint catch the race
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"]; try {
await db.query("INSERT INTO idempotency_keys (key) VALUES ($1)", [idempotencyKey]);
} catch (err) {
if (err.code === "23505") { // unique constraint violation
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
throw err;
}
const order = await createOrder(req.body);
await db.query(
"UPDATE idempotency_keys SET response_body = $1, status_code = $2 WHERE key = $3",
[JSON.stringify(order), 201, idempotencyKey]
);
res.status(201).json(order);
});
Imposer une contrainte d’unicité sur la colonne key déplace la décision de votre logique d’application vers la base de données elle-même : lorsque deux requêtes simultanées entrent en conflit, c’est la base de données qui décide laquelle l’emporte, et l’autre reçoit une erreur claire et détectable au lieu de passer inaperçue. Une instruction if dans votre gestionnaire de route ne peut pas combler cette lacune à elle seule — les problèmes de concurrence de ce type doivent être résolus au niveau où l’accès est effectivement sérialisé, et ce niveau, c’est la base de données, pas une vérification conditionnelle dans votre code.
Tout cela a-t-il également de l’importance pour les requêtes GET ?
Pas de la même manière, et cela prête souvent à confusion. GET est conçu pour être idempotent par défaut — il ne devrait rien modifier, donc le relancer librement est intrinsèquement sûr sans nécessiter de traitement spécial. Le schéma de clé d’idempotence existe spécifiquement pour les opérations qui créent ou modifient un état, où un relancement imprudent pourrait doubler l’effet. Si une extrémité GET n’est pas déjà sûre à appeler en boucle, le véritable problème est qu’elle exécute des effets secondaires qu’elle ne devrait absolument pas avoir selon la sémantique GET.
Pendant combien de temps une clé d’idempotence doit-elle rester valide ?
Idéalement suffisamment longtemps pour couvrir des scénarios de relance réalistes, mais pas au point que les clés stockées s’accumulent indéfiniment. De nombreuses plateformes de paiement choisissent une période allant de 24 heures à quelques jours. Une tâche de nettoyage planifiée peut alors supprimer les entrées expirées :
await db.query("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'");
Si la fenêtre de temps est trop restreinte, une tentative de traitement reportée pour une raison légitime — par exemple, si le téléphone d’un client perd le signal pendant dix minutes au milieu du processus de paiement — pourrait sortir de cette fenêtre et provoquer une duplication réelle. Si elle reste ouverte indéfiniment, le tableau continue de s’alourdir sans aucun avantage réel.
Ce schéma ne mérite-t-il d’être pris en compte que pour les systèmes de paiement ?
Les paiements sont souvent le premier domaine où les gens apprennent cette leçon, principalement parce qu’une facturation double est le type d’erreur qui provoque une réponse furieuse du client en moins d’une heure. Mais le problème sous-jacent — un client incapable de distinguer entre « ma demande a échoué » et « ma demande a réussi mais je n’ai reçu aucune réponse » — se manifeste partout où il y a des effets secondaires : l’envoi d’un e-mail, le déclenchement d’un webhook, la création d’un nouveau compte, le lancement d’une tâche en arrière-plan. Toute opération pour laquelle une tentative de répétition est plausible, et où l’exécution deux fois serait pire que ne pas l’exécuter du tout, est un bon candidat pour cette même approche.
Lectures complémentaires
- Conception d’une API Node.js en couches : des controlleurs lourds à une architecture nette — Apprenez comment refactorer une API Node.js en couches de controlleurs, de services et d’accès aux données afin de résoudre les problèmes liés à une logique métier complexe, des erreurs incohérentes et des difficultés de mise à l’échelle.
- 20 patterns Node.js pour éviter l’interruption des serveurs en production — Découvrez 20 patterns pratiques Node.js, allant du traitement des erreurs au arrêt propre et à la gestion des connexions en pool, qui permettent d’éviter les pannes avant même qu’un redémarrage ne devienne nécessaire.