Aller au contenu

Le média des artisans du web dimanche 30 août 2026

Back-end

Clés d’idempotence : rendre les API et les webhooks rejouables sans doublon

Un même appel réseau peut arriver deux fois : coupure, réessai automatique, webhook redélivré. La clé d'idempotence permet au serveur de reconnaître la répétition et de renvoyer le premier résultat, sans doublon ni double débit.

Idempotence : requetes rejouables

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.

Client Idempotency-Key Serveur cle inconnue cle connue Execute et stocke Renvoie le stocke

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.

ApprocheGarantieCoûtQuand la retenir
Contrainte d’unicité en baseAtomique, résiste à la concurrenceUn index, une tableCas général, écritures transactionnelles
Verrou applicatif (Redis SET NX)Rapide, hors baseDépendance externe, expiration à gérerTrafic élevé, réponse stockée ailleurs
Déduplication par identifiant d’événementSimple côté consommateurNe couvre que les flux à identifiant stableWebhooks, 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.

À lire aussi sur Mail Studio

Partager LinkedIn Bluesky Hacker News E-mail

À lire aussi