Les clés d'idempotence dans les API REST
Un client envoie une demande de paiement, le réseau expire avant le retour de la réponse, et le client, c'est raisonnable, réessaie. Si votre endpoint POST /payments se contente de relancer le prélèvement, le client est débité deux fois. Les clés d'idempotence résolvent exactement ce problème : elles permettent à un client de réessayer sans risque une requête qui a peut-être réussi, sans risquer un effet de bord en double.
L'idée de base
Une clé d'idempotence est une valeur unique que le client génère une fois par opération logique (généralement un UUID) et envoie à chaque tentative de cette opération, y compris les nouvelles tentatives.
POST /payments
Idempotency-Key: 8f14e45f-ceea-4f3f-9d5f-3b6b1a2c9a10
{ "amount": 4999, "currency": "usd", "customerId": "cus_123" }
Le travail du serveur est simple en principe : la première fois qu'il voit une clé donnée, il exécute l'opération et enregistre le résultat associé à cette valeur. Chaque requête suivante avec la même clé renvoie le résultat enregistré au lieu de relancer l'opération, même si le corps de la requête paraît identique, même si elle arrive quelques secondes ou quelques heures plus tard.
Une implémentation minimale
Voici la forme que cela prend avec un gestionnaire Node/Express adossé à une table de base de données :
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- 'in_progress' | 'completed'
response_body JSONB,
response_status INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function handlePayment(req, res) {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' });
const requestHash = hashBody(req.body);
const existing = await db.query('SELECT * FROM idempotency_keys WHERE key = $1', [key]);
if (existing.rows.length) {
const record = existing.rows[0];
if (record.request_hash !== requestHash) {
return res.status(422).json({ error: 'Idempotency key reused with a different request body' });
}
if (record.status === 'completed') {
return res.status(record.response_status).json(record.response_body);
}
return res.status(409).json({ error: 'Request with this key is still processing' });
}
await db.query(
'INSERT INTO idempotency_keys (key, request_hash, status) VALUES ($1, $2, $3)',
[key, requestHash, 'in_progress'],
);
const result = await chargeCustomer(req.body);
await db.query(
'UPDATE idempotency_keys SET status = $1, response_body = $2, response_status = $3 WHERE key = $4',
['completed', result.body, result.status, key],
);
return res.status(result.status).json(result.body);
}
La vérification de request_hash est importante : elle empêche un client de réutiliser, par accident (ou malveillance), une clé pour une opération réellement différente. Si le corps ne correspond pas à ce qui a été enregistré pour cette clé, rejetez la requête plutôt que d'exécuter silencieusement la nouvelle ou de renvoyer l'ancienne réponse.
La condition de concurrence à gérer
La version naïve ci-dessus a une faille : deux requêtes avec la même clé peuvent toutes deux passer le test « existe-t-elle ? » avant que l'une ou l'autre n'insère une ligne, et le client est alors débité deux fois. Corrigez cela en faisant de l'insertion elle-même le point d'atomicité : utilisez une contrainte d'unicité sur key et laissez la base rejeter la seconde insertion :
try {
await db.query(
'INSERT INTO idempotency_keys (key, request_hash, status) VALUES ($1, $2, $3)',
[key, requestHash, 'in_progress'],
);
} catch (err) {
if (err.code === '23505') { // unique_violation
// Someone else beat us to it — poll or return 409, don't proceed.
return res.status(409).json({ error: 'Request with this key is already being processed' });
}
throw err;
}
C'est précisément pourquoi les clés d'idempotence exigent une base de données (ou équivalent) : une Map en mémoire sur une seule instance de serveur ne vous sauvera plus dès que vous avez plusieurs serveurs derrière un répartiteur de charge, et ne survivra certainement pas à un redémarrage en pleine requête.
Ce qui compte comme « l'opération »
Les clés d'idempotence protègent contre les nouvelles tentatives d'une même requête, pas contre un client qui appelle volontairement l'endpoint deux fois avec des clés différentes. Cette distinction relève de la responsabilité du client : générez une clé par action logique (par tentative de paiement, par envoi de formulaire), et non une clé par appel HTTP que le client se trouve à faire, ni une clé fixe réutilisée pour des opérations sans rapport.
Une erreur courante consiste à générer un nouvel UUID à chaque nouvelle tentative au lieu de réutiliser celui de la première : cela annule tout le mécanisme, car le serveur voit chaque nouvelle tentative comme une clé inédite et la traite à neuf. La clé doit être générée une seule fois, avant la première tentative, et transportée à travers chaque nouvelle tentative de ce même essai.
Faire expirer les anciennes clés
Vous ne voulez pas que cette table grossisse indéfiniment. Les clés d'idempotence ne servent que pendant la fenêtre de nouvelles tentatives qu'un client peut raisonnablement utiliser : une tâche de fond ou un cron peut supprimer sans risque les lignes de plus de, disons, 24 heures :
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';
Si un client réessaie d'une manière ou d'une autre après cette fenêtre avec la même clé, le pire qui puisse arriver est une vraie opération en double : un compromis acceptable, puisqu'une nouvelle tentative un jour plus tard n'est presque certainement plus la même action utilisateur.
Où cela a réellement sa place
Tous les endpoints n'en ont pas besoin. Les requêtes GET sont idempotentes par définition : les exécuter deux fois n'a aucun effet de bord, il n'y a donc rien à protéger. La technique compte pour les endpoints POST et parfois PATCH qui créent des ressources ou déclenchent des effets de bord aux conséquences réelles : paiements, passage de commande, envoi d'e-mail ou de SMS, provisionnement d'infrastructure. Si réessayer une requête échouée serait inoffensif dans les deux cas, vous n'avez pas besoin de cette mécanique.
À retenir
Les clés d'idempotence transforment « est-ce que ça a vraiment abouti ? », question à laquelle votre client ne peut pas répondre de façon fiable après un timeout, en un non-problème : réessayez avec la même clé, obtenez le même résultat, une seule fois. Le schéma est simple, mais c'est l'atomicité de l'insertion qui le rend réellement sûr sous requêtes concurrentes, et pas seulement sous requêtes séquentielles.