Skip to content

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 :

  1. Client POST {email: "alice@x.y"}.
  2. Serveur insère l'utilisateur. Mais réponse perdue (timeout réseau).
  3. 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).