Les deux articles précédents ont traité l’idempotence côté événement : le pattern Outbox garantit qu’un message est publié au moins une fois, et le pattern Inbox garantit qu’il n’est consommé qu’une fois. Reste un troisième endroit où le même problème se pose, plus en amont : l’API HTTP elle-même.
Quand un client lance un POST /api/payments et que sa connexion lâche avant de recevoir la réponse, il ne sait pas si le paiement a été créé ou non. S’il retry, il risque de payer deux fois. S’il ne retry pas, il risque de ne pas payer du tout. Le pattern Idempotency Key, popularisé par Stripe et adopté depuis par la plupart des APIs de paiement, résout ce dilemme en mettant le contrôle du retry dans les mains du client.
C’est le quatrième article de la série sur les patterns d’architecture distribuée, et le pendant côté API publique des patterns Outbox et Inbox.
Le problème : un POST n’est pas rejouable
Reprenons un endpoint typique :
@api_view(["POST"])
def creer_paiement(request):
paiement = Paiement.objects.create(
montant=request.data["montant"],
client_id=request.user.id,
)
stripe.Charge.create(amount=paiement.montant, ...)
return Response(PaiementSerializer(paiement).data, status=201)
Quatre scénarios font qu’un client peut envoyer la même requête deux fois :
- La requête a abouti côté serveur, mais le réseau a coupé avant que la réponse arrive. Le client retry, persuadé que rien n’a été fait.
- L’utilisateur double-clique sur “Payer”. Deux requêtes partent simultanément.
- Un mobile en zone instable retry automatiquement les requêtes qui n’ont pas reçu de réponse en 30 secondes.
- Un load balancer ou un proxy en frontal retry une requête qu’il considère comme ayant timeout.
Dans tous ces cas, deux requêtes identiques arrivent. Sans protection, deux paiements sont créés, deux charges Stripe sont émises, le client est débité deux fois. Le pattern Outbox côté serveur ne protège pas de ça : le problème est en amont, avant même que l’événement soit écrit.
Le principe : le client fournit la clé
L’idée centrale renverse la responsabilité : ce n’est pas au serveur de deviner si une requête est un duplicata, c’est au client de le signaler. Avant d’envoyer une requête, le client génère un UUID et le passe en header :
POST /api/payments
Idempotency-Key: 7f3a1b9e-2c4d-4e8f-9a1b-3c5d7e9f1a2b
Content-Type: application/json
{"montant": 4990, "devise": "EUR"}
Si la requête échoue (timeout, erreur réseau, panne du serveur), le client retry avec la même clé. Le serveur reconnaît la clé, voit qu’il a déjà traité cette requête, et retourne la réponse stockée sans re-exécuter l’opération.
Si le client crash avant de noter la clé qu’il a utilisée, il devra en générer une nouvelle au prochain essai, et la requête sera traitée comme nouvelle. C’est un compromis : on protège contre les doublons réseau, pas contre les pertes totales d’état côté client. La plupart des SDK officiels (Stripe, Square, AWS) gèrent cette persistance pour leurs utilisateurs.
Le stockage côté serveur
Un schéma minimal :
from django.db import models
class IdempotencyKey(models.Model):
key = models.CharField(max_length=128, primary_key=True)
user_id = models.BigIntegerField()
request_hash = models.CharField(max_length=64)
response_status = models.IntegerField(null=True)
response_body = models.JSONField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True)
class Meta:
indexes = [
models.Index(fields=["created_at"]),
]
Quelques choix structurants.
La clé primaire est la combinaison de la clé et implicitement de l’utilisateur (via user_id). Deux clients différents qui utiliseraient par hasard le même UUID restent isolés. En pratique, on préfère stocker la clé sous forme f"{user_id}:{key}" pour rendre l’isolation explicite et profiter d’un seul index PK.
Le request_hash est un SHA-256 du corps de requête. Il sert à détecter un cas dangereux : un client qui réutilise une clé pour une requête différente. Si le hash ne correspond pas à celui stocké, on renvoie un 422 Unprocessable Entity plutôt que la réponse précédente.
response_status et response_body stockent ce qu’on a renvoyé au premier appel. Au replay, on les rejoue tels quels.
completed_at distingue trois états : NULL = en cours de traitement, non NULL = terminé. Cette distinction est cruciale pour gérer les requêtes concurrentes.
Le middleware d’idempotence
Le flux complet ressemble à ça :
import hashlib, json
from django.db import IntegrityError, transaction
from rest_framework.response import Response
def idempotency_middleware(view_func):
def wrapper(request, *args, **kwargs):
key = request.headers.get("Idempotency-Key")
if not key or request.method != "POST":
return view_func(request, *args, **kwargs)
body_hash = hashlib.sha256(request.body).hexdigest()
composite_key = f"{request.user.id}:{key}"
try:
with transaction.atomic():
IdempotencyKey.objects.create(
key=composite_key,
user_id=request.user.id,
request_hash=body_hash,
)
except IntegrityError:
existing = IdempotencyKey.objects.get(key=composite_key)
if existing.request_hash != body_hash:
return Response(
{"error": "Idempotency-Key reused with different body"},
status=422,
)
if existing.completed_at is None:
return Response(
{"error": "Request already in flight"},
status=409,
)
return Response(existing.response_body, status=existing.response_status)
response = view_func(request, *args, **kwargs)
IdempotencyKey.objects.filter(key=composite_key).update(
response_status=response.status_code,
response_body=response.data,
completed_at=timezone.now(),
)
return response
return wrapper
Le pattern repose entièrement sur la contrainte d’unicité de la PK, exactement comme le pattern Inbox : deux requêtes simultanées avec la même clé déclenchent un IntegrityError sur la seconde, ce qui élimine toute fenêtre de course entre SELECT et INSERT. C’est la base qui arbitre, pas le code applicatif.
Le cas du retry concurrent
Le 409 Conflict quand completed_at IS NULL mérite une explication. Imaginez : un client lance une requête avec la clé K, qui prend 5 secondes à traiter (appel Stripe, génération de PDF). Pendant ces 5 secondes, le réseau coupe, le client retry avec la même clé K.
Sans la branche completed_at IS NULL, le serveur trouverait la ligne d’idempotence existante, n’aurait aucune réponse à rejouer (puisque la première requête n’a pas terminé), et renverrait probablement un état incohérent. Le 409 dit au client : “ta première requête est encore en cours, attends et retry dans quelques secondes”.
C’est l’équivalent HTTP du select_for_update(skip_locked=True) qu’on a vu dans le relay Outbox : on évite que deux requêtes concurrentes fassent le même travail en parallèle.
Le piège du body modifié
Le request_hash n’est pas une formalité. Sans lui, un bug client qui réutiliserait la même clé pour une requête différente verrait toujours la première réponse, et ne saurait jamais que sa seconde requête n’a jamais été traitée.
Le scénario type : un script qui boucle sur 500 paiements avec une clé fixe par erreur. Sans hash, les 499 suivants retournent silencieusement la réponse du premier. Aucun paiement créé. L’erreur passe inaperçue jusqu’à la facturation mensuelle.
Avec le hash, le 2e appel reçoit un 422 immédiat. Le bug remonte à la surface, le script plante, l’opérateur peut intervenir avant que les dégâts se propagent.
Le TTL et la rétention
Une clé d’idempotence n’a pas vocation à vivre éternellement. La durée de vie typique est 24 heures, parfois 7 jours pour les opérations financières lourdes. Au-delà, on considère que le client a abandonné le retry, et on libère la clé.
La purge se fait via un job Celery périodique :
@shared_task
def purge_idempotency_keys():
cutoff = timezone.now() - timedelta(hours=24)
IdempotencyKey.objects.filter(created_at__lt=cutoff).delete()
L’index sur created_at rend cette requête rapide. Sur PostgreSQL, on peut aller plus loin avec un partitionnement par jour si le volume justifie la complexité.
Idempotency Keys vs Inbox : deux niveaux, même idée
Les deux patterns reposent sur la même primitive (une contrainte d’unicité + une transaction atomique), mais s’appliquent à des couches différentes.
L’Idempotency Key protège la frontière HTTP : entre le client et l’API. La clé est générée par le client, transportée dans un header, et stockée par le serveur avec la réponse.
L’Inbox protège la frontière broker : entre Kafka et le consommateur. L’identifiant est généré par le producteur, transporté dans le payload de l’événement, et stocké par le consommateur sans réponse à rejouer (les événements n’ont pas de réponse).
Un système distribué moderne combine souvent les deux. L’API publique expose un endpoint avec Idempotency-Key qui crée une commande et publie un événement via l’Outbox. Les consommateurs en aval utilisent l’Inbox pour ne pas traiter deux fois le même événement. Chaque frontière a sa propre protection, et le système entier devient résilient aux duplicatas à tous les niveaux.
Quand ne pas mettre d’Idempotency Key
L’idempotence native HTTP existe déjà pour certaines méthodes. GET, PUT, DELETE sont définies comme idempotentes par la spec : un même DELETE /resource/1 répété mille fois doit produire le même état (ressource absente). Ajouter une clé d’idempotence sur ces endpoints est redondant.
Le pattern est réellement utile sur :
- les
POSTqui créent une ressource avec un effet métier (paiement, commande, transfert, envoi) - les
POSTqui déclenchent une action externe coûteuse (envoi d’email, génération de PDF, appel à un partenaire) - les endpoints publics exposés à des clients dont on ne contrôle pas la logique de retry
À l’inverse, pour un endpoint interne entre microservices où le contrôle des retries est partagé, ou pour une API de lecture seule, le coût d’implémentation et de stockage n’est pas justifié.
Conclusion
L’Idempotency Key est le pattern le moins glamour des trois, mais probablement le plus visible quand il manque. Un client qui paie deux fois ne pardonne pas le retry mal géré. Un client qui voit 409 Conflict ou 422 Idempotency-Key reused comprend qu’il s’est passé quelque chose et peut réagir.
C’est aussi le pattern qui ferme la série. Avec Saga, Outbox, Inbox et Idempotency Keys, on a couvert les quatre frontières où un système distribué peut perdre la cohérence : entre étapes d’un workflow, entre une base et un broker, entre un broker et un consommateur, et entre un client HTTP et le serveur. Chacun protège un endroit précis. Aucun ne suffit seul.
