Votre service plante au milieu d’une requête POST /charge. Le client voit un timeout et retry. Vous avez maintenant deux charges. Le client est en colère. La base de données est cohérente. Votre business logic ne l’est pas.
Ce n’est pas un edge case. C’est le comportement par défaut des distributed systems. Les networks perdent des packets. Les containers se font OOM-killer au milieu d’une requête. Les load balancers retournent des 502 pour des requêtes qui ont déjà atteint le backend. Si votre API part du principe que “j’ai reçu une erreur, donc l’opération ne s’est définitivement pas produite”, vous construisez une bug factory.
La réalité at-least-once dont vous ne pouvez pas échapper
HTTP est at-least-once par défaut. TCP retry les packets perdus. Votre HTTP client retry sur timeout. Votre infrastructure retry sur les 5xx. Chaque couche suppose que la précédente peut échouer et renvoie.
Le problème n’est pas les retries. Le problème est les opérations non idempotentes.
Un GET est sûr à retry car lire deux fois revient au même que lire une fois. Un POST /charge ou POST /orders ne l’est pas. L’exécuter deux fois crée deux resources. L’exécuter zéro fois fait perdre une vente. Vous ne pouvez pas choisir “exactly once” sur un network non fiable. Vous ne pouvez choisir qu’entre “at least once” avec déduplication, ou “peut-être zéro, peut-être deux” avec de la data corruption.
L’idempotence est ce qui rend “at least once” sûr.
Comment les idempotency keys fonctionnent réellement
Stripe a popularisé ce pattern, mais l’idée est plus ancienne. Le client génère une clé unique (un UUID) et l’envoie dans un header : Idempotency-Key: <uuid>. Le server stocke le tuple (key, request_body, response) avant d’exécuter le side effect. Si la même clé arrive à nouveau, le server retourne la response stockée sans réexécuter l’opération.
L’insight clé : le server doit stocker la clé avant de faire le travail, pas après. Si vous écrivez d’abord la charge row et que vous plantez avant de stocker la clé, le retry crée une seconde charge. Le stockage de l’idempotency key et la business mutation doivent être atomiques.
En pratique, cela signifie l’une de ces deux choses :
-
L’idempotency store et le business store partagent une database transaction. Vous insérez dans
idempotency_keysetchargesdans le mêmeBEGIN ... COMMIT. Si le commit réussit, les deux existent. S’il échoue, aucun des deux n’existe. -
L’idempotency store est le business store. Votre table
chargesa une colonneclient_idempotency_keyavec une contrainteUNIQUE. Le retry échoue sur le unique check, et vous retournez la row existante.
L’option 2 est plus simple et ce avec quoi la plupart des équipes devraient commencer.
Une implémentation fonctionnelle avec un stockage atomique
Voici un server Python minimal mais complet utilisant SQLite. L’idempotency key vit dans la même transaction que la business mutation. Si le server plante après COMMIT, le retry touche le cache. S’il plante avant COMMIT, rien n’est persisté et le retry s’exécute à nouveau en toute sécurité.
import sqlite3
import json
import uuid
DB_PATH = "/data/inventory.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS reservations (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL,
quantity INTEGER NOT NULL,
idempotency_key TEXT UNIQUE NOT NULL,
created_at INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS idempotency_responses (
key TEXT PRIMARY KEY,
response_body TEXT NOT NULL,
created_at INTEGER
)
""")
conn.commit()
return conn
def reserve_inventory(conn, idempotency_key, item_id, quantity):
# Step 1: Check for an existing response.
cursor = conn.execute(
"SELECT response_body FROM idempotency_responses WHERE key = ?",
(idempotency_key,)
)
row = cursor.fetchone()
if row:
return json.loads(row[0])
# Step 2: Do the work inside a single transaction.
conn.execute("BEGIN")
# Deduct inventory atomically.
conn.execute(
"UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
(quantity, item_id, quantity)
)
if conn.total_changes == 0:
conn.rollback()
return {"error": "insufficient inventory"}
# Record the reservation.
reservation_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
(reservation_id, item_id, quantity, idempotency_key)
)
# Cache the response for retries.
response = {"reservation_id": reservation_id, "status": "reserved"}
conn.execute(
"INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
(idempotency_key, json.dumps(response))
)
conn.commit()
return response
La table idempotency_responses est le safety net. La première requête effectue la mutation, commit le résultat, et cache la response. Chaque requête suivante avec la même clé saute le travail et retourne le JSON mis en cache. La reservation et l’entrée de cache sont écrites dans la même transaction, donc elles sont soit toutes les deux visibles, soit toutes les deux absentes.
Où cela échoue : la side-effect boundary
Les idempotency keys gèrent les duplicate requests du même client. Elles ne gèrent pas les duplicate requests concurrents de clients différents. Si deux utilisateurs cliquent sur “Acheter” sur le dernier article en stock, vous avez toujours besoin de pessimistic locking ou d’optimistic concurrency control. Le check available >= ? dans l’exemple ci-dessus est une forme primitive de cela, mais les vrais systèmes d’inventory ont besoin de plus.
Le plus gros problème est les side effects en dehors de la transaction. Si vous chargez une carte via Stripe, envoyez un email via SendGrid, et écrivez dans votre base de données, l’idempotency key ne protège que la partie base de données. L’email pourrait être envoyé deux fois. La carte pourrait être chargée deux fois si la propre idempotency window de Stripe a expiré. La vraie sécurité exige que chaque downstream system participe.
C’est pourquoi Stripe accepte sa propre Idempotency-Key lors de la création d’une charge. Ils dédupliquent à leur couche. Vous devriez faire de même à la vôtre. Passez la même clé à tout downstream service idempotent. Pour les services qui ne le supportent pas, enveloppez l’appel dans une local transaction ou acceptez le risque.
Collisions de clés, TTLs, et autres pièges opérationnels
UUID4 a 122 bits de randomness. La probabilité de collision est négligeable pour n’importe quel volume réaliste. N’utilisez pas d’entiers séquentiels, de timestamps, ou de hashed request bodies comme clés. Une UUID générée par le client est le standard de l’industrie pour une raison.
Le stockage des clés croît indéfiniment à moins d’expirer les anciennes entrées. Définissez un TTL : 24 heures est le standard. Après cela, supprimez les anciennes clés. Si un client retry après le TTL, il obtient un doublon. Documentez cela. La retry window et le TTL sont un business contract, pas un détail technique.
L’idempotency store doit être au moins aussi disponible que l’API. Si votre cache Redis est down, vous ne pouvez pas vérifier les clés. Certaines équipes basculent sur “assume new request”, ce qui crée des doublons pendant les outages. D’autres rejettent la requête, ce qui est plus sûr mais crée un failure mode différent. Il n’y a pas de free lunch ici.
Le client side compte tout autant
Les idempotency keys côté server sont inutiles si le client ne les envoie pas. Chaque requête mutante devrait générer une clé au call site et retry sur timeout avec la même clé :
import uuid
import requests
def safe_post(url, payload, max_retries=3):
key = str(uuid.uuid4())
for attempt in range(max_retries):
try:
resp = requests.post(
url,
json=payload,
headers={"Idempotency-Key": key},
timeout=10,
)
return resp
except requests.Timeout:
if attempt == max_retries - 1:
raise
# Retry with the SAME key. The server deduplicates.
La clé doit être générée une fois par logical operation, pas une fois par HTTP attempt. Si vous générez un nouvel UUID à chaque retry, vous avez raté le point. La clé est le contract qui lie les retries ensemble.
FAQ
Et si le client est un browser et que l’utilisateur rafraîchit la page ?
Un refresh de page crée un nouveau JavaScript context. La nouvelle requête obtient une nouvelle idempotency key à moins que vous ne persistiez la clé dans le localStorage ou l’URL. La plupart des équipes ne s’en occupent pas pour les flows non critiques. Pour les paiements, vous devriez.
Les requêtes GET devraient-elles utiliser des idempotency keys ?
Non. GET est déjà sûr par les sémantiques HTTP. Les idempotency keys sont pour les méthodes qui changent l’état : POST, PUT, PATCH, DELETE.
Puis-je utiliser le hash du request body comme idempotency key ?
Seulement si le body est déterministe et ne contient pas de timestamps ou de valeurs aléatoires. En pratique, les UUID générés par le client sont plus simples et plus fiables.
Combien de temps dois-je garder les idempotency keys ?
Plus longtemps que votre plus longue client retry window. Si les clients retry pendant 60 secondes, gardez les clés pendant 24 heures. Si les clients peuvent retry demain à cause d’un batch job, gardez les clés pendant une semaine.
Rendez votre première requête idempotente dès aujourd’hui
Ajoutez un header Idempotency-Key à chaque endpoint mutating. Commencez avec un cache de 24 heures. Utilisez un UUID4. Rendez le stockage atomique avec votre business transaction. La première fois qu’un container meurt au milieu d’une requête et que le client retry, vous serez content de l’avoir fait. L’alternative est d’expliquer à votre finance team pourquoi un seul client a dix-sept charges identiques.