Le client clique sur "Payer". Rien ne se passe. Le réseau est lent, l'animation tourne. Il reclique. Cette fois ça passe. Votre API a reçu deux requêtes identiques à 800 ms d'intervalle.
Deux scénarios possibles. Soit votre client vient d'être débité deux fois — et vous avez un problème légal, un chargeback à gérer, et une conversation difficile en perspective. Soit vous avez pensé à l'idempotence, et la deuxième requête est traitée comme un doublon : même réponse, zéro effet de bord supplémentaire.
C'est quoi l'idempotence ?
La définition mathématique : une opération f est idempotente si
f(f(x)) = f(x). L'appliquer plusieurs fois donne le même résultat qu'une seule fois.
En pratique : appuyer cinq fois sur le bouton d'appel de l'ascenseur — l'ascenseur arrive une seule fois. C'est idempotent. Commander cinq fois le même plat au restaurant — vous recevez cinq assiettes. Ce n'est pas idempotent, et votre addition non plus.
En HTTP, les verbes ont des garanties précises :
- GET — idempotent. Appeler
GET /orders/42cent fois ne change rien côté serveur. - PUT — idempotent. Mettre à jour une ressource avec les mêmes données plusieurs fois : même résultat.
- DELETE — idempotent. La première suppression retire la ressource ; les suivantes retournent 404. Le résultat final est le même : la ressource n'existe plus.
- POST — pas idempotent par défaut. Chaque appel à
POST /orderscrée une nouvelle commande.
Un point souvent mal compris : idempotent ne veut pas dire "sans effet de bord".
DELETE /users/42 supprime bien l'utilisateur — c'est un effet de bord très concret.
Mais cet effet est stable : après le premier appel, les suivants ne changent plus
l'état du système. C'est ça, l'idempotence.
Pourquoi c'est crucial en pratique
Les doublons arrivent de trois façons dans un système distribué. Aucune n'est un cas rare ou théorique.
1. Les retries automatiques
Le client envoie une requête. Le réseau timeout à 30 secondes. Le client relance automatiquement. Sauf que votre serveur avait bien reçu la première requête — il était juste occupé à la traiter. Vous avez maintenant deux exécutions pour la même intention.
C'est le cas classique du SDK de paiement qui retry trois fois en cas d'échec réseau.
Si votre endpoint POST /payments n'est pas idempotent, le client est
potentiellement débité trois fois.
2. Le double-clic
L'utilisateur clique sur "Valider la commande". L'interface ne retourne pas de feedback immédiat. Il reclique. Deux requêtes identiques partent à quelques centaines de millisecondes d'intervalle. Deux commandes créées en base. Le support client va adorer.
3. At-least-once delivery
Kafka, RabbitMQ, les webhooks Stripe ou GitHub garantissent la livraison du message au moins une fois — pas exactement une fois. Si le consumer crash après avoir traité le message mais avant d'avoir acquitté la réception, le broker renvoie le message. C'est une garantie normale du protocole, pas un bug. Votre consumer doit être capable de recevoir le même message deux fois sans créer de doublon.
Les conséquences concrètes :
- Débit bancaire en double — chargeback, incident critique.
- Email de confirmation envoyé trois fois — le client pense à un bug ou une attaque.
- Commande créée en double — stock incorrect, livraison dupliquée, comptabilité fausse.
L'idempotency key — le pattern universel
La solution canonique pour rendre un endpoint POST idempotent. Stripe l'utilise depuis des années et en fait un prérequis pour les paiements en production.
Le principe : le client génère un UUID v4 pour chaque intention d'opération et l'envoie
dans le header Idempotency-Key. Le serveur stocke le résultat du premier traitement.
Pour toute requête suivante avec la même clé, il retourne le résultat stocké sans retraiter.
POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{"amount": 100, "currency": "EUR", "customer_id": "cus_8Rn2xM"}
Le flux complet :
- Première requête avec la clé : traitement normal, résultat stocké.
- Même clé, même payload : résultat stocké retourné immédiatement. Zéro retraitement.
- Même clé, payload différent : erreur 422. Incohérence côté client.
- Clé différente : nouvelle intention, traitement normal.
La clé est générée côté client, pas côté serveur — c'est le client qui sait que "cette requête est une répétition de celle d'il y a 30 secondes". Le serveur ne peut pas deviner ça.
Implémentation en Go — middleware HTTP
package middleware
import (
"bytes"
"net/http"
"sync"
"time"
)
type CachedResult struct {
StatusCode int
Body []byte
CreatedAt time.Time
}
// IdempotencyStore — map en mémoire, démo uniquement.
// Pour la production, voir la section suivante : store PostgreSQL.
type IdempotencyStore struct {
mu sync.RWMutex
results map[string]CachedResult
}
func NewIdempotencyStore() *IdempotencyStore {
return &IdempotencyStore{results: make(map[string]CachedResult)}
}
func (s *IdempotencyStore) Get(key string) (CachedResult, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
result, ok := s.results[key]
return result, ok
}
func (s *IdempotencyStore) Set(key string, result CachedResult) {
s.mu.Lock()
defer s.mu.Unlock()
s.results[key] = result
}
// responseRecorder capture la réponse pour pouvoir la stocker.
type responseRecorder struct {
http.ResponseWriter
code int
body bytes.Buffer
}
func (r *responseRecorder) WriteHeader(code int) {
r.code = code
r.ResponseWriter.WriteHeader(code)
}
func (r *responseRecorder) Write(b []byte) (int, error) {
r.body.Write(b)
return r.ResponseWriter.Write(b)
}
func IdempotencyMiddleware(store *IdempotencyStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
next.ServeHTTP(w, r)
return
}
key := r.Header.Get("Idempotency-Key")
if key == "" {
http.Error(w, `{"error": "Idempotency-Key header required"}`, http.StatusBadRequest)
return
}
// Résultat déjà en cache ? Retour immédiat.
if cached, ok := store.Get(key); ok {
w.Header().Set("X-Idempotent-Replayed", "true")
w.WriteHeader(cached.StatusCode)
w.Write(cached.Body)
return
}
// Premier passage : capturer et stocker la réponse.
rec := &responseRecorder{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(rec, r)
store.Set(key, CachedResult{
StatusCode: rec.code,
Body: rec.body.Bytes(),
CreatedAt: time.Now(),
})
})
}
}
Cette version a deux limites inacceptables en production :
- Multi-instances : chaque pod a sa propre map. Une requête sur le pod A n'est pas vue par le pod B.
- TTL absent : les clés s'accumulent en mémoire jusqu'au redémarrage.
La solution habituelle est Redis. Mais si votre stack n'inclut pas Redis — et c'est souvent le cas sur des projets qui veulent rester simples — PostgreSQL fait exactement le même travail.
Le store PostgreSQL — zéro Redis, multi-instances, TTL natif
L'idée : une table dédiée idempotency_keys qui joue le rôle du store partagé.
Tous les pods lisent et écrivent dans la même base. PostgreSQL gère l'atomicité lui-même.
CREATE TABLE idempotency_keys (
key UUID PRIMARY KEY,
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending | completed
status_code INT,
body TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Nettoyage automatique des vieilles clés (Stripe garde 24h)
-- À lancer périodiquement via un job ou une goroutine de fond
DELETE FROM idempotency_keys
WHERE created_at < NOW() - INTERVAL '24 hours';
Le middleware utilise INSERT ... ON CONFLICT DO NOTHING pour poser le verrou
de façon atomique — exactement comme SETNX sous Redis, mais garanti par PostgreSQL.
package middleware
import (
"bytes"
"database/sql"
"fmt"
"net/http"
"time"
"github.com/jmoiron/sqlx"
)
type PgIdempotencyStore struct {
db *sqlx.DB
}
func NewPgIdempotencyStore(db *sqlx.DB) *PgIdempotencyStore {
return &PgIdempotencyStore{db: db}
}
// TryAcquire tente de poser le verrou PENDING sur la clé.
// Retourne true si c'est la première requête, false si une autre est déjà en cours ou terminée.
func (s *PgIdempotencyStore) TryAcquire(ctx context.Context, key string) (bool, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO idempotency_keys (key) VALUES ($1) ON CONFLICT (key) DO NOTHING`,
key,
)
if err != nil {
return false, fmt.Errorf("acquiring idempotency key: %w", err)
}
n, _ := res.RowsAffected()
return n == 1, nil // true = premier passage
}
// GetCompleted récupère le résultat d'une clé déjà traitée.
func (s *PgIdempotencyStore) GetCompleted(ctx context.Context, key string) (statusCode int, body []byte, found bool, err error) {
var row struct {
Status string `db:"status"`
StatusCode sql.NullInt32 `db:"status_code"`
Body sql.NullString `db:"body"`
}
err = s.db.GetContext(ctx, &row,
`SELECT status, status_code, body FROM idempotency_keys WHERE key = $1`,
key,
)
if err == sql.ErrNoRows {
return 0, nil, false, nil
}
if err != nil {
return 0, nil, false, fmt.Errorf("getting idempotency key: %w", err)
}
if row.Status != "completed" {
return 0, nil, false, nil // PENDING — encore en cours
}
return int(row.StatusCode.Int32), []byte(row.Body.String), true, nil
}
// Complete marque la clé comme terminée et stocke la réponse.
func (s *PgIdempotencyStore) Complete(ctx context.Context, key string, statusCode int, body []byte) error {
_, err := s.db.ExecContext(ctx,
`UPDATE idempotency_keys SET status = 'completed', status_code = $2, body = $3 WHERE key = $1`,
key, statusCode, string(body),
)
return err
}
// StartCleanup lance une goroutine de fond qui supprime les clés expirées.
func (s *PgIdempotencyStore) StartCleanup(ctx context.Context, ttl time.Duration) {
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.db.ExecContext(ctx,
`DELETE FROM idempotency_keys WHERE created_at < NOW() - $1::interval`,
ttl.String(),
)
case <-ctx.Done():
return
}
}
}()
}
// responseRecorder capture la réponse pour la stocker.
type responseRecorder struct {
http.ResponseWriter
code int
body bytes.Buffer
}
func (r *responseRecorder) WriteHeader(code int) {
r.code = code
r.ResponseWriter.WriteHeader(code)
}
func (r *responseRecorder) Write(b []byte) (int, error) {
r.body.Write(b)
return r.ResponseWriter.Write(b)
}
// IdempotencyMiddleware garantit l'idempotence des POST via PostgreSQL.
func IdempotencyMiddleware(store *PgIdempotencyStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
next.ServeHTTP(w, r)
return
}
key := r.Header.Get("Idempotency-Key")
if key == "" {
http.Error(w, `{"error": "Idempotency-Key header required"}`, http.StatusBadRequest)
return
}
// Déjà traité ? Retourner le résultat en cache.
if statusCode, body, found, err := store.GetCompleted(r.Context(), key); found && err == nil {
w.Header().Set("X-Idempotent-Replayed", "true")
w.WriteHeader(statusCode)
w.Write(body)
return
}
// Tenter de poser le verrou PENDING.
acquired, err := store.TryAcquire(r.Context(), key)
if err != nil {
http.Error(w, `{"error": "internal error"}`, http.StatusInternalServerError)
return
}
if !acquired {
// Une autre requête traite déjà cette clé.
// Option simple : 409 — le client doit réessayer dans quelques secondes.
http.Error(w, `{"error": "request in progress, retry shortly"}`, http.StatusConflict)
return
}
// Premier passage : traiter et stocker le résultat.
rec := &responseRecorder{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(rec, r)
store.Complete(r.Context(), key, rec.code, rec.body.Bytes())
})
}
}
Ce middleware fonctionne en multi-instances sans configuration supplémentaire.
Le INSERT ... ON CONFLICT DO NOTHING est atomique : si deux pods reçoivent
la même clé simultanément, un seul obtient le verrou. L'autre reçoit un 409 et le client
peut réessayer quelques secondes plus tard — à ce moment la clé sera COMPLETED et
il obtiendra la réponse en cache.
Pour démarrer le nettoyage automatique à l'init de l'application :
store := middleware.NewPgIdempotencyStore(db)
store.StartCleanup(ctx, 24*time.Hour) // supprime les clés > 24h en fond
handler := middleware.IdempotencyMiddleware(store)(myHandler)
La contrainte UNIQUE directement sur vos données
Pour des cas simples où l'idempotency key peut vivre directement sur la table métier
(paiements, commandes), pas besoin d'une table dédiée. Une contrainte UNIQUE
suffit — c'est la couche la plus solide car atomique par construction.
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key UUID UNIQUE NOT NULL,
customer_id VARCHAR(64) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO payments (idempotency_key, customer_id, amount, currency)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id, status, created_at;
func CreatePayment(ctx context.Context, db *sqlx.DB, req PaymentRequest) (*Payment, error) {
var payment Payment
err := db.GetContext(ctx, &payment, `
INSERT INTO payments (idempotency_key, customer_id, amount, currency)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id, idempotency_key, customer_id, amount, currency, status, created_at
`, req.IdempotencyKey, req.CustomerID, req.Amount, req.Currency)
if err == sql.ErrNoRows {
// Clé déjà connue — retourner le paiement existant.
err = db.GetContext(ctx, &payment,
"SELECT * FROM payments WHERE idempotency_key = $1",
req.IdempotencyKey,
)
if err != nil {
return nil, fmt.Errorf("fetching existing payment: %w", err)
}
return &payment, nil
}
if err != nil {
return nil, fmt.Errorf("inserting payment: %w", err)
}
return &payment, nil
}
Deux requêtes concurrentes avec la même clé : PostgreSQL pose un lock au niveau de la ligne
pendant l'insertion. Une seule réussit l'INSERT. Pas de race condition,
pas de Redis, pas d'état intermédiaire à gérer.
En résumé
- Les doublons viennent de trois endroits : retries automatiques, double-clic, at-least-once delivery des brokers.
- L'idempotency key est le pattern universel pour les POST : le client génère un UUID par intention, le serveur déduplique.
- Le store PostgreSQL remplace Redis : table
idempotency_keys,INSERT ON CONFLICTpour le verrou atomique, goroutine de nettoyage TTL. Multi-instances, zéro dépendance externe. - La contrainte UNIQUE en base est la couche la plus solide : atomique par construction, pas de race condition possible.
La partie 2 va plus loin : dans une architecture CQRS et Event Sourcing, l'idempotence touche les commands, les events et les aggregates. Optimistic locking, outbox pattern — les quatre couches qui font qu'un système distribué ne crée jamais de doublon, même sous charge.