Un même appel réseau peut arriver deux fois : une coupure survient pendant la réponse, le client réessaie automatiquement, un fournisseur redélivre son webhook. Sans garde-fou, le serveur crée deux commandes, envoie deux e-mails ou débite deux fois. La clé d’idempotence est le mécanisme standard qui reconnaît la répétition et renvoie le résultat du premier appel, à l’identique.
Pourquoi un même appel arrive deux fois
La sémantique HTTP (RFC 9110) qualifie GET, PUT et DELETE d’idempotents : les rejouer laisse le serveur dans le même état. POST ne l’est pas, et c’est précisément lui qui crée des ressources, déclenche des paiements ou pousse des messages. Or un client bien conçu réessaie après un délai dépassé, sans savoir si la première requête a été traitée avant la coupure.
La même incertitude gouverne les files de messages et les webhooks, qui garantissent une livraison au moins une fois. Un consommateur reçoit donc parfois deux fois le même événement, et un double-clic sur un bouton de commande produit la même collision côté navigateur. Le point commun de ces situations : l’émetteur ne sait pas si l’effet a déjà eu lieu.
Ce que garantit, et ne garantit pas, une clé d’idempotence
Le principe tient en une phrase : le client attache à chaque opération métier un identifiant unique, transmis dans l’en-tête Idempotency-Key. Le serveur associe cette clé au résultat produit. Si la clé réapparaît, il renvoie la réponse déjà calculée au lieu de rejouer le traitement.
POST /v1/payments HTTP/1.1
Host: api.exemple.com
Idempotency-Key: 5f0c8b1e-2a3d-4c6f-9b21-7d0e4a9c1f88
Content-Type: application/json
{"amount": 4200, "currency": "eur", "order": "cmd_9182"}La portée du mécanisme mérite d’être posée nettement. Une clé d’idempotence déduplique deux requêtes porteuses de la même clé ; elle n’annule rien et ne réconcilie pas deux clés différentes visant la même intention. Elle transforme un « au moins une fois » réseau en un « exactement une fois » côté effet, à condition que la clé désigne l’intention métier et non la tentative HTTP.
Une clé d’idempotence ne rend pas l’opération réversible : elle empêche seulement qu’elle se produise deux fois.
Le cycle d’une requête idempotente
À la réception, le serveur cherche la clé. Absente, il l’enregistre en état « en cours », exécute le traitement, puis stocke le code et le corps de la réponse. Présente et terminée, il renvoie la réponse conservée. Présente mais encore en cours, il signale qu’une requête identique est déjà en vol.
Stocker les clés : le cœur du mécanisme
Tout repose sur une table dédiée et sur une contrainte d’unicité. C’est cette contrainte, et non un test applicatif préalable, qui arbitre deux requêtes concurrentes : l’insertion qui gagne exécute, l’autre échoue et bascule sur la relecture.
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
scope TEXT NOT NULL, -- compte + endpoint
idem_key TEXT NOT NULL,
request_hash TEXT NOT NULL, -- empreinte du corps
status TEXT NOT NULL, -- 'in_progress' | 'completed'
response_code SMALLINT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (scope, idem_key) -- verrou atomique
);Le gestionnaire s’articule autour de cette insertion atomique. Il compare aussi l’empreinte du corps : une même clé accompagnée d’une charge utile différente traduit une réutilisation abusive, qu’il vaut mieux rejeter que servir avec un résultat trompeur.
public function store(Request $request): JsonResponse
{
$key = $request->header('Idempotency-Key');
$scope = $request->user()->id . ':POST /v1/payments';
$hash = hash('sha256', $request->getContent());
// Insertion atomique : la contrainte d'unicité arbitre les concurrents
try {
DB::table('idempotency_keys')->insert([
'scope' => $scope, 'idem_key' => $key, 'request_hash' => $hash,
'status' => 'in_progress', 'expires_at' => now()->addDay(),
]);
} catch (QueryException $e) { // Cle deja presente : rejeu ou requete concurrente
$row = DB::table('idempotency_keys')
->where(compact('scope'))->where('idem_key', $key)->first();
if ($row->request_hash !== $hash) {
return response()->json(['error' => 'key_reuse'], 422); // Meme cle, corps different : reutilisation interdite
}
if ($row->status === 'in_progress') {
return response()->json(['error' => 'in_flight'], 409); // Operation encore en cours cote serveur
}
return response()->json($row->response_body, $row->response_code); // Rejeu : on renvoie la reponse d'origine, a l'identique
}
$payment = $this->charge($request); // Le traitement metier n'a lieu qu'une fois
DB::table('idempotency_keys')->where(compact('scope'))->where('idem_key', $key)
->update(['status' => 'completed', 'response_code' => 201,
'response_body' => $payment]);
return response()->json($payment, 201);
}La réponse d’origine est conservée telle quelle, code de statut compris, pour être resservie au rejeu.
{
"scope": "acct_42:POST /v1/payments",
"idem_key": "5f0c8b1e-2a3d-4c6f-9b21-7d0e4a9c1f88",
"request_hash": "b9f3...c1",
"status": "completed",
"response_code": 201,
"response_body": { "id": "pay_7Kd2", "amount": 4200, "status": "succeeded" },
"expires_at": "2026-08-30T22:00:00Z"
}Plusieurs stratégies de déduplication coexistent selon le contexte.
| Approche | Garantie | Coût | Quand la retenir |
|---|---|---|---|
| Contrainte d’unicité en base | Atomique, résiste à la concurrence | Un index, une table | Cas général, écritures transactionnelles |
Verrou applicatif (Redis SET NX) | Rapide, hors base | Dépendance externe, expiration à gérer | Trafic élevé, réponse stockée ailleurs |
| Déduplication par identifiant d’événement | Simple côté consommateur | Ne couvre que les flux à identifiant stable | Webhooks, files de messages |
Les pièges en production
Concurrence. Deux requêtes portant la même clé arrivent en même temps. Sans insertion atomique, les deux passent le test « la clé existe-t-elle ? » et exécutent le traitement. La contrainte d’unicité règle le conflit dès l’écriture : le perdant reçoit un 409 ou attend la réponse en cours, jamais un second débit.
Trois autres écueils reviennent régulièrement. La portée de la clé doit inclure le compte et l’endpoint : une clé partagée entre deux clients ouvre une fuite de réponse. L’expiration évite une table qui gonfle sans fin ; un délai de vingt-quatre heures à quelques jours couvre les fenêtres de réessai réalistes. Enfin, l’idempotence ne remplace pas la cohérence transactionnelle : l’écriture métier et la mise à jour du statut de la clé gagnent à partager la même transaction, sous peine d’une réponse marquée « terminée » alors que le débit a échoué.
Côté client et côté fournisseur : générer et consommer les clés
Le client génère une clé aléatoire, un UUID v4 par exemple, une fois par intention métier. Le réflexe déterminant : conserver cette clé pour tous les réessais de la même opération, et surtout ne pas la régénérer à chaque tentative HTTP, faute de quoi la garantie disparaît. Les grandes API de paiement exposent l’en-tête sous ce nom exact et retournent une erreur explicite en cas de collision.
Côté consommation d’événements, la logique est symétrique. Un webhook porte un identifiant stable ; le consommateur conserve les identifiants déjà traités et ignore les redélivrances. C’est la même idempotence, appliquée à l’entrée plutôt qu’à la sortie.
Ce qu’il faut retenir
- Une clé d’idempotence transforme une livraison « au moins une fois » en effet « exactement une fois », à condition de désigner l’intention métier.
- La déduplication fiable repose sur une contrainte d’unicité en base, pas sur un test applicatif préalable.
- La clé se cadre par compte et par endpoint, expire après une fenêtre courte, et la réponse d’origine est resservie à l’identique.
- Côté client, une clé par opération, réutilisée à chaque réessai ; côté consommateur, déduplication par identifiant d’événement.
J’ai vu plus d’incidents de double facturation causés par une clé mal cadrée que par l’absence de clé : une clé réutilisée pour deux opérations distinctes, ou régénérée à chaque tentative, et la garantie s’effondre en silence. La règle que je retiens du terrain tient en deux points : une clé par intention métier, jamais par requête HTTP, et un test qui rejoue délibérément l’appel avant toute mise en production. — Simon Janvier
Pour aller plus loin
Spécification de référence : The Idempotency-Key HTTP Header Field, groupe de travail HTTP API de l’IETF.
