Retry sans idempotence¶
Anti-pattern. Le client retente une requête échouée, mais le serveur n'est pas idempotent. Résultat : doublons en prod.
Symptômes¶
# Côté client
for attempt in range(3):
try:
response = client.post("/users", json=payload)
break
except (ConnectionError, TimeoutError):
time.sleep(2 ** attempt)
# Côté serveur
@app.post("/users")
def create_user(payload: CreateUser):
user = User(**payload.dict())
session.add(user)
session.commit()
return user
Scénario :
- Client POST
{email: "alice@x.y"}. - Serveur insère l'utilisateur. Mais réponse perdue (timeout réseau).
- Client retente. Serveur insère un deuxième utilisateur "alice@x.y".
Résultat : deux utilisateurs avec le même email, la contrainte UNIQUE plante sur la 3e tentative. Logs incompréhensibles.
Pourquoi c'est tentant¶
- Le retry seul est facile.
- L'idempotence est plus difficile à concevoir.
- "Ça marche en dev" parce qu'il n'y a pas de perte de paquets.
Pourquoi c'est mauvais¶
- Doublons silencieux sur les opérations côté serveur (CRUD, effets de bord).
- Ressources gaspillées : 3 envois SMTP au lieu d'1.
- État incohérent quand le retry plante au milieu d'une transaction partielle.
- Débogage impossible : "l'utilisateur a été créé combien de fois ?"
Le bon motif (3 options)¶
Option 1 — PK upsert idempotent¶
@app.post("/index")
def index_profile(payload: IndexProfilePayload):
# PK = profile_id ; upsert idempotent
existing = session.get(SearchableProfile, payload.profile_id)
if existing is None:
session.add(SearchableProfile(...))
else:
# update fields
...
session.commit()
Le client peut retenter à l'infini, le résultat reste le même.
Option 2 — event_id côté client¶
Le client génère un UUID stable :
event_id = str(uuid.uuid4())
for attempt in range(3):
try:
response = client.post("/api/v1/messages", json={
"event_id": event_id, # ← même UUID à chaque tentative
"recipient": "u@v.w",
...
})
Côté serveur :
@app.post("/api/v1/messages")
def enqueue_mail(payload: EnqueueMailPayload):
existing = session.exec(
select(MailJob).where(MailJob.event_id == payload.event_id)
).first()
if existing is not None:
return existing # déjà inséré, nouvelle tentative sans effet
...
Option 3 — header HTTP Idempotency-Key¶
Convention HTTP standard (Stripe, GitHub). Identique à l'option 2 mais
le event_id est dans un header dédié plutôt que dans le body.
La règle d'or¶
Toute route qui peut être retentée DOIT être idempotente. En pratique : toutes les routes POST.
Si une route ne peut pas être idempotente (rare — typiquement une opération avec effet de bord non annulable comme un transfert d'argent), alors le client ne doit pas retenter — utilisez un timeout strict + une escalade humaine.
Référence dans MIC¶
search.POST /index est idempotent par PK (profile_id).
mail-out.POST /api/v1/messages n'est pas idempotent à v0.2 — un
event_id est en backlog (item v0.4 dans
ADR-0008).
En attendant, le worker outbox côté api ne retente que les
échecs où il sait que la requête n'a pas atteint mail-out (ConnectionError).