Vol. 7 · № 03 · Un atelier d'architectureAn architecture workshop

Idempotence & Saga, ou concevoir pour « au moins une fois »Idempotency & Saga, or designing for 'at least once'

Les #1 et #2 ont laissé une dette : « et si l'autre côté répond deux fois ? ». Sa racine : le réseau n'est pas fiable — un message peut se perdre, arriver en double, tarder. « Exactly-once » est un mythe ; on conçoit pour « at-least-once ». Deux réflexes. L'idempotence rend le rejeu inoffensif : une clé pour que le serveur n'exécute la même demande qu'une fois. La saga remplace la transaction unique impossible : des étapes locales, chacune dotée d'une compensation, rejouées à rebours quand une étape tardive échoue. La règle : conçois pour « au moins une fois » — idempotence partout, réparation par compensation, jamais par verrou distribué.Issues #1 and #2 left a debt: 'what if the other side answers twice?'. Its root: the network isn't reliable — a message can be lost, arrive twice, lag. 'Exactly-once' is a myth; you design for 'at-least-once'. Two reflexes. Idempotency makes the replay harmless: a key so the server runs the same request only once. The saga replaces the impossible single transaction: local steps, each with a compensation, replayed backward when a late step fails. The rule: design for 'at least once' — idempotency everywhere, repair by compensation, never by distributed lock.

AudienceAudience
Dev dont le retry double les débits et dont les workflows multi-services échouent à moitiéDev whose retry double-charges and whose multi-service workflows half-fail
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026June 2026
≈ 18 min●●●○Résilience distribuéeIdempotenceSagaTradeoff
SommaireContents ·
№ 01CadrageFraming3 min

« Exactly-once » n'existe pas. On conçoit pour « au moins une fois ».'Exactly-once' doesn't exist. You design for 'at least once'.

Le #1 a installé le retry, le #2 le disjoncteur — et tous deux ont laissé une dette qu'ils nommaient sans la régler : « et si l'autre côté répond deux fois ? ». Cette dette a la même racine qu'un second problème, qu'on rencontre dès qu'une opération traverse plusieurs services : le réseau n'est pas fiable. Un message peut se perdre, arriver en double, ou tarder — donc « exactly-once » (l'illusion qu'une action s'exécute exactement une fois) est un mythe que rien ne garantit sur un réseau. On conçoit pour la réalité : « at-least-once », reçu au moins une fois. Deux réflexes en découlent. L'idempotence rend le rejeu inoffensif — une clé pour que le serveur reconnaisse « c'est la même demande » et ne l'exécute qu'une fois. La saga remplace la transaction unique impossible — une suite d'étapes locales, chacune avec une action qui la défait, pour rattraper un workflow qui a échoué à mi-chemin.Issue #1 installed the retry, #2 the breaker — and both left a debt they named without settling: 'what if the other side answers twice?'. That debt shares its root with a second problem, met the moment an operation crosses several services: the network isn't reliable. A message can be lost, arrive twice, or lag — so 'exactly-once' (the illusion that an action runs exactly one time) is a myth nothing guarantees on a network. You design for reality: 'at-least-once', received at least once. Two reflexes follow. Idempotency makes the replay harmless — a key so the server recognizes 'it's the same request' and runs it only once. The saga replaces the impossible single transaction — a sequence of local steps, each with an action that undoes it, to recover a workflow that failed midway.

Le code naïf — un débit sans clé, un workflow sans rattrapageThe naive code — a charge with no key, a workflow with no recovery
# deux dettes laissées par les #1 et #2, qui ont la MÊME racine : le réseau n'est pas fiable.
@app.post("/charge")
def charge(amount):
    billing.debit(amount)          # 💥 aucune clé : si le client retente, on débite DEUX fois
    return {"status": "ok"}

def checkout(order):               # un workflow qui traverse trois services…
    reserve_stock(order)           # T1 ✓
    charge(order.total)            # T2 ✓  ← argent pris
    ship(order)                    # T3 💥 le transporteur renvoie 500
    # …pas de transaction qui englobe les trois : T3 échoue, mais T1 et T2 ont eu lieu.
    # résultat : stock réservé, client débité, RIEN d'expédié — et aucun rollback automatique.
# « exactly-once » sur un réseau est un mythe : un message peut se perdre, se dupliquer, tarder.
Le mythe vs la réalité du réseauThe myth vs the network's reality
exactly-onceexactly-once
un mythe · non garantia myth · not guaranteed
⇄
vsvs
at-least-onceat-least-once
la réalité · conçu pourthe reality · designed for

Vouloir « exactly-once » sur un réseau, c'est exiger une garantie que le médium ne peut pas donner : l'émetteur ne sait jamais si son message est arrivé ou si c'est l'accusé de réception qui s'est perdu. Alors il renvoie — et le récepteur voit deux fois. La seule garantie atteignable est « au moins une fois ». On ne lutte pas contre le doublon : on le rend inoffensif (idempotence) et on rend le workflow réparable quand une étape lâche (saga). « Exactly-once » n'est pas une propriété du transport — c'est un effet qu'on fabrique côté traitement.Wanting 'exactly-once' on a network demands a guarantee the medium can't give: the sender never knows whether its message arrived or whether the acknowledgement was lost. So it resends — and the receiver sees it twice. The only attainable guarantee is 'at least once'. You don't fight the duplicate: you make it harmless (idempotency) and make the workflow repairable when a step fails (saga). 'Exactly-once' isn't a property of the transport — it's an effect you manufacture on the processing side.

Le réflexe — deux outils pour une même réalitéThe reflex — two tools for one reality

L'idempotence répond à « reçu deux fois ». On attache à chaque opération mutante une clé d'idempotence — un identifiant unique de la demande. Le serveur l'enregistre au premier passage ; si la même clé revient, il renvoie le résultat déjà calculé sans ré-exécuter. Débiter, créer, envoyer : une seule fois, peu importe le nombre de rejeux. La saga répond à « échoué à mi-chemin ». Quand une opération traverse plusieurs services, on ne peut pas l'envelopper dans une transaction ACID unique. On la découpe en étapes locales — chacune transactionnelle chez elle — et on dote chaque étape d'une compensation : l'action qui la défait. Si une étape tardive échoue, on rejoue les compensations à rebours pour ramener le système à un état cohérent.Idempotency answers 'received twice'. You attach to each mutating operation an idempotency key — a unique identifier of the request. The server records it on the first pass; if the same key returns, it replays the already-computed result without re-running. Charge, create, send: once, no matter the number of replays. The saga answers 'failed midway'. When an operation crosses several services, you can't wrap it in a single ACID transaction. You split it into local steps — each transactional at home — and give each step a compensation: the action that undoes it. If a late step fails, you replay the compensations backward to bring the system to a consistent state.

Ce numéro paie la dette des deux précédents.This issue settles the prior two's debt.

Le retry et le disjoncteur ne sont pas sûrs sans idempotence — c'est ce que le #1 répétait : rejouer un appel non idempotent corrompt. Ce numéro fournit enfin le garde-fou qui rendait les deux premiers utilisables. Et il clôt le volume en bouclant sa question fondatrice : « l'autre côté ne répond pas » (timeout, retry, breaker — #1 et #2) « — ou répond deux fois ? » (idempotence — #3). La saga, elle, prolonge le fil du Vol 6 : une fois qu'on a découpé en services, la transaction qui tenait dans un monolithe ne tient plus, et il faut un mécanisme pour garder la cohérence à travers la frontière réseau.The retry and the breaker aren't safe without idempotency — that's what #1 kept repeating: replaying a non-idempotent call corrupts. This issue finally provides the guardrail that made the first two usable. And it closes the volume by completing its founding question: 'the other side doesn't answer' (timeout, retry, breaker — #1 and #2) '— or answers twice?' (idempotency — #3). The saga, in turn, extends Vol 6's thread: once you've split into services, the transaction that held inside a monolith no longer holds, and you need a mechanism to keep consistency across the network boundary.

№ 02La douleurThe pain4 min

Le rejeu duplique, le workflow échoue à moitié, et la transaction distribuée verrouille tout.The replay duplicates, the workflow half-fails, and the distributed transaction locks everything.

Ignorer le « at-least-once » a trois coûts. D'abord, sans clé d'idempotence, chaque rejeu — et le retry du #1 en fabrique — ré-exécute l'effet : le client est débité deux fois, reçoit deux mails, voit deux commandes. Ensuite, un workflow qui traverse plusieurs services échoue à mi-chemin : le stock est réservé, l'argent pris, mais l'expédition rate — et comme aucune transaction n'englobe les trois services, rien ne défait automatiquement les étapes déjà faites. L'état reste durablement incohérent. Enfin, le remède naïf — une « transaction distribuée » (le commit en deux phases, 2PC) — est pire que le mal : il verrouille toutes les ressources le temps du vote, ne tient pas la charge, et si le coordinateur tombe pendant le vote, tout le monde reste bloqué. Trois manières de payer le fait qu'un réseau n'est ni fiable ni transactionnel.Ignoring 'at-least-once' has three costs. First, without an idempotency key, every replay — and #1's retry manufactures them — re-runs the effect: the customer is charged twice, gets two emails, sees two orders. Then, a workflow crossing several services fails midway: stock is reserved, money taken, but shipping fails — and since no transaction wraps the three services, nothing automatically undoes the already-done steps. The state stays durably inconsistent. Finally, the naive cure — a 'distributed transaction' (two-phase commit, 2PC) — is worse than the disease: it locks all resources during the vote, doesn't hold under load, and if the coordinator falls mid-vote, everyone stays blocked. Three ways to pay for the fact that a network is neither reliable nor transactional.

№ 1 · Le rejeu duplique l'effetThe replay duplicates the effect

Le premier appel a débité, mais sa réponse s'est perdue ; le client (ou le retry) renvoie la même demande. Sans clé pour reconnaître « je l'ai déjà traitée », le serveur débite encore. Chaque effet de bord se cumule — paiements en double, mails répétés, stock décrémenté deux fois. L'outil de résilience du #1 devient, sans idempotence, une machine à doublons.The first call charged, but its response was lost; the client (or the retry) resends the same request. Without a key to recognize 'I already handled this', the server charges again. Every side effect compounds — duplicate payments, repeated emails, stock decremented twice. The #1 resilience tool becomes, without idempotency, a duplication machine.

№ 2 · Le workflow échoue à mi-cheminThe workflow fails midway

Réserver le stock, débiter, expédier : trois étapes, trois services, trois bases. La troisième échoue. Dans un monolithe, une transaction unique aurait tout annulé ; ici, rien n'englobe les trois. Le stock reste réservé, l'argent pris, l'expédition absente. L'état est incohérent et le restera, parce qu'aucun mécanisme ne sait défaire ce qui a déjà été commité chez les autres.Reserve stock, charge, ship: three steps, three services, three databases. The third fails. In a monolith, a single transaction would have rolled everything back; here, nothing wraps the three. Stock stays reserved, money taken, shipping absent. The state is inconsistent and will stay so, because no mechanism knows how to undo what's already committed elsewhere.

№ 3 · La transaction distribuée verrouille toutThe distributed transaction locks all

La tentation : recoller une transaction ACID par-dessus les services, via un commit en deux phases (2PC). Mais il faut verrouiller toutes les ressources pendant le vote, ce qui effondre le débit ; et si le coordinateur tombe entre le « prêt ? » et le « commit ! », chaque participant reste bloqué, ressources gelées, sans savoir quoi faire. On a réintroduit le couplage fort qu'on avait fui — en pire.The temptation: re-glue an ACID transaction over the services, via two-phase commit (2PC). But you must lock all resources during the vote, which collapses throughput; and if the coordinator falls between 'ready?' and 'commit!', every participant stays blocked, resources frozen, not knowing what to do. You've reintroduced the tight coupling you fled — only worse.

Sans clé, sans compensation — le doublon et l'incohérence d'un coupNo key, no compensation — the duplicate and the inconsistency at once
def checkout(order):
    reserve_stock(order)          # T1 commit chez l'inventaire ✓
    billing.debit(order.total)    # T2 commit chez billing ✓  — et SANS clé : un retry re-débite
    ship(order)                   # T3 💥 le transporteur renvoie 500
    # pas de "except" qui défasse T1 et T2 : ils sont commités, chez d'autres services.
# → argent pris, stock bloqué, rien d'expédié. l'incohérence est PERMANENTE.
# et si checkout est retenté en entier : 2e réservation, 2e débit. on aggrave en réessayant.
Le diagnostic.The diagnosis.

Deux détecteurs jumeaux. Pour l'idempotence : « si cette opération est exécutée deux fois avec la même demande, le résultat est-il le même qu'une seule fois ? ». Si non, il manque une clé. Pour la saga : « si l'étape 3 de ce workflow échoue, qui défait les étapes 1 et 2 déjà commitées ailleurs ? ». Si la réponse est « personne », l'incohérence est permanente. Le réflexe : une clé d'idempotence sur chaque mutation, pour que rejouer ne duplique jamais ; et, pour tout workflow multi-services, une saga — des étapes locales dotées de compensations — plutôt qu'une transaction distribuée qui verrouille. On ne cherche pas l'atomicité parfaite : on cherche la cohérence à terme, atteinte par rattrapage.Two twin detectors. For idempotency: 'if this operation runs twice with the same request, is the result the same as running it once?'. If not, a key is missing. For the saga: 'if step 3 of this workflow fails, who undoes steps 1 and 2 already committed elsewhere?'. If the answer is 'no one', the inconsistency is permanent. The reflex: an idempotency key on every mutation, so replaying never duplicates; and, for any multi-service workflow, a saga — local steps with compensations — rather than a distributed transaction that locks. You don't seek perfect atomicity: you seek eventual consistency, reached by recovery.

№ 03La compensationThe compensation5 min

Pas de rollback global — chaque pas en avant mémorise son pas en arrière.No global rollback — each step forward memorizes its step back.

La saga remplace la transaction unique impossible par une chorégraphie : des étapes locales qui avancent, chacune sachant comment se défaire. L'animation joue une commande qui traverse trois services — réserver, débiter, expédier —, puis fait échouer la dernière étape. Comme rien ne peut « rollback » ce qui est déjà commité ailleurs, la saga rejoue les compensations à rebours : rembourser, libérer. Suis le sens de la flèche — vers l'avant tant que tout va bien, vers l'arrière dès qu'une étape lâche — et regarde le système revenir, pas à pas, à un état cohérent.The saga replaces the impossible single transaction with a choreography: local steps that move forward, each knowing how to undo itself. The animation plays an order crossing three services — reserve, charge, ship —, then fails the last step. Since nothing can 'roll back' what's already committed elsewhere, the saga replays the compensations backward: refund, release. Follow the arrow's direction — forward while all is well, backward the moment a step fails — and watch the system return, step by step, to a consistent state.

Animation — une saga qui avance, échoue, puis compense à reboursAnimation — a saga that advances, fails, then compensates backward
La saga démarreThe saga begins · 1/6
· Réserver le stockReserve stock
inverse : inverse: Libérer le stockRelease stock
→
· Débiter le clientCharge customer
inverse : inverse: RembourserRefund
→
· ExpédierShip
(non compensable)(no inverse)
→ marche avant — étapes locales→ forward — local steps
Une commande, trois étapes, trois services différents. Aucune transaction unique ne peut les englober — elles vivent dans trois bases séparées. La saga les enchaîne une à une : chaque étape est une vraie transaction locale, qui commit chez elle avant de passer la main à la suivante.One order, three steps, three different services. No single transaction can wrap them — they live in three separate databases. The saga chains them one by one: each step is a real local transaction, committing at home before handing off to the next.

La même saga, en une orchestration — trois lignes à retrouverThe same saga, as one orchestration — three lines to recall
def checkout_saga(order):
    done = []                                   # pile des étapes réussies, pour compenser
    steps = [
        (reserve_stock, release_stock),         # (action, compensation)
        (charge,        refund),                # charge porte une clé d'idempotence
        (ship,          None),
    ]
    try:
        for action, compensate in steps:
            action(order, key=order.id)         # transaction LOCALE, idempotente    except StepFailed:        raise SagaAborted()
2 ligne(s) à retrouver — écris-la de tête avant de cliquer2 line(s) to recall — write it in your head before clicking
La compensation n'est pas un rollback.The compensation isn't a rollback.

Un rollback de base de données efface des écritures non encore commitées : c'est technique, instantané, invisible. Une compensation, elle, est une nouvelle transaction qui annule l'effet métier d'une transaction déjà commitée et déjà visible. Nuance lourde de conséquences : entre le débit et le remboursement, le client a réellement été débité — il a peut-être reçu une notification. Et certaines actions ne se défont pas vraiment : on ne « dé-envoie » pas un e-mail, on envoie un correctif ; on ne « dé-expédie » pas un colis, on lance un retour. Concevoir une saga, c'est surtout concevoir ces inverses imparfaits — et accepter une fenêtre d'incohérence visible, le temps que le rattrapage opère. Et comme une compensation peut elle aussi être rejouée, elle doit être idempotente, exactement comme l'action qu'elle annule.A database rollback erases not-yet-committed writes: it's technical, instant, invisible. A compensation, by contrast, is a new transaction that cancels the business effect of an already-committed, already-visible transaction. A nuance heavy with consequences: between the charge and the refund, the customer was really charged — they may have received a notification. And some actions don't truly undo: you don't 'un-send' an email, you send a correction; you don't 'un-ship' a parcel, you start a return. Designing a saga is mostly designing these imperfect inverses — and accepting a window of visible inconsistency, while the recovery runs. And since a compensation can itself be replayed, it must be idempotent, exactly like the action it cancels.

Modèle mentalMental model
L'imageThe image

Réserver un voyage en trois coups de fil — vol, hôtel, voiture. Si la voiture n'est pas disponible, aucune baguette magique ne remet tout à zéro : tu rappelles l'hôtel, puis la compagnie, pour annuler à rebours, un appel à la fois.Booking a trip in three phone calls — flight, hotel, car. If the car isn't available, no magic wand resets everything: you call the hotel back, then the airline, to cancel backward, one call at a time.

La règleThe rule

Pas de transaction globale : des étapes locales qui commitent chacune chez elles, chacune sachant se défaire ; si une échoue, on rejoue les inverses à rebours jusqu'à un état cohérent.No global transaction: local steps each committing at home, each knowing how to undo itself; if one fails, you replay the inverses backward to a consistent state.

Où l'analogie casseWhere the analogy breaks

Le coup de fil suppose qu'une annulation efface tout, du premier coup. En vrai, certaines étapes n'ont pas d'inverse propre — on ne « dé-envoie » pas un e-mail, on envoie un correctif — et l'annulation est elle-même un appel qui peut se perdre : elle doit être idempotente et rejouable, comme l'action qu'elle défait. La saga ne restaure pas le passé, elle fabrique un présent cohérent.The phone call assumes a cancellation erases everything, first try. In reality, some steps have no clean inverse — you don't 'un-send' an email, you send a correction — and the cancellation is itself a call that can be lost: it must be idempotent and replayable, like the action it undoes. The saga doesn't restore the past, it manufactures a consistent present.

№ 04Le piègeThe trap4 min

Nier le doublon, ou recoller une transaction qui verrouille tout.Denying the duplicate, or re-gluing a transaction that locks everything.

Deux pièges symétriques encadrent le bon usage. Le premier nie la réalité « at-least-once » : on expose une mutation sans clé d'idempotence, et le moindre rejeu — que le retry du #1 fabrique en routine — débite, crée ou notifie deux fois. Le second sur-réagit : effrayé par l'incohérence, on tente de recoller une transaction ACID par-dessus les services, via un commit en deux phases. Mais le 2PC verrouille toutes les ressources le temps du vote, s'effondre sous la charge, et gèle tout le monde si le coordinateur tombe au mauvais moment. Et entre les deux, le piège le plus courant : le workflow multi-services sans rien — ni clé, ni compensation, ni 2PC — où une étape ratée laisse une incohérence définitive. Bascule le toggle : repère le débit sans clé, et la transaction distribuée qui rigidifie ce qu'on avait découpé.Two symmetric traps frame correct use. The first denies the 'at-least-once' reality: you expose a mutation with no idempotency key, and the slightest replay — which #1's retry manufactures routinely — charges, creates or notifies twice. The second over-reacts: scared of inconsistency, you try to re-glue an ACID transaction over the services, via two-phase commit. But 2PC locks all resources during the vote, collapses under load, and freezes everyone if the coordinator falls at the wrong moment. And between the two, the commonest trap: the multi-service workflow with nothing — no key, no compensation, no 2PC — where a failed step leaves a permanent inconsistency. Flip the toggle: spot the keyless charge, and the distributed transaction that rigidifies what you'd split apart.

# at-least-once assumé : clé d'idempotence sur chaque mutation + saga avec compensations
@app.post("/charge")
def charge(amount, idempotency_key):
    if not store.insert_if_absent(idempotency_key, PENDING):  # réserve la clé ATOMIQUEMENT
        return store.await_result(idempotency_key)  # déjà vue (même en parallèle) : MÊME résultat,
                                                    #   sans ré-exécuter — le rejeu est inoffensif
    result = billing.debit(amount)
    store.put(idempotency_key, result)          # on mémorise (clé → résultat)
    return result

def checkout_saga(order):
    done = []
    for action, compensate in [(reserve, release), (charge, refund), (ship, None)]:
        action(order, key=order.id); done.append(compensate)   # étapes LOCALES idempotentes
    # échec en cours de route → on rejoue les compensations à rebours (cf. ch. 03)
# at-least-once : rejouer ne duplique rien (clé), et un workflow à demi ne reste pas incohérent.
Pourquoi c'est justeWhy it's right

Les deux outils assument la réalité au lieu de la combattre. La clé d'idempotence accepte que le rejeu arrive — c'est le prix du retry — et le rend inoffensif : le serveur reconnaît la demande déjà vue et renvoie le résultat mémorisé sans ré-exécuter. La saga accepte qu'on ne puisse pas verrouiller des services indépendants dans une transaction unique, et obtient la cohérence autrement : des étapes locales, chacune dotée de son inverse, et un rattrapage à rebours quand l'une lâche. On ne vise pas l'atomicité parfaite et instantanée — un mythe sur un réseau —, on vise la cohérence à terme, atteinte par compensation. Résultat : un rejeu ne duplique rien, et un workflow interrompu ne laisse jamais le système coincé dans un demi-état.Both tools assume reality instead of fighting it. The idempotency key accepts that the replay will happen — it's the retry's price — and makes it harmless: the server recognizes the already-seen request and returns the memorized result without re-running. The saga accepts that you can't lock independent services in a single transaction, and gets consistency otherwise: local steps, each with its inverse, and a backward recovery when one fails. You don't aim for perfect, instant atomicity — a myth on a network —, you aim for eventual consistency, reached by compensation. Result: a replay duplicates nothing, and an interrupted workflow never leaves the system stuck in a half-state.

La règle qui tranche.The rule that settles it.

Conçois pour « au moins une fois », jamais pour « exactly-once ». Deux tests jumeaux. Sur chaque mutation : « si on me rejoue cette demande à l'identique, le résultat reste-t-il celui d'une seule exécution ? » — sinon, ajoute une clé d'idempotence. Sur chaque workflow multi-services : « si une étape tardive échoue, qu'est-ce qui défait celles déjà commitées ? » — si la réponse est « rien », il manque des compensations ; et si la réponse est « un commit en deux phases », tu rigidifies ce que tu avais découpé. La cohérence dans le distribué ne s'obtient pas en verrouillant : elle s'obtient en rendant chaque pas rejouable et chaque pas réversible.Design for 'at least once', never for 'exactly-once'. Two twin tests. On each mutation: 'if this request is replayed identically, does the result stay that of a single execution?' — if not, add an idempotency key. On each multi-service workflow: 'if a late step fails, what undoes the ones already committed?' — if the answer is 'nothing', compensations are missing; and if the answer is 'a two-phase commit', you're rigidifying what you split. Consistency in the distributed isn't won by locking: it's won by making each step replayable and each step reversible.

Le piège jumeau : l'inverse qui n'inverse pas.The twin trap: the inverse that doesn't invert.

Une saga ne vaut que ses compensations — et certaines sont des illusions. On ne « dé-envoie » pas un e-mail déjà parti, on ne « dé-livre » pas un colis remis : la compensation honnête est alors un correctif (un second mail, un retour produit), pas une annulation. Deux conséquences à tenir. D'abord, place les étapes peu ou non compensables le plus tard possible dans la saga, pour qu'un échec ait le moins à défaire. Ensuite, n'oublie pas que la compensation est elle-même un appel réseau : elle peut se perdre et être rejouée, donc elle doit être idempotente, exactement comme l'action qu'elle annule. Une saga, c'est de l'idempotence partout — à l'aller comme au retour.A saga is only worth its compensations — and some are illusions. You don't 'un-send' an email already gone, you don't 'un-deliver' a parcel handed over: the honest compensation is then a correction (a second email, a product return), not a cancellation. Two consequences to hold. First, place the poorly- or non-compensable steps as late as possible in the saga, so a failure has the least to undo. Then, don't forget the compensation is itself a network call: it can be lost and replayed, so it must be idempotent, exactly like the action it cancels. A saga is idempotency everywhere — on the way out as on the way back.

№ 05Bilan & éditoWrap-up & editorial2 min

Exiger « une seule fois », ou concevoir pour « au moins une fois ».Demanding 'exactly once', or designing for 'at least once'.

Nier le réseau vs assumer le « at-least-once », côte à côteDenying the network vs assuming 'at-least-once', side by side
on nie le réseaudenying the networkon assume « at-least-once »assuming 'at-least-once'
Le rejeuThe replayduplique l'effetduplicates the effectinoffensif · une cléharmless · a key
Workflow multi-servicesMulti-service workflowincohérence si une étape lâcheinconsistent if a step failssaga · compensations à rebourssaga · backward compensations
La cohérenceConsistencyatomicité rêvée (2PC qui verrouille)dreamed atomicity (2PC that locks)à terme · par rattrapageeventual · by recovery
L'échec d'une étapeA step's failurelaisse un demi-état figéleaves a frozen half-staterejoue les inversesreplays the inverses
Le couplageCouplingfort (verrous partagés)tight (shared locks)lâche · étapes localesloose · local steps

Quand ne pas dégainer clé ou sagaWhen not to reach for a key or a saga

L'idempotence et la saga ont un loyer : un état à stocker pour dé-doublonner, des inverses à concevoir et à maintenir, une fenêtre d'incohérence visible à assumer. On s'en passe — et c'est correct — quand :Idempotency and the saga have rent: a state to store for de-duplication, inverses to design and maintain, a window of visible inconsistency to accept. You skip them — and that's fine — when:

  • l'opération tient dans une seule transaction ACID locale : un monolithe, une base, un commit. Inutile de découper en saga ce que la base sait rendre atomique d'elle-même — c'est réintroduire à la main une garantie qu'on avait gratuitement (cf. Vol 6 #1 : la frontière logique avant la frontière réseau).the operation fits in a single local ACID transaction: one monolith, one database, one commit. No need to split into a saga what the database makes atomic on its own — that's hand-reintroducing a guarantee you had for free (cf. Vol 6 #1: the logical boundary before the network one).
  • l'opération est naturellement idempotente : une lecture, un « mettre à tel état » (PUT), un calcul sans effet de bord. La rejouer donne déjà le même résultat ; une clé n'ajoute qu'un état inutile à maintenir.the operation is naturally idempotent: a read, a 'set to this state' (PUT), a side-effect-free computation. Replaying it already yields the same result; a key only adds useless state to maintain.
  • le workflow n'a qu'une seule étape mutante : s'il n'y a rien à enchaîner ni à défaire après elle, il n'y a pas de saga à orchestrer — une clé d'idempotence sur cette étape suffit à couvrir le rejeu.the workflow has a single mutating step: if there's nothing to chain or undo after it, there's no saga to orchestrate — an idempotency key on that step is enough to cover the replay.

Le réflexe n'est donc pas « mets une clé et une saga partout » : c'est « cette opération franchit-elle une frontière réseau, et peut-elle être rejouée ou interrompue à mi-chemin ? Alors rends-la idempotente, et donne au workflow ses compensations. Sinon, une transaction locale fait déjà tout le travail. »So the reflex isn't 'put a key and a saga everywhere': it's 'does this operation cross a network boundary, and can it be replayed or interrupted midway? Then make it idempotent, and give the workflow its compensations. Otherwise, a local transaction already does all the work.'

Mot de l'éditeurFrom the editor

Ce numéro clôt le Vol 7 en répondant à la seconde moitié de sa question fondatrice. « Qu'est-ce qui se passe quand l'autre côté ne répond pas — ou répond deux fois ? » Les #1 et #2 ont traité le « ne répond pas » : le retry pour le hoquet, le disjoncteur pour la mort durable. Le #3 traite le « répond deux fois » : l'idempotence pour que le rejeu n'abîme rien, la saga pour qu'un workflow brisé se rattrape. Les trois numéros ne sont pas trois recettes indépendantes — c'est une seule conviction déclinée : sur un réseau, l'échec et la répétition ne sont pas des exceptions, ce sont des états normaux. Le réseau oublie (d'où le retry et la saga), il répète (d'où l'idempotence), il tarde (d'où le timeout et le disjoncteur). La résilience n'est pas une couche qu'on ajoute après : c'est ce qu'on admet d'emblée sur la nature du médium, et qu'on encode dans chaque appel qui franchit une frontière.This issue closes Vol 7 by answering the second half of its founding question. 'What happens when the other side doesn't answer — or answers twice?' Issues #1 and #2 handled the 'doesn't answer': the retry for the hiccup, the breaker for lasting death. Issue #3 handles the 'answers twice': idempotency so the replay harms nothing, the saga so a broken workflow recovers. The three issues aren't three independent recipes — they're a single conviction declined: on a network, failure and repetition aren't exceptions, they're normal states. The network forgets (hence the retry and the saga), it repeats (hence idempotency), it lags (hence the timeout and the breaker). Resilience isn't a layer you add afterward: it's what you admit from the start about the medium's nature, and encode into every call that crosses a boundary.

Deux fils se nouent ici. La saga prolonge directement le Vol 6 : une fois la frontière réseau tracée, la transaction qui tenait dans le monolithe ne tient plus, et il faut la remplacer par une chorégraphie — c'est le prix, longtemps différé, de la découpe. Et la « cohérence à terme » qu'on a touchée du doigt — un système qui n'est pas cohérent à chaque instant, mais qui le redevient — n'est pas un détail technique : c'est un théorème. Sous partition réseau, on ne peut pas avoir à la fois la cohérence parfaite et la disponibilité ; il faut choisir. C'est précisément le sujet du Vol 8, qui ferme la série par la scalabilité — le cache et son invalidation, le partitionnement de la donnée, et le théorème CAP qui formalise le renoncement qu'on vient d'apprendre à vivre. On y entrera par notre règle la plus stricte : jamais sans un chiffre.Two threads tie here. The saga directly extends Vol 6: once the network boundary is drawn, the transaction that held inside the monolith no longer holds, and must be replaced by a choreography — that's the long-deferred price of the split. And the 'eventual consistency' we touched — a system not consistent at every instant, but that becomes so again — isn't a technical detail: it's a theorem. Under network partition, you can't have both perfect consistency and availability; you must choose. That's precisely Vol 8's subject, which closes the series with scalability — the cache and its invalidation, data partitioning, and the CAP theorem that formalizes the renunciation we've just learned to live with. We'll enter it through our strictest rule: never without a number.

Les voisins que ce numéro débloqueThe neighbors this issue unlocks

  • Idempotency key — l'identifiant unique de la demande, fourni par le client et porté par chaque rejeu. Le serveur l'enregistre avec son résultat ; à la même clé, il renvoie ce résultat sans ré-exécuter. C'est la brique concrète de l'idempotence — celle qui rend le retry du #1 enfin sûr, et sans laquelle « at-least-once » se traduit par « facturé plusieurs fois ».the request's unique identifier, supplied by the client and carried by every replay. The server records it with its result; on the same key, it returns that result without re-running. It's idempotency's concrete brick — the one that finally makes #1's retry safe, and without which 'at-least-once' translates to 'charged several times'.
  • Compensating transaction — l'inverse métier d'une étape déjà commitée : rembourser annule débiter, libérer annule réserver. Ce n'est pas un rollback technique mais une nouvelle transaction qui défait l'effet visible de la précédente. C'est le cœur de la saga, et sa principale difficulté de conception : certaines actions n'ont pas d'inverse propre, et il faut alors un correctif plutôt qu'une annulation.the business inverse of an already-committed step: refund cancels charge, release cancels reserve. It's not a technical rollback but a new transaction that undoes the previous one's visible effect. It's the saga's core, and its main design difficulty: some actions have no clean inverse, and then you need a correction rather than a cancellation.
  • Outbox — le motif qui fiabilise la publication d'événements. Écrire en base puis publier un message sont deux actions séparées : entre les deux, le process peut mourir, et l'événement se perd. L'outbox écrit l'événement dans la même transaction locale que la donnée, dans une table dédiée, qu'un relais lit et publie ensuite — au moins une fois. C'est ce qui garantit qu'une étape de saga commitée déclenche bien la suivante, sans message perdu : le pont entre la transaction locale et le monde des files (Vol 4 #2).the pattern that makes event publishing reliable. Writing to the database then publishing a message are two separate actions: between them, the process can die, and the event is lost. The outbox writes the event in the same local transaction as the data, into a dedicated table, which a relay then reads and publishes — at least once. It's what guarantees that a committed saga step actually triggers the next, with no lost message: the bridge between the local transaction and the world of queues (Vol 4 #2).

Le kata — écris-le, ne le relis pasThe kata — write it, don't reread it

Tu as retrouvé la saga au chapitre 3 ; voici l'autre moitié du numéro, celle qui la rend sûre à l'aller comme au retour : la clé d'idempotence. Copie ce fichier de tests dans ton langage et ton runner habituels — la spec tient dans les noms — et fais-les passer au vert sans revenir au numéro. Le dernier test est le sournois : il vérifie qu'un échec ne « brûle » pas la clé pour toujours. La plupart des implémentations naïves mémorisent trop tôt et se rendent impossibles à retenter — l'ordre des deux dernières lignes de la solution est tout le sujet.You recalled the saga in chapter 3; here's the issue's other half, the one that makes it safe out and back: the idempotency key. Copy this test file into your usual language and runner — the spec fits in the names — and turn them green without coming back to the issue. The last test is the sneaky one: it checks that a failure doesn't 'burn' the key forever. Most naive implementations memorize too early and make themselves impossible to retry — the order of the solution's last two lines is the whole point.

test_idempotence.py
# test_idempotence.py — la spec EST le harnais : fais-le passer au vert.
# une clé d'idempotence : la même demande, rejouée, ne s'exécute qu'une fois.

def test_premier_appel_execute():
    h = Idempotent(store={})
    sonde = compteur(lambda: debit(100))
    assert h.run("k1", sonde) == 100
    assert sonde.count == 1                       # exécuté une fois, résultat renvoyé

def test_rejeu_meme_cle_ne_reexecute_pas():
    h = Idempotent(store={})
    sonde = compteur(lambda: debit(100))
    h.run("k1", sonde)
    assert h.run("k1", sonde) == 100              # même clé → même résultat…
    assert sonde.count == 1                        # …SANS ré-exécuter : le rejeu est inoffensif

def test_cles_differentes_sont_independantes():
    h = Idempotent(store={})
    assert h.run("k1", lambda: debit(100)) == 100
    assert h.run("k2", lambda: debit(30)) == 30   # une clé par demande distincte

def test_renvoie_le_resultat_memorise():
    h = Idempotent(store={})
    monde = {"montant": 100}
    h.run("k1", lambda: debit(monde["montant"]))
    monde["montant"] = 999                          # le monde change sous nos pieds…
    assert h.run("k1", lambda: debit(monde["montant"])) == 100   # …le rejeu rend l'ANCIEN résultat

def test_l_echec_n_est_pas_memorise():
    h = Idempotent(store={})
    with raises(Transient): h.run("k1", dead)      # le 1er essai échoue…
    assert h.run("k1", lambda: "ok") == "ok"       # …la clé reste libre : on peut vraiment retenter
Chez toi, cette semaine.In your codebase, this week.

Trouve la mutation qui débite, crée ou notifie sans clé d'idempotence — celle que le retry du #1 peut rejouer. Un point de départ : grep -rniE "@app\.(post|put)|def (charge|debit|create|send|pay)" src/, puis, pour chaque endpoint mutant, cherche le mot idempotency dans sa signature et son corps. Absent ? Pose la question du chapitre 4 : « si l'accusé de réception se perd et que l'appelant retente, cette opération s'exécute-t-elle deux fois ? » Si oui, c'est ton candidat n°1 à une clé.Find the mutation that charges, creates or notifies with no idempotency key — the one #1's retry can replay. A starting point: grep -rniE "@app\.(post|put)|def (charge|debit|create|send|pay)" src/, then, for each mutating endpoint, look for the word idempotency in its signature and body. Absent? Ask chapter 4's question: 'if the acknowledgement is lost and the caller retries, does this operation run twice?' If yes, it's your #1 candidate for a key.