Aller au contenu principal

Concept #014

Architecture

L'Idempotence

#0146 min de lecture
  • architecture
  • api
  • programmation
Idempotence : 1 fois ou N fois, même résultat

Définition

Une opération est dite idempotente si l'appliquer une seule fois ou plusieurs fois de suite produit exactement le même résultat.

En notation mathématique : f(f(x)) = f(x). Peu importe combien de fois vous l'appelez, l'état final est identique.

Ce n'est pas la même chose que d'être "sans effet de bord" (opération pure). Une opération idempotente peut modifier l'état du système — mais une deuxième exécution n'apporte aucun changement supplémentaire.

Exemples du quotidien

Le bouton d'ascenseur est l'exemple parfait. Appuyer une fois sur le bouton du 5ème étage ou appuyer dix fois : l'ascenseur viendra quand même au 5ème étage, une seule fois. Le système ignore les appuis redondants. La demande est idempotente.

Allumer une lumière suit la même logique. Si la lumière est déjà allumée et que vous actionnez l'interrupteur "allumer", rien ne change. L'état cible est déjà atteint.

En revanche, incrémenter un compteur n'est pas idempotent. Exécuter counter += 1 trois fois donne un résultat différent que l'exécuter une seule fois.

Méthodes HTTP : idempotentes vs non-idempotentes

Le protocole HTTP définit explicitement quelles méthodes doivent être idempotentes :

Méthodes idempotentes :

  • GET — récupérer une ressource ne modifie pas l'état du serveur. Appeler GET /users/42 dix fois retourne toujours le même utilisateur.
  • PUT — remplace entièrement une ressource par une nouvelle version. Envoyer PUT /users/42 avec le même body deux fois laisse la ressource dans le même état final.
  • DELETE — supprimer une ressource déjà supprimée ne change rien. Le résultat souhaité (la ressource n'existe plus) est déjà atteint.
  • HEAD, OPTIONS — par nature en lecture seule ou sans effet.

Méthode non-idempotente :

  • POST — crée une nouvelle ressource à chaque appel. Envoyer POST /orders deux fois crée deux commandes distinctes. C'est le comportement attendu, mais cela rend POST naturellement non-idempotent.
PUT /users/42  { "name": "Alice" }  → 200 OK  (mis à jour)
PUT /users/42  { "name": "Alice" }  → 200 OK  (aucun changement réel)
PUT /users/42  { "name": "Alice" }  → 200 OK  (toujours identique)

POST /orders   { "item": "livre" }  → 201 Created  (commande #1)
POST /orders   { "item": "livre" }  → 201 Created  (commande #2)  ← problème !
POST /orders   { "item": "livre" }  → 201 Created  (commande #3)  ← encore !

Pourquoi c'est crucial dans les systèmes distribués

Dans un réseau, rien n'est garanti. Un message peut être :

  • perdu avant d'arriver au serveur,
  • reçu mais la réponse se perd avant de revenir au client,
  • reçu deux fois à cause d'un mécanisme de retry.

Le client ne sait alors pas si l'opération a été exécutée ou non. Sa seule option sûre : rejouer la requête.

Si l'opération est idempotente, rejouer ne pose aucun problème. Si elle ne l'est pas, vous risquez de créer des doublons, de débiter un client deux fois, ou de déclencher deux envois d'e-mail.

Trois scénarios courants où l'idempotence protège votre système :

  1. Retry automatique — les librairies HTTP (comme axios avec une config retry) rejoueront une requête échouée. Sans idempotence, chaque retry peut créer un doublon.
  2. Réseau instable — un utilisateur mobile perd sa connexion au milieu d'un paiement. Il recharge la page. L'opération doit être sans danger à rejouer.
  3. Double-clic — un utilisateur clique deux fois sur "Confirmer la commande". Sans protection, deux commandes sont créées.

Implémenter l'idempotence avec une clé

La technique standard pour rendre une opération POST idempotente est d'utiliser une clé d'idempotence (idempotency key) : un identifiant unique généré côté client et transmis dans les headers.

// Côté client : générer une clé unique par tentative d'opération
import { randomUUID } from "crypto";
 
async function createOrder(item: string): Promise<Order> {
  const idempotencyKey = randomUUID(); // ex: "550e8400-e29b-41d4-a716-446655440000"
 
  const response = await fetch("/api/orders", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey, // header standard
    },
    body: JSON.stringify({ item }),
  });
 
  return response.json();
}
// Côté serveur : stocker et vérifier la clé
import { Request, Response } from "express";
 
const processedKeys = new Map<string, Order>(); // en prod : Redis ou BDD
 
async function handleCreateOrder(req: Request, res: Response) {
  const idempotencyKey = req.headers["idempotency-key"] as string;
 
  if (!idempotencyKey) {
    return res.status(400).json({ error: "Idempotency-Key header requis" });
  }
 
  // Si la clé a déjà été traitée, retourner le résultat mémorisé
  if (processedKeys.has(idempotencyKey)) {
    const cachedResult = processedKeys.get(idempotencyKey)!;
    return res.status(200).json(cachedResult); // même réponse, pas de doublon
  }
 
  // Traiter la requête normalement
  const order = await createOrderInDatabase(req.body);
 
  // Mémoriser le résultat pour les prochains appels avec la même clé
  processedKeys.set(idempotencyKey, order);
 
  return res.status(201).json(order);
}

La clé d'idempotence est stockée avec le résultat. Si la même clé arrive une deuxième fois, le serveur retourne le résultat déjà calculé sans ré-exécuter l'opération. Stripe, PayPal et la plupart des APIs de paiement implémentent exactement ce mécanisme.

Bonnes pratiques

Durée de vie des clés — ne stockez pas les clés d'idempotence indéfiniment. Une TTL de 24 heures est un bon compromis : couvre les retries légitimes sans faire grossir votre stockage.

Scope de la clé — associez toujours la clé à un utilisateur ou un contexte spécifique. Une clé UUID globale sans scope peut théoriquement entrer en collision avec celle d'un autre utilisateur.

Préférez PUT à POST quand c'est possible — si votre client peut générer l'identifiant de la ressource (UUID v4), utilisez PUT /resources/{id} au lieu de POST /resources. Vous obtenez l'idempotence gratuitement, sans logique de clé.

Rendez vos workers de queue idempotents — les systèmes de messages comme Kafka, SQS ou RabbitMQ garantissent en général une livraison at-least-once, pas exactly-once. Votre consumer doit être capable de traiter le même message plusieurs fois sans effet de bord.

Testez le cas du replay — dans vos tests d'intégration, appelez systématiquement vos endpoints critiques deux fois avec la même payload et vérifiez que le résultat est identique. C'est le test le plus simple et le plus révélateur.

L'idempotence n'est pas une optimisation facultative. C'est une propriété fondamentale que tout système sérieux doit garantir sur ses opérations critiques — particulièrement celles qui touchent à l'argent, aux notifications ou à des effets irréversibles.