BG
EN
Tous les articles
#Génie logiciel#Architecture#Fintech

Le réseau coupe, l'utilisateur réessaie, le client relance la requête : dans un système de paiement, chaque requête peut arriver deux fois. L'idempotence garantit qu'elle ne sera exécutée qu'une seule fois.

Boris Gautier5 novembre 2026 2 min de lecture

Sur un service de paiement mobile, la scène est banale : l'utilisateur valide un transfert, la connexion faiblit, l'écran tourne, rien ne s'affiche. Il appuie de nouveau. Côté serveur, le premier transfert était peut-être déjà passé. Sans précaution, le client est débité deux fois. Ce n'est pas un bug rare : à grande échelle, c'est une certitude statistique.

Le réseau ne garantit qu'une chose : le doute

Quand une requête expire, le client ne sait pas si elle a échoué avant d'arriver, pendant le traitement, ou après, sur le chemin du retour. Les trois cas se ressemblent parfaitement de son côté. Réessayer est donc indispensable, et réessayer n'est sûr que si l'opération est idempotente : l'exécuter deux fois produit le même effet que l'exécuter une fois.

La clé d'idempotence

Le principe : le client génère un identifiant unique pour chaque intention (un UUID créé au moment où l'utilisateur valide), et l'envoie avec la requête et avec toutes ses relances. Le serveur enregistre cette clé avant de traiter. S'il la revoit, il ne refait rien : il renvoie la réponse déjà calculée. La clé appartient à l'intention de l'utilisateur, pas à la requête HTTP : si on la régénère à chaque essai, elle ne sert à rien.

payments/idempotency.ts
export async function withIdempotency<T>(key: string, payload: unknown, run: () => Promise<T>): Promise<T> {
  const hash = sha256(JSON.stringify(payload));
  try {
    // Unique index on "key": only one request can claim it, even under heavy concurrency.
    await Idempotency.create({ key, hash, status: "processing", expiresAt: inHours(24) });
  } catch (e) {
    if (!isDuplicateKey(e)) throw e;
    const seen = await Idempotency.findOne({ key });
    if (seen.hash !== hash) throw new Conflict("Key reused with a different payload");
    if (seen.status === "processing") throw new RetryLater(); // first attempt still running
    return seen.response as T; // replay: same answer, no second charge
  }

  try {
    const response = await run();
    await Idempotency.updateOne({ key }, { status: "done", response });
    return response;
  } catch (e) {
    await Idempotency.deleteOne({ key }); // failed before any effect: allow a clean retry
    throw e;
  }
}

Les détails qui comptent

  • Un index unique sur la clé, pas une vérification « si elle existe, alors… » : deux requêtes simultanées passeraient toutes les deux.
  • Comparer l'empreinte du contenu : une même clé avec un montant différent est une erreur du client, pas une relance.
  • Un état « en cours » : la relance qui arrive pendant le premier traitement doit attendre, pas le doubler.
  • Une durée de vie : 24 heures suffisent en général, au-delà la clé n'a plus de sens et la table ne doit pas grossir indéfiniment.
  • Propager la clé aux partenaires : si l'opérateur de paiement accepte sa propre clé d'idempotence, transmettez-lui la vôtre.

Au-delà des paiements

La même discipline s'applique partout où un message peut être livré deux fois : files de messages, webhooks, tâches planifiées, agents IA qui rejouent un appel d'outil. Les systèmes distribués livrent « au moins une fois » ; c'est l'idempotence côté consommateur qui transforme ce « au moins une fois » en « exactement une fois » du point de vue de l'utilisateur. C'est une des rares propriétés qui coûtent peu à concevoir dès le départ et très cher à ajouter après un incident.

À lire ensuite