<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: mohamed Tayel</title>
    <description>The latest articles on DEV Community by mohamed Tayel (@moh_moh701).</description>
    <link>https://dev.to/moh_moh701</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1274001%2Fbae69db9-154d-4094-a7ad-183f2fef9a65.png</url>
      <title>DEV Community: mohamed Tayel</title>
      <link>https://dev.to/moh_moh701</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/moh_moh701"/>
    <language>en</language>
    <item>
      <title>Day 16 — Retries, Backoff &amp; Dead-Letter: Handling the Poison Message</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 21:09:05 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-16-retries-backoff-dead-letter-handling-the-poison-message-23nn</link>
      <guid>https://dev.to/moh_moh701/day-16-retries-backoff-dead-letter-handling-the-poison-message-23nn</guid>
      <description>&lt;p&gt;Day 15 decided &lt;em&gt;who&lt;/em&gt; owns an outbox message. But leasing says nothing about what happens when processing keeps failing. A message that can never succeed — a poison message — will be retried forever unless retry has a limit, a backoff, and an exit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3ylcku3qfg0dfop8xei.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3ylcku3qfg0dfop8xei.png" alt="Retries, backoff &amp;amp; the dead-letter exit" width="800" height="983"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🙏 &lt;strong&gt;Credit where it's due&lt;/strong&gt; — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, &lt;em&gt;Fundamentals of Distributed Systems&lt;/em&gt; on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where we are — after Days 13, 14 and 15
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Day 13 — Durable Reconciliation:&lt;/strong&gt; the pending work survives a restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 14 — Transactional Outbox:&lt;/strong&gt; the payment row and the outbox row commit together, atomically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 15 — Outbox Leasing:&lt;/strong&gt; exactly one worker owns a message at a time, and ownership expires if the worker dies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 16 (this document):&lt;/strong&gt; leasing decides &lt;em&gt;who&lt;/em&gt; processes a message — not what happens when processing keeps &lt;em&gt;failing&lt;/em&gt;. A poison message needs bounded retries, backoff, and a dead-letter exit.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away:&lt;/strong&gt; Leasing says who owns the work now. Retry says when to try again. Dead-letter says when to stop and ask a human. A durable, atomic, leased message can still fail every time it is processed. Without a limit and an exit, one bad message is retried forever and can starve the whole queue.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Why a leased, durable outbox can still loop forever
&lt;/h2&gt;

&lt;p&gt;Everything so far assumed processing eventually succeeds. Real provider calls do not. They fail two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transient failure&lt;/strong&gt; — a timeout, a blip, a temporary 503. Another try later will probably work.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Permanent failure&lt;/strong&gt; — a malformed record, a rejected account, a bug. This message will &lt;em&gt;never&lt;/em&gt; succeed. It is a &lt;strong&gt;poison message&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;✅ Day 15 solved — ownership:&lt;/strong&gt; one worker owns a message at a time; a dead owner's lease expires and another worker takes over.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;❌ Day 16 solves — when to stop:&lt;/strong&gt; a naive "retry until it works" loop never stops on a poison message — it re-claims and re-fails forever.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The lease actually makes it worse.&lt;/strong&gt; Because a message returns to the queue after each failure (or after its lease expires), the poison message is &lt;em&gt;guaranteed&lt;/em&gt; to come back — forever — unless something counts the attempts and calls a halt.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. The BEFORE scenario — retried forever, and the state hides no lie but never ends
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;session-16-poison-message-retried-forever&lt;/code&gt; seeds the Day 14 durable outbox (6 payments + 6 pending messages), then a single worker processes them with the most naive policy: on failure, put the message straight back to &lt;code&gt;Pending&lt;/code&gt; and try again — no limit, no backoff, no exit. One message, &lt;code&gt;PAY-003&lt;/code&gt;, is a poison message: its provider status check fails every time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;healthy ×5:&lt;/strong&gt; &lt;code&gt;PAY-001, 002, 004, 005, 006&lt;/code&gt; each processed once → &lt;code&gt;Processed&lt;/code&gt; (4 Paid, 1 Failed).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PAY-003 ×∞:&lt;/strong&gt; claim → status check FAILS → back to &lt;code&gt;Pending&lt;/code&gt; → claim → FAILS → … the attempt count climbs with no ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;demo cap:&lt;/strong&gt; a real run never ends, so the scenario stops after &lt;strong&gt;10&lt;/strong&gt; retries and inspects the state: still &lt;code&gt;Pending&lt;/code&gt;, &lt;code&gt;AttemptCount = 10&lt;/code&gt;, never &lt;code&gt;Processed&lt;/code&gt;, no dead-letter to catch it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the real run: healthy processed &lt;strong&gt;5/5&lt;/strong&gt;, poison retries &lt;strong&gt;10&lt;/strong&gt;, poison &lt;code&gt;AttemptCount&lt;/code&gt; &lt;strong&gt;10&lt;/strong&gt;, poison final status &lt;strong&gt;Pending&lt;/strong&gt;, provider status-check calls &lt;strong&gt;15&lt;/strong&gt; (of which &lt;strong&gt;10 failed&lt;/strong&gt;), dead-lettered &lt;strong&gt;0&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Verdict from the real report:&lt;/strong&gt; &lt;em&gt;"A POISON MESSAGE RETRIED FOREVER — NO LIMIT, NO EXIT ❌"&lt;/em&gt;. Nothing here is a lie or a double-charge — it is unbounded, wasted work that a shared queue would let starve every other message.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  3. The retry columns and the new terminal state
&lt;/h2&gt;

&lt;p&gt;The AFTER scenario adds a retry policy to the leased outbox row and one new status. A message now ends in one of two terminal states — &lt;code&gt;Processed&lt;/code&gt; or &lt;code&gt;DeadLettered&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pending ─claim─▶ Processing ─success─▶ Processed        (terminal, good)
                     │
                     ├─fail, attempts &amp;lt; max ─▶ Pending + NextAttemptUtc  (backoff, retry later)
                     └─fail, attempts ≥ max ─▶ DeadLettered + LastError  (terminal, parked)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AttemptCount&lt;/code&gt; / &lt;code&gt;MaxAttempts&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;How many times we've tried, and the ceiling before giving up.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NextAttemptUtc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Backoff gate — the message is not claimable again until this time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LastError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Why the last attempt failed — recorded for the human who inspects a dead letter.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;Status&lt;/code&gt; is now &lt;code&gt;Pending&lt;/code&gt; | &lt;code&gt;Processing&lt;/code&gt; | &lt;code&gt;Processed&lt;/code&gt; | &lt;code&gt;DeadLettered&lt;/code&gt;. The &lt;code&gt;DeadLettered&lt;/code&gt; state is terminal but &lt;em&gt;not&lt;/em&gt; lost — the message is parked, out of the worker's way, with its error attached.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The claim, now gated by backoff
&lt;/h2&gt;

&lt;p&gt;The Day 15 atomic claim gets one extra clause: a message is only claimable if its backoff has elapsed. The claim stays a single &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; transaction with a conditional update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="k"&gt;IMMEDIATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Processing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Pending'&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;LockedUntilUtc&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;-- lease (Day 15)&lt;/span&gt;
     &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NextAttemptUtc&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;NextAttemptUtc&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;-- backoff (Day 16)&lt;/span&gt;
   &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LockedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;LockedUntilUtc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AttemptCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AttemptCount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;same&lt;/span&gt; &lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On failure the worker chooses one of two paths, purely from &lt;code&gt;AttemptCount&lt;/code&gt; vs &lt;code&gt;MaxAttempts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AttemptCount&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MaxAttempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;Pending&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NextAttemptUtc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AttemptCount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;LastError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;   &lt;span class="c1"&gt;// retry later&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;DeadLettered&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LastError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;                                            &lt;span class="c1"&gt;// give up, park it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Backoff — and proving it without waiting
&lt;/h2&gt;

&lt;p&gt;Backoff spreads retries over time instead of hammering a struggling provider. Here the schedule is exponential: &lt;code&gt;200 ms × 2^(n-1)&lt;/code&gt; → 200 ms, then 400 ms. A message in backoff is genuinely &lt;em&gt;not claimable&lt;/em&gt; — the worker's claim returns nothing, so it advances a deterministic &lt;code&gt;ScenarioClock&lt;/code&gt; to the next attempt time (no real waiting). The real run's timeline for &lt;code&gt;PAY-003&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;t = 09:00:00.000Z:&lt;/strong&gt; attempt 1/3 FAILED → backoff &lt;strong&gt;200 ms&lt;/strong&gt; → NextAttempt = 09:00:00.200Z (not claimable until then).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;t = 09:00:00.201Z:&lt;/strong&gt; attempt 2/3 FAILED → backoff &lt;strong&gt;400 ms&lt;/strong&gt; → NextAttempt = 09:00:00.601Z.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;t = 09:00:00.602Z:&lt;/strong&gt; attempt 3/3 FAILED → limit reached → &lt;strong&gt;DEAD-LETTERED&lt;/strong&gt; (LastError recorded).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Deterministic by design.&lt;/strong&gt; Because the clock is injected, the backoff windows are exact and the test never sleeps. In production the same gate is a real wall-clock comparison; the logic is identical.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  6. Dead-letter — parked, not lost
&lt;/h2&gt;

&lt;p&gt;After &lt;code&gt;MaxAttempts&lt;/code&gt;, the poison message moves to &lt;code&gt;DeadLettered&lt;/code&gt; — a terminal state that takes it out of the worker's claim query so the queue keeps moving. Crucially, it is not deleted: its &lt;code&gt;LastError&lt;/code&gt; is kept for a human or a redrive tool.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;🔁 A retry loop says — never stop:&lt;/strong&gt; keep trying the same failing work forever — burning provider calls and blocking the queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;☠️ A dead-letter says — stop and surface:&lt;/strong&gt; after N tries, park the message with its error so people can see it — and let everything else proceed.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The payment is honestly still pending.&lt;/strong&gt; Dead-lettering the &lt;em&gt;message&lt;/em&gt; does not fake a payment outcome — &lt;code&gt;PAY-003&lt;/code&gt;'s payment row stays &lt;code&gt;PendingConfirmation&lt;/code&gt;. We stopped the automatic retry; we did not invent a result.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  7. The real implementation — proven by running it
&lt;/h2&gt;

&lt;p&gt;Day 16 reuses the Day 14 outbox (&lt;code&gt;OutboxSqliteCore&lt;/code&gt;) and the Day 15 lease model (&lt;code&gt;Session15LeasingCore&lt;/code&gt;) unchanged, and adds a small retry core plus two scenarios registered in &lt;code&gt;Program.cs&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BEFORE:&lt;/strong&gt; &lt;code&gt;Session16PoisonRetriedForeverScenario&lt;/code&gt; → command &lt;code&gt;session-16-poison-message-retried-forever&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AFTER:&lt;/strong&gt; &lt;code&gt;Session16RetryWithDeadLetterScenario&lt;/code&gt; → command &lt;code&gt;session-16-retry-with-dead-letter&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No new packages — the same &lt;code&gt;Microsoft.Data.Sqlite&lt;/code&gt;. Standard .NET only: the atomic claim via &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt;, a &lt;code&gt;ScenarioClock&lt;/code&gt; for deterministic backoff windows, and a provider whose status check throws for the poison key (&lt;code&gt;PAY-003&lt;/code&gt;). The poison message is a would-be &lt;code&gt;Failed&lt;/code&gt; payment, so dead-lettering it loses no money.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real BEFORE vs AFTER metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (real run)&lt;/th&gt;
&lt;th&gt;BEFORE (naive)&lt;/th&gt;
&lt;th&gt;AFTER (policy)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Healthy messages Processed&lt;/td&gt;
&lt;td&gt;5 / 5&lt;/td&gt;
&lt;td&gt;5 / 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poison attempts&lt;/td&gt;
&lt;td&gt;10 (capped; unbounded)&lt;/td&gt;
&lt;td&gt;3 (= MaxAttempts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retries with backoff&lt;/td&gt;
&lt;td&gt;0 (none)&lt;/td&gt;
&lt;td&gt;2 (200 ms, 400 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poison final outbox status&lt;/td&gt;
&lt;td&gt;Pending (never ends)&lt;/td&gt;
&lt;td&gt;DeadLettered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead-lettered messages&lt;/td&gt;
&lt;td&gt;0 (no such concept)&lt;/td&gt;
&lt;td&gt;1 (LastError recorded)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider status-check calls&lt;/td&gt;
&lt;td&gt;15 (10 failed)&lt;/td&gt;
&lt;td&gt;8 (3 failed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbox Pending / Processing at end&lt;/td&gt;
&lt;td&gt;1 / 0 (stuck)&lt;/td&gt;
&lt;td&gt;0 / 0 (drained)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved Paid / Failed (healthy)&lt;/td&gt;
&lt;td&gt;4 / 1&lt;/td&gt;
&lt;td&gt;4 / 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate payment / outbox rows&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fake successes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The headline: the poison message goes from an &lt;strong&gt;unbounded&lt;/strong&gt; loop (10 and climbing, 10 wasted provider calls) to &lt;strong&gt;exactly 3&lt;/strong&gt; attempts and a clean exit — and the queue ends fully drained instead of stuck with one &lt;code&gt;Pending&lt;/code&gt; row forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safety checks from the real AFTER report — all passing
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;poison tried exactly MaxAttempts → 3 / 3&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;poison dead-lettered with LastError → status DeadLettered, error set&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;queue drained → 0 pending / 0 processing&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;no duplicate payment/outbox rows → max 1 per key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tx ids only from provider, Paid only → 4 Paid w/ id, 0 Failed w/ id&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Verdicts from the real reports.&lt;/strong&gt; BEFORE: &lt;em&gt;"A POISON MESSAGE RETRIED FOREVER — NO LIMIT, NO EXIT ❌"&lt;/em&gt;. AFTER: &lt;em&gt;"BOUNDED RETRY + DEAD-LETTER — THE QUEUE KEEPS MOVING ✅"&lt;/em&gt;. Both exit with code &lt;code&gt;0&lt;/code&gt; only when every check passes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Run it yourself
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-16-poison-message-retried-forever
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-16-retry-with-dead-letter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run prints the schema, the processing/backoff timeline, the per-message table, the ledgers, the checks, and the verdict, then saves a timestamped report under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reports/Session-16/Session-16-Poison-Message-Retried-Forever-RunReport-&amp;lt;timestamp&amp;gt;.txt
Reports/Session-16/Session-16-Retry-With-Dead-Letter-RunReport-&amp;lt;timestamp&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQLite file is created in a per-scenario temp folder — e.g. &lt;code&gt;%LOCALAPPDATA%\Temp\wassal-day16-deadletter-&amp;lt;random&amp;gt;\wassal-outbox.db&lt;/code&gt; — and cleaned up at the end; its path is printed in the report.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Final takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A durable, atomic, leased message can still fail every time — leasing decides ownership, not when to stop.&lt;/li&gt;
&lt;li&gt;Retry needs a &lt;strong&gt;limit&lt;/strong&gt; (&lt;code&gt;MaxAttempts&lt;/code&gt;), a &lt;strong&gt;backoff&lt;/strong&gt; (&lt;code&gt;NextAttemptUtc&lt;/code&gt; gating re-claim), and an &lt;strong&gt;exit&lt;/strong&gt; (&lt;code&gt;DeadLettered&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A dead-lettered message is &lt;strong&gt;parked, not lost&lt;/strong&gt;: terminal, out of the worker's way, with its &lt;code&gt;LastError&lt;/code&gt; for a human.&lt;/li&gt;
&lt;li&gt;Dead-lettering the message does not fake the payment — the payment stays honestly &lt;code&gt;PendingConfirmation&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;All Day 14/15 guarantees still hold: one payment per key, one outbox row per payment, exclusive ownership, no duplicate charges, provider-only transaction ids.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The four days, side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Day 13:  The work survives a restart.
Day 14:  The business state and outbox row commit together.
Day 15:  Only one worker owns a message at a time.
Day 16:  A message that always fails is tried a bounded number of times, then parked.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Leasing says who owns the work now. Retry says when to try again. Dead-letter says when to stop and ask a human.&lt;/strong&gt; Retry scheduling refinements, a hosted worker, and dead-letter redrive tooling are later lessons.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>architecture</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>Day 15 — Outbox Leasing: Preventing Competing Workers from Processing the Same Message</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 21:08:27 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-15-outbox-leasing-preventing-competing-workers-from-processing-the-same-message-4bh</link>
      <guid>https://dev.to/moh_moh701/day-15-outbox-leasing-preventing-competing-workers-from-processing-the-same-message-4bh</guid>
      <description>&lt;p&gt;Day 14 made the payment and its outbox message commit atomically, and Day 13 made them durable. But a durable message with two workers is a shared message — both can read it and process it. Day 15 gives each message one temporary owner: a lease.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fevqu0ku5nc2ntwk7l5zx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fevqu0ku5nc2ntwk7l5zx.png" alt="Outbox leasing — one owner per message" width="800" height="911"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🙏 &lt;strong&gt;Credit where it's due&lt;/strong&gt; — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, &lt;em&gt;Fundamentals of Distributed Systems&lt;/em&gt; on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where we are — after Days 13 and 14
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Day 13 — Durable Reconciliation:&lt;/strong&gt; the pending work survives a restart — a fresh instance reloads it from durable storage. &lt;em&gt;The work survives a restart.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 14 — Transactional Outbox:&lt;/strong&gt; the payment row and the outbox row commit or roll back &lt;em&gt;together&lt;/em&gt; in one SQLite transaction. &lt;em&gt;The business state and outbox row commit together.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 15 (this document):&lt;/strong&gt; in a real deployment there is more than one application instance, each running an outbox worker. Two workers can read the same pending row. &lt;em&gt;Only one worker owns a message at a time — and ownership expires if the worker dies.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away:&lt;/strong&gt; Durability preserves the work. Atomicity creates the work safely. Leasing decides who owns the work now. The message is durable — now we need &lt;strong&gt;exclusive, temporary ownership&lt;/strong&gt;. Durable does not mean exclusively owned; pending does not mean safe for every worker to process.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Why a durable, atomic outbox can still be processed twice
&lt;/h2&gt;

&lt;p&gt;Day 14 answered "is the message durable and consistent?" with a firm yes. But that is a different question from "who is allowed to process it right now?"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;✅ Days 13–14 solved — durability + atomicity:&lt;/strong&gt; the message survives restart and always commits with its payment. One row, no orphans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;❌ Day 15 solves — exclusive ownership:&lt;/strong&gt; with two app instances, both workers &lt;code&gt;SELECT … WHERE Status='Pending'&lt;/code&gt; and both process the same rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single-worker deployment hides this. Scale to two instances and the naive worker loop becomes a race: &lt;code&gt;Worker A&lt;/code&gt; reads the pending rows, &lt;code&gt;Worker B&lt;/code&gt; reads the same pending rows, and both start processing before either marks anything done.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;For payment reconciliation the work is a status &lt;em&gt;read&lt;/em&gt;,&lt;/strong&gt; so a duplicate does not double-charge. But it is duplicate provider load, wasted work, competing DB updates, and misleading metrics — and for other message types (send an email, ship an order) a duplicate is a real, visible side effect.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. The BEFORE scenario — competing workers, and why the final state hides it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;session-15-competing-workers-no-claim&lt;/code&gt; seeds the Day 14 durable outbox (6 payments + 6 pending messages), then starts two workers, each on its &lt;em&gt;own&lt;/em&gt; SQLite connection (separate application instances). A &lt;code&gt;Barrier(2)&lt;/code&gt; makes the race deterministic — neither worker processes until both have loaded the full pending snapshot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Load:&lt;/strong&gt; &lt;code&gt;Worker-A&lt;/code&gt; loads all 6 pending rows. &lt;code&gt;Worker-B&lt;/code&gt; loads all 6 pending rows. Neither has marked anything processed yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process:&lt;/strong&gt; both workers call &lt;code&gt;CheckPaymentStatus&lt;/code&gt; for every message they loaded — &lt;strong&gt;12 processing attempts, 12 provider status checks&lt;/strong&gt; for 6 unique messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final DB:&lt;/strong&gt; all 6 rows end &lt;code&gt;Processed&lt;/code&gt;, 4 Paid + 2 Failed, no duplicate rows. The database looks perfect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That tidy final state is the trap. From the real run: unique messages &lt;strong&gt;6&lt;/strong&gt;, Worker-A loaded &lt;strong&gt;6&lt;/strong&gt;, Worker-B loaded &lt;strong&gt;6&lt;/strong&gt;, processing attempts &lt;strong&gt;12&lt;/strong&gt;, status checks &lt;strong&gt;12&lt;/strong&gt;, messages processed more than once &lt;strong&gt;6&lt;/strong&gt;. The doubling is only visible because the report keeps the ledgers separate.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Verdict from the real report:&lt;/strong&gt; &lt;em&gt;"THE OUTBOX WAS DURABLE — BUT TWO WORKERS PROCESSED THE SAME WORK ❌"&lt;/em&gt;. Final row state alone cannot tell you whether work happened once or twice.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  3. The lease columns and message states
&lt;/h2&gt;

&lt;p&gt;The AFTER scenario adds two columns and a third status. A message now moves through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pending  ──claim──▶  Processing  ──finalize──▶  Processed
                         │
                         └── lease expires ──▶ claimable again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;New column&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LockedBy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The worker id that currently owns the message (NULL when not leased).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LockedUntilUtc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the lease expires. After this time another worker may reclaim the message.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;Status&lt;/code&gt; is now &lt;code&gt;Pending&lt;/code&gt; | &lt;code&gt;Processing&lt;/code&gt; | &lt;code&gt;Processed&lt;/code&gt;. A message is &lt;em&gt;claimable&lt;/em&gt; when it is &lt;code&gt;Pending&lt;/code&gt;, or &lt;code&gt;Processing&lt;/code&gt; with an expired lease.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The atomic claim — and claim vs processing
&lt;/h2&gt;

&lt;p&gt;The claim must be atomic, or the race just moves from "process" to "claim". A worker claims inside one &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; transaction (SQLite takes the write lock up front, serializing claimers) with a conditional update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="k"&gt;IMMEDIATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Pending'&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Processing'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;LockedUntilUtc&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt;
     &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LockedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LockedUntilUtc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;leaseUntil&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
     &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Pending'&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Processing'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;LockedUntilUtc&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- claimed only if this UPDATE affected exactly one row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two operations are deliberately &lt;strong&gt;different&lt;/strong&gt;, and the implementation keeps two separate ledgers for them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claim&lt;/strong&gt; = acquire temporary ownership of a row. Cheap, atomic, may fail (someone else won).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing&lt;/strong&gt; = the actual work (&lt;code&gt;CheckPaymentStatus&lt;/code&gt;, apply outcome). Happens only for messages you successfully claimed.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Claim attempts ≠ work done.&lt;/strong&gt; In the real AFTER run there were 5 successful claims in the concurrent phase plus a handful of empty claims (nothing claimable at that instant) — but exactly &lt;strong&gt;6&lt;/strong&gt; unique messages were processed, once each.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  5. Why leases expire — a lease is not a lock
&lt;/h2&gt;

&lt;p&gt;A permanent lock would be a bug waiting to happen: if the owner crashes mid-work, the message is locked forever and the work is lost. A &lt;em&gt;lease&lt;/em&gt; is ownership with an expiry.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;🔒 A lock says — permanent:&lt;/strong&gt; "Nobody else may ever take this work." If the owner dies, the work is stuck forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🎟️ A lease says — temporary:&lt;/strong&gt; "I own this work until a specific time. If I disappear, another worker may recover it after expiry."&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Why the lease matters.&lt;/strong&gt; A worker may claim a message and then crash. A permanent lock would lose the work forever. An expired lease lets another worker recover it — which is exactly what the recovery sub-scenario below proves.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  6. Worker crash after claim, then reclaim by another worker
&lt;/h2&gt;

&lt;p&gt;The AFTER scenario includes a small deterministic recovery demo on a scenario-controlled clock (so lease expiry needs no real waiting). Real run, verbatim from the report:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;t = 09:00:00.000Z:&lt;/strong&gt; &lt;code&gt;Worker-A&lt;/code&gt; claims &lt;code&gt;PAY-001&lt;/code&gt; → CLAIMED (LockedBy=Worker-A, lease 500 ms).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;crash:&lt;/strong&gt; &lt;code&gt;Worker-A&lt;/code&gt; crashes before processing — the row stays &lt;code&gt;Status=Processing, LockedBy=Worker-A&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;before expiry:&lt;/strong&gt; &lt;code&gt;Worker-B&lt;/code&gt; tries to claim &lt;code&gt;PAY-001&lt;/code&gt; → &lt;strong&gt;BLOCKED&lt;/strong&gt; (owner still Worker-A, lease not expired).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;t = 09:00:00.600Z:&lt;/strong&gt; advance the clock past the lease → &lt;code&gt;Worker-B&lt;/code&gt; &lt;strong&gt;RECLAIMS&lt;/strong&gt; &lt;code&gt;PAY-001&lt;/code&gt; (LockedBy=Worker-B).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;process:&lt;/strong&gt; &lt;code&gt;Worker-B&lt;/code&gt; processes &lt;code&gt;PAY-001&lt;/code&gt; &lt;strong&gt;exactly once&lt;/strong&gt; → outbox=Processed. The work was never lost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A lock would have left &lt;code&gt;PAY-001&lt;/code&gt; stuck under a dead owner. The lease let a healthy worker take over — safely, and only after the previous owner's claim demonstrably expired.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Ownership-verified finalization
&lt;/h2&gt;

&lt;p&gt;Claiming is only half the guarantee — completing must also check ownership, so a worker never finalizes a message it no longer owns (e.g. one whose lease expired and was reclaimed). Payment finalization and message completion happen in one transaction, guarded by &lt;code&gt;LockedBy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="k"&gt;IMMEDIATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;Payments&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;PaymentStatus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TransactionId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt;
     &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Processed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProcessedAtUtc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LockedUntilUtc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Processing'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;LockedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- ownership check&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- only if that UPDATE affected exactly one row; otherwise ROLLBACK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Ownership violations = 0&lt;/strong&gt; in the real run. If the guarded update affects zero rows, the worker does not own the message any more and the transaction rolls back — it never writes a result it has no right to write.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  8. The real implementation — proven by running it
&lt;/h2&gt;

&lt;p&gt;Day 15 reuses the Day 14 SQLite infrastructure (&lt;code&gt;OutboxSqliteCore&lt;/code&gt;) unchanged and adds a small leasing core plus two scenarios registered in &lt;code&gt;Program.cs&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BEFORE:&lt;/strong&gt; &lt;code&gt;Session15CompetingWorkersNoClaimScenario&lt;/code&gt; → command &lt;code&gt;session-15-competing-workers-no-claim&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AFTER:&lt;/strong&gt; &lt;code&gt;Session15OutboxLeasingScenario&lt;/code&gt; → command &lt;code&gt;session-15-outbox-leasing&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No new packages — the same &lt;code&gt;Microsoft.Data.Sqlite&lt;/code&gt;. Standard .NET only: a &lt;code&gt;Barrier(2)&lt;/code&gt; to make the BEFORE race deterministic, two &lt;code&gt;SqliteConnection&lt;/code&gt;s (WAL + &lt;code&gt;busy_timeout&lt;/code&gt;) for two application instances, &lt;code&gt;BeginTransaction(deferred:false)&lt;/code&gt; for &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; atomic claims, and a small &lt;code&gt;ScenarioClock&lt;/code&gt; so lease expiry is deterministic without real waiting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real BEFORE vs AFTER metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (real run)&lt;/th&gt;
&lt;th&gt;BEFORE (no claim)&lt;/th&gt;
&lt;th&gt;AFTER (leasing)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Payments / pending outbox seeded&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker-A loaded / Worker-B loaded&lt;/td&gt;
&lt;td&gt;6 / 6 (both, all rows)&lt;/td&gt;
&lt;td&gt;claimed per-message, not bulk-loaded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processing attempts&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unique messages processed&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messages processed more than once&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider status-check calls&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ownership violations&lt;/td&gt;
&lt;td&gt;n/a (no ownership)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker ownership split (A / B)&lt;/td&gt;
&lt;td&gt;both did all 6&lt;/td&gt;
&lt;td&gt;3 / 3 (varies by who wins each claim)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved Paid / Failed / pending&lt;/td&gt;
&lt;td&gt;4 / 2 / 0&lt;/td&gt;
&lt;td&gt;4 / 2 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbox Processed / Pending / Processing&lt;/td&gt;
&lt;td&gt;6 / 0 / 0&lt;/td&gt;
&lt;td&gt;6 / 0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate payment / outbox rows&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment creation calls in workers&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fake successes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The headline is the halving: &lt;strong&gt;12 → 6&lt;/strong&gt; processing attempts and status checks for the same 6 messages. Same durable outcome, half the provider load, zero duplicate work. The A/B split is whatever the atomic claims happen to produce (3/3 in this run; 2/4 in another) — the invariant is 6 unique, once each.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lease-expiry recovery proof (AFTER)
&lt;/h3&gt;

&lt;p&gt;From the real report: &lt;code&gt;Worker-A claimed PAY-001 then crashed&lt;/code&gt; → &lt;code&gt;Worker-B blocked before expiry (true)&lt;/code&gt; → advance clock → &lt;code&gt;Worker-B reclaimed after expiry (true)&lt;/code&gt; → &lt;code&gt;reclaimed message processed once&lt;/code&gt;. A lock would have lost &lt;code&gt;PAY-001&lt;/code&gt;; the lease recovered it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safety checks from the real AFTER report — all passing
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;each message processed exactly once → 6 unique, 0 duplicates&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zero ownership violations → 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;no duplicate payment/outbox rows → max 1 per key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;one gateway creation attempt per key → 6 / 6&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tx ids only from provider, Paid only → 4 Paid w/ id, 0 Failed w/ id&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Verdicts from the real reports.&lt;/strong&gt; BEFORE: &lt;em&gt;"THE OUTBOX WAS DURABLE — BUT TWO WORKERS PROCESSED THE SAME WORK ❌"&lt;/em&gt;. AFTER: &lt;em&gt;"OUTBOX LEASING — ONE OWNER PER MESSAGE, AND OWNERSHIP EXPIRES ✅"&lt;/em&gt;. Both exit with code &lt;code&gt;0&lt;/code&gt; only when every check passes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Run it yourself
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-15-competing-workers-no-claim
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-15-outbox-leasing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run prints the schema, the worker/claim timeline, the lease-expiry demo, the per-message ownership table, the ledgers, the checks, and the verdict, then saves a timestamped report under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reports/Session-15/Session-15-Competing-Workers-No-Claim-RunReport-&amp;lt;timestamp&amp;gt;.txt
Reports/Session-15/Session-15-Outbox-Leasing-RunReport-&amp;lt;timestamp&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQLite file is created in a per-scenario temp folder — e.g. &lt;code&gt;%LOCALAPPDATA%\Temp\wassal-day15-leasing-&amp;lt;random&amp;gt;\wassal-outbox.db&lt;/code&gt; — and cleaned up at the end; its path is printed in the report.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Final takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A durable, atomic outbox is still a &lt;em&gt;shared&lt;/em&gt; outbox — two workers can read and process the same pending row.&lt;/li&gt;
&lt;li&gt;Final row state cannot prove single processing; you must count claim and processing attempts separately.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;atomic claim&lt;/strong&gt; (&lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; + conditional update) gives exactly one worker temporary ownership of a message.&lt;/li&gt;
&lt;li&gt;Finalization must &lt;strong&gt;verify ownership&lt;/strong&gt; (&lt;code&gt;LockedBy = me&lt;/code&gt;) so a worker never completes a message it no longer owns.&lt;/li&gt;
&lt;li&gt;Ownership is a &lt;strong&gt;lease, not a lock&lt;/strong&gt;: it expires, so a crashed owner's work can be reclaimed instead of lost forever.&lt;/li&gt;
&lt;li&gt;All Day 14 guarantees still hold: one payment per key, one outbox row per payment, no orphans, no duplicate charges, provider-only transaction ids.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three days, side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Day 13:  The work survives a restart.
Day 14:  The business state and outbox row commit together.
Day 15:  Only one worker owns a message at a time — and ownership expires if the worker dies.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Durability preserves the work. Atomicity creates it safely. Leasing decides who owns it now.&lt;/strong&gt; Retry backoff, dead-letter handling, and a hosted worker are later lessons — Day 15's invariant is exactly one temporary owner per message.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>architecture</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>Day 14 — Transactional Outbox: Saving the Payment and the Promise Atomically</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 21:08:21 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-14-transactional-outbox-saving-the-payment-and-the-promise-atomically-49gc</link>
      <guid>https://dev.to/moh_moh701/day-14-transactional-outbox-saving-the-payment-and-the-promise-atomically-49gc</guid>
      <description>&lt;p&gt;&lt;em&gt;Day 13 made the follow-up work survive a restart — but it saved the payment and the promise as one JSON file, deliberately labelled outbox-shaped. Day 14 moves to a real SQLite database and asks the sharper question: can the payment be saved while its work item goes missing? The answer is a single database transaction.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4urvxw26a333yv268ynu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4urvxw26a333yv268ynu.png" alt="Transactional outbox — one atomic commit" width="800" height="802"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🙏 &lt;strong&gt;Credit where it's due&lt;/strong&gt; — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, &lt;em&gt;Fundamentals of Distributed Systems&lt;/em&gt; on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  🧭 Where we are — after Days 11, 12 and 13
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="//Day-11-Payment-Fallback-Without-Lying.html"&gt;Day 11 — Honest Fallback&lt;/a&gt;:&lt;/strong&gt; on a payment timeout, return &lt;code&gt;PendingConfirmation&lt;/code&gt; honestly. &lt;em&gt;We admitted the result was unknown.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="//Day-12-Payment-Reconciliation-Resolving-the-Unknown-Safely.html"&gt;Day 12 — Reconciliation&lt;/a&gt;:&lt;/strong&gt; a worker calls &lt;code&gt;CheckPaymentStatus(originalKey)&lt;/code&gt; and resolves each pending payment. &lt;em&gt;We learned how to check the original attempt.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="//Day-13-Durable-Reconciliation-Surviving-Application-Restarts.html"&gt;Day 13 — Durable Reconciliation&lt;/a&gt;:&lt;/strong&gt; the pending work is persisted so it survives a restart — as one JSON snapshot, &lt;em&gt;outbox-shaped, not transactional&lt;/em&gt;. &lt;em&gt;We made the follow-up work survive a restart.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 14 (this document):&lt;/strong&gt; the payment record and its reconciliation obligation must be committed &lt;em&gt;together&lt;/em&gt;. &lt;em&gt;We commit the business state and the follow-up work in one database transaction.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away:&lt;/strong&gt; Durability prevents forgetting after restart. Atomicity prevents forgetting during the write. Save the business state &lt;strong&gt;+&lt;/strong&gt; save the follow-up work &lt;strong&gt;=&lt;/strong&gt; one database transaction. Either both commit, or neither does.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Why Day 13's JSON store was outbox-shaped, not transactional
&lt;/h2&gt;

&lt;p&gt;Day 13 solved a real problem — in-memory work vanishes on restart, so we wrote it to disk with a safe atomic file replace. That answered one question well:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;✅ Day 13 solved — durability:&lt;/strong&gt; &lt;em&gt;Will the saved work survive a restart?&lt;/em&gt; Yes — a fresh instance reloads the pending work from disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;❌ Day 14 solves — atomicity:&lt;/strong&gt; &lt;em&gt;Can the payment be saved while the work item is missing?&lt;/em&gt; In a database with two separate writes — yes, and that is the bug.&lt;/p&gt;

&lt;p&gt;Day 13's snapshot always wrote the payment and the work item &lt;em&gt;in the same file&lt;/em&gt;, so they never diverged — but that convenience hid the real production hazard. In a database-backed system the natural implementation is &lt;strong&gt;two writes&lt;/strong&gt;: save the payment, then save the outbox row. That is the &lt;em&gt;dual write&lt;/em&gt;, and it has a crash window.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Outbox-shaped ≠ transactional.&lt;/strong&gt; A single JSON document with an atomic replace is not a database transaction spanning a business row and an outbox row. Day 14 builds the real thing on SQLite so the transaction boundary is visible.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. The dual-write problem — a crash window between two durable writes
&lt;/h2&gt;

&lt;p&gt;The dangerous pattern is two independent, separately-committed writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INSERT Payment   → COMMIT      // durable
   ... crash ...               // the window
INSERT Outbox    → COMMIT      // never happens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the process dies in the window, the database is left &lt;strong&gt;durably inconsistent&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Payment exists:   PAY-002 = PendingConfirmation
Outbox row:       missing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payment is perfectly durable — and completely forgotten. No worker has a row telling it this payment needs reconciling, so it stays pending forever even after the provider recovers holding the real answer. The mirror-image inconsistency (outbox row written first, payment missing) is equally possible if you flip the order.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Durability did not save us.&lt;/strong&gt; Both rows were individually durable. The missing guarantee is that they become durable &lt;em&gt;together&lt;/em&gt; — all or nothing.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  3. The SQLite schema
&lt;/h2&gt;

&lt;p&gt;Both scenarios use the same small, explicit schema in a real local SQLite file (&lt;code&gt;Microsoft.Data.Sqlite&lt;/code&gt;, direct SQL — no EF Core, no server):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;Payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;-- the business identity&lt;/span&gt;
    &lt;span class="n"&gt;PaymentStatus&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;PaymentConfirmed&lt;/span&gt;     &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RequiresStatusCheck&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IsFallback&lt;/span&gt;           &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;TransactionId&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;UpdatedAtUtc&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Id&lt;/span&gt;                   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- deterministic per payment&lt;/span&gt;
    &lt;span class="n"&gt;MessageType&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AggregateId&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- FK → Payments(IdempotencyKey)&lt;/span&gt;
    &lt;span class="n"&gt;Payload&lt;/span&gt;              &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;               &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- Pending | Processed&lt;/span&gt;
    &lt;span class="n"&gt;AttemptCount&lt;/span&gt;         &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ProcessedAtUtc&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AggregateId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;Payments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_Outbox_Pending&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Payments.IdempotencyKey&lt;/code&gt; primary key is the DB-level guard against a duplicate payment; each outbox row uses a deterministic id such as &lt;code&gt;OUTBOX-PAYMENT-RECONCILIATION-PAY-001&lt;/code&gt;, so a retried write can never create a duplicate work item either.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The BEFORE scenario — the dual-write gap, proven
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;session-14-dual-write-gap&lt;/code&gt; replays Days 11–13 (6 payments, hung gateway, 300 ms timeout, &lt;code&gt;SemaphoreSlim(2)&lt;/code&gt; → all &lt;code&gt;PendingConfirmation&lt;/code&gt;), then persists each with the dual write. A controlled failure fires &lt;em&gt;between&lt;/em&gt; the two writes for &lt;code&gt;PAY-002&lt;/code&gt;, &lt;code&gt;PAY-004&lt;/code&gt;, &lt;code&gt;PAY-006&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PAY-001/003/005:&lt;/strong&gt; &lt;code&gt;payment=COMMIT&lt;/code&gt; then &lt;code&gt;outbox=COMMIT&lt;/code&gt; — both rows land.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PAY-002/004/006:&lt;/strong&gt; &lt;code&gt;payment=COMMIT&lt;/code&gt; → &lt;strong&gt;injected crash&lt;/strong&gt; → &lt;code&gt;outbox=MISSING&lt;/code&gt;. The payment is durable; its obligation never gets written.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart:&lt;/strong&gt; a fresh Instance 2 opens the same DB and reads only the &lt;strong&gt;3&lt;/strong&gt; pending outbox rows it can find. It reconciles those 3; the other 3 stay pending forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Real run: payments persisted &lt;strong&gt;6&lt;/strong&gt;, outbox persisted &lt;strong&gt;3&lt;/strong&gt;, payments missing outbox &lt;strong&gt;3&lt;/strong&gt;, orphan outbox &lt;strong&gt;0&lt;/strong&gt;. The 3 reconciled split into &lt;strong&gt;1 Paid&lt;/strong&gt; (PAY-001) + &lt;strong&gt;2 Failed&lt;/strong&gt; (PAY-003, PAY-005), leaving &lt;strong&gt;3 still pending&lt;/strong&gt; (PAY-002/004/006 — which the provider actually charged).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Verdict from the real report:&lt;/strong&gt; &lt;em&gt;"THE DATA WAS DURABLE — BUT THE FOLLOW-UP WAS NOT ATOMIC ❌"&lt;/em&gt;. Three customers were charged, and nothing in our system will ever ask about them.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  5. The AFTER fix — one transaction, commit or rollback
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;session-14-transactional-outbox&lt;/code&gt; writes both rows on &lt;strong&gt;one connection&lt;/strong&gt; inside &lt;strong&gt;one &lt;code&gt;SqliteTransaction&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="n"&gt;Payment&lt;/span&gt;       &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="n"&gt;OutboxMessage&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;           &lt;span class="c1"&gt;-- both rows land together&lt;/span&gt;
      &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;failure&lt;/span&gt; &lt;span class="k"&gt;before&lt;/span&gt; &lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;ROLLBACK&lt;/span&gt;         &lt;span class="c1"&gt;-- neither row remains&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same failure is injected &lt;em&gt;inside&lt;/em&gt; the transaction, before &lt;code&gt;COMMIT&lt;/code&gt;. From the real write timeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PAY-001 | BEGIN → payment+outbox → COMMIT
PAY-002 | BEGIN → payment → ROLLBACK   ← injected failure
PAY-002 | BEGIN → payment+outbox → COMMIT (retry #2)
PAY-003 | BEGIN → payment+outbox → COMMIT
PAY-004 | BEGIN → payment → ROLLBACK   ← injected failure
PAY-004 | BEGIN → payment+outbox → COMMIT (retry #2)
PAY-005 | BEGIN → payment+outbox → COMMIT
PAY-006 | BEGIN → payment → ROLLBACK   ← injected failure
PAY-006 | BEGIN → payment+outbox → COMMIT (retry #2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3 rollbacks, 6 commits.&lt;/strong&gt; After every rollback the operation is retried with the &lt;em&gt;same idempotency key&lt;/em&gt; and commits both rows cleanly. The rollback is not hidden — it is in the report, on purpose.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Commit-or-rollback is the whole point.&lt;/strong&gt; A rolled-back transaction leaves &lt;em&gt;nothing&lt;/em&gt; — no half-written payment, no orphan outbox row. There is never a moment where one row exists without the other.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  6. Consistency rules and the role of the idempotency key
&lt;/h2&gt;

&lt;p&gt;Retrying after a rollback must not create duplicates. The safety comes from the &lt;em&gt;database&lt;/em&gt;, not an in-memory check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same &lt;code&gt;IdempotencyKey&lt;/code&gt; = same payment operation.&lt;/strong&gt; It is the primary key, so a second committed payment for the same key is impossible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic outbox id&lt;/strong&gt; (&lt;code&gt;OUTBOX-PAYMENT-RECONCILIATION-&amp;lt;key&amp;gt;&lt;/code&gt;) is the outbox primary key, so a duplicate work row is impossible.&lt;/li&gt;
&lt;li&gt;Both inserts use &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;, so a retry is a safe no-op if a row already exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two durable invariants the AFTER report verifies with SQL, not assertions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Payments&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AggregateId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- = 0&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Payments&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IdempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AggregateId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;-- = 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Never a payment without outbox; never an outbox without payment.&lt;/strong&gt; Real run: payments missing outbox = &lt;strong&gt;0&lt;/strong&gt;, outbox missing payments = &lt;strong&gt;0&lt;/strong&gt;, duplicate payment rows = &lt;strong&gt;0&lt;/strong&gt;, duplicate outbox rows = &lt;strong&gt;0&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  7. Restart and outbox processing — check, never charge
&lt;/h2&gt;

&lt;p&gt;After the writes, a simulated crash discards Instance 1; a fresh Instance 2 opens the same SQLite file and processes the pending outbox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AggregateId&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;OutboxMessages&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Pending'&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each pending message, inside &lt;em&gt;its own&lt;/em&gt; transaction, the worker calls &lt;code&gt;CheckPaymentStatus(originalKey)&lt;/code&gt; — a status &lt;strong&gt;read&lt;/strong&gt;, never &lt;code&gt;CreatePayment&lt;/code&gt; — applies the provider's real result, and marks the message &lt;code&gt;Processed&lt;/code&gt;. Finalizing the payment and completing the message commit together, so a payment can never be finalized while its message stays pending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PAY-001 | PendingConfirmation → Paid   | outbox Pending → Processed | TX-PAY-001
PAY-002 | PendingConfirmation → Paid   | outbox Pending → Processed | TX-PAY-002
PAY-003 | PendingConfirmation → Failed | outbox Pending → Processed | TransactionId=null
PAY-004 | PendingConfirmation → Paid   | outbox Pending → Processed | TX-PAY-004
PAY-005 | PendingConfirmation → Failed | outbox Pending → Processed | TransactionId=null
PAY-006 | PendingConfirmation → Paid   | outbox Pending → Processed | TX-PAY-006
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Our application restarted. The SQLite database survived. The external payment provider also survived.&lt;/strong&gt; The provider is a separate simulated system that keeps its original outcomes across our restart; the SQLite file is our durable state; no static field carries business data between the two instances.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Creation vs check — still separate ledgers.&lt;/strong&gt; 6 gateway creation attempts (Phase 1, one per key), 0 creation calls during the worker, 6 status checks after restart. Reconciliation reads the original attempt; it never starts a new one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  8. The real implementation — proven by running it
&lt;/h2&gt;

&lt;p&gt;Day 14 is three files under the session-test project: a shared &lt;code&gt;OutboxSqliteCore&lt;/code&gt; (schema, provider, consistency queries, the shared Phase 1) and two &lt;code&gt;ISessionScenario&lt;/code&gt; classes registered in &lt;code&gt;Program.cs&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BEFORE:&lt;/strong&gt; &lt;code&gt;Session14DualWriteGapScenario&lt;/code&gt; → command &lt;code&gt;session-14-dual-write-gap&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AFTER:&lt;/strong&gt; &lt;code&gt;Session14TransactionalOutboxScenario&lt;/code&gt; → command &lt;code&gt;session-14-transactional-outbox&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only new dependency is &lt;code&gt;Microsoft.Data.Sqlite&lt;/code&gt;. Everything else is standard .NET — &lt;code&gt;Task.Delay&lt;/code&gt; for the hung gateway and the status read, &lt;code&gt;SemaphoreSlim(2)&lt;/code&gt; for the bulkhead, a linked &lt;code&gt;CancellationTokenSource&lt;/code&gt; for the 300 ms timeout, and direct SQL over a real SQLite file created in a per-scenario temp folder and deleted afterwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real BEFORE vs AFTER metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (real run)&lt;/th&gt;
&lt;th&gt;BEFORE (dual write)&lt;/th&gt;
&lt;th&gt;AFTER (one transaction)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Write-transaction commits&lt;/td&gt;
&lt;td&gt;9 (6 payment + 3 outbox, separate)&lt;/td&gt;
&lt;td&gt;6 (payment + outbox together)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write-transaction rollbacks&lt;/td&gt;
&lt;td&gt;0 (nothing to roll back)&lt;/td&gt;
&lt;td&gt;3 (then retried)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Committed payment rows&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Committed outbox rows&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments missing outbox rows&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbox rows missing payments&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pending outbox loaded on restart&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status checks after restart&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;6 (one per key)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New payment attempts during worker&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved as Paid&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved as Failed&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still pending&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processed outbox rows&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate payment / outbox rows&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fake successes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Transaction commit &amp;amp; rollback evidence (AFTER)
&lt;/h3&gt;

&lt;p&gt;The real write timeline shows all three rollbacks and their retries: &lt;code&gt;PAY-002&lt;/code&gt;, &lt;code&gt;PAY-004&lt;/code&gt; and &lt;code&gt;PAY-006&lt;/code&gt; each go &lt;code&gt;BEGIN → payment → ROLLBACK&lt;/code&gt;, then &lt;code&gt;BEGIN → payment+outbox → COMMIT (retry #2)&lt;/code&gt;. Final: 6 write commits, 3 rollbacks, 6 processing commits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safety checks from the real AFTER report — all passing
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;no duplicate payment rows → max 1 row per key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;no duplicate outbox rows → max 1 row per key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;one gateway creation attempt per key → 6 / 6&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zero fake successes → 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tx ids only from provider, Paid only → 4 Paid w/ id, 0 Failed w/ id&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Verdicts from the real reports.&lt;/strong&gt; BEFORE: &lt;em&gt;"THE DATA WAS DURABLE — BUT THE FOLLOW-UP WAS NOT ATOMIC ❌"&lt;/em&gt;. AFTER: &lt;em&gt;"TRANSACTIONAL OUTBOX — THE PAYMENT AND THE PROMISE COMMITTED TOGETHER ✅"&lt;/em&gt;. Both exit with code &lt;code&gt;0&lt;/code&gt; only when every check passes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Run it yourself
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-14-dual-write-gap
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-14-transactional-outbox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run prints the schema, the write timeline, the consistency query results, the per-payment table, the summary, the checks, and the verdict, then saves a timestamped report under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reports/Session-14/Session-14-Dual-Write-Gap-RunReport-&amp;lt;timestamp&amp;gt;.txt
Reports/Session-14/Session-14-Transactional-Outbox-RunReport-&amp;lt;timestamp&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQLite file is created in a per-scenario temp folder — e.g. &lt;code&gt;%LOCALAPPDATA%\Temp\wassal-day14-outbox-&amp;lt;random&amp;gt;\wassal-outbox.db&lt;/code&gt; — and cleaned up at the end; its path is printed in the report.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Final takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two separate durable writes have a crash window that can leave a payment without its follow-up obligation — durability alone does not close it.&lt;/li&gt;
&lt;li&gt;Writing the payment row and the outbox row in &lt;strong&gt;one database transaction&lt;/strong&gt; makes them commit or roll back &lt;strong&gt;together&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A rollback leaves nothing; a retry with the same idempotency key is safe because DB primary keys enforce one payment and one outbox row per key.&lt;/li&gt;
&lt;li&gt;On restart, the worker reads pending outbox rows and &lt;strong&gt;checks&lt;/strong&gt; the original attempt — it never re-charges — finalizing payment and message in one transaction.&lt;/li&gt;
&lt;li&gt;This is now a &lt;strong&gt;real local transactional outbox demonstration using SQLite&lt;/strong&gt; — not outbox-shaped. Retries with backoff, leasing, dead-letter handling, and a hosted worker are later lessons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The four days, side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Day 11:  We admitted that the result was unknown.
Day 12:  We learned how to check the original attempt.
Day 13:  We made the follow-up work survive a restart.
Day 14:  We commit the payment state and the follow-up obligation together.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Day 13 solved: will the saved work survive restart? Day 14 solves: can the payment be saved while the work item is missing?&lt;/strong&gt; Durability prevents forgetting after restart; atomicity prevents forgetting during the write.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Six payments, three injected failures, three rollbacks, six clean commits — then a restart that reloaded all six outbox rows and resolved them: 4 Paid, 2 Failed, 0 pending, 0 duplicates, 0 fake successes. Save the business state and the follow-up work in one database transaction: both, or neither.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>sqlite</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Day 13 — Durable Reconciliation: Surviving Application Restarts</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 21:07:41 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-13-durable-reconciliation-surviving-application-restarts-2o5l</link>
      <guid>https://dev.to/moh_moh701/day-13-durable-reconciliation-surviving-application-restarts-2o5l</guid>
      <description>&lt;p&gt;&lt;em&gt;Day 12 built a worker that resolves pending payments by asking the provider — but the pending work lived only in memory. Day 13 asks the harder question: what happens when the process crashes before that work runs? In-memory work disappears. Durable work survives.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs926wyhvgocp7bx2yuaa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs926wyhvgocp7bx2yuaa.png" alt="Durable reconciliation — surviving restarts" width="800" height="785"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🙏 &lt;strong&gt;Credit where it's due&lt;/strong&gt; — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, &lt;em&gt;Fundamentals of Distributed Systems&lt;/em&gt; on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  🧭 Where we are — after Days 11 and 12
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="//Day-11-Payment-Fallback-Without-Lying.html"&gt;Day 11 — Payment Fallback Without Lying&lt;/a&gt;:&lt;/strong&gt; when the provider times out, we return an honest degraded response — &lt;code&gt;PendingConfirmation&lt;/code&gt;, &lt;code&gt;PaymentConfirmed = false&lt;/code&gt;, &lt;code&gt;RequiresStatusCheck = true&lt;/code&gt;, idempotency key preserved, no fabricated transaction id. &lt;em&gt;"We do not know yet."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="//Day-12-Payment-Reconciliation-Resolving-the-Unknown-Safely.html"&gt;Day 12 — Payment Reconciliation&lt;/a&gt;:&lt;/strong&gt; a worker scans the pending store and calls &lt;code&gt;CheckPaymentStatus(originalKey)&lt;/code&gt; — a status &lt;em&gt;read&lt;/em&gt;, never a re-charge — and moves each payment to the provider's real final state. &lt;em&gt;"We checked the original attempt and learned the result."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 13 (this document):&lt;/strong&gt; in Day 12 the pending store &lt;em&gt;and&lt;/em&gt; the reconciliation queue lived in memory, inside one process. If that process crashes before the worker runs, the work is gone. &lt;em&gt;Even if our application crashes, it must remember that the check is still required.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away:&lt;/strong&gt; Pending is a promise. Durability makes sure the system remembers the promise. In-memory work disappears when the process dies. Durable work survives restarts. Knowing &lt;em&gt;what&lt;/em&gt; to do is not enough — the work must outlive the process that created it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Why in-memory background work is unsafe
&lt;/h2&gt;

&lt;p&gt;Day 12's worker was correct — but it was only as durable as the process it ran in. A reconciliation queue held in a &lt;code&gt;List&amp;lt;WorkItem&amp;gt;&lt;/code&gt; and a pending store held in a &lt;code&gt;Dictionary&lt;/code&gt; share one fate: the moment the process exits, they are gone. Real processes exit all the time — a deploy, an OOM kill, a crash, a pod eviction, a machine reboot.&lt;/p&gt;

&lt;p&gt;The dangerous window is small but real:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A payment times out and becomes &lt;code&gt;PendingConfirmation&lt;/code&gt;, &lt;code&gt;RequiresStatusCheck = true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A reconciliation work item is enqueued &lt;strong&gt;in memory&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The application crashes &lt;strong&gt;before&lt;/strong&gt; the worker runs.&lt;/li&gt;
&lt;li&gt;The in-memory pending work disappears.&lt;/li&gt;
&lt;li&gt;After restart, the application does not remember what it must check.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;✅ The worker (Day 12) — solved:&lt;/strong&gt; knows exactly how to resolve a pending payment: &lt;code&gt;CheckPaymentStatus(key)&lt;/code&gt;, never re-charge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;❌ Remembering the work (Day 13 · still missing):&lt;/strong&gt; the queue lives in memory. A restart erases it, and the recovered provider's answers are never collected.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The failure mode here is not a double charge — it is &lt;em&gt;forgetting&lt;/em&gt;.&lt;/strong&gt; The provider may recover holding the real outcome, but our side no longer has a queued item asking about it. The follow-up obligation vanished with the process that created it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. Our application state vs the external provider's state
&lt;/h2&gt;

&lt;p&gt;This distinction is the heart of the lesson. Two systems, two separate fates when &lt;em&gt;our&lt;/em&gt; process crashes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🧠 Our application state — volatile:&lt;/strong&gt; pending store + reconciliation queue. Held in memory → &lt;strong&gt;lost&lt;/strong&gt; on crash unless it is made durable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🏦 The external provider — survives:&lt;/strong&gt; a separate system. It never lost its records — it can be hung, then recover, still holding every original outcome.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Our application restarted.
The external payment provider did NOT lose its records.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In both scenarios the provider is modelled as its own object (&lt;code&gt;ExternalPaymentProvider&lt;/code&gt;) created once and handed to both application instances — because a real provider does not restart when our process does. The gap Day 13 closes is entirely on &lt;em&gt;our&lt;/em&gt; side: remembering that a check is still owed.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The BEFORE problem — the crash erases the work
&lt;/h2&gt;

&lt;p&gt;The BEFORE scenario (&lt;code&gt;session-13-in-memory-work-lost&lt;/code&gt;) replays Day 11/12, then models a real process boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1 — Application Instance 1.&lt;/strong&gt; 6 payments (&lt;code&gt;PAY-001&lt;/code&gt;…&lt;code&gt;PAY-006&lt;/code&gt;) hit a hung gateway (2000 ms) behind the &lt;code&gt;SemaphoreSlim(2)&lt;/code&gt; bulkhead with the 300 ms timeout. All 6 time out → 6 &lt;code&gt;PendingConfirmation&lt;/code&gt; records and 6 reconciliation work items, all &lt;strong&gt;in memory only&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulated crash — Instance 1 is discarded.&lt;/strong&gt; Every reference to its pending dictionary and its reconciliation queue is dropped and a brand-new &lt;strong&gt;Instance 2&lt;/strong&gt; is constructed — no shared references, no static state. This is what a crash does to memory, not a &lt;code&gt;list.Clear()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 3 — the provider recovers&lt;/strong&gt;, still holding every answer (4 charged, 2 failed). But Instance 2's queue is empty. &lt;strong&gt;Work items after restart: 0.&lt;/strong&gt; Status checks: 0. Resolved: 0. The obligation is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the real run: pending before crash &lt;strong&gt;6&lt;/strong&gt;, in-memory work items before crash &lt;strong&gt;6&lt;/strong&gt;, work items after restart &lt;strong&gt;0&lt;/strong&gt;, status checks after restart &lt;strong&gt;0&lt;/strong&gt;, resolved payments &lt;strong&gt;0&lt;/strong&gt;, still unresolved externally &lt;strong&gt;6&lt;/strong&gt;, duplicate charge attempts &lt;strong&gt;0&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Verdict from the real report:&lt;/strong&gt; &lt;em&gt;"THE WORKER KNEW WHAT TO DO — BUT THE CRASH ERASED THE WORK ❌"&lt;/em&gt;. Every check in the BEFORE report passes — because the problem it proves is real: a correct worker with no durable memory forgets its obligations the instant the process dies.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  4. The AFTER fix — a durable reconciliation store
&lt;/h2&gt;

&lt;p&gt;The AFTER scenario (&lt;code&gt;session-13-durable-reconciliation&lt;/code&gt;) changes one thing: &lt;em&gt;where the pending work lives&lt;/em&gt;. When a payment becomes &lt;code&gt;PendingConfirmation&lt;/code&gt;, its payment record &lt;strong&gt;and&lt;/strong&gt; its reconciliation work item are persisted together as one JSON snapshot, written with a safe pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;write temporary file
flush / close   (fsync — force the bytes to disk)
atomically move the temp file over the final file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instance 1&lt;/strong&gt; creates the payment attempts and persists each pending payment + work item durably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulated crash:&lt;/strong&gt; all of Instance 1's in-memory state is discarded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instance 2&lt;/strong&gt; starts with empty memory, reloads the six pending work items &lt;em&gt;only&lt;/em&gt; from durable storage, and reconciles using status checks only.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From the real run, every payment reloaded and resolved to the provider's truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PAY-001 | PendingConfirmation → Paid    | TX-PAY-001
PAY-002 | PendingConfirmation → Paid    | TX-PAY-002
PAY-003 | PendingConfirmation → Failed  | TransactionId=null
PAY-004 | PendingConfirmation → Paid    | TX-PAY-004
PAY-005 | PendingConfirmation → Failed  | TransactionId=null
PAY-006 | PendingConfirmation → Paid    | TX-PAY-006
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;This is outbox-&lt;em&gt;shaped&lt;/em&gt;, not a production Transactional Outbox.&lt;/strong&gt; A single JSON file plus an atomic replace buys us restart-survival — but it is &lt;em&gt;not&lt;/em&gt; a database transaction that commits the business write and the outbox row together. A real transactional outbox, a hosted background worker, schedules, retries, and dead-letter handling are later lessons. Day 13 introduces the &lt;em&gt;direction&lt;/em&gt;, honestly labelled.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  5. The durable work-item model
&lt;/h2&gt;

&lt;p&gt;One durable snapshot holds two lists — payments and their work items — persisted together so a restart never sees a payment without its follow-up obligation (or vice-versa):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Durable payment record&lt;/th&gt;
&lt;th&gt;Durable reconciliation work item&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IdempotencyKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WorkItemId&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PaymentStatus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PaymentIdempotencyKey&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PaymentConfirmed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Status&lt;/code&gt; — Pending | Completed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RequiresStatusCheck&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AttemptCount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TransactionId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CreatedAt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IsFallback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CompletedAt&lt;/code&gt; (when completed)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Persisted together, as one logical snapshot.&lt;/strong&gt; The payment state and its work item are written in a single atomic replace — so the durable store never records a pending payment whose reconciliation obligation was lost, which is exactly the failure the BEFORE scenario proved.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  6. State transitions — before and after the restart
&lt;/h2&gt;

&lt;p&gt;Every transition is driven by the &lt;em&gt;provider's&lt;/em&gt; reported truth — reloaded from disk, never guessed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before crash:  PendingConfirmation | WorkItem = Pending   (durably saved)
After restart: Loaded from durable store                  (in-memory memory was empty)
After check:   Paid or Failed        | WorkItem = Completed (durably updated)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Final state&lt;/th&gt;
&lt;th&gt;PaymentConfirmed&lt;/th&gt;
&lt;th&gt;RequiresStatusCheck&lt;/th&gt;
&lt;th&gt;IsFallback&lt;/th&gt;
&lt;th&gt;TransactionId&lt;/th&gt;
&lt;th&gt;WorkItem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Paid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;real provider id (e.g. TX-PAY-001)&lt;/td&gt;
&lt;td&gt;Completed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;Completed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;A transaction id is earned, never invented.&lt;/strong&gt; It appears only after the provider reports a final successful status — the Day 11 rule, carried through Day 12's reconciliation and now across a restart. Failed payments carry no id; nothing is fabricated locally.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  7. Creation attempts vs status checks — the ledgers still hold
&lt;/h2&gt;

&lt;p&gt;Durability must not weaken any Day 11/12 invariant. Two separate ledgers keep &lt;em&gt;creation attempts&lt;/em&gt; and &lt;em&gt;status checks&lt;/em&gt; apart, and the restart must add nothing to the first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Correct — a status READ of the original attempt, after restart:&lt;/span&gt;
&lt;span class="nf"&gt;CheckPaymentStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PAY-001"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong — a brand-new charge wearing the old key's name:&lt;/span&gt;
&lt;span class="nf"&gt;CreatePayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PAY-001"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exactly one creation attempt per key, ever&lt;/strong&gt; — 6 for 6 keys, all in Instance 1. Zero new creation attempts after restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Six status checks after restart&lt;/strong&gt; — one per reloaded work item, all in Instance 2.&lt;/li&gt;
&lt;li&gt;The idempotency key is the payment's identity; it is preserved &lt;em&gt;through the durable store&lt;/em&gt;, so the reloaded worker asks about the &lt;em&gt;original&lt;/em&gt; attempt, never starts a new one.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Why the key still matters (Days 3–5, again).&lt;/strong&gt; Without the preserved &lt;a href="//Day-05-Concurrent-Idempotency.html"&gt;idempotency key&lt;/a&gt; in the durable record, a restarted worker would have nothing to ask about — and any "retry" would become a new charge. Durability preserves the key so follow-up stays a read.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  8. The real implementation — proven by running it
&lt;/h2&gt;

&lt;p&gt;Day 13 is implemented as two &lt;code&gt;ISessionScenario&lt;/code&gt; classes registered in &lt;code&gt;Program.cs&lt;/code&gt;, same as every previous day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BEFORE:&lt;/strong&gt; &lt;code&gt;Session13InMemoryWorkLostScenario&lt;/code&gt; → command &lt;code&gt;session-13-in-memory-work-lost&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AFTER:&lt;/strong&gt; &lt;code&gt;Session13DurableReconciliationScenario&lt;/code&gt; → command &lt;code&gt;session-13-durable-reconciliation&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything is simulated in-process with standard .NET only — &lt;code&gt;Task.Delay&lt;/code&gt; for the hung gateway and the status-read latency, &lt;code&gt;SemaphoreSlim(2)&lt;/code&gt; for the bulkhead, a linked &lt;code&gt;CancellationTokenSource&lt;/code&gt; for the 300 ms timeout, and &lt;code&gt;System.Text.Json&lt;/code&gt; + &lt;code&gt;FileStream&lt;/code&gt; for the durable store (write temp → &lt;code&gt;Flush(flushToDisk: true)&lt;/code&gt; → &lt;code&gt;File.Move(overwrite: true)&lt;/code&gt;). Two separate &lt;code&gt;ApplicationInstance&lt;/code&gt; objects model the crash boundary; a single &lt;code&gt;ExternalPaymentProvider&lt;/code&gt; survives it. No SQL, no EF, no broker, no scheduler, no packages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real BEFORE vs AFTER metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (real run)&lt;/th&gt;
&lt;th&gt;BEFORE&lt;/th&gt;
&lt;th&gt;AFTER&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pending before crash&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-memory work items before crash&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durably saved (payments / work items)&lt;/td&gt;
&lt;td&gt;— / —&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-memory items inherited on restart&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Work items available after restart&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6 (reloaded from disk)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status checks after restart&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6 (one per key)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved as Paid&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved as Failed&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still pending&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completed work items&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New payment attempts after restart&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate charge attempts&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fake successes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The AFTER reconciliation pass took ~360 ms — six status checks × ~50 ms plus the durable re-writes. Remembering the promise across a restart costs six small reads and a few file writes, not a single re-charge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safety checks from the real AFTER report — all passing
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;one creation attempt per key, ever → 6 attempts / 6 keys&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zero new creations after restart → 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;duplicate charge attempts == 0 → 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;confirmed ⇔ provider reported Paid → fake successes 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tx ids only from provider, Paid only → all match&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Verdicts from the real reports.&lt;/strong&gt; BEFORE: &lt;em&gt;"THE WORKER KNEW WHAT TO DO — BUT THE CRASH ERASED THE WORK ❌"&lt;/em&gt;. AFTER: &lt;em&gt;"DURABLE RECONCILIATION — THE PROMISE SURVIVED THE CRASH ✅"&lt;/em&gt;. Both scenarios exit with code &lt;code&gt;0&lt;/code&gt; only when every check passes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Run it yourself
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-13-in-memory-work-lost
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-13-durable-reconciliation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run prints the per-payment transition table, the summary, the checks, and the verdict, then saves a timestamped report under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reports/Session-13/Session-13-In-Memory-Work-Lost-RunReport-&amp;lt;timestamp&amp;gt;.txt
Reports/Session-13/Session-13-Durable-Reconciliation-RunReport-&amp;lt;timestamp&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AFTER durable file is created in a scenario-specific temp folder and cleaned up at the end of the run — e.g. &lt;code&gt;%LOCALAPPDATA%\Temp\wassal-day13-durable-&amp;lt;random&amp;gt;\reconciliation-store.json&lt;/code&gt;. No durable test state is written into the application's own folders.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Final takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A correct worker is not enough if its work lives only in memory — a crash erases the obligation.&lt;/li&gt;
&lt;li&gt;When a payment becomes &lt;code&gt;PendingConfirmation&lt;/code&gt;, persist the payment record and its reconciliation work item &lt;strong&gt;together&lt;/strong&gt;, durably, with a safe atomic write.&lt;/li&gt;
&lt;li&gt;A restarted instance must reload its work &lt;strong&gt;only&lt;/strong&gt; from the durable store — no static fields carry state across the boundary.&lt;/li&gt;
&lt;li&gt;Our application restarts; the external provider does not. Durability is how our side remembers what the provider still knows.&lt;/li&gt;
&lt;li&gt;All Day 11/12 invariants still hold: one creation per key ever, status checks are reads, no duplicate charges, transaction ids only from the provider.&lt;/li&gt;
&lt;li&gt;This is &lt;strong&gt;outbox-shaped&lt;/strong&gt; — the direction toward a Transactional Outbox — not a production database-backed outbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three days, side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Day 11:  We do not know yet.
Day 12:  We checked the original attempt and learned the result.
Day 13:  Even if our application crashes, it must remember that the check is still required.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;In-memory work disappears. Durable work survives restarts.&lt;/strong&gt; Pending is a promise — durability makes sure the system remembers the promise.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Six payments went pending and were persisted durably; the process crashed; a fresh instance reloaded six work items from disk and resolved them all — 6 status checks, 4 Paid, 2 Failed, 0 new charges. A later lesson can make this a real transactional outbox with a hosted worker, schedules, and retries; the invariant to carry forward is simpler: &lt;strong&gt;the work must survive the process that created it&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>reliability</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Day 12 — Payment Reconciliation: Resolving the Unknown Safely</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 21:07:35 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-12-payment-reconciliation-resolving-the-unknown-safely-4134</link>
      <guid>https://dev.to/moh_moh701/day-12-payment-reconciliation-resolving-the-unknown-safely-4134</guid>
      <description>&lt;p&gt;Day 11 taught the honest fallback: when the provider times out, say &lt;em&gt;"we do not know yet"&lt;/em&gt;. But pending-confirmation is a promise that someone will check again. Day 12 keeps that promise — with a reconciliation worker that asks about the &lt;em&gt;original&lt;/em&gt; attempt, and never, ever charges again.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7gk6jd4xqseoypoe2akz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7gk6jd4xqseoypoe2akz.png" alt="Payment reconciliation — resolving the unknown" width="800" height="1287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🙏 &lt;strong&gt;Credit where it's due&lt;/strong&gt; — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, &lt;em&gt;Fundamentals of Distributed Systems&lt;/em&gt; on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.&lt;/p&gt;

&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away.&lt;/strong&gt; Fallback admits uncertainty. Reconciliation resolves uncertainty. Day 11: &lt;em&gt;"We do not know yet."&lt;/em&gt; — Day 12: &lt;em&gt;"We checked the original attempt and now know the final result."&lt;/em&gt; Resolving means &lt;strong&gt;asking&lt;/strong&gt; about the original attempt — never charging again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where we are — after Day 11
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Day 11 — Payment Fallback Without Lying:&lt;/strong&gt; when the payment provider times out, we return an honest degraded response — &lt;code&gt;PendingConfirmation&lt;/code&gt;, &lt;code&gt;PaymentConfirmed = false&lt;/code&gt;, &lt;code&gt;IsFallback = true&lt;/code&gt;, &lt;code&gt;RequiresStatusCheck = true&lt;/code&gt;, idempotency key preserved, no fabricated transaction id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 12 (this document):&lt;/strong&gt; that response contains a to-do item — &lt;code&gt;RequiresStatusCheck = true&lt;/code&gt;. &lt;em&gt;Who&lt;/em&gt; performs the check? &lt;em&gt;How&lt;/em&gt; does a payment move from pending to a real final state? And how do we prove the check never becomes a second charge?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Why PendingConfirmation cannot remain forever
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PendingConfirmation&lt;/code&gt; is honest — and it is also &lt;strong&gt;not a final business outcome&lt;/strong&gt;. Nothing downstream can act on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The customer who &lt;em&gt;was&lt;/em&gt; charged never gets an order confirmation — money taken, nothing delivered.&lt;/li&gt;
&lt;li&gt;The customer who was &lt;em&gt;not&lt;/em&gt; charged is never told to retry — the sale is silently lost.&lt;/li&gt;
&lt;li&gt;Finance cannot close the books on a payment that is neither paid nor failed.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Support sees "pending" forever and has no answer to give.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;✅ Honest fallback&lt;/strong&gt; &lt;em&gt;(Day 11 · solved)&lt;/em&gt; — On timeout, say the truth: pending, not confirmed, check later. Nobody is lied to.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;❌ The promise-keeper&lt;/strong&gt; &lt;em&gt;(Day 12 · still missing)&lt;/em&gt; — &lt;code&gt;RequiresStatusCheck = true&lt;/code&gt; is a to-do with no owner. Nothing checks, so nothing ever resolves.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Honest but incomplete.&lt;/strong&gt; An unresolved pending payment is a debt of truth. The fallback bought time honestly — reconciliation is how the system pays the debt back.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. The BEFORE problem — nobody ever checks
&lt;/h2&gt;

&lt;p&gt;The BEFORE scenario (&lt;code&gt;session-12-no-reconciliation&lt;/code&gt;) replays Day 11 exactly, then lets time pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1:&lt;/strong&gt; 6 payments (&lt;code&gt;PAY-001&lt;/code&gt;…&lt;code&gt;PAY-006&lt;/code&gt;) hit a hung gateway (2000 ms) behind the Day 9 bulkhead with the Day 10 timeout (300 ms). All 6 time out → all 6 become &lt;code&gt;PendingConfirmation&lt;/code&gt;, each stored with its idempotency key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2:&lt;/strong&gt; The provider &lt;strong&gt;recovers&lt;/strong&gt;. It now knows the real fate of every original attempt: 4 were actually charged, 2 actually failed. The answers exist and are one status call away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;…forever:&lt;/strong&gt; Our system has no reconciliation worker. &lt;strong&gt;Status checks performed: 0.&lt;/strong&gt; Resolved: 0. All 6 payments stay pending until the end of time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the real run: initial pending &lt;strong&gt;6&lt;/strong&gt;, provider recovered &lt;strong&gt;true&lt;/strong&gt;, status checks &lt;strong&gt;0&lt;/strong&gt;, resolved &lt;strong&gt;0&lt;/strong&gt;, still pending &lt;strong&gt;6&lt;/strong&gt;, duplicate charges &lt;strong&gt;0&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Verdict from the real report:&lt;/strong&gt; &lt;em&gt;"THE SYSTEM ADMITTED UNCERTAINTY — BUT NEVER RESOLVED IT ❌"&lt;/em&gt;. Every check in the BEFORE report passes — because the problem it proves is real: honesty without follow-up leaves the business stuck.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. The AFTER fix — a reconciliation worker
&lt;/h2&gt;

&lt;p&gt;The AFTER scenario (&lt;code&gt;session-12-background-reconciliation&lt;/code&gt;) is identical through Phase 1 and the provider recovery. Then one new piece runs — a small, deterministic, in-process reconciliation worker:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scan the pending store — only payments still in &lt;code&gt;PendingConfirmation&lt;/code&gt; are work.&lt;/li&gt;
&lt;li&gt;For each, call &lt;code&gt;CheckPaymentStatus(key)&lt;/code&gt; — a &lt;strong&gt;status read&lt;/strong&gt; of the &lt;em&gt;original&lt;/em&gt; attempt, identified by the same idempotency key. A ~50 ms read, not a charge.&lt;/li&gt;
&lt;li&gt;Apply the provider's real answer: &lt;code&gt;Paid&lt;/code&gt; → confirmed with the provider's transaction id; &lt;code&gt;Failed&lt;/code&gt; → a real failed state, no id.&lt;/li&gt;
&lt;li&gt;Clear &lt;code&gt;RequiresStatusCheck&lt;/code&gt; — the promise is kept, the payment is final.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From the real run, every payment resolved to the provider's truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PAY-001 | PendingConfirmation → Paid
PAY-002 | PendingConfirmation → Paid
PAY-003 | PendingConfirmation → Failed
PAY-004 | PendingConfirmation → Paid
PAY-005 | PendingConfirmation → Failed
PAY-006 | PendingConfirmation → Paid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Kept deliberately small.&lt;/strong&gt; The worker is a plain awaited loop — no hosted service, no queue, no scheduler, no database. Durability, crash recovery, and retry schedules belong to later lessons. Day 12's invariant is the part worth keeping forever: &lt;em&gt;ask, don't re-charge&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. Checking a payment is not retrying a payment
&lt;/h2&gt;

&lt;p&gt;This is the sharpest edge of the lesson. Two operations that sound similar and must never be confused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Correct — a status READ of the original attempt:&lt;/span&gt;
&lt;span class="nf"&gt;CheckPaymentStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PAY-001"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong — a brand-new charge wearing the old key's name:&lt;/span&gt;
&lt;span class="nf"&gt;CreateNewPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PAY-001"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same idempotency key = same original payment attempt.&lt;/strong&gt; The key is the payment's identity, carried from Day 11's fallback into Day 12's check.&lt;/li&gt;
&lt;li&gt;A status check asks: &lt;em&gt;"what happened to attempt PAY-001?"&lt;/em&gt; It moves no money.&lt;/li&gt;
&lt;li&gt;A new creation says: &lt;em&gt;"charge the customer."&lt;/em&gt; If the original attempt actually succeeded at the provider, this is a &lt;strong&gt;double charge&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;That is why the implementation keeps &lt;strong&gt;two separate ledgers&lt;/strong&gt; — payment creation attempts and status-check calls — and the report never hides them behind one generic "provider calls" counter.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Why the key matters (Days 3–5, again).&lt;/strong&gt; Without the preserved idempotency key, "check later" is impossible — there is nothing to ask about — and any retry becomes a new charge. The key is what turns follow-up from a gamble into a read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. Explicit state transitions
&lt;/h2&gt;

&lt;p&gt;Three states, and every transition is driven by the &lt;em&gt;provider's&lt;/em&gt; reported truth — never by our hopes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PendingConfirmation ──(provider says Paid)───→ Paid
PendingConfirmation ──(provider says Failed)─→ Failed
PendingConfirmation ──(still unresolved)─────→ PendingConfirmation  (check again later)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Final state&lt;/th&gt;
&lt;th&gt;PaymentConfirmed&lt;/th&gt;
&lt;th&gt;RequiresStatusCheck&lt;/th&gt;
&lt;th&gt;IsFallback&lt;/th&gt;
&lt;th&gt;TransactionId&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Paid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;real provider id (e.g. TX-PAY-001)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PendingConfirmation&lt;/code&gt; (unresolved)&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;A transaction id is earned, never invented.&lt;/strong&gt; It appears only after the provider reports a final successful status — exactly the Day 11 rule, carried forward: no fake success, no fabricated id, and a provider-reported failure becomes a real &lt;code&gt;Failed&lt;/code&gt;, not a quiet retry.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. The real implementation — proven by running it
&lt;/h2&gt;

&lt;p&gt;Day 12 is implemented as two &lt;code&gt;ISessionScenario&lt;/code&gt; classes registered in &lt;code&gt;Program.cs&lt;/code&gt;, same as every previous day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BEFORE:&lt;/strong&gt; &lt;code&gt;Session12NoReconciliationScenario&lt;/code&gt; → command &lt;code&gt;session-12-no-reconciliation&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AFTER:&lt;/strong&gt; &lt;code&gt;Session12BackgroundReconciliationScenario&lt;/code&gt; → command &lt;code&gt;session-12-background-reconciliation&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything is simulated in-process with standard .NET primitives — &lt;code&gt;Task.Delay&lt;/code&gt; for the hung gateway and the status-read latency, &lt;code&gt;SemaphoreSlim(2)&lt;/code&gt; for the bulkhead, a linked &lt;code&gt;CancellationTokenSource&lt;/code&gt; for the 300 ms timeout, and an in-memory dictionary of provider outcomes (deterministic: 4 Paid with transaction ids, 2 Failed). Two &lt;code&gt;ConcurrentDictionary&lt;/code&gt; ledgers count creation attempts and status checks per key, so the safety claims are measured, not asserted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real BEFORE vs AFTER metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (real run)&lt;/th&gt;
&lt;th&gt;BEFORE&lt;/th&gt;
&lt;th&gt;AFTER&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Initially pending&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider later recovered&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment creation attempts (ever)&lt;/td&gt;
&lt;td&gt;6 (6 keys)&lt;/td&gt;
&lt;td&gt;6 (6 keys)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Creation attempts during reconciliation&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status checks performed&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6 (one per key)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved as Paid&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolved as Failed&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still pending&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate charge attempts&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fake successes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total scenario duration&lt;/td&gt;
&lt;td&gt;938 ms&lt;/td&gt;
&lt;td&gt;1250 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The AFTER run is ~300 ms longer — that is the reconciliation pass itself: 6 status checks × ~50 ms. Resolving the unknown costs six small reads, not one risky re-charge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safety checks from the real AFTER report — all passing
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;one creation attempt per key, ever → 6 attempts / 6 keys&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zero creations during reconciliation → 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;duplicate charge attempts == 0 → 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;confirmed ⇔ provider reported Paid → fake successes 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;transaction ids only from provider → all match&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;failed payments carry no tx id → none do&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Verdicts from the real reports.&lt;/strong&gt; BEFORE: &lt;em&gt;"THE SYSTEM ADMITTED UNCERTAINTY — BUT NEVER RESOLVED IT ❌"&lt;/em&gt;. AFTER: &lt;em&gt;"BACKGROUND RECONCILIATION — THE UNKNOWN WAS RESOLVED, AND NOBODY WAS CHARGED TWICE ✅"&lt;/em&gt;. Both scenarios exit with code &lt;code&gt;0&lt;/code&gt; only when every check passes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Run it yourself
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-12-no-reconciliation
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-12-background-reconciliation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run prints the per-payment transition table, the summary, the checks, and the verdict, then saves a timestamped report under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reports/Session-12/Session-12-No-Reconciliation-RunReport-&amp;lt;timestamp&amp;gt;.txt
Reports/Session-12/Session-12-Background-Reconciliation-RunReport-&amp;lt;timestamp&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. Final takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PendingConfirmation&lt;/code&gt; is honest, but it is &lt;strong&gt;not a final business outcome&lt;/strong&gt; — it is a promise to check again.&lt;/li&gt;
&lt;li&gt;Reconciliation keeps the promise by &lt;strong&gt;asking about the original attempt&lt;/strong&gt; — &lt;code&gt;CheckPaymentStatus(key)&lt;/code&gt;, never &lt;code&gt;CreateNewPayment(key)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The same idempotency key means the same payment attempt. One creation per key, ever; status checks are reads.&lt;/li&gt;
&lt;li&gt;A payment becomes &lt;code&gt;Paid&lt;/code&gt; only when the provider reports it; a provider-reported failure becomes a real &lt;code&gt;Failed&lt;/code&gt;; an unresolved answer may stay pending — to be checked again.&lt;/li&gt;
&lt;li&gt;Transaction ids come from the provider or not at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two days, side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Day 11:  We do not know yet.
Day 12:  We checked the original attempt and now know the final result.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Fallback admits uncertainty. Reconciliation resolves uncertainty.&lt;/strong&gt; Together they let a payment system survive a provider outage without ever lying — and without ever charging twice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Pending is a promise to check again. Day 12 keeps the promise. Six payments went pending honestly, the provider recovered, and one small worker resolved them all — 6 status checks, 4 Paid, 2 Failed, 0 new charges. A later lesson can make the worker durable (schedules, queues, crash recovery); the invariant to carry forward is simpler: &lt;strong&gt;ask, don't re-charge&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>resilience</category>
      <category>payments</category>
    </item>
    <item>
      <title>Day 11 — Payment Fallback: Without Lying</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:27:57 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-11-payment-fallback-without-lying-336m</link>
      <guid>https://dev.to/moh_moh701/day-11-payment-fallback-without-lying-336m</guid>
      <description>&lt;p&gt;Bulkhead limits how many. Circuit breaker stops the storm. Timeout stops the wait. But when the payment provider still cannot give a final answer, one business question remains: &lt;em&gt;what do we tell the caller?&lt;/em&gt; For payments, the fallback must be honest — never a fake success.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F47xptzep6wnqbhl4g37l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F47xptzep6wnqbhl4g37l.png" alt="Payment fallback — without lying" width="800" height="849"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🙏 &lt;strong&gt;Credit where it's due&lt;/strong&gt; — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, &lt;em&gt;Fundamentals of Distributed Systems&lt;/em&gt; on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.&lt;/p&gt;

&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away.&lt;/strong&gt; The safest fallback is not a fake answer. It is an honest state. For payments, &lt;em&gt;unknown&lt;/em&gt; is a valid state. Do not convert unknown into success — and do not convert it into failure either. Say what is true: &lt;strong&gt;we do not know yet.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where we are — after Day 10
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Day 8 — Circuit Breaker:&lt;/strong&gt; when a dependency keeps failing, stop hammering it. Fail fast and give it room to recover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 9 — Bulkhead Isolation:&lt;/strong&gt; give each dependency its own pool of slots, so one slow dependency cannot starve the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 10 — Timeout &amp;amp; Cancellation:&lt;/strong&gt; bound how long a call may hold a slot. Stop waiting, cancel, release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 11 (this document):&lt;/strong&gt; all of that protects the &lt;em&gt;system&lt;/em&gt;. Now we answer for the &lt;em&gt;caller&lt;/em&gt;: what should the system return when the dependency still cannot give a final answer?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Three protections in place — one question still open
&lt;/h2&gt;

&lt;p&gt;After Days 8–10, our calls to a payment provider are well protected. Each mechanism answers its own question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;✅ Bulkhead&lt;/strong&gt; &lt;em&gt;(Day 9 · how many?)&lt;/em&gt; — At most N payment calls run at once. One slow dependency cannot starve the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;✅ Circuit breaker&lt;/strong&gt; &lt;em&gt;(Day 8 · keep trying?)&lt;/em&gt; — When the provider keeps failing, stop hammering it and fail fast for a while.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;✅ Timeout&lt;/strong&gt; &lt;em&gt;(Day 10 · how long?)&lt;/em&gt; — A call may wait ~300 ms, then it is cancelled and its slot is released.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;❌ Fallback&lt;/strong&gt; &lt;em&gt;(Day 11 · what to return?)&lt;/em&gt; — The timeout fired. The breaker is open. &lt;strong&gt;What do we tell the caller?&lt;/strong&gt; Nothing so far decides that.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The gap.&lt;/strong&gt; Timeout decides &lt;em&gt;when to stop waiting&lt;/em&gt;. It does not decide &lt;em&gt;what to say after you stopped&lt;/em&gt;. All three mechanisms are infrastructure answers — the fallback is a &lt;strong&gt;business&lt;/strong&gt; answer, and it must match the business risk.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. Why a "normal" fallback is not safe for payments
&lt;/h2&gt;

&lt;p&gt;For a read-only, low-risk feature, a fallback can be simple and nobody gets hurt. Product recommendations are the classic example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;return cached recommendations,&lt;/li&gt;
&lt;li&gt;return the popular items list,&lt;/li&gt;
&lt;li&gt;or even return an empty list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worst case, the customer sees slightly generic suggestions. The business risk is near zero — so a cheap default is a perfectly good fallback.&lt;/p&gt;

&lt;p&gt;Payments are the opposite end of the risk scale. A payment call &lt;strong&gt;moves money and changes state&lt;/strong&gt;. And a payment timeout is ambiguous by nature: the provider may have &lt;em&gt;already charged the card&lt;/em&gt; — we simply never received the confirmation. There is no "safe default value" for money.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Recommendations&lt;/th&gt;
&lt;th&gt;Payments&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What does the call do?&lt;/td&gt;
&lt;td&gt;Reads data&lt;/td&gt;
&lt;td&gt;Moves money, changes state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is a default value harmless?&lt;/td&gt;
&lt;td&gt;Yes — popular items are fine&lt;/td&gt;
&lt;td&gt;No — there is no default for money&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What does a timeout mean?&lt;/td&gt;
&lt;td&gt;No suggestions this time&lt;/td&gt;
&lt;td&gt;Unknown — the charge may have happened&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of a wrong fallback&lt;/td&gt;
&lt;td&gt;A slightly generic page&lt;/td&gt;
&lt;td&gt;Double charge, broken reconciliation, lost trust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good fallback&lt;/td&gt;
&lt;td&gt;Cached / popular / empty&lt;/td&gt;
&lt;td&gt;An honest pending state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Fallback must match the business risk.&lt;/strong&gt; The same pattern — "return something instead of an error" — is harmless for recommendations and dangerous for payments. Copying a read-side fallback onto a payment path is how systems start lying.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. The wrong solution — fake success
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut: the provider timed out, the customer is waiting, so just… pretend it worked and sort it out later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PaymentStatus = Paid
TransactionId = FAKE-123
Message       = Payment completed successfully
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This response is a lie, and every part of the business eventually pays for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The customer may receive an order without a real payment&lt;/strong&gt; — the charge never happened, but the order shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finance reconciliation breaks&lt;/strong&gt; — &lt;code&gt;FAKE-123&lt;/code&gt; matches nothing at the provider. Someone has to untangle it by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support cannot know the real payment state&lt;/strong&gt; — the system itself no longer knows which payments are real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrying later may double-charge the customer&lt;/strong&gt; — the original attempt may have succeeded at the provider; a "retry" creates a second real charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The system loses truth&lt;/strong&gt; — once fake data enters your records, every report, refund, and audit built on top of it inherits the lie.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Do not convert unknown into success.&lt;/strong&gt; A fake &lt;code&gt;Paid&lt;/code&gt; does not remove the uncertainty — it hides it inside your own database, which is the worst place for it to live.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. The correct solution — explicit pending confirmation
&lt;/h2&gt;

&lt;p&gt;The honest answer has three properties: it does &lt;strong&gt;not&lt;/strong&gt; say the payment failed, it does &lt;strong&gt;not&lt;/strong&gt; say the payment succeeded, and it clearly marks itself as a degraded response that needs a follow-up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PaymentStatus       = PendingConfirmation
PaymentConfirmed    = false
IsFallback          = true
RequiresStatusCheck = true
Message             = Payment provider did not confirm in time.
                      Payment status must be checked later.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it back in plain words: &lt;em&gt;"We asked the provider to charge the card. We did not hear back in time. We do not know yet — and here is what to do about it."&lt;/em&gt; That is the honest distributed systems answer.&lt;/p&gt;

&lt;p&gt;As a response model, it stays small and boring on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"paymentStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PendingConfirmation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"paymentConfirmed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isFallback"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requiresStatusCheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Payment provider did not confirm in time. Please check payment status later."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why it is there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;paymentStatus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PendingConfirmation&lt;/td&gt;
&lt;td&gt;Not success, not failure — the true state.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;paymentConfirmed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Nothing downstream (order confirmation, shipping) may proceed as if money moved.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isFallback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;The response is clearly marked as degraded — the UI and the logs can both tell.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;requiresStatusCheck&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;An actionable next step, not a dead end.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"…check payment status later."&lt;/td&gt;
&lt;td&gt;Something honest the caller can show a human.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;For payments, unknown is a valid state.&lt;/strong&gt; &lt;code&gt;PendingConfirmation&lt;/code&gt; gives uncertainty a name and a home in your model — instead of forcing it, wrongly, into &lt;code&gt;Paid&lt;/code&gt; or &lt;code&gt;Failed&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. What the caller should do next
&lt;/h2&gt;

&lt;p&gt;A pending response is only useful if the caller knows what to do with it. Three simple rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Show the truth to the user.&lt;/strong&gt; "Your payment is being confirmed — we will update you shortly." Not "Payment successful", not "Payment failed".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not trigger success-only side effects.&lt;/strong&gt; No order confirmation email, no shipping label, no loyalty points — those wait for &lt;code&gt;paymentConfirmed = true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check status later, or retry with the &lt;em&gt;same&lt;/em&gt; idempotency key.&lt;/strong&gt; The follow-up asks about the &lt;em&gt;same&lt;/em&gt; payment attempt — it never starts a new one.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Kept deliberately simple.&lt;/strong&gt; In a production system the "check later" part often becomes a background reconciliation job that polls the provider and settles pending payments automatically. That deserves its own lesson — Day 11 stays focused on the honest response itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. Why idempotency still matters
&lt;/h2&gt;

&lt;p&gt;The pending state only works because of an old friend from Days 3–5: the &lt;code&gt;Idempotency-Key&lt;/code&gt;. The fallback must &lt;strong&gt;preserve&lt;/strong&gt; it, because the key is what makes "try again later" safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same order + same idempotency key = same payment attempt.&lt;/strong&gt; Always.&lt;/li&gt;
&lt;li&gt;A retry &lt;strong&gt;checks or continues&lt;/strong&gt; that same attempt — it never creates a second one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; create multiple real payment attempts for one customer action.&lt;/li&gt;
&lt;li&gt;The pending status must be &lt;strong&gt;replayable&lt;/strong&gt;: asking again returns the same pending result, or the latest known status — not a new charge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idempotent retry story, in two beats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First request:&lt;/strong&gt; &lt;code&gt;OrderId = 1001&lt;/code&gt;, &lt;code&gt;Idempotency-Key = abc-123&lt;/code&gt; → payment provider &lt;strong&gt;timeout&lt;/strong&gt; → return &lt;code&gt;PaymentPendingConfirmation&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second request:&lt;/strong&gt; Same &lt;code&gt;Idempotency-Key = abc-123&lt;/code&gt; → &lt;strong&gt;do not create a second payment&lt;/strong&gt; → return the same pending result, or the latest known status.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The double-charge trap.&lt;/strong&gt; A retry with a &lt;em&gt;new&lt;/em&gt; key is a brand-new payment in the provider's eyes. If the first (timed-out) attempt actually succeeded, the customer is now charged twice. The idempotency key is the difference between "asking again" and "charging again".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  7. How this connects to the previous days
&lt;/h2&gt;

&lt;p&gt;Each day answered one question. Day 11 adds the final, business-facing one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;The question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Days 3–5&lt;/td&gt;
&lt;td&gt;Idempotency&lt;/td&gt;
&lt;td&gt;How do we make retries safe?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day 8&lt;/td&gt;
&lt;td&gt;Circuit breaker&lt;/td&gt;
&lt;td&gt;Should we keep trying a failing dependency?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day 9&lt;/td&gt;
&lt;td&gt;Bulkhead&lt;/td&gt;
&lt;td&gt;How many calls may run at once?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day 10&lt;/td&gt;
&lt;td&gt;Timeout + cancellation&lt;/td&gt;
&lt;td&gt;How long may a call wait?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Day 11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Honest fallback&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;What do we return when there is still no final answer?&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the shape: Days 8–10 protect the &lt;em&gt;system&lt;/em&gt; and never touch the response. Day 11 protects the &lt;em&gt;truth&lt;/em&gt; of the response — and it leans on Days 3–5 to make the follow-up safe. All of it composes: the bulkhead and timeout free the slot quickly, the breaker stops the storm, the idempotency key keeps the retry safe, and the honest fallback tells the caller exactly where things stand.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The real implementation — proven by running it
&lt;/h2&gt;

&lt;p&gt;This lesson is not just a diagram — it is implemented and verified in the test runner as two &lt;code&gt;ISessionScenario&lt;/code&gt; classes, registered in &lt;code&gt;Program.cs&lt;/code&gt; like every previous day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BEFORE:&lt;/strong&gt; &lt;code&gt;Session11NoSafeFallbackScenario&lt;/code&gt; → command &lt;code&gt;session-11-no-safe-fallback&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AFTER:&lt;/strong&gt; &lt;code&gt;Session11SafeFallbackScenario&lt;/code&gt; → command &lt;code&gt;session-11-safe-fallback&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How the failure is simulated
&lt;/h3&gt;

&lt;p&gt;Everything runs in-process — no server, no database, no packages. The payment gateway is a bounded &lt;code&gt;Task.Delay(2000 ms)&lt;/code&gt; that never answers in time. Each call runs through the Day 9 bulkhead (&lt;code&gt;SemaphoreSlim(2)&lt;/code&gt;) with the Day 10 per-call timeout: a linked &lt;code&gt;CancellationTokenSource&lt;/code&gt; with &lt;code&gt;CancelAfter(300 ms)&lt;/code&gt;. Six payment operations are fired, each carrying its own idempotency key: &lt;code&gt;PAY-001&lt;/code&gt; … &lt;code&gt;PAY-006&lt;/code&gt;. In both scenarios all 6 gateway calls time out — the difference is only what the caller is told.&lt;/p&gt;

&lt;h3&gt;
  
  
  What BEFORE demonstrates
&lt;/h3&gt;

&lt;p&gt;The timeout path returns a bare hard failure: &lt;code&gt;Result = failed&lt;/code&gt;, &lt;code&gt;Reason = payment-timeout&lt;/code&gt; — with no pending state, no &lt;code&gt;RequiresStatusCheck&lt;/code&gt;, no retry advice, and the idempotency key &lt;em&gt;not&lt;/em&gt; echoed back. Every infrastructure check passes (slots freed at ~305 ms, whole run ~920 ms), yet user-visible graceful responses are &lt;strong&gt;0 / 6&lt;/strong&gt;. The system is protected; the caller hits an ambiguous dead end.&lt;/p&gt;

&lt;h3&gt;
  
  
  What AFTER changes
&lt;/h3&gt;

&lt;p&gt;One thing only: the &lt;code&gt;catch (OperationCanceledException)&lt;/code&gt; block builds the honest fallback instead of a bare failure —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PaymentResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Result&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"pending-confirmation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;PaymentConfirmed&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IsFallback&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Source&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"safe-payment-fallback"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Reason&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"primary-timeout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RequiresStatusCheck&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RetryAdvice&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"retry-with-same-idempotency-key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IdempotencyKey&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// preserved and echoed back&lt;/span&gt;
    &lt;span class="n"&gt;TransactionId&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// never fabricated&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A gateway-attempt ledger (a &lt;code&gt;ConcurrentDictionary&lt;/code&gt; keyed by idempotency key) counts every real gateway call, so the report can &lt;em&gt;prove&lt;/em&gt; there was exactly one attempt per key — no silent automatic retry.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the real run proves
&lt;/h3&gt;

&lt;p&gt;From the generated reports (both scenarios exit with code &lt;code&gt;0&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (real run)&lt;/th&gt;
&lt;th&gt;BEFORE&lt;/th&gt;
&lt;th&gt;AFTER&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Payment gateway timeouts&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;td&gt;6 / 6 (still down)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard failures returned to caller&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pending-confirmation responses&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fallback clearly marked (&lt;code&gt;IsFallback&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fake successes / confirmed payments / fabricated tx ids&lt;/td&gt;
&lt;td&gt;0 / 0 / 0&lt;/td&gt;
&lt;td&gt;0 / 0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotency keys preserved &amp;amp; echoed&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RequiresStatusCheck responses&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate charge attempts&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 (6 attempts / 6 keys)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg slot-hold time&lt;/td&gt;
&lt;td&gt;305 ms&lt;/td&gt;
&lt;td&gt;300 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total scenario duration&lt;/td&gt;
&lt;td&gt;920 ms&lt;/td&gt;
&lt;td&gt;905 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User-visible graceful responses&lt;/td&gt;
&lt;td&gt;0 / 6 (0%)&lt;/td&gt;
&lt;td&gt;6 / 6 (100%, all degraded)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hold time is ~300–305 ms, but average response time is higher (~600 ms) because later waves wait in the bulkhead queue first: response time = wait + hold.&lt;/p&gt;

&lt;p&gt;The AFTER report ends with dedicated &lt;strong&gt;safety checks&lt;/strong&gt;, all passing on the real run: &lt;code&gt;fake successes == 0&lt;/code&gt;, &lt;code&gt;confirmed payments == 0 (timed out)&lt;/code&gt;, &lt;code&gt;fabricated transaction ids == 0&lt;/code&gt;, &lt;code&gt;every fallback marked IsFallback=true → 6 / 6&lt;/code&gt;, &lt;code&gt;idempotency key preserved &amp;amp; echoed → 6 / 6&lt;/code&gt;, and &lt;code&gt;exactly one gateway attempt per key → 6 attempts / 6 keys, duplicates 0&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Verdicts from the real reports.&lt;/strong&gt; BEFORE: &lt;em&gt;"TIMEOUT PROTECTED THE SYSTEM — BUT EVERY PAYMENT ENDED IN AN AMBIGUOUS HARD FAILURE ❌"&lt;/em&gt;. AFTER: &lt;em&gt;"SAFE PAYMENT FALLBACK — GATEWAY STILL DOWN, NOBODY WAS LIED TO ✅"&lt;/em&gt;. Same hung gateway, same timeout — only the honesty of the answer changed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Run it yourself
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-11-no-safe-fallback
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-11-safe-fallback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run prints the per-operation table, summary, checks, and verdict, then saves a timestamped report under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reports/Session-11/Session-11-No-Safe-Fallback-RunReport-&amp;lt;timestamp&amp;gt;.txt
Reports/Session-11/Session-11-Safe-Fallback-RunReport-&amp;lt;timestamp&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  9. Final takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A fallback is not always "return some default data". &lt;strong&gt;Fallback must match the business risk.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;For low-risk reads (recommendations), a cached or popular-items default is fine.&lt;/li&gt;
&lt;li&gt;For payments, a timeout means &lt;strong&gt;unknown&lt;/strong&gt; — and unknown is a valid state. Give it a name: &lt;code&gt;PendingConfirmation&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Never return &lt;code&gt;Paid&lt;/code&gt;, a fake transaction id, or "payment completed" without real confirmation from the provider.&lt;/li&gt;
&lt;li&gt;Preserve the idempotency key so that checking or retrying later continues the &lt;em&gt;same&lt;/em&gt; attempt instead of charging twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Do not convert unknown into success.&lt;/strong&gt; The system that admits "we do not know yet" keeps its truth — and a system that keeps its truth can always recover. A system that lies to its own database cannot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The safest fallback is not a fake answer. It is an honest state. Bulkhead, breaker, and timeout protect the system. The honest fallback protects the truth. For payments that means: not success, not failure — &lt;strong&gt;pending, clearly marked, safely retryable&lt;/strong&gt;. A later lesson can add background reconciliation and status checking on top of this foundation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>resilience</category>
      <category>payments</category>
    </item>
    <item>
      <title>Day 10 — Timeout &amp; Cancellation: Do Not Wait Forever</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:27:19 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-10-timeout-cancellation-do-not-wait-forever-4m73</link>
      <guid>https://dev.to/moh_moh701/day-10-timeout-cancellation-do-not-wait-forever-4m73</guid>
      <description>&lt;p&gt;&lt;em&gt;Day 9's bulkhead limits how many calls run at once — but never how long a call may hold a slot. When a dependency hangs, its slots stay occupied far too long and everyone queues behind them. First we prove the gap, then we close it with a per-call timeout and cooperative cancellation.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbvp0ly1z8uc49pfj6xm7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbvp0ly1z8uc49pfj6xm7.png" alt="Timeout &amp;amp; cancellation — do not wait forever" width="800" height="803"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🙏 &lt;strong&gt;Credit where it's due&lt;/strong&gt; — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, &lt;em&gt;Fundamentals of Distributed Systems&lt;/em&gt; on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.&lt;/p&gt;

&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away:&lt;/strong&gt; Bulkhead answers "how many calls can run at once?" — Timeout answers "how long is a call allowed to hold a slot?" A bulkhead with no timeout still lets one hung call sit in a slot for as long as the dependency hangs. Limiting concurrency is only half the job.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 1 — The problem: a hung dependency holds its slots forever
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Two different questions
&lt;/h3&gt;

&lt;p&gt;Protecting a dependency call has &lt;strong&gt;two&lt;/strong&gt; independent dimensions. Day 9 answered the first. Day 10 is about the second — the one a bulkhead leaves wide open.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;What it caps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;✅ Bulkhead — how many?&lt;/td&gt;
&lt;td&gt;Day 9 · solved&lt;/td&gt;
&lt;td&gt;"At most 2 Payment calls run at once." Concurrency is capped by &lt;code&gt;SemaphoreSlim(2)&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ Timeout — how long?&lt;/td&gt;
&lt;td&gt;Day 10 · still missing&lt;/td&gt;
&lt;td&gt;"A call may hold a slot for at most 300 ms." Nothing here yet — a hung call holds its slot for &lt;strong&gt;2000 ms&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The gap.&lt;/strong&gt; A bulkhead caps concurrency, but a hung call still occupies its slot for the dependency's full hang time. Two slots held for 2000 ms each means the next calls simply wait — the bulkhead can't help, because the slow calls are &lt;em&gt;inside&lt;/em&gt; the slots, not outside them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Scenario setup — one hung dependency, its own bulkhead
&lt;/h3&gt;

&lt;p&gt;Everything is simulated in-process (no real server, no database, no external packages). We deliberately keep Day 10 focused on &lt;strong&gt;one&lt;/strong&gt; dependency — Payment — and its own bulkhead. No Inventory, no Notification this time; the whole lesson is about slot-hold time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dependency &lt;strong&gt;💳 Payment&lt;/strong&gt; is &lt;strong&gt;hung&lt;/strong&gt; — every call takes &lt;strong&gt;2000 ms&lt;/strong&gt; (simulated with a bounded &lt;code&gt;await Task.Delay(2000, token)&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;It runs through its own bulkhead: &lt;code&gt;paymentPool = new SemaphoreSlim(2)&lt;/code&gt; — carried straight over from Day 9.&lt;/li&gt;
&lt;li&gt;We fire &lt;strong&gt;6&lt;/strong&gt; Payment operations. There is &lt;strong&gt;no per-call timeout&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Payment bulkhead has 2 slots, and both get stuck on a 2000 ms hang. Payment #1 and #2 hold the slots; Payments #3, #4, #5, and #6 wait in line for a slot to free — which won't happen for ~2000 ms.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Why this is deterministic.&lt;/strong&gt; 6 calls through 2 slots = 3 waves. Each wave holds both slots for the full 2000 ms hang, so wave 2 starts at ~2000 ms and wave 3 at ~4000 ms. Every number below falls out of that simple arithmetic.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Timeline — three slow waves
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~0 ms&lt;/strong&gt; — &lt;strong&gt;Wave 1&lt;/strong&gt; — Payment #1 and #2 grab both slots and start their 2000 ms hang. #3–#6 queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~2000 ms&lt;/strong&gt; — &lt;strong&gt;Wave 2&lt;/strong&gt; — #1 and #2 finally finish and release. #3 and #4 take the slots and hang for another 2000 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~4000 ms&lt;/strong&gt; — &lt;strong&gt;Wave 3&lt;/strong&gt; — #5 and #6 finally get in and hang for 2000 ms more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~6000 ms&lt;/strong&gt; — Everything is done. Total run ≈ &lt;strong&gt;6034 ms&lt;/strong&gt; for work that should have taken a fraction of that.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The slots never free early.&lt;/strong&gt; Because there's no timeout, a slot is only released when the 2000 ms hang finally returns. Nothing can shorten it. &lt;strong&gt;0 of 6&lt;/strong&gt; slots were released early.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The star metric — slot-hold time
&lt;/h3&gt;

&lt;p&gt;The number that matters in Day 10 is &lt;strong&gt;slot-hold time&lt;/strong&gt;: how long a call keeps its bulkhead slot from the moment it acquires it to the moment it releases it. With no timeout, that equals the full hang — about &lt;strong&gt;2007 ms&lt;/strong&gt; on average. The calls behind it wait &lt;strong&gt;~2013 ms&lt;/strong&gt; on average, and up to &lt;strong&gt;4020 ms&lt;/strong&gt; in the last wave.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The slot is the bottleneck.&lt;/strong&gt; A hung call holds its slot ~2007 ms, so the calls behind it wait ~2013 ms on average and up to 4020 ms. The bulkhead limited concurrency to 2 — it did nothing about the 2000 ms hold.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Per-operation results (from the real run):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Dependency&lt;/th&gt;
&lt;th&gt;Timeout&lt;/th&gt;
&lt;th&gt;Work&lt;/th&gt;
&lt;th&gt;Wait&lt;/th&gt;
&lt;th&gt;Hold&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2021&lt;/td&gt;
&lt;td&gt;2021&lt;/td&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2018&lt;/td&gt;
&lt;td&gt;2018&lt;/td&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;2020&lt;/td&gt;
&lt;td&gt;2001&lt;/td&gt;
&lt;td&gt;4021&lt;/td&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;2020&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;4020&lt;/td&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;4020&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;6021&lt;/td&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;4020&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;6021&lt;/td&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every call "completed" — but only after holding its slot for ~2000 ms. Waits climb 0 → 2020 → 4020 ms across the three waves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📊 Report numbers (from the real run):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total operations&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment bulkhead size&lt;/td&gt;
&lt;td&gt;2 slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hung dependency duration (each)&lt;/td&gt;
&lt;td&gt;2000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeout&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average wait (queue for a slot)&lt;/td&gt;
&lt;td&gt;2013 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max wait&lt;/td&gt;
&lt;td&gt;4020 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;★ Average slot-hold time&lt;/td&gt;
&lt;td&gt;2007 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max slot-hold time&lt;/td&gt;
&lt;td&gt;2021 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timed-out operations&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slots released early&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total scenario duration&lt;/td&gt;
&lt;td&gt;6034 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Diagnostic verdict:&lt;/strong&gt; NO TIMEOUT — HUNG DEPENDENCY HELD ITS SLOTS AND STALLED EVERYTHING ✅&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What this proves:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The bulkhead did its Day 9 job — never more than 2 Payment calls ran at once.&lt;/li&gt;
&lt;li&gt;But with &lt;strong&gt;no timeout&lt;/strong&gt;, each hung call held its slot for the full &lt;strong&gt;2000 ms&lt;/strong&gt; — slots never freed early.&lt;/li&gt;
&lt;li&gt;So the 6 calls serialized into 3 slow waves, later calls waited up to &lt;strong&gt;4020 ms&lt;/strong&gt;, and the whole run took &lt;strong&gt;6034 ms&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Limiting &lt;em&gt;how many&lt;/em&gt; calls run is not enough. You also have to limit &lt;em&gt;how long&lt;/em&gt; each one may hold a slot.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part 2 — The fix: timeout + cooperative cancellation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Both dimensions, together
&lt;/h3&gt;

&lt;p&gt;Day 9 and Day 10 are two halves of the same protection. Once you have both, a dependency can neither run away with all your concurrency nor sit forever in the slots it holds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;What it caps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;✅ Bulkhead — how many?&lt;/td&gt;
&lt;td&gt;Day 9&lt;/td&gt;
&lt;td&gt;At most 2 Payment calls run at once — &lt;code&gt;SemaphoreSlim(2)&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✅ Timeout — how long?&lt;/td&gt;
&lt;td&gt;Day 10 · this step&lt;/td&gt;
&lt;td&gt;A call may hold a slot for at most ~300 ms, then it is cancelled and the slot is released.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;Two knobs, two questions.&lt;/strong&gt; Concurrency is capped by the bulkhead; hold time is capped by the timeout. Neither replaces the other — you want both.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The fix — timeout + cooperative cancellation
&lt;/h3&gt;

&lt;p&gt;The dependency is unchanged: &lt;code&gt;paymentPool = new SemaphoreSlim(2)&lt;/code&gt;, still 6 calls, each still hanging for &lt;strong&gt;2000 ms&lt;/strong&gt;. The only new thing is a &lt;strong&gt;300 ms per-call timeout&lt;/strong&gt; built from a linked &lt;code&gt;CancellationTokenSource&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Link a token&lt;/strong&gt; from the outer (Ctrl+C) token so external cancellation still works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CancelAfter(300 ms)&lt;/strong&gt; — the linked source will fire the token when the call overruns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass the linked token&lt;/strong&gt; into the dependency call (&lt;code&gt;Task.Delay(HungWorkMs, timeoutCts.Token)&lt;/code&gt;), so cancellation is &lt;em&gt;cooperative&lt;/em&gt; — the call actually stops when asked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch the cancellation&lt;/strong&gt; and tell &lt;em&gt;our&lt;/em&gt; timeout apart from an external cancel, so we report &lt;code&gt;timed-out&lt;/code&gt; vs &lt;code&gt;aborted&lt;/code&gt; correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release the slot in &lt;code&gt;finally&lt;/code&gt;&lt;/strong&gt; — on every path, success or timeout. This is what frees the bulkhead early.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;timeoutCts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateLinkedTokenSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;timeoutCts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CancelAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeoutMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HungWorkMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeoutCts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OperationCanceledException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeoutCts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// our timeout fired&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"timed-out"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;finally&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;paymentPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Cancellation must be cooperative.&lt;/strong&gt; Passing the token into &lt;code&gt;Task.Delay&lt;/code&gt; is what makes the hung call actually stop at 300 ms. A timeout you don't pass down is just a stopwatch that nobody obeys — the slot would still be held for 2000 ms.&lt;/p&gt;

&lt;p&gt;💡 &lt;strong&gt;Always release in &lt;code&gt;finally&lt;/code&gt;.&lt;/strong&gt; If the slot were only released on the success path, a timed-out call would keep its slot for the full hang — silently recreating the Part 1 bug.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Timeline — waves that move fast
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~0 ms&lt;/strong&gt; — &lt;strong&gt;Wave 1&lt;/strong&gt; — #1 and #2 take both slots. At ~300 ms their timeout fires, they abandon and release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~300 ms&lt;/strong&gt; — &lt;strong&gt;Wave 2&lt;/strong&gt; — #3 and #4 immediately take the freed slots, time out at ~300 ms more, release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~600 ms&lt;/strong&gt; — &lt;strong&gt;Wave 3&lt;/strong&gt; — #5 and #6 get in, time out at ~300 ms, release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~920 ms&lt;/strong&gt; — All 6 done — each returned a deterministic &lt;code&gt;timed-out&lt;/code&gt; result. Total run ≈ &lt;strong&gt;920 ms&lt;/strong&gt; vs 6034 ms before.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;The dependency is still hung.&lt;/strong&gt; Every call would still take 2000 ms if we let it. We simply stop waiting at 300 ms — so the slots free ~6.5× faster and the whole run finishes in under a second.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The star metric — slot-hold time: 2007 ms → 306 ms
&lt;/h3&gt;

&lt;p&gt;Same metric as Part 1, now bounded by the timeout. A slot is held only until the 300 ms timeout fires, not until the 2000 ms hang returns. Slot-hold drops from &lt;strong&gt;2007 ms&lt;/strong&gt; to &lt;strong&gt;306 ms&lt;/strong&gt;, the average wait falls to &lt;strong&gt;309 ms&lt;/strong&gt;, and the whole scenario finishes in &lt;strong&gt;920 ms&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;~6.5× less slot-hold.&lt;/strong&gt; The hang is unchanged at 2000 ms, but the slot is held for only ~306 ms — so waits collapse to ~309 ms and the whole run drops from 6034 ms to 920 ms.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Per-operation results (from the real run):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Dependency&lt;/th&gt;
&lt;th&gt;Timeout&lt;/th&gt;
&lt;th&gt;Work&lt;/th&gt;
&lt;th&gt;Wait&lt;/th&gt;
&lt;th&gt;Hold&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;311&lt;/td&gt;
&lt;td&gt;311&lt;/td&gt;
&lt;td&gt;timed-out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;310&lt;/td&gt;
&lt;td&gt;310&lt;/td&gt;
&lt;td&gt;timed-out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;310&lt;/td&gt;
&lt;td&gt;308&lt;/td&gt;
&lt;td&gt;618&lt;/td&gt;
&lt;td&gt;timed-out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;310&lt;/td&gt;
&lt;td&gt;307&lt;/td&gt;
&lt;td&gt;618&lt;/td&gt;
&lt;td&gt;timed-out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;618&lt;/td&gt;
&lt;td&gt;299&lt;/td&gt;
&lt;td&gt;918&lt;/td&gt;
&lt;td&gt;timed-out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Payment&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;2000&lt;/td&gt;
&lt;td&gt;618&lt;/td&gt;
&lt;td&gt;299&lt;/td&gt;
&lt;td&gt;918&lt;/td&gt;
&lt;td&gt;timed-out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every call returned a deterministic &lt;code&gt;timed-out&lt;/code&gt; result after ~300 ms — not a hang. Waits climb only 0 → 310 → 618 ms, because slots free quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📊 Report numbers (from the real run):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total operations&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment bulkhead size&lt;/td&gt;
&lt;td&gt;2 slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hung dependency duration (each)&lt;/td&gt;
&lt;td&gt;2000 ms (still hung)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeout&lt;/td&gt;
&lt;td&gt;300 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average wait (queue for a slot)&lt;/td&gt;
&lt;td&gt;309 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max wait&lt;/td&gt;
&lt;td&gt;618 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;★ Average slot-hold time&lt;/td&gt;
&lt;td&gt;306 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max slot-hold time&lt;/td&gt;
&lt;td&gt;311 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timed-out operations&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slots released&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total scenario duration&lt;/td&gt;
&lt;td&gt;920 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;⚖️ Before vs after — same hang, bounded hold:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;BEFORE&lt;/th&gt;
&lt;th&gt;AFTER&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;★ Avg slot-hold time&lt;/td&gt;
&lt;td&gt;2007 ms&lt;/td&gt;
&lt;td&gt;306 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg wait (queue for a slot)&lt;/td&gt;
&lt;td&gt;2013 ms&lt;/td&gt;
&lt;td&gt;309 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max wait&lt;/td&gt;
&lt;td&gt;4020 ms&lt;/td&gt;
&lt;td&gt;618 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timed-out operations&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scenario duration&lt;/td&gt;
&lt;td&gt;6034 ms&lt;/td&gt;
&lt;td&gt;920 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hung dependency (each)&lt;/td&gt;
&lt;td&gt;2000 ms&lt;/td&gt;
&lt;td&gt;2000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The hang is &lt;strong&gt;identical&lt;/strong&gt; in both runs — 2000 ms. Timeout did not fix the dependency; it bounded how long a call may hold a slot, so slot-hold dropped from 2007 ms to 306 ms and the run went from 6034 ms to 920 ms.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Proof verdict:&lt;/strong&gt; TIMEOUT &amp;amp; CANCELLATION — SLOTS RELEASED, SYSTEM STAYED RESPONSIVE ✅&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What this proves:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The dependency is &lt;strong&gt;still hung&lt;/strong&gt; — every call would still take 2000 ms if allowed. Timeout did not heal it.&lt;/li&gt;
&lt;li&gt;But each call now holds its slot for only ~&lt;strong&gt;306 ms&lt;/strong&gt; before the timeout cancels it and &lt;code&gt;finally&lt;/code&gt; releases the slot.&lt;/li&gt;
&lt;li&gt;All &lt;strong&gt;6 / 6&lt;/strong&gt; calls timed out deterministically and every slot was released — no call hung, no slot leaked.&lt;/li&gt;
&lt;li&gt;The run dropped from &lt;strong&gt;6034 ms → 920 ms&lt;/strong&gt;. Callers fail fast instead of waiting forever, and the bulkhead recovers instead of strangling itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The lesson behind the lesson:&lt;/strong&gt; a timeout is a promise you make to &lt;em&gt;yourself&lt;/em&gt; about how long you'll wait — not a promise the dependency will be quick. Pair it with the Day 9 bulkhead and you've capped both how many calls run and how long each may hold a slot.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;Run the BEFORE / diagnostic scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-10-no-timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the AFTER / proof scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-10-timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each prints the per-operation table, the summary, the checks, and the verdict — then exits with code &lt;code&gt;0&lt;/code&gt; when the scenario is proven. Timestamped reports are saved under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reports/Session-10/Session-10-No-Timeout-RunReport-&amp;lt;timestamp&amp;gt;.txt
Reports/Session-10/Session-10-Timeout-RunReport-&amp;lt;timestamp&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;No setup needed.&lt;/strong&gt; The hung dependency, its bulkhead, and the timeout all live inside the test runner. The 2000 ms hang is a &lt;em&gt;bounded&lt;/em&gt; delay (never a real infinite wait), and a scenario-level safety cap guards against anything hanging the runner.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The dependency stayed hung at 2000 ms, but our caller stopped waiting at 300 ms, cancelled cooperatively, and released the slot. Bulkhead limits how many; timeout limits how long. &lt;strong&gt;Do not wait forever.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>resilience</category>
      <category>async</category>
    </item>
    <item>
      <title>Day 9 — Bulkhead Isolation: Contain the Slow Dependency</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:27:15 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-9-bulkhead-isolation-contain-the-slow-dependency-491j</link>
      <guid>https://dev.to/moh_moh701/day-9-bulkhead-isolation-contain-the-slow-dependency-491j</guid>
      <description>&lt;p&gt;&lt;em&gt;When every kind of work shares one small pool of slots, a single slow dependency grabs them all — and perfectly healthy work waits behind it. First we build the ship without bulkheads to watch the water spread, then we add watertight walls that contain the leak.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffrxf46oilh9df1o8sejg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffrxf46oilh9df1o8sejg.png" alt="Bulkhead isolation — contain the slow dependency" width="800" height="877"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🙏 &lt;strong&gt;Credit where it's due&lt;/strong&gt; — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, &lt;em&gt;Fundamentals of Distributed Systems&lt;/em&gt; on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.&lt;/p&gt;

&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away:&lt;/strong&gt; A slow dependency should never be allowed to consume all of your shared resources. If all your work shares one pool of slots, then one slow thing fills the pool — and everything else, healthy or not, has to wait. Bulkhead isolation gives each dependency its own pool, so a slow one can only flood its own compartment.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 1 — The problem: one shared pool for everything
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Real-world analogy — the ship's compartments
&lt;/h3&gt;

&lt;p&gt;A ship's hull is divided into sealed &lt;strong&gt;compartments&lt;/strong&gt; called &lt;em&gt;bulkheads&lt;/em&gt;. If one compartment springs a leak, the watertight walls keep the water trapped there — the other compartments stay dry, and the ship stays afloat.&lt;/p&gt;

&lt;p&gt;A ship with &lt;strong&gt;no&lt;/strong&gt; bulkheads is the opposite: one leak, and water spreads through the entire hull until the whole ship sinks — even though only one small area was actually damaged.&lt;/p&gt;

&lt;p&gt;In software it's the same idea. Without resource isolation, one slow dependency "floods" the shared pool and the delay spreads to unrelated, healthy work. Today we build the ship &lt;em&gt;without&lt;/em&gt; bulkheads on purpose — to see the water spread.&lt;/p&gt;

&lt;h3&gt;
  
  
  One shared pool for everything
&lt;/h3&gt;

&lt;p&gt;Client-side, work usually runs against a limited pool of worker slots (threads, connections, permits). The mistake is letting &lt;strong&gt;every&lt;/strong&gt; kind of dependency call compete for the &lt;strong&gt;same&lt;/strong&gt; pool.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Different dependency calls share the &lt;strong&gt;same limited slots&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If one dependency turns &lt;strong&gt;slow&lt;/strong&gt;, its calls sit in those slots for a long time.&lt;/li&gt;
&lt;li&gt;Healthy calls — which don't even use the slow dependency — can't get a slot, so they &lt;strong&gt;wait&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The failure &lt;strong&gt;spreads&lt;/strong&gt; from one slow dependency to unrelated healthy work.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The trap:&lt;/strong&gt; the healthy work isn't slow because &lt;em&gt;it&lt;/em&gt; is broken. It's slow because it had to &lt;em&gt;queue behind&lt;/em&gt; something else that was broken — all because they shared one pool.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Scenario setup — three kinds of work, one pool
&lt;/h3&gt;

&lt;p&gt;We simulate three kinds of client work, all in-process (no real server, no database). Two are perfectly healthy; one is slow.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dependency&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Operations&lt;/th&gt;
&lt;th&gt;Work each&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;💳 Payment&lt;/td&gt;
&lt;td&gt;SLOW dependency&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;700 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📦 Inventory&lt;/td&gt;
&lt;td&gt;healthy &amp;amp; fast&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;60 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔔 Notification&lt;/td&gt;
&lt;td&gt;healthy &amp;amp; fast&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;60 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The anti-pattern: one global pool of 4 slots.&lt;/strong&gt; All three kinds of work share a single &lt;code&gt;SemaphoreSlim(4)&lt;/code&gt;. Payment starts first and grabs all four slots — so everyone else queues, healthy or not. Payments #5 and #6 queue too, along with all 3 Inventory and all 3 Notification operations, none of which can get a slot for ~700 ms.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Why this is deterministic.&lt;/strong&gt; Payment is launched first and holds all 4 slots for 700 ms. Healthy work only starts contending ~50 ms later — so it cannot possibly get a slot until a Payment call finishes. Every healthy op is guaranteed to wait.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Timeline — watch the water spread
&lt;/h3&gt;

&lt;p&gt;Here is what actually happens, in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~0 ms&lt;/strong&gt; — 6 &lt;strong&gt;Payment&lt;/strong&gt; ops launch. The first 4 grab all shared slots instantly; Payments #5 and #6 queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~50 ms&lt;/strong&gt; — 6 &lt;strong&gt;healthy&lt;/strong&gt; ops (Inventory + Notification) arrive — but every slot is already full of Payment. They join the queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~50–700 ms&lt;/strong&gt; — The pool stays full of 700 ms Payment work. Healthy work — which only needs 60 ms — sits idle, waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~700 ms&lt;/strong&gt; — The first Payments finish and free slots. Healthy work finally starts… having already waited ~650+ ms for nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~725–860 ms&lt;/strong&gt; — Healthy ops run their tiny 60 ms of work and finish — but their &lt;em&gt;total&lt;/em&gt; time was dominated by waiting, not working.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The leak spread.&lt;/strong&gt; Inventory and Notification were never broken. They were healthy the whole time — they just drowned in the same pool as slow Payment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The cost — 60 ms of work, ~726 ms of waiting
&lt;/h3&gt;

&lt;p&gt;A healthy operation should take about &lt;strong&gt;60 ms&lt;/strong&gt; — that's all the work it does. Instead, it spent most of its life stuck in the queue: &lt;strong&gt;726 ms&lt;/strong&gt; on average, up to &lt;strong&gt;792 ms&lt;/strong&gt; in the worst case.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;~12× slower than it should be.&lt;/strong&gt; Healthy work that needed 60 ms waited around 726 ms — almost all of that time was pure queuing behind slow Payment calls, not real work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;📊 Report numbers (from the real run):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total operations&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared pool size&lt;/td&gt;
&lt;td&gt;4 slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;💳 Payment operations&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📦🔔 Healthy operations (Inventory + Notification)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment work duration (each)&lt;/td&gt;
&lt;td&gt;700 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inventory work duration (each)&lt;/td&gt;
&lt;td&gt;60 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notification work duration (each)&lt;/td&gt;
&lt;td&gt;60 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average wait — healthy work&lt;/td&gt;
&lt;td&gt;726 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max wait — healthy work&lt;/td&gt;
&lt;td&gt;792 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Healthy ops delayed significantly&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 6 healthy operations completed successfully — they were never broken. They were simply made &lt;strong&gt;~12× slower&lt;/strong&gt; by sharing a pool with slow Payment work.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Diagnostic verdict:&lt;/strong&gt; NO BULKHEAD — SLOW DEPENDENCY DELAYED HEALTHY WORK ✅&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What this proves:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The healthy work was &lt;strong&gt;not&lt;/strong&gt; slow because Inventory or Notification were broken — they did their 60 ms of work just fine.&lt;/li&gt;
&lt;li&gt;It was slow because &lt;strong&gt;all dependency calls shared the same pool&lt;/strong&gt; with the slow Payment calls.&lt;/li&gt;
&lt;li&gt;One slow dependency was enough to &lt;strong&gt;delay unrelated healthy work&lt;/strong&gt; by an order of magnitude.&lt;/li&gt;
&lt;li&gt;This is a &lt;em&gt;contagion&lt;/em&gt; problem: the failure (slowness) leaked from one place to everywhere through the shared resource.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part 2 — The fix: give each dependency its own bulkhead
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What bulkhead isolation means
&lt;/h3&gt;

&lt;p&gt;In software, a bulkhead is the ship-compartment idea applied to resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each dependency gets its &lt;strong&gt;own isolated pool&lt;/strong&gt; of slots, instead of sharing one global pool.&lt;/li&gt;
&lt;li&gt;A slow dependency can only ever consume &lt;strong&gt;its own slots&lt;/strong&gt; — it can't touch anyone else's.&lt;/li&gt;
&lt;li&gt;The goal is &lt;strong&gt;not&lt;/strong&gt; to make Payment faster. Payment is still slow.&lt;/li&gt;
&lt;li&gt;The goal is to stop Payment from &lt;strong&gt;slowing down Inventory and Notification&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Watertight walls between dependencies.&lt;/strong&gt; If a slow dependency can only flood its own compartment, it can't take the rest of the system down with it. Bulkhead isolation is about limiting the &lt;em&gt;blast radius&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The AFTER setup — three isolated bulkheads
&lt;/h3&gt;

&lt;p&gt;The work is &lt;strong&gt;identical&lt;/strong&gt; to Part 1 — same dependencies, same durations, same counts. The &lt;em&gt;only&lt;/em&gt; thing that changes is the pools: instead of one shared pool, each dependency gets its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;paymentPool&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SemaphoreSlim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// Payment's own bulkhead&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;inventoryPool&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SemaphoreSlim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// Inventory's own bulkhead&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;notificationPool&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SemaphoreSlim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// Notification's own bulkhead&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Payment operations only ever take a slot from &lt;code&gt;paymentPool&lt;/code&gt;. Inventory only uses &lt;code&gt;inventoryPool&lt;/code&gt;. Notification only uses &lt;code&gt;notificationPool&lt;/code&gt;. Nothing crosses between them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Same head start, opposite result.&lt;/strong&gt; Just like Part 1, Payment launches first and gets a ~50 ms head start. But this time it fills only &lt;em&gt;its own&lt;/em&gt; 2-slot bulkhead — so when the healthy work arrives, the inventory and notification bulkheads are still wide open. The head start no longer matters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Where the slow work goes now
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before — one shared pool:&lt;/strong&gt; Payment fills the shared 4-slot pool; everyone else — healthy or not — queues behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After — three isolated bulkheads:&lt;/strong&gt; each dependency has its own 2-slot compartment. Payment fills its own bulkhead and Payments #3–#6 queue &lt;em&gt;inside the Payment bulkhead only&lt;/em&gt;. Meanwhile the Inventory and Notification bulkheads have free slots, so healthy work runs immediately.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The leak is contained.&lt;/strong&gt; The slow Payment "flood" can only fill the Payment compartment. Inventory and Notification stay dry — their slots were never Payment's to take.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Timeline — the healthy work no longer waits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~0 ms&lt;/strong&gt; — 6 &lt;strong&gt;Payment&lt;/strong&gt; ops launch. The first 2 fill the Payment bulkhead; Payments #3–#6 queue &lt;em&gt;inside the Payment bulkhead&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~50 ms&lt;/strong&gt; — 6 &lt;strong&gt;healthy&lt;/strong&gt; ops arrive. Their bulkheads are empty, so the first 2 Inventory + first 2 Notification ops start &lt;em&gt;immediately&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~60–130 ms&lt;/strong&gt; — Healthy ops finish their 60 ms of work. The 3rd Inventory + 3rd Notification op wait only ~60 ms — for &lt;em&gt;their own&lt;/em&gt; bulkhead, not for Payment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~130 ms&lt;/strong&gt; — All 6 healthy ops are &lt;strong&gt;done&lt;/strong&gt; — while Payment is still grinding through its own queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~2120 ms&lt;/strong&gt; — Payment finally finishes its 6 × 700 ms of work (queued behind itself, 2 at a time). Still slow — but it hurt no one else.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The flood stayed in one compartment.&lt;/strong&gt; Payment took ~2.1 seconds, exactly as slow as before. But the healthy work finished in ~130 ms — it never even noticed Payment was struggling.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The win — 726 ms of waiting → 21 ms
&lt;/h3&gt;

&lt;p&gt;A healthy operation does about &lt;strong&gt;60 ms&lt;/strong&gt; of work. In Part 1 it spent most of its life queued behind Payment. With bulkheads, the wait nearly vanishes: the average healthy wait drops to &lt;strong&gt;21 ms&lt;/strong&gt;, and even the worst case (&lt;strong&gt;62 ms&lt;/strong&gt;) is below the operation's own work duration.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;~35× less waiting.&lt;/strong&gt; Healthy work went from ~726 ms of average wait down to just 21 ms. None of the 6 healthy ops was delayed significantly (threshold: wait ≥ 200 ms).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;📊 Report numbers (from the real run):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total operations&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;💳 Payment bulkhead size&lt;/td&gt;
&lt;td&gt;2 slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📦 Inventory bulkhead size&lt;/td&gt;
&lt;td&gt;2 slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔔 Notification bulkhead size&lt;/td&gt;
&lt;td&gt;2 slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;💳 Payment operations&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📦🔔 Healthy operations (Inventory + Notification)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment work duration (each)&lt;/td&gt;
&lt;td&gt;700 ms (still slow)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Healthy work duration (each)&lt;/td&gt;
&lt;td&gt;60 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average wait — Payment (its own bulkhead)&lt;/td&gt;
&lt;td&gt;705 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average wait — healthy work&lt;/td&gt;
&lt;td&gt;21 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max wait — healthy work&lt;/td&gt;
&lt;td&gt;62 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Healthy ops delayed significantly&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total scenario duration&lt;/td&gt;
&lt;td&gt;2120 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;⚖️ Before vs after — same work, isolated pools:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (healthy work)&lt;/th&gt;
&lt;th&gt;BEFORE&lt;/th&gt;
&lt;th&gt;AFTER&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Avg wait — healthy&lt;/td&gt;
&lt;td&gt;~726 ms&lt;/td&gt;
&lt;td&gt;21 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max wait — healthy&lt;/td&gt;
&lt;td&gt;~792 ms&lt;/td&gt;
&lt;td&gt;62 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Healthy ops delayed significantly&lt;/td&gt;
&lt;td&gt;6 / 6&lt;/td&gt;
&lt;td&gt;0 / 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment still slow?&lt;/td&gt;
&lt;td&gt;700 ms&lt;/td&gt;
&lt;td&gt;700 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Payment is &lt;strong&gt;exactly as slow&lt;/strong&gt; in both runs — that's the point. Bulkhead isolation didn't fix Payment; it stopped Payment from hurting the healthy work. The healthy wait collapsed from ~726 ms to 21 ms.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Proof verdict:&lt;/strong&gt; BULKHEAD ISOLATION — SLOW PAYMENT WAS CONTAINED ✅&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What this proves:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The slow Payment work &lt;strong&gt;still ran slowly&lt;/strong&gt; (700 ms each, ~2.1 s total) — the bulkhead does not fix the slow dependency.&lt;/li&gt;
&lt;li&gt;But Payment was &lt;strong&gt;contained to its own pool&lt;/strong&gt; — it queued only behind itself, never in front of healthy work.&lt;/li&gt;
&lt;li&gt;The healthy work waited an average of just &lt;strong&gt;21 ms&lt;/strong&gt; (max 62 ms), down from ~726 ms — &lt;strong&gt;0 of 6&lt;/strong&gt; healthy ops were delayed significantly.&lt;/li&gt;
&lt;li&gt;One slow dependency could no longer leak across a shared resource and drag down unrelated work.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The lesson behind the lesson:&lt;/strong&gt; isolation is about &lt;em&gt;limiting blast radius&lt;/em&gt;. You may not be able to make a dependency fast — but you can make sure its slowness stays its own problem.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;Run the BEFORE / diagnostic scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-09-no-bulkhead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the AFTER / proof scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-09-bulkhead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each prints the per-operation table, the summary, the checks, and the verdict — then exits with code &lt;code&gt;0&lt;/code&gt; when the scenario is proven. Timestamped reports are saved under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reports/Session-09/Session-09-No-Bulkhead-RunReport-&amp;lt;timestamp&amp;gt;.txt
Reports/Session-09/Session-09-Bulkhead-RunReport-&amp;lt;timestamp&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;No setup needed.&lt;/strong&gt; The three dependencies and their pools all live inside the test runner. There is no server to start and no database to seed — just run the command.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Bulkhead isolation does not fix the slow dependency itself — Payment was just as slow as before. What it does is limit the blast radius: the slow work is trapped in its own compartment, and the healthy work sails through untouched. &lt;strong&gt;One leak should not sink the whole ship.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>resilience</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Day 8 — Circuit Breaker: Stops the Retry Storm</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:26:37 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-8-circuit-breaker-stops-the-retry-storm-44fl</link>
      <guid>https://dev.to/moh_moh701/day-8-circuit-breaker-stops-the-retry-storm-44fl</guid>
      <description>&lt;p&gt;Retries rescue you from a &lt;em&gt;blip&lt;/em&gt;. But when a dependency is fully down, those same retries pile on and become a storm. Today the client learns to stop knocking on a door that won't open — and to notice, on its own, when the door opens again.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fguif70jpt1flqtq49jn4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fguif70jpt1flqtq49jn4.png" alt="Circuit breaker — three states, one self-healing loop" width="800" height="939"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🙏 &lt;strong&gt;Credit where it's due&lt;/strong&gt; — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, &lt;em&gt;Fundamentals of Distributed Systems&lt;/em&gt; on Udemy. The course material, structure, and topic flow are his work; the explanations, code examples, and diagrams in these articles are my own rewrite in my own words. If you find this useful, please consider taking the course — it goes much deeper than these articles can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Recap — where retries left us
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Day 6&lt;/strong&gt; taught the client to retry with backoff + jitter. &lt;strong&gt;Day 7&lt;/strong&gt; taught the server to push back with &lt;code&gt;429 + Retry-After&lt;/code&gt;, and the client (Part 2) to obey it — turning a &lt;em&gt;temporary&lt;/em&gt; rejection into an eventual success.&lt;/p&gt;

&lt;p&gt;That whole story assumes the dependency is still &lt;em&gt;alive&lt;/em&gt;, just busy. Day 8 asks the harder question: &lt;strong&gt;what if the dependency is genuinely down?&lt;/strong&gt; Now retrying isn't patience — it's a hammer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea to take away
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Retries help with temporary failure. A circuit breaker protects the system when failure is sustained.&lt;/strong&gt; When every call is failing, the smartest thing a client can do is &lt;em&gt;stop calling for a while&lt;/em&gt; — fail fast, give the dependency room to recover, and probe gently to see when it's back.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. The Problem — retries become a storm
&lt;/h2&gt;

&lt;p&gt;Take the polite retry client from Day 7 and point it at a dependency (say a &lt;code&gt;payments&lt;/code&gt; service) that is &lt;strong&gt;completely down&lt;/strong&gt;. Retry-After never arrives because the dependency isn't answering at all — it just fails. So the client does what it was told: try, wait, try again, up to its budget.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;12 concurrent operations&lt;/strong&gt; and &lt;strong&gt;4 attempts each&lt;/strong&gt;, that is &lt;strong&gt;12 × 4 = 48&lt;/strong&gt; calls slammed into something that is already on the floor. Every call fails. Nothing completes. The retries didn't help — they &lt;em&gt;multiplied the load&lt;/em&gt; on a broken dependency exactly when it needed breathing room.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🐛 &lt;strong&gt;The retry storm.&lt;/strong&gt; A naive retrying client treats "down" the same way it treats "busy." Against a sustained outage that means maximum wasted load: &lt;strong&gt;48 dependency calls, 0 completions&lt;/strong&gt;. This is the trap a circuit breaker exists to stop.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. Mental Model — the three states
&lt;/h2&gt;

&lt;p&gt;A circuit breaker is a tiny state machine that sits &lt;em&gt;in front of&lt;/em&gt; the dependency call. It watches failures and decides whether a call is even allowed to go out.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;● Closed&lt;/strong&gt; — Normal. Calls flow through. Consecutive failures are counted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;▲ Open&lt;/strong&gt; — Tripped. Calls &lt;strong&gt;fail fast&lt;/strong&gt; — the dependency isn't touched at all — for a cooldown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;◆ Half-Open&lt;/strong&gt; — After cooldown, &lt;strong&gt;one probe&lt;/strong&gt; is allowed through to test the waters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The loop closes itself: a probe that &lt;strong&gt;fails&lt;/strong&gt; sends the breaker back to Open for another cooldown; a probe that &lt;strong&gt;succeeds&lt;/strong&gt; returns it to Closed and normal traffic resumes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cycle, as a timeline
&lt;/h3&gt;

&lt;p&gt;Here is the actual path the breaker walked in our run (one shared breaker protecting the dependency, with the dependency recovering partway through):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~0 ms&lt;/td&gt;
&lt;td&gt;CLOSED&lt;/td&gt;
&lt;td&gt;First calls go out. The dependency is down → they fail. After &lt;strong&gt;3&lt;/strong&gt; consecutive failures…&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~5 ms&lt;/td&gt;
&lt;td&gt;→ OPEN&lt;/td&gt;
&lt;td&gt;Breaker trips. The next wave of attempts &lt;strong&gt;fails fast&lt;/strong&gt; — no dependency calls made.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~250 ms&lt;/td&gt;
&lt;td&gt;→ HALF-OPEN&lt;/td&gt;
&lt;td&gt;Cooldown elapsed → one probe allowed. Dependency still down (&amp;lt; 350 ms) → probe fails.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~250 ms&lt;/td&gt;
&lt;td&gt;→ OPEN&lt;/td&gt;
&lt;td&gt;Failed probe re-opens the breaker for another cooldown.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~500 ms&lt;/td&gt;
&lt;td&gt;→ HALF-OPEN&lt;/td&gt;
&lt;td&gt;Cooldown elapsed again → one probe allowed. Dependency has recovered (≥ 350 ms) → probe &lt;strong&gt;succeeds&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~500 ms&lt;/td&gt;
&lt;td&gt;→ CLOSED&lt;/td&gt;
&lt;td&gt;Breaker closes. Remaining operations sail through and complete.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;One breaker per dependency.&lt;/strong&gt; All 12 operations share a single breaker, because the thing being protected is the shared dependency — not any one operation. When it trips, it trips for everyone; when it heals, it heals for everyone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. What does "fail fast" mean?
&lt;/h2&gt;

&lt;p&gt;"Fail fast" sounds strange at first — why would &lt;em&gt;failing&lt;/em&gt; ever be a good thing? The trick is in the word &lt;strong&gt;fast&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When the dependency is down, a normal call doesn't fail instantly. It waits — for a connection, for a timeout, for a retry delay — and only &lt;em&gt;then&lt;/em&gt; gives up. Multiply that wasted waiting across 12 operations × 4 attempts and you get a pile of slow, pointless calls hammering something that is already broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail fast means the client's own circuit breaker says "no" immediately&lt;/strong&gt;, before any network call goes out. The attempt is rejected by a tiny in-memory guard in microseconds, instead of waiting on a dependency that is almost certainly still down.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;🏠 Everyday picture.&lt;/strong&gt; Imagine knocking on a shop door and seeing a &lt;em&gt;"CLOSED"&lt;/em&gt; sign in the window. You don't stand there knocking for 30 seconds hoping — you read the sign and move on in a split second. The "Open" circuit breaker &lt;em&gt;is&lt;/em&gt; that CLOSED sign: it lets the client give up instantly instead of waiting.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In our run, &lt;strong&gt;22 attempts failed fast&lt;/strong&gt; while the circuit was Open. Those 22 attempts never touched the &lt;code&gt;payments&lt;/code&gt; dependency at all — that is exactly the load the breaker shed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;Why this helps the dependency.&lt;/strong&gt; Every fast-failed attempt is one less call landing on a service that is trying to restart. Failing fast isn't giving up on the user — it's giving the broken dependency the quiet it needs to actually come back.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. Why Half-Open exists
&lt;/h2&gt;

&lt;p&gt;If the breaker only had &lt;strong&gt;Closed&lt;/strong&gt; and &lt;strong&gt;Open&lt;/strong&gt;, we'd hit a new problem: once it tripped Open, &lt;em&gt;how would it ever know the dependency came back?&lt;/em&gt; If it stays Open forever, it has just turned a temporary outage into a permanent one — now &lt;em&gt;we&lt;/em&gt; are the reason nothing works.&lt;/p&gt;

&lt;p&gt;But the opposite is also dangerous. If, the moment the cooldown ends, the breaker flung &lt;em&gt;all&lt;/em&gt; traffic back at the dependency at once, it could knock a still-fragile service straight back down. We need a gentle, careful test — not a stampede.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half-Open is that careful test.&lt;/strong&gt; After the cooldown (&lt;code&gt;OpenDurationMs = 200&lt;/code&gt;), the breaker lets &lt;em&gt;exactly one&lt;/em&gt; probe request through while everyone else keeps fast-failing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the single probe &lt;strong&gt;succeeds&lt;/strong&gt; → the dependency is back → the breaker &lt;strong&gt;closes&lt;/strong&gt; and normal traffic resumes for everyone.&lt;/li&gt;
&lt;li&gt;If the probe &lt;strong&gt;fails&lt;/strong&gt; → still down → straight back to &lt;strong&gt;Open&lt;/strong&gt; for another cooldown, and we try again later.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;🚪 Everyday picture.&lt;/strong&gt; After the shop's posted closing time, you don't send the whole queue crashing through the door. You send &lt;em&gt;one&lt;/em&gt; person to gently try the handle. If it opens, everyone follows. If it's still locked, the queue waits a bit longer. That one careful tester is the Half-Open probe.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In our run this happened &lt;strong&gt;twice&lt;/strong&gt;: the first probe (at ~250 ms) landed while the dependency was still down and re-opened the breaker; a later probe (after the 350 ms recovery) succeeded and closed it. That is the self-healing loop working on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Knobs — and why the timing is what it is
&lt;/h2&gt;

&lt;p&gt;The breaker is hand-written with four visible constants:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constant&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MaxAttempts = 4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Retry budget per operation (same as the BEFORE scenario, so the two compare fairly).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RetryDelayMs = 250&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wait between attempts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FailureThreshold = 3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Consecutive failures that trip the breaker Closed → Open.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OpenDurationMs = 200&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cooldown the breaker stays Open before allowing a Half-Open probe.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The simulated dependency is &lt;strong&gt;down while elapsed &amp;lt; &lt;code&gt;RecoveryAfterMs = 350&lt;/code&gt;&lt;/strong&gt;, then it recovers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;OpenDurationMs&lt;/code&gt; and &lt;code&gt;RecoveryAfterMs&lt;/code&gt; were tuned (a teaching choice, not a fudge)
&lt;/h3&gt;

&lt;p&gt;One operation's lifetime is bounded by its retry budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;operation&lt;/span&gt; &lt;span class="n"&gt;lifetime&lt;/span&gt; &lt;span class="err"&gt;≈&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MaxAttempts&lt;/span&gt; &lt;span class="err"&gt;−&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;RetryDelayMs&lt;/span&gt;
                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="err"&gt;−&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="m"&gt;250&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="m"&gt;750&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That ~750 ms ceiling is the whole reason the other two numbers are what they are. For this lesson to actually &lt;em&gt;show&lt;/em&gt; a full recovery, the breaker must travel &lt;strong&gt;Open → Half-Open → (failed probe) → Open → Half-Open → (successful probe) → Closed&lt;/strong&gt; &lt;em&gt;before&lt;/em&gt; the operations run out of attempts and finish.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;OpenDurationMs&lt;/code&gt; were large (e.g. the 750 ms a real system might use), the cooldown alone would consume an operation's entire lifetime. The operations would all finish &lt;em&gt;while the circuit was still Open&lt;/em&gt; — you'd never see a Half-Open probe, never see the circuit close, and never see recovery. The lesson would be invisible.&lt;/li&gt;
&lt;li&gt;So &lt;code&gt;OpenDurationMs = 200&lt;/code&gt; (shorter than 750 ms) lets the breaker cycle through Half-Open at least twice inside the budget.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RecoveryAfterMs = 350&lt;/code&gt; is placed so the &lt;em&gt;first&lt;/em&gt; probe lands while the dependency is still down (proving fast-fail and re-open), and a &lt;em&gt;later&lt;/em&gt; probe lands after recovery (proving the close). It sits comfortably below the ~750 ms ceiling, leaving room for completed work after the breaker closes.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;In production these numbers are bigger.&lt;/strong&gt; A real breaker might stay Open for many seconds and probe slowly. We shrank the clock here purely so a single short, deterministic run demonstrates the full Closed → Open → Half-Open → Closed cycle. The &lt;em&gt;mechanism&lt;/em&gt; is identical; only the dial settings are scaled down for teaching.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. How the Code Works
&lt;/h2&gt;

&lt;p&gt;Let's walk through the AFTER scenario — &lt;code&gt;Session08CircuitBreakerScenario.cs&lt;/code&gt; — one piece at a time. No huge code dumps; just the moving parts and how they fit together. Everything below uses the &lt;em&gt;actual&lt;/em&gt; names from the file.&lt;/p&gt;

&lt;h3&gt;
  
  
  ① The constants — the dials of the experiment
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constant&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OperationCount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many logical operations fire at once. It comes from the session context (&lt;code&gt;ctx.OperationCount&lt;/code&gt;) and is &lt;strong&gt;12&lt;/strong&gt; in this run.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MaxAttempts = 4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Each operation may try up to 4 times before it gives up.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RetryDelayMs = 250&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The wait between two attempts of the same operation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FailureThreshold = 3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Three consecutive failures while Closed trip the breaker to Open.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OpenDurationMs = 200&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long the breaker stays Open (fast-failing) before it lets one probe through.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RecoveryAfterMs = 350&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The simulated dependency is down before this time and healthy after it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  ② The fake dependency — no server, no database
&lt;/h3&gt;

&lt;p&gt;The dependency is not a real &lt;code&gt;payments&lt;/code&gt; service. It is a tiny local method, &lt;code&gt;CallDependencyAsync()&lt;/code&gt;, living inside the test runner. It does only two things: count itself, and answer "am I up yet?" purely from the clock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CallDependencyAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Interlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;dependencyCallsMade&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// count every real call&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Yield&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ElapsedMilliseconds&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;RecoveryAfterMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// down &amp;lt; 350 ms, up after&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;In-process only.&lt;/strong&gt; While elapsed time is &lt;em&gt;less than&lt;/em&gt; &lt;code&gt;RecoveryAfterMs&lt;/code&gt; it returns &lt;code&gt;false&lt;/code&gt; (down); once elapsed reaches &lt;code&gt;RecoveryAfterMs&lt;/code&gt; it returns &lt;code&gt;true&lt;/code&gt; (healthy). There is no network call, no real server, and no database anywhere in this lesson — just a stopwatch and a counter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  ③ The three breaker states
&lt;/h3&gt;

&lt;p&gt;The breaker is the small &lt;code&gt;CircuitBreaker&lt;/code&gt; class, and its mood is a single &lt;code&gt;enum BreakerState { Closed, Open, HalfOpen }&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;● Closed&lt;/strong&gt; — Healthy. Every call is allowed through, and consecutive failures are counted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;▲ Open&lt;/strong&gt; — Tripped. Calls are rejected instantly (fast fail) — the dependency is not touched — for &lt;code&gt;OpenDurationMs&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;◆ HalfOpen&lt;/strong&gt; — Testing the water. Exactly one probe is let through; everyone else still fast-fails until that probe resolves.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ④ The gate method — ask before you call
&lt;/h3&gt;

&lt;p&gt;The key idea of the whole lesson lives in one method, &lt;code&gt;TryAcquire()&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;Before calling the dependency, the operation asks the breaker first.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;TryAcquire()&lt;/code&gt; returns an &lt;code&gt;AcquireResult(bool Allowed, bool IsProbe)&lt;/code&gt;. The operation then reacts to that answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;acq&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;breaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryAcquire&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;acq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fastFails&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt;                 &lt;span class="c1"&gt;// breaker said NO → fast fail, dependency untouched&lt;/span&gt;
    &lt;span class="c1"&gt;// …wait, then try the next attempt&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;depCalls&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt;                      &lt;span class="c1"&gt;// breaker said YES → the call reaches the dependency&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;CallDependencyAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;If the breaker says &lt;strong&gt;yes&lt;/strong&gt; (&lt;code&gt;Allowed = true&lt;/code&gt;) the call actually reaches &lt;code&gt;CallDependencyAsync()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the breaker says &lt;strong&gt;no&lt;/strong&gt; (&lt;code&gt;Allowed = false&lt;/code&gt;) the attempt becomes a &lt;strong&gt;fast fail&lt;/strong&gt; — &lt;code&gt;fastFails&lt;/code&gt; is incremented and the dependency is never touched.&lt;/li&gt;
&lt;li&gt;When Open and the cooldown has elapsed, &lt;code&gt;TryAcquire()&lt;/code&gt; promotes exactly one caller to a probe (&lt;code&gt;IsProbe = true&lt;/code&gt;) and flips the state to &lt;code&gt;HalfOpen&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⑤ Recording failures
&lt;/h3&gt;

&lt;p&gt;When a call fails, the operation tells the breaker with &lt;code&gt;RecordFailure(acq.IsProbe)&lt;/code&gt;. Inside, &lt;code&gt;_consecutiveFailures&lt;/code&gt; climbs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;While &lt;strong&gt;Closed&lt;/strong&gt;, reaching &lt;code&gt;FailureThreshold = 3&lt;/code&gt; consecutive failures trips the breaker to &lt;strong&gt;Open&lt;/strong&gt; and records the moment. Three is the line in the sand: a blip or two is tolerated, but a sustained run of failures means "stop hammering."&lt;/li&gt;
&lt;li&gt;If a &lt;strong&gt;Half-Open probe&lt;/strong&gt; fails (&lt;code&gt;wasProbe = true&lt;/code&gt;), the breaker goes &lt;em&gt;straight back to Open&lt;/em&gt; for another full cooldown — no need to wait for three more failures; one failed probe is proof the dependency is still down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⑥ Recording success
&lt;/h3&gt;

&lt;p&gt;A successful call calls &lt;code&gt;RecordSuccess(acq.IsProbe)&lt;/code&gt;, which:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Resets &lt;code&gt;_consecutiveFailures&lt;/code&gt; back to 0 and sets the state to &lt;strong&gt;Closed&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If the success was a &lt;strong&gt;Half-Open probe&lt;/strong&gt;, it also flips &lt;code&gt;ClosedAfterRecovery = true&lt;/code&gt; — that is the breaker noticing, on its own, that the dependency came back. A good probe &lt;em&gt;closes the circuit&lt;/em&gt; and normal traffic resumes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⑦ The retry loop — every attempt asks first
&lt;/h3&gt;

&lt;p&gt;Each operation runs the same loop up to &lt;code&gt;MaxAttempts = 4&lt;/code&gt; times. The crucial detail: &lt;strong&gt;every attempt begins by asking the breaker&lt;/strong&gt;, so the &lt;em&gt;same&lt;/em&gt; attempt can become either a real dependency call or a fast fail depending on the breaker's current state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One attempt, step by step:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;breaker.TryAcquire()&lt;/code&gt; — ask permission.

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not allowed?&lt;/strong&gt; &lt;code&gt;fastFails++&lt;/code&gt;, wait &lt;code&gt;RetryDelayMs&lt;/code&gt;, go to next attempt. &lt;em&gt;Dependency untouched.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowed?&lt;/strong&gt; &lt;code&gt;depCalls++&lt;/code&gt;, call &lt;code&gt;CallDependencyAsync()&lt;/code&gt;.

&lt;ul&gt;
&lt;li&gt;Success → &lt;code&gt;RecordSuccess&lt;/code&gt; → mark completed → &lt;strong&gt;break&lt;/strong&gt; (stop early, no more attempts).&lt;/li&gt;
&lt;li&gt;Failure → &lt;code&gt;RecordFailure&lt;/code&gt; → wait &lt;code&gt;RetryDelayMs&lt;/code&gt; → next attempt.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So early attempts (breaker Closed) tend to be real dependency calls; the wave right after the breaker trips becomes fast fails; later attempts may be probes. That mix is exactly what the report counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⑧ Reading the report numbers
&lt;/h3&gt;

&lt;p&gt;Two independent counters drive the verdict: &lt;code&gt;dependencyCallsMade&lt;/code&gt; (incremented inside the dependency) and &lt;code&gt;fastFails&lt;/code&gt; (incremented when the gate says no). Here is how the headline numbers fall out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BEFORE = 48 dependency calls.&lt;/strong&gt; With no breaker, all 12 operations always burn their full 4 attempts and every attempt hits the dependency: &lt;code&gt;12 × 4 = 48&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AFTER = 25 dependency calls + 22 fast fails.&lt;/strong&gt; With the breaker in front, only 25 attempts actually reached the dependency; the other 22 were rejected instantly while the circuit was Open. Those 22 calls are the load the breaker shed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why total attempts can be 47, not 48.&lt;/strong&gt; Every attempt is &lt;em&gt;either&lt;/em&gt; a dependency call &lt;em&gt;or&lt;/em&gt; a fast fail, so total attempts = &lt;code&gt;25 + 22 = 47&lt;/code&gt;. The baseline is 48 (&lt;code&gt;12 × 4&lt;/code&gt;). The "missing" attempt is simply an operation that &lt;strong&gt;completed early&lt;/strong&gt;: once a call succeeds, the loop &lt;code&gt;break&lt;/code&gt;s and the operation does not use its remaining attempt(s).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not a bug.&lt;/strong&gt; A lower-than-baseline total attempt count is the breaker working as designed — finishing the moment the dependency is healthy instead of mechanically spending all four attempts. Fewer calls &lt;em&gt;and&lt;/em&gt; fewer wasted attempts is the win.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Quick check:&lt;/strong&gt; &lt;code&gt;dependencyCallsMade (25) + fastFails (22) = totalAttempts (47) ≤ baseline (48)&lt;/code&gt;. If you see 48, no operation finished early; if you see less, at least one did.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  7. BEFORE — No Circuit Breaker
&lt;/h2&gt;

&lt;p&gt;File: &lt;code&gt;tools/Wassal.SessionTests/Sessions/Session08NoCircuitBreakerScenario.cs&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-08-no-circuit-breaker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; fires 12 concurrent operations at a dependency that is always down. Each operation retries naively up to &lt;code&gt;MaxAttempts&lt;/code&gt; — no breaker, nothing to stop the calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verified result:&lt;/strong&gt; 12 operations, each burning all 4 attempts → &lt;strong&gt;48 dependency calls&lt;/strong&gt;, &lt;strong&gt;0 completed&lt;/strong&gt;, &lt;strong&gt;12 failed&lt;/strong&gt;, average attempts/operation = 4.00.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VERDICT: NO CIRCUIT BREAKER RETRY STORM DEMONSTRATED ✅&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🐛 &lt;strong&gt;Passes by proving the problem.&lt;/strong&gt; Like every BEFORE diagnostic, success here means the bug was demonstrated: every operation consumed its full retry budget against a down dependency, and all 48 calls were wasted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  8. AFTER — With the Circuit Breaker
&lt;/h2&gt;

&lt;p&gt;File: &lt;code&gt;tools/Wassal.SessionTests/Sessions/Session08CircuitBreakerScenario.cs&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commands&lt;/strong&gt; (the short &lt;code&gt;session-08&lt;/code&gt; is an alias for the AFTER scenario):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-08-circuit-breaker

&lt;span class="c"&gt;# Short alias → runs the AFTER (circuit-breaker) scenario:&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-08
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; the same 12 concurrent operations, but every attempt goes &lt;em&gt;through&lt;/em&gt; a shared circuit breaker. The dependency is down for the first 350 ms, then recovers. The breaker trips, fast-fails the open-circuit attempts, probes at each cooldown, and closes once the dependency is back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verified result:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;12 operations · &lt;strong&gt;12 completed&lt;/strong&gt; · 0 failed&lt;/li&gt;
&lt;li&gt;Circuit opened: &lt;strong&gt;2&lt;/strong&gt; · Half-open probes: &lt;strong&gt;2&lt;/strong&gt; · Closed after recovery: &lt;strong&gt;YES&lt;/strong&gt; · final state: &lt;strong&gt;Closed&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency calls made: 25&lt;/strong&gt; (vs the 48 baseline) → &lt;strong&gt;23 calls avoided&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast-failed attempts: 22&lt;/strong&gt; (rejected instantly while the circuit was Open — dependency untouched)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;VERDICT: CIRCUIT BREAKER PROTECTED THE DEPENDENCY ✅&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;The proof is protection + recovery, not perfect completion.&lt;/strong&gt; The verdict does &lt;em&gt;not&lt;/em&gt; require all 12 to finish — under concurrency some operations can exhaust their attempts before recovery. What it requires is that the breaker &lt;em&gt;opened&lt;/em&gt;, &lt;em&gt;fast-failed&lt;/em&gt;, made &lt;em&gt;fewer calls than the baseline&lt;/em&gt;, allowed a &lt;em&gt;half-open probe&lt;/em&gt;, &lt;em&gt;closed after recovery&lt;/em&gt;, and completed &lt;em&gt;at least one&lt;/em&gt; operation. In this run all 12 happened to complete — even better.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  BEFORE vs AFTER
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;BEFORE — no breaker&lt;/th&gt;
&lt;th&gt;AFTER — circuit breaker&lt;/th&gt;
&lt;th&gt;Difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dependency calls&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;23 avoided&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Completed operations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;+12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fast-failed attempts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;load shed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auto-recovery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;half-open → closed&lt;/td&gt;
&lt;td&gt;self-healing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Verdict&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RETRY STORM DEMONSTRATED ✅&lt;/td&gt;
&lt;td&gt;DEPENDENCY PROTECTED ✅&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same outage, same retry budget, same 12 operations. The only change is a small state machine in front of the call — and it nearly &lt;strong&gt;halved&lt;/strong&gt; the load on the broken dependency (48 → 25) while taking completions from &lt;strong&gt;0 to 12&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What We Proved · What We Didn't Do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;✅ What we proved:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Against a sustained outage, blind retries make &lt;strong&gt;48 wasted calls&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A circuit breaker &lt;strong&gt;trips Open&lt;/strong&gt; after a few failures and &lt;strong&gt;fails fast&lt;/strong&gt; — sparing the dependency.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;probes&lt;/strong&gt; in Half-Open and &lt;strong&gt;re-opens&lt;/strong&gt; if still broken.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;closes automatically&lt;/strong&gt; when a probe succeeds after recovery.&lt;/li&gt;
&lt;li&gt;Net effect: &lt;strong&gt;48 → 25 calls&lt;/strong&gt; (23 avoided) and &lt;strong&gt;0 → 12 completed&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;🚧 What we intentionally did not do:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No &lt;strong&gt;Polly&lt;/strong&gt; — the breaker is hand-written so the states are visible.&lt;/li&gt;
&lt;li&gt;No &lt;strong&gt;external package&lt;/strong&gt; or resilience library.&lt;/li&gt;
&lt;li&gt;No &lt;strong&gt;real server dependency&lt;/strong&gt; — the dependency is simulated in-process.&lt;/li&gt;
&lt;li&gt;No &lt;strong&gt;database&lt;/strong&gt; involved in this lesson.&lt;/li&gt;
&lt;li&gt;No &lt;strong&gt;production-ready&lt;/strong&gt; implementation (no sliding-window stats, half-open concurrency limits, metrics, or per-endpoint breakers).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. The Important Lesson — three tools, three different jobs
&lt;/h2&gt;

&lt;p&gt;Days 6, 7 and 8 each added a resilience tool. Beginners often blur them together, but they solve &lt;em&gt;different&lt;/em&gt; problems and live on &lt;em&gt;different&lt;/em&gt; sides of the wire. Here is the one-line difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;🔁 Retry — "try harder"&lt;/strong&gt; — Lives on the &lt;strong&gt;client&lt;/strong&gt;. Assumes the failure is a brief &lt;em&gt;blip&lt;/em&gt;, so it tries again (with backoff + jitter). Great for transient hiccups — but against a real outage it just &lt;em&gt;tries harder at the wrong moment&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🚦 Rate limiting — "protect the server from too much traffic"&lt;/strong&gt; — Lives on the &lt;strong&gt;server&lt;/strong&gt;. The server defends &lt;em&gt;itself&lt;/em&gt; from overload by rejecting excess requests (&lt;code&gt;429 + Retry-After&lt;/code&gt;). It protects the thing being called &lt;em&gt;from the callers&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;⛓ Circuit breaker — "protect a failing dependency from being hammered"&lt;/strong&gt; — Lives on the &lt;strong&gt;client&lt;/strong&gt;. When a dependency is clearly &lt;em&gt;down&lt;/em&gt;, the client stops calling it (fail fast) so the broken dependency gets room to recover — then probes gently to detect when it's back.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The mental shortcut:&lt;/strong&gt; Retry says &lt;em&gt;"keep trying."&lt;/em&gt; Rate limiting says &lt;em&gt;"the server protects itself."&lt;/em&gt; Circuit breaker says &lt;em&gt;"the caller protects the callee — by knowing when to stop."&lt;/em&gt; Retry and circuit breaker are partners: retry handles blips, the breaker steps in when blips turn into an outage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  11. Run It Yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;① Run the AFTER scenario (the circuit breaker):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-08-circuit-breaker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;② Compare against the BEFORE scenario (no breaker):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-08-no-circuit-breaker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run prints the per-operation table, the breaker summary, the PASS/FAIL checks, and the final verdict — then exits with code &lt;code&gt;0&lt;/code&gt; when the proof holds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Where to find the report:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every run also saves a timestamped text report under the &lt;code&gt;Reports/Session-08/&lt;/code&gt; folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Reports/Session-08/Session-08-Circuit-Breaker-RunReport-&amp;lt;timestamp&amp;gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run behind this article is saved as &lt;code&gt;Reports/Session-08/Session-08-Circuit-Breaker-RunReport-2026-06-28-130200.txt&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;No setup needed.&lt;/strong&gt; The dependency, the clock, and the breaker all live inside the test runner. There is no server to start and no database to seed — just run the command and read the report.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;A circuit breaker is one of the smallest pieces of code with one of the biggest payoffs in distributed systems. In this lesson, a single hand-written state machine in front of the dependency call turned a wasteful storm — &lt;strong&gt;48 calls, 0 completions&lt;/strong&gt; — into protected, self-healing traffic: &lt;strong&gt;25 calls, 22 fast fails, 12 completions, and an automatic recovery&lt;/strong&gt;. The client stopped knocking on a locked door, waited, gently checked the handle, and walked through the moment it opened.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Retries help with &lt;strong&gt;temporary&lt;/strong&gt; failure. Circuit breakers protect the system when failure is &lt;strong&gt;sustained&lt;/strong&gt;. Day 6 made retries gentle. Day 7 made the server push back. Day 8 teaches the client when to &lt;em&gt;stop pushing&lt;/em&gt; — and how to notice, by itself, that the dependency has come back.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>resilience</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Distributed Systems in .NET — Day 7 — Part 2</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:26:32 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-7-part-2-the-polite-client-obeys-retry-after-3k9b</link>
      <guid>https://dev.to/moh_moh701/day-7-part-2-the-polite-client-obeys-retry-after-3k9b</guid>
      <description>&lt;p&gt;The server already learned to say "not now." Now the client learns to listen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz7e37djm4gyw8slg78mi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz7e37djm4gyw8slg78mi.png" alt="Retry-After — the polite client waits" width="800" height="693"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;This is Day 7 — Part 2, not Lesson 8.&lt;/strong&gt; It is a direct continuation of the &lt;code&gt;429 + Retry-After&lt;/code&gt; lesson from Part 1. Part 1 was the &lt;em&gt;server&lt;/em&gt; side (rate limiting); Part 2 is the &lt;em&gt;client&lt;/em&gt; side (cooperating with the server's signal). Same lesson, second half.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Recap from Day 7 · Part 1
&lt;/h2&gt;

&lt;p&gt;In Part 1 we taught the &lt;strong&gt;server&lt;/strong&gt; to defend itself with a token-bucket rate limiter on &lt;code&gt;POST /api/orders&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the bucket runs dry, the server returns &lt;strong&gt;HTTP 429 Too Many Requests&lt;/strong&gt; with a &lt;strong&gt;Retry-After&lt;/strong&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rejected requests did no database work&lt;/strong&gt; — a 429 creates no order, so there is never a half-finished mess to clean up.&lt;/li&gt;
&lt;li&gt;The server &lt;strong&gt;stayed healthy&lt;/strong&gt; under a flood by shedding the excess instead of trying to serve everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Part 1 numbers, side by side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Accepted (200)&lt;/th&gt;
&lt;th&gt;Rejected (429)&lt;/th&gt;
&lt;th&gt;Retry-After&lt;/th&gt;
&lt;th&gt;DB rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BEFORE (no limiter)&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AFTER (token bucket)&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;25 / 25&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That AFTER row is exactly where Part 2 begins. The server protected itself — but &lt;strong&gt;25 requests walked away with a 429&lt;/strong&gt;. What happens to those 25? That depends entirely on the client.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;A note on the numbers.&lt;/strong&gt; For the Part 2 demo we use a smaller, faster run — &lt;strong&gt;12 logical operations instead of 30&lt;/strong&gt; — so the retry behaviour is easier to read in the console. The lesson is the same: some requests hit 429; the naive client loses them, while the polite client waits and completes them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one idea to take away
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;429 is not "never." 429 is "not now."&lt;/strong&gt; The server already did its job in Part 1. In Part 2 the client does its job: read &lt;strong&gt;Retry-After&lt;/strong&gt;, wait, and retry the same operation with the same key — until it completes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Skills you'll lean on again (Lessons 3–7)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Idempotency-Key = one key per business operation&lt;/li&gt;
&lt;li&gt;Idempotency makes a retry safe (no duplicates)&lt;/li&gt;
&lt;li&gt;Backoff + jitter to avoid a stampede (Lesson 6)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;429 + Retry-After&lt;/code&gt; from the server (Part 1)&lt;/li&gt;
&lt;li&gt;Before/After discipline + saved reports&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Skills you'll add in Part 2
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Reading the &lt;code&gt;Retry-After&lt;/code&gt; header on the client&lt;/li&gt;
&lt;li&gt;Waiting at least that long before retrying&lt;/li&gt;
&lt;li&gt;Retrying the SAME operation with the SAME key&lt;/li&gt;
&lt;li&gt;Bounding attempts so the client never loops forever&lt;/li&gt;
&lt;li&gt;Turning a 429 into an eventual success&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Part 2 Goal
&lt;/h2&gt;

&lt;p&gt;Take the Part-1 server exactly as it is (no server change) and put two clients in front of it. First a &lt;strong&gt;naive&lt;/strong&gt; client that treats a 429 as a final failure — and loses operations. Then a &lt;strong&gt;polite&lt;/strong&gt; client that reads &lt;code&gt;Retry-After&lt;/code&gt;, waits, and retries the same operation with the same Idempotency-Key — until &lt;strong&gt;every&lt;/strong&gt; operation completes, with no duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem — a naive client throws work away
&lt;/h2&gt;

&lt;p&gt;A naive client sees a non-200 status and gives up. To it, &lt;code&gt;429&lt;/code&gt; looks like "failed," so it reports failure to the user and stops. But the server didn't say the operation was impossible — it said "not right now."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;12 logical operations, fired at a rate-limited server.

bucket allows ~5 now → 5 operations get 200 OK  → completed
the other ~7 hit the empty bucket → 429          → naive client GIVES UP

Result with a naive client:
  completed = 5
  lost      = 7   ← thrown away, never retried
  DB rows   = 5   ← only the accepted operations created orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;🐛 &lt;strong&gt;The loss is silent.&lt;/strong&gt; The data that &lt;em&gt;does&lt;/em&gt; exist is correct (5 orders for 5 accepted requests), so nothing looks broken on the server. But 7 real customer intents just vanished because the client mistook "not now" for "never." DB rows match only the accepted requests — not what the user actually asked for.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Mental Model — 429 means "not now," not "never"
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;403 Forbidden        → "No. You are not allowed." (never)
404 Not Found        → "That doesn't exist."       (never, here)
429 Too Many Requests→ "Not now — try again soon." (later!)

Retry-After: 1       → "Here is exactly how long to wait: ~1 second."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the key reframe. A &lt;code&gt;429&lt;/code&gt; is a &lt;strong&gt;temporary&lt;/strong&gt; answer with an expiry date attached. The server is not rejecting the operation forever — it is asking the client to come back. And it even tells the client &lt;em&gt;when&lt;/em&gt; via &lt;code&gt;Retry-After&lt;/code&gt;. A good client treats that as an instruction, not an error.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;السيرفر مش بيقولك مستحيل. هو بيقولك: استنى شوية وجرب تاني.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(The server isn't telling you "impossible." It's telling you: wait a little and try again.)&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Correct Client Behavior
&lt;/h2&gt;

&lt;p&gt;For &lt;strong&gt;each logical operation&lt;/strong&gt;, the polite client follows this loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate &lt;strong&gt;one&lt;/strong&gt; Idempotency-Key for the operation (once, up front).&lt;/li&gt;
&lt;li&gt;Send the request.&lt;/li&gt;
&lt;li&gt;If &lt;strong&gt;200 OK&lt;/strong&gt; → done.&lt;/li&gt;
&lt;li&gt;If &lt;strong&gt;429&lt;/strong&gt; →

&lt;ul&gt;
&lt;li&gt;read &lt;code&gt;Retry-After&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;wait &lt;code&gt;Retry-After&lt;/code&gt; seconds&lt;/li&gt;
&lt;li&gt;optionally add a small &lt;strong&gt;jitter&lt;/strong&gt; on top (so clients don't all return on the same tick)&lt;/li&gt;
&lt;li&gt;retry using the &lt;strong&gt;SAME&lt;/strong&gt; Idempotency-Key&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Stop after a &lt;strong&gt;max attempt count&lt;/strong&gt; (never loop forever).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In pseudocode (illustrative — the real loop lives in the verification scenario, not the app):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One key for the whole operation — generated ONCE, reused on every retry.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;PostOrderAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// ✅ completed&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfterSeconds&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// server told us when&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NextDouble&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// small wobble (Lesson 6)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                         &lt;span class="c1"&gt;// try the SAME key again&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Unexpected status &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Bounded: after maxAttempts, give up gracefully (don't hammer forever).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Wait at least Retry-After — and add jitter on top.&lt;/strong&gt; Waiting &lt;em&gt;less&lt;/em&gt; than &lt;code&gt;Retry-After&lt;/code&gt; disobeys the server and just earns another 429. Waiting the &lt;em&gt;exact same&lt;/em&gt; value on every client re-creates the thundering herd from Lesson 6 — they all come back on the same tick. So: honor &lt;code&gt;Retry-After&lt;/code&gt; as the floor, then add a little randomness.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why the SAME Idempotency-Key matters
&lt;/h2&gt;

&lt;p&gt;The Idempotency-Key belongs to the &lt;strong&gt;operation&lt;/strong&gt;, not the &lt;strong&gt;attempt&lt;/strong&gt;. Generate it once, reuse it on every retry of that operation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ CORRECT — same key across retries

operation "create my order"  →  key = ABC-123  (generated once)
   attempt 1 → 429 (not now)
   attempt 2 → POST  Idempotency-Key: ABC-123   ← same key
   attempt 3 → 200 OK → Order #55
Result: exactly 1 order. ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ WRONG — a new key per retry

operation "create my order"
   attempt 1 → key KEY-1 → 429
   attempt 2 → key KEY-2 → 200 → Order #55
   attempt 3 → key KEY-3 → 200 → Order #56
Result: the server sees 3 DIFFERENT operations → duplicate orders. 💥
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the key is stable, the server recognizes a retry as the &lt;em&gt;same&lt;/em&gt; intent and never creates a second order. This is the guarantee built in Lessons 3–5 (in-memory dedup → durable store → concurrency-safe replay). Part 2 simply &lt;strong&gt;depends&lt;/strong&gt; on it: idempotency is what makes "just retry" a safe thing to do.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Rule of thumb.&lt;/strong&gt; New key = new intent. Same retry = same key. If you can't point to where the key was created &lt;em&gt;once&lt;/em&gt; and held across retries, your "safe retry" isn't safe.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Verification Discipline — same rule, Part 2
&lt;/h2&gt;

&lt;p&gt;As always, we prove this with repeatable .NET scenarios, not throwaway PowerShell. Part 2 adds two scenarios that run against the &lt;strong&gt;unchanged Part-1 rate-limited server&lt;/strong&gt; — only the client behaviour differs. Each cleans the DB, fires the operations, prints a readable table, applies a clear verdict, and saves a timestamped report that previous runs never overwrite.&lt;/p&gt;

&lt;p&gt;The naive scenario expects &lt;em&gt;loss&lt;/em&gt; (operations thrown away). The polite scenario expects &lt;em&gt;full completion&lt;/em&gt; (every operation eventually succeeds, no duplicates). The only thing that changes is whether the client obeys &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two scenarios with different verdict logic
&lt;/h2&gt;

&lt;p&gt;Both fire the same 12 operations at the same rate-limited server. What differs is how the client reacts to a 429:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① BEFORE — Naive gives up&lt;/strong&gt; (a diagnostic; happy when loss is demonstrated)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2-naive-gives-up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verdict — PASS when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;At least one &lt;strong&gt;429&lt;/strong&gt; observed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;completed &amp;gt; 0&lt;/strong&gt; and &lt;strong&gt;lost &amp;gt; 0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB rows == completed&lt;/strong&gt; (lost ops created nothing)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;② AFTER — Polite obeys Retry-After&lt;/strong&gt; (a proof; happy only when everything completes, exactly once)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2-polite-retry-after

&lt;span class="c"&gt;# Short alias:&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verdict — PASS when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;completed == total operations&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB rows == total&lt;/strong&gt;, &lt;strong&gt;unique ids == total&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;every retry waited ≥ &lt;code&gt;Retry-After&lt;/code&gt;; &lt;strong&gt;attempts &amp;gt; operations&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Same input, opposite verdicts — on purpose.&lt;/strong&gt; Both keep the database correct (no duplicates). The naive client just leaves work unfinished; the polite client finishes it. The improvement Part 2 proves is &lt;em&gt;completion&lt;/em&gt;, not correctness.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  BEFORE — Watch the Naive Client Lose Operations
&lt;/h2&gt;

&lt;p&gt;Start the Part-1 server (token bucket already in place — no change):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/Wassal.Monolith &lt;span class="nt"&gt;--launch-profile&lt;/span&gt; https
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the naive scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2-naive-gives-up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; fires 12 logical operations (each its own Idempotency-Key), one attempt each. A 200 is &lt;code&gt;completed&lt;/code&gt;; a 429 is &lt;code&gt;lost-429&lt;/code&gt; (no retry).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected shape:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;12 logical operations&lt;/li&gt;
&lt;li&gt;some complete (≈5), some get 429 (≈7)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;lost &amp;gt; 0&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DB rows == completed only&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🐛 &lt;strong&gt;Diagnostic PASS = loss observed.&lt;/strong&gt; Like every BEFORE scenario, this one "passes" when it proves the problem: at least one 429, some completed, some lost, and the DB holding only the completed operations. That is the naive client throwing work away.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; lost &amp;gt; 0 and DB rows == completed (the lost operations created nothing).&lt;/p&gt;

&lt;h2&gt;
  
  
  AFTER — The Polite Client Completes Everything
&lt;/h2&gt;

&lt;p&gt;Same server, same 12 operations — now with a client that listens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2-polite-retry-after

&lt;span class="c"&gt;# Short alias:&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; for each operation, on a 429 the client reads &lt;code&gt;Retry-After&lt;/code&gt;, waits that long (plus a little jitter), and retries with the &lt;strong&gt;same&lt;/strong&gt; Idempotency-Key — up to a bounded max attempts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected shape:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;12 logical operations&lt;/li&gt;
&lt;li&gt;429s may happen as &lt;em&gt;intermediate&lt;/em&gt; attempts (not final)&lt;/li&gt;
&lt;li&gt;the client waits according to &lt;code&gt;Retry-After&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the same Idempotency-Key is reused per operation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;all operations eventually complete&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DB rows == total operations&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;unique order ids == total operations&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;attempts &amp;gt; operations&lt;/strong&gt; (retries actually happened)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;Loss → zero.&lt;/strong&gt; The same flood that left 7 operations behind in the naive run now completes all 12. Idempotency keeps it to exactly 12 orders (no duplicates), and obeying &lt;code&gt;Retry-After&lt;/code&gt; keeps the client from hammering the recovering server. The 429s become speed bumps, not dead ends.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; completed == 12, DB rows == 12, unique ids == 12, and attempts &amp;gt; 12.&lt;/p&gt;

&lt;h2&gt;
  
  
  Console &amp;amp; Report Expectations
&lt;/h2&gt;

&lt;p&gt;Each scenario prints a per-operation table (operation number, short key, status, result, &lt;code&gt;Retry-After&lt;/code&gt; seen, attempts, elapsed ms, order id) plus a summary block and a verdict. Reports save automatically — timestamped, never overwritten — under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Reports/Session-07-Part-2/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example file names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Session-07-Part-2-Naive-Gives-Up-RunReport-yyyy-MM-dd-HHmmss.txt
Session-07-Part-2-Polite-Retry-After-RunReport-yyyy-MM-dd-HHmmss.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Why save reports?&lt;/strong&gt; Same discipline as every session: a saved, timestamped report is evidence you can attach to this writeup and compare run-to-run. The naive report shows loss; the polite report shows full completion — side by side they tell the whole Part-2 story.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Implementation Walkthrough — What We Built &amp;amp; Tested
&lt;/h2&gt;

&lt;p&gt;A beginner-friendly tour of the two verification scenarios behind Day 7 Part 2 — the naive client that loses work, and the polite client that obeys &lt;code&gt;Retry-After&lt;/code&gt; and completes everything exactly once. The server (the Part 1 token-bucket rate limiter) was &lt;strong&gt;not changed&lt;/strong&gt; — Part 2 is about client behaviour only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole idea in one line:&lt;/strong&gt; Part 1 taught the &lt;strong&gt;server&lt;/strong&gt; to say &lt;em&gt;"not now"&lt;/em&gt; with HTTP &lt;code&gt;429 + Retry-After&lt;/code&gt;. Part 2 is about the &lt;strong&gt;client&lt;/strong&gt;: does it &lt;em&gt;listen&lt;/em&gt;? We built two clients and ran them against the same rate-limited server to find out.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Naive client&lt;/strong&gt; — treats &lt;code&gt;429&lt;/code&gt; as a final failure and throws the work away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polite client&lt;/strong&gt; — reads &lt;code&gt;Retry-After&lt;/code&gt;, waits, adds a little jitter, and retries the &lt;em&gt;same&lt;/em&gt; operation with the &lt;em&gt;same&lt;/em&gt; Idempotency-Key until it completes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  BEFORE — The Naive Client Gives Up
&lt;/h3&gt;

&lt;p&gt;File: &lt;code&gt;tools/Wassal.SessionTests/Sessions/Session07Part2NaiveGivesUpScenario.cs&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools&lt;span class="se"&gt;\W&lt;/span&gt;assal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2-naive-gives-up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sends &lt;strong&gt;12 logical operations concurrently&lt;/strong&gt; at the rate-limited &lt;code&gt;POST /api/orders&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Each operation has &lt;strong&gt;its own Idempotency-Key&lt;/strong&gt; (distinct operations, not retries of one).&lt;/li&gt;
&lt;li&gt;Each operation makes &lt;strong&gt;only one attempt&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If the server returns &lt;strong&gt;200&lt;/strong&gt; → the operation is &lt;code&gt;completed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the server returns &lt;strong&gt;429&lt;/strong&gt; → the operation is marked &lt;code&gt;lost-429&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retry happens.&lt;/strong&gt; The naive client gives up.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🐛 &lt;strong&gt;Why this "passes" by failing.&lt;/strong&gt; This is a &lt;strong&gt;BEFORE diagnostic&lt;/strong&gt;. It is designed to &lt;em&gt;prove the problem exists&lt;/em&gt;. PASS here means the bug was demonstrated: the naive client mistook &lt;code&gt;429&lt;/code&gt; ("not now") for a permanent failure and lost real customer work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Verified result:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total operations&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Everything we fired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completed (200)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Got through the bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lost (429, gave up)&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Thrown away — never retried&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB rows (Orders)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;== completed only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unique order ids&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;No duplicates, but work is missing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;VERDICT: NAIVE LOSS DEMONSTRATED ✅ (diagnostic)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The data that exists is correct — &lt;code&gt;DB rows == completed&lt;/code&gt; — so nothing looks broken on the server. But &lt;strong&gt;7 real intents silently vanished&lt;/strong&gt;. That silent loss is exactly what the polite client fixes. (The 5/7 split can vary slightly with timing; the verdict only requires &lt;em&gt;some&lt;/em&gt; loss, not exactly 5 and 7.)&lt;/p&gt;

&lt;h3&gt;
  
  
  AFTER — The Polite Client Obeys Retry-After
&lt;/h3&gt;

&lt;p&gt;File: &lt;code&gt;tools/Wassal.SessionTests/Sessions/Session07Part2PoliteRetryAfterScenario.cs&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools&lt;span class="se"&gt;\W&lt;/span&gt;assal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2-polite-retry-after
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Short alias (runs the same polite scenario):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools&lt;span class="se"&gt;\W&lt;/span&gt;assal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sends the &lt;strong&gt;same 12 logical operations concurrently&lt;/strong&gt; at the &lt;strong&gt;same&lt;/strong&gt; Part 1 server.&lt;/li&gt;
&lt;li&gt;Each operation gets &lt;strong&gt;one Idempotency-Key generated once&lt;/strong&gt;, up front.&lt;/li&gt;
&lt;li&gt;If the server returns &lt;strong&gt;200&lt;/strong&gt; → the operation is &lt;code&gt;completed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the server returns &lt;strong&gt;429&lt;/strong&gt;, the client:

&lt;ul&gt;
&lt;li&gt;reads &lt;code&gt;Retry-After&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;waits &lt;strong&gt;at least&lt;/strong&gt; &lt;code&gt;Retry-After&lt;/code&gt; seconds,&lt;/li&gt;
&lt;li&gt;adds a small &lt;strong&gt;jitter&lt;/strong&gt; on top,&lt;/li&gt;
&lt;li&gt;retries using the &lt;strong&gt;SAME Idempotency-Key&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The knobs (explicit and visible in code):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bounded retries:&lt;/strong&gt; &lt;code&gt;MaxAttempts = 6&lt;/code&gt; — the loop can never run forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jitter:&lt;/strong&gt; &lt;code&gt;0..250 ms&lt;/code&gt; added on top of &lt;code&gt;Retry-After&lt;/code&gt; (never less than it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback:&lt;/strong&gt; if &lt;code&gt;Retry-After&lt;/code&gt; is missing or unparsable → use &lt;strong&gt;1 second&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; generate a new key per retry — a retry is the same intent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Verified result:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total operations&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Same as the naive run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completed (200)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Every operation finished&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429 intermediate attempts&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Speed bumps, not dead ends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total attempts (POSTs)&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;Retries actually happened&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB rows (Orders)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;== total operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unique order ids&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;No duplicates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every retry honored Retry-After&lt;/td&gt;
&lt;td&gt;YES&lt;/td&gt;
&lt;td&gt;The client obeyed the server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;VERDICT: PASS ✅&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;21 attempts → only 12 orders.&lt;/strong&gt; The client made &lt;strong&gt;21 HTTP attempts&lt;/strong&gt;, but the database holds &lt;strong&gt;only 12 orders&lt;/strong&gt; with &lt;strong&gt;12 unique ids&lt;/strong&gt;. That gap is the proof of correctness: every retry reused the &lt;em&gt;same&lt;/em&gt; Idempotency-Key, so the server recognized the retry as the &lt;em&gt;same&lt;/em&gt; operation and never created a duplicate. Completion went up; duplicates stayed at zero.&lt;/p&gt;

&lt;p&gt;💡 &lt;strong&gt;Same flood, opposite outcome.&lt;/strong&gt; The naive run left 7 operations behind. The polite run — same 12 operations, same rate limiter — completed all 12 by turning each &lt;code&gt;429&lt;/code&gt; into a short wait and a retry.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  How to Run It Locally
&lt;/h3&gt;

&lt;p&gt;You need &lt;strong&gt;two terminal windows&lt;/strong&gt;: one keeps the server running, the other runs the scenarios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Window 1 — start the server (keep this open):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /d D:&lt;span class="se"&gt;\b&lt;/span&gt;ooks&lt;span class="se"&gt;\d&lt;/span&gt;istributed-system&lt;span class="se"&gt;\W&lt;/span&gt;assal&lt;span class="se"&gt;\s&lt;/span&gt;rc
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; Wassal.Monolith &lt;span class="nt"&gt;--launch-profile&lt;/span&gt; https
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait until you see this line — it means the server is ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Now listening on: https://localhost:7126
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Leave Window 1 open.&lt;/strong&gt; The server must stay running the whole time you run the scenarios in Window 2. If you close it (or press &lt;code&gt;Ctrl+C&lt;/code&gt;), every scenario will fail to connect to &lt;code&gt;https://localhost:7126&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Window 2 — build, then run the scenarios:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /d D:&lt;span class="se"&gt;\b&lt;/span&gt;ooks&lt;span class="se"&gt;\d&lt;/span&gt;istributed-system&lt;span class="se"&gt;\W&lt;/span&gt;assal

dotnet build

REM BEFORE — naive client &lt;span class="o"&gt;(&lt;/span&gt;should show loss&lt;span class="o"&gt;)&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools&lt;span class="se"&gt;\W&lt;/span&gt;assal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2-naive-gives-up

REM AFTER — polite client &lt;span class="o"&gt;(&lt;/span&gt;should &lt;span class="nb"&gt;complete &lt;/span&gt;everything&lt;span class="o"&gt;)&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools&lt;span class="se"&gt;\W&lt;/span&gt;assal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2-polite-retry-after

REM AFTER via the short &lt;span class="nb"&gt;alias&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;same polite scenario&lt;span class="o"&gt;)&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools&lt;span class="se"&gt;\W&lt;/span&gt;assal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-part-2

REM Part 1 regression — the server should still shed load correctly
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools&lt;span class="se"&gt;\W&lt;/span&gt;assal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-rate-limited
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;What to expect.&lt;/strong&gt; The naive run prints &lt;em&gt;NAIVE LOSS DEMONSTRATED ✅&lt;/em&gt;, the polite run and its alias print &lt;em&gt;PASS ✅&lt;/em&gt;, and the Part 1 regression still prints &lt;em&gt;PASS ✅&lt;/em&gt;. Each run also saves a timestamped report.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What I Should Understand Before Moving On
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;429 means "not now", not "never"&lt;/strong&gt; — a temporary answer with an expiry attached; come back soon, the door isn't locked forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry-After tells you how long to wait&lt;/strong&gt; — the server hands the client an exact instruction. Treat it as a rule, not noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Naive client ignores Retry-After&lt;/strong&gt; — it treats 429 as final and loses real work; completion drops, silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polite client obeys Retry-After&lt;/strong&gt; — it waits, retries, and completes every operation. 429 becomes a speed bump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The key belongs to the operation&lt;/strong&gt; — Idempotency-Key identifies the &lt;em&gt;logical operation&lt;/em&gt;, not a single HTTP attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same retry = same key · New key = new intent&lt;/strong&gt; — reuse the key on retries so the server sees one operation. A new key means a new order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded retries prevent infinite loops&lt;/strong&gt; — &lt;code&gt;MaxAttempts = 6&lt;/code&gt; guarantees the client eventually stops instead of hammering forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jitter de-synchronizes clients&lt;/strong&gt; — a small random add-on stops every client from retrying on the exact same tick (no new stampede).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB rows are the proof — naive&lt;/strong&gt; — &lt;code&gt;DB rows = completed only&lt;/code&gt;. The lost operations created nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB rows are the proof — polite&lt;/strong&gt; — &lt;code&gt;DB rows = total operations&lt;/code&gt;, with &lt;code&gt;unique ids = total operations&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  الخلاصة بالمصري 🇪🇬
&lt;/h3&gt;

&lt;p&gt;السيرفر لما يرجع &lt;code&gt;429&lt;/code&gt; هو مش بيقول إن العملية فشلت للأبد. هو بيقول للعميل: استنى شوية وجرب تاني. العميل الغلط بيسيب الطلب يضيع. العميل الصح بيستنى &lt;code&gt;Retry-After&lt;/code&gt; ويرجع يحاول بنفس &lt;code&gt;Idempotency-Key&lt;/code&gt;، علشان السيرفر يفهم إن دي نفس العملية مش طلب جديد. عشان كده كل العمليات كملت، والداتابيز فيها 12 أوردر فقط من غير أي تكرار.&lt;/p&gt;

&lt;h3&gt;
  
  
  Final Comparison — Naive vs Polite
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Behavior on 429&lt;/th&gt;
&lt;th&gt;Completed&lt;/th&gt;
&lt;th&gt;Lost&lt;/th&gt;
&lt;th&gt;Attempts&lt;/th&gt;
&lt;th&gt;DB Rows&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Naive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gives up&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;NAIVE LOSS DEMONSTRATED ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Polite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Waits + retries same key&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;PASS ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the two rows together: the polite client made &lt;strong&gt;more attempts (21 vs 12)&lt;/strong&gt; but produced the &lt;strong&gt;same kind of clean database&lt;/strong&gt; — no duplicates — while finishing &lt;strong&gt;every&lt;/strong&gt; operation (12 vs 5). More attempts, zero duplicates, full completion. That is the whole lesson in one table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaway — the whole contract
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Backoff&lt;/strong&gt; is the client choosing to slow down (Lesson 6). &lt;strong&gt;Rate limiting&lt;/strong&gt; is the server enforcing the slowdown (Part 1). &lt;strong&gt;429 + Retry-After&lt;/strong&gt; is the contract between them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;strong&gt;polite client&lt;/strong&gt; completes the operation safely by listening to the server — waiting &lt;code&gt;Retry-After&lt;/code&gt; — and reusing the same Idempotency-Key so the eventual retry creates exactly one order. Server protection (Part 1) + client cooperation (Part 2) = every operation completes, exactly once, even through a rate limiter.&lt;/p&gt;

&lt;p&gt;Part 1 gave the server a voice (&lt;code&gt;429 + Retry-After&lt;/code&gt;). Part 2 gave that voice a listener. Together they complete the rate-limiting lesson. This is &lt;strong&gt;not&lt;/strong&gt; Lesson 8 — it's the second half of Day 7. Lesson 8 will move on to the next topic.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>resilience</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Distributed Systems in .NET — Day 7</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:25:54 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-7-when-politeness-isnt-enough-rate-limiting-load-shedding-3o9d</link>
      <guid>https://dev.to/moh_moh701/day-7-when-politeness-isnt-enough-rate-limiting-load-shedding-3o9d</guid>
      <description>&lt;p&gt;&lt;em&gt;Lesson 6 taught the client to back off — but you can't trust every client to. Today the server learns to defend itself: shed the excess, and tell callers exactly when to come back.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Bots, buggy SDKs, and panicking apps will still hammer you. In this session we flood the order endpoint and watch the unprotected server accept every single request — no backpressure at all. Then we add a token-bucket rate limiter using .NET 8's built-in middleware, so the excess gets &lt;code&gt;429 + Retry-After&lt;/code&gt; and the load is shed. This is Part 1 (the server side); Part 2 is the client side.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjjen01lavpl0jqsjxnuw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjjen01lavpl0jqsjxnuw.png" alt="Token bucket — rate limiting &amp;amp; load shedding" width="800" height="901"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📘 &lt;strong&gt;Where we left off in Lesson 6:&lt;/strong&gt; You built the &lt;em&gt;client's&lt;/em&gt; half of idempotency: a bounded retry loop with &lt;strong&gt;exponential backoff + jitter&lt;/strong&gt;, reusing one Idempotency-Key per operation. Naive retries piled into one window (a storm); polite retries spread out over time. Tagged as &lt;code&gt;lesson-06-after&lt;/code&gt;. But Lesson 6 ended on an uncomfortable admission: &lt;em&gt;"you can't trust every client to back off."&lt;/em&gt; A polite client cooperates — a bot doesn't. So the server cannot rely on good manners. It needs its own defence, one it enforces no matter who is calling. That defence is &lt;strong&gt;rate limiting&lt;/strong&gt;, and the way it cooperates with polite clients is &lt;strong&gt;429 + Retry-After&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away:&lt;/strong&gt; Backoff is the client choosing to slow down. Rate limiting is the server &lt;strong&gt;enforcing&lt;/strong&gt; it. You cannot outsource load control to clients. The server must have &lt;strong&gt;backpressure&lt;/strong&gt; of its own — and &lt;strong&gt;429 + Retry-After&lt;/strong&gt; is how the two sides cooperate. Same theme as Lesson 6, now from the other side of the wire: idempotency keeps it &lt;em&gt;correct&lt;/em&gt;; rate limiting keeps it &lt;em&gt;standing&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What you'll reinforce and add
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;🔁 Reinforce — from Lessons 5 &amp;amp; 6:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Idempotency keeps accepted work correct (no duplicates)&lt;/li&gt;
&lt;li&gt;Bursts of concurrent requests from the .NET runner&lt;/li&gt;
&lt;li&gt;DB row count as the authoritative check&lt;/li&gt;
&lt;li&gt;Before/After discipline + saved reports&lt;/li&gt;
&lt;li&gt;Reading a console table + verdict&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;✨ New — added today:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backpressure &amp;amp; why client politeness can't be trusted&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;token bucket&lt;/strong&gt; algorithm (rate + burst)&lt;/li&gt;
&lt;li&gt;.NET 8's built-in rate limiter middleware&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;429 Too Many Requests&lt;/code&gt; + &lt;code&gt;Retry-After&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Load shedding: protect the majority by failing the excess fast&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🎯 &lt;strong&gt;Today's goal:&lt;/strong&gt; Flood the order endpoint with a burst of requests and watch the unprotected server accept &lt;em&gt;every single one&lt;/em&gt; — no backpressure at all. Then add a &lt;strong&gt;token-bucket rate limiter&lt;/strong&gt; using .NET 8's built-in middleware, so the excess gets &lt;code&gt;429 + Retry-After&lt;/code&gt; and the load is shed. Prove that the accepted requests still each create exactly one order (rejected requests do no work) — and see how the &lt;code&gt;Retry-After&lt;/code&gt; header is exactly the signal a Lesson-6 client backs off on.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧠 Mental Model 1 — Why client politeness isn't enough
&lt;/h2&gt;

&lt;p&gt;Lesson 6 made &lt;em&gt;our&lt;/em&gt; client polite. But your server doesn't only talk to your client. It talks to whatever shows up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Good mobile app   →  retries with backoff + jitter   (polite, Lesson 6)
Old cached build  →  retries instantly, no backoff    (storm)
Third-party SDK   →  retries 50× in a tight loop       (worse storm)
A bot / scraper   →  10,000 requests/sec, doesn't care (attack)

The server cannot CHOOSE its callers. Politeness is voluntary.
So load control cannot live only on the client — the server needs
its OWN limit that it enforces on everyone, no exceptions.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That server-side limit is called &lt;strong&gt;backpressure&lt;/strong&gt;: the system's ability to say &lt;em&gt;"I am full, slow down"&lt;/em&gt; instead of trying to accept everything and collapsing. Without backpressure, one rude caller can consume all the capacity and starve every well-behaved user. Idempotency won't save you here — it keeps the data &lt;em&gt;correct&lt;/em&gt;, but it happily does the (correct) work for all 10,000 requests. Correct, and on fire.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Fail fast beats slow death.&lt;/strong&gt; When overloaded, a server that &lt;em&gt;rejects&lt;/em&gt; the excess in microseconds stays healthy for everyone it &lt;em&gt;does&lt;/em&gt; serve. A server that tries to serve everyone slows to a crawl and fails for &lt;em&gt;everyone&lt;/em&gt;. Rejecting some requests on purpose to protect the rest is called &lt;strong&gt;load shedding&lt;/strong&gt; — and it is a feature, not a failure.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧠 Mental Model 2 — The token bucket &amp;amp; the 429 + Retry-After contract
&lt;/h2&gt;

&lt;p&gt;The classic way to enforce "X requests per second, with a little burst room" is a &lt;strong&gt;token bucket&lt;/strong&gt;. Picture a bucket that holds a few tokens. Every request must take one token to be served. The bucket refills at a steady rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bucket capacity = 5 tokens      (max burst it will absorb at once)
Refill          = 5 tokens / second  (steady allowed rate)

request arrives → is there a token?
   YES → take one, serve it (200 OK)
   NO  → bucket empty → REJECT with 429 Too Many Requests
                        + Retry-After: 1   ("come back in ~1s")

A burst of 30 at once → ~5 served now, ~25 rejected with 429.
The bucket refills, so a polite client that waits gets served next round.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The teaching analogy: the bucket is a doorman holding 5 entry tickets. Five people walk straight in; the rest are told &lt;em&gt;"no tickets right now — come back in a second"&lt;/em&gt; (that's the &lt;code&gt;Retry-After&lt;/code&gt;). The doorman prints 5 new tickets every second. Nobody is hurt, and the room never overflows.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;429 + Retry-After is a contract, not just an error.&lt;/strong&gt; A bare rejection says "go away." &lt;code&gt;429 + Retry-After: 1&lt;/code&gt; says "go away &lt;em&gt;for one second, then you're welcome&lt;/em&gt;." That second value is precisely what a Lesson-6 client feeds into its backoff: instead of guessing how long to wait, it obeys the number the server gave it. Server and client cooperate — one sets the pace, the other respects it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧪 Verification Discipline — same rule, new session
&lt;/h2&gt;

&lt;p&gt;As always: no throwaway PowerShell. Session 7 gets its own repeatable scenarios under &lt;code&gt;tools/Wassal.SessionTests&lt;/code&gt;, sharing the same engine and reporting as Sessions 5 &amp;amp; 6. Each scenario cleans the DB, fires the burst in a repeatable way, prints a readable table (including the &lt;code&gt;Retry-After&lt;/code&gt; column), applies clear pass/fail rules, and saves a &lt;strong&gt;timestamped report&lt;/strong&gt; under &lt;code&gt;Reports/Session-07/&lt;/code&gt; that previous runs never overwrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One engine, two scenarios — different verdicts.&lt;/strong&gt; The shared mechanics (clean → fire one burst of distinct requests → record 2xx / 429 + Retry-After → count rows) live once in &lt;code&gt;FloodCore&lt;/code&gt;. Each scenario reuses that engine but brings its own verdict. The &lt;strong&gt;only&lt;/strong&gt; difference between BEFORE and AFTER is whether the rate limiter is switched on in the server — which is a reader step below, exactly like the Day-05 controller edit.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⚖️ Lesson 7 has TWO scenarios with different verdict logic
&lt;/h3&gt;

&lt;p&gt;Both fire the same burst of distinct requests. What differs is what counts as success:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① BEFORE — Unprotected flood&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-unprotected-flood
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A diagnostic. Its job is to &lt;strong&gt;show there's no defence&lt;/strong&gt;, so it's happy when everything gets through. &lt;strong&gt;PASS when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nothing was shed (&lt;strong&gt;0 × 429&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Every request accepted (accepted == total)&lt;/li&gt;
&lt;li&gt;DB rows == accepted (each made one order)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Meaning: the server has no backpressure. A bot could soak all capacity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② AFTER — Rate limited&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-rate-limited
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short alias: &lt;code&gt;session-07&lt;/code&gt;. A proof. Happy only when the server shed load &lt;em&gt;and&lt;/em&gt; stayed correct &lt;em&gt;and&lt;/em&gt; cooperated. &lt;strong&gt;PASS when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some 429s (load shed) &amp;amp; some 2xx (not a brick wall)&lt;/li&gt;
&lt;li&gt;Every 429 carried a &lt;code&gt;Retry-After&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;DB rows == accepted (rejected did no work)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Run the AFTER scenario before adding the limiter and it will (correctly) fail&lt;/strong&gt; — nothing was shed, so it looks exactly like BEFORE. That's the same rule as every session: match the scenario to the state of the server. Unprotected scenario on the undefended build; rate-limited scenario on the protected build.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Task 1 — Flood the Undefended Server (BEFORE) &lt;em&gt;(10 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Start the API exactly as it is today — the idempotent server from Lessons 5 &amp;amp; 6, with &lt;strong&gt;no&lt;/strong&gt; rate limiter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/Wassal.Monolith &lt;span class="nt"&gt;--launch-profile&lt;/span&gt; https
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now fire a burst of 30 distinct requests at once and see how many the server accepts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From the repo root&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-unprotected-flood
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional overrides (defaults come from &lt;code&gt;appsettings.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-unprotected-flood &lt;span class="sb"&gt;`&lt;/span&gt;
    &lt;span class="nt"&gt;--requests&lt;/span&gt; 30 &lt;span class="sb"&gt;`&lt;/span&gt;
    &lt;span class="nt"&gt;--cleanDb&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The console prints a per-request table (with a &lt;code&gt;Retry-After&lt;/code&gt; column, all dashes for now) and a summary, then saves a report like &lt;code&gt;Reports/Session-07/Session-07-Unprotected-Flood-RunReport-2026-06-21-143012.txt&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🐛 &lt;strong&gt;No backpressure.&lt;/strong&gt; You'll see &lt;strong&gt;30 accepted, 0 rejected&lt;/strong&gt;, and &lt;strong&gt;30 orders&lt;/strong&gt; in the DB. The server shed nothing — it dutifully did the work for every request in the burst. Now imagine the burst is 30,000 from a bot: the server still says "yes" to all of them, until it falls over. Idempotency kept the data correct, but there is no defence against volume. &lt;em&gt;Correct, and on fire.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;💡 &lt;strong&gt;This scenario passes (exit 0) when the storm gets through&lt;/strong&gt; — that's the point of a BEFORE diagnostic. "Nothing shed + DB rows == accepted" is the documented undefended state we want to make visible before we fix it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; the flood shows 0 × 429 and DB rows == accepted == total (the server accepted everything).&lt;/p&gt;




&lt;h2&gt;
  
  
  Task 2 — Add a .NET 8 Token-Bucket Rate Limiter &lt;em&gt;(15 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The fix lives entirely on the server, and .NET 8 ships the rate limiter &lt;strong&gt;in the framework&lt;/strong&gt; — no Polly, no NuGet package. We register a single named token bucket and apply it to the orders endpoint.&lt;/p&gt;

&lt;p&gt;First, register the limiter in &lt;code&gt;Program.cs&lt;/code&gt; (add the &lt;code&gt;using&lt;/code&gt;s, then the service):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Wassal.Monolith/Program.cs (additions)&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Threading.RateLimiting&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.AspNetCore.RateLimiting&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ... after builder.Services.AddControllersWithViews(); ...&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRateLimiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// One named token bucket for the orders endpoint.&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTokenBucketLimiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokenLimit&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                        &lt;span class="c1"&gt;// bucket holds 5 tokens (max burst)&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TokensPerPeriod&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                        &lt;span class="c1"&gt;// refill 5 tokens...&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplenishmentPeriod&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// ...every 1 second&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueueLimit&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                        &lt;span class="c1"&gt;// don't queue — reject immediately&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AutoReplenishment&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// What the server sends when a request is rejected: 429 + Retry-After.&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RejectionStatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status429TooManyRequests&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnRejected&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The token bucket can estimate when a token will next be free.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MetadataName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalSeconds&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Too many requests. Please retry after a moment."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then enable the middleware in the pipeline (after &lt;code&gt;UseRouting&lt;/code&gt;, before the endpoints):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Wassal.Monolith/Program.cs (pipeline)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseRouting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseRateLimiter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// ← add this line&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, attach the named limiter to the order-creation action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Wassal.Monolith/Controllers/OrdersController.cs&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.AspNetCore.RateLimiting&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// at the top&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;EnableRateLimiting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;   &lt;span class="c1"&gt;// ← apply the "orders" bucket to this endpoint&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;FromBody&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;CreateOrderRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... unchanged idempotency logic from Lessons 4 &amp;amp; 5 ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noticing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;QueueLimit = 0&lt;/code&gt; means we &lt;strong&gt;reject immediately&lt;/strong&gt; instead of making callers wait in a queue — that's load shedding, the fail-fast behaviour from Mental Model 1.&lt;/li&gt;
&lt;li&gt;We set &lt;code&gt;RejectionStatusCode&lt;/code&gt; to &lt;code&gt;429&lt;/code&gt; (the default is 503) and add &lt;code&gt;Retry-After&lt;/code&gt; in &lt;code&gt;OnRejected&lt;/code&gt; — turning a bare rejection into the cooperative contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Restart the app so the limiter is live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/Wassal.Monolith &lt;span class="nt"&gt;--launch-profile&lt;/span&gt; https
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Why a token bucket and not "X per second" flat?&lt;/strong&gt; A flat counter either forbids all bursts (bad for normal traffic that clumps) or resets on a hard boundary (letting a double-burst slip through at the edges). The token bucket allows a small, controlled burst (the bucket size) on top of a steady refill rate — the sweet spot for real APIs. We teach this one algorithm only; fixed/sliding windows exist but are out of scope today.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; the app rebuilds with no errors and the orders endpoint is decorated with &lt;code&gt;[EnableRateLimiting("orders")]&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Task 3 — Prove Load Shedding (AFTER) &lt;em&gt;(10 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Run the exact same burst — only the server changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SAME runner, SAME burst engine — different verdict&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07-rate-limited

&lt;span class="c"&gt;# Short alias:&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-07
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scenario applies &lt;strong&gt;4 explicit pass/fail checks&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Load shed (some 429s)&lt;/code&gt; — the server rejected the excess&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Some requests still succeeded&lt;/code&gt; — it's a limiter, not a brick wall&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Every 429 carried Retry-After&lt;/code&gt; — the cooperative contract held&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Rejected did no work (DB rows == accepted)&lt;/code&gt; — shed requests created nothing&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;Sheds load AND stays correct.&lt;/strong&gt; With a bucket of 5, a burst of 30 yields roughly &lt;strong&gt;5 accepted (200) and ~25 rejected (429)&lt;/strong&gt;, and every one of those 429s carries a &lt;code&gt;Retry-After&lt;/code&gt; value in the table. The DB holds exactly as many orders as were accepted — the rejected requests did &lt;em&gt;no&lt;/em&gt; work, so there's no half-finished mess to clean up. The server protected itself, served whoever it could, and told the rest precisely when to return.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Exact counts will wiggle.&lt;/strong&gt; Because the bucket refills every second, a slightly slower burst may let a few extra through. The verdict doesn't pin an exact number — it checks the &lt;em&gt;shape&lt;/em&gt;: some shed, some served, every 429 cooperative, and the DB matching the accepted count. That's robust across machines.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; all 4 checks PASS — some 429s with Retry-After, some 2xx, and DB rows == accepted.&lt;/p&gt;




&lt;h2&gt;
  
  
  Task 4 — Close the Loop: 429 + Retry-After Meets Backoff &lt;em&gt;(10 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Put Lessons 6 and 7 side by side and the whole picture clicks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SERVER (Lesson 7):  "I'm full. 429. Retry-After: 1."
CLIENT (Lesson 6):  reads Retry-After → waits ~1s → retries
                    with the SAME Idempotency-Key
SERVER:             bucket refilled → 200 OK, replayed=false → 1 order

Three layers, three jobs:
  Idempotency   → the retry is SAFE          (no duplicate order)   [L3–5]
  Backoff+jitter→ the client is GENTLE        (no client-side storm) [L6]
  Rate limiting → the server is PROTECTED     (no server overload)   [L7]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice how the pieces depend on each other. Rate limiting &lt;em&gt;causes&lt;/em&gt; rejections, which &lt;em&gt;require&lt;/em&gt; the client to retry, which is only &lt;em&gt;safe&lt;/em&gt; because the server is idempotent, and only &lt;em&gt;gentle&lt;/em&gt; because the client backs off — using the &lt;code&gt;Retry-After&lt;/code&gt; the server handed it. None of the three lessons stands alone; together they are the real shape of a system that survives a Friday-night surge.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;🔜 Continued in Day 7 · Part 2 — The Polite Client Obeys Retry-After.&lt;/strong&gt; Part 1 (here) taught the &lt;em&gt;server&lt;/em&gt; to return &lt;code&gt;429 + Retry-After&lt;/code&gt;. Part 2 teaches the &lt;em&gt;client&lt;/em&gt; to read &lt;code&gt;Retry-After&lt;/code&gt;, wait at least that long, and retry the same operation with the &lt;strong&gt;same Idempotency-Key&lt;/strong&gt; — turning the 25 rejected requests into eventual successes instead of lost work. Same Day 7, second half.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; you can explain how a single 429 + Retry-After connects all three layers (idempotency, backoff, rate limiting).&lt;/p&gt;




&lt;h2&gt;
  
  
  Task 5 — Capture the Evidence, Tag, Document &lt;em&gt;(5 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The BEFORE and AFTER reports under &lt;code&gt;Reports/Session-07/&lt;/code&gt; are your evidence — attach both to the writeup. Then commit the server change + scenarios + session plan and tag the AFTER state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;D:&lt;span class="se"&gt;\b&lt;/span&gt;ooks&lt;span class="se"&gt;\d&lt;/span&gt;istributed-system&lt;span class="se"&gt;\W&lt;/span&gt;assal
git add src/Wassal.Monolith/ tools/Wassal.SessionTests/ &lt;span class="sb"&gt;`&lt;/span&gt;
        docs/sessions/Day-07-Rate-Limiting-Load-Shedding.html
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"feat: Lesson 7 — token-bucket rate limiting + load shedding (429 + Retry-After)"&lt;/span&gt;
git tag lesson-07-after
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suggested lesson writeup — &lt;code&gt;docs/lessons/lesson-07-rate-limiting-load-shedding.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Lesson 7 — Rate Limiting &amp;amp; Load Shedding&lt;/span&gt;

&lt;span class="gs"&gt;**Tags:**&lt;/span&gt; &lt;span class="sb"&gt;`lesson-07-before`&lt;/span&gt; (unprotected) → &lt;span class="sb"&gt;`lesson-07-after`&lt;/span&gt; (rate limited)
&lt;span class="gs"&gt;**Builds on:**&lt;/span&gt; Lesson 6 (&lt;span class="sb"&gt;`lesson-06-after`&lt;/span&gt;)

&lt;span class="gu"&gt;## The pain (BEFORE)&lt;/span&gt;

With no limiter, a burst is accepted in full — the server has no
backpressure. Idempotency keeps the data correct, but one rude client
can soak all capacity and starve everyone else. Correct, and on fire.

&lt;span class="gu"&gt;## The fix (AFTER)&lt;/span&gt;

A .NET 8 built-in token-bucket limiter (5 tokens, +5/sec, QueueLimit 0)
on the orders endpoint. Excess requests get 429 + Retry-After; the load
is shed fast; accepted requests still each create exactly one order
(rejected requests do no work). Verified by &lt;span class="sb"&gt;`session-07-rate-limited`&lt;/span&gt;.

| Scenario                  | Shed?      | Correctness         | Caller told when? |
|---------------------------|------------|---------------------|-------------------|
| Unprotected flood         | no (0×429) | 1 order/accepted ✅ | no                |
| Rate limited (token bucket)| yes (429) | 1 order/accepted ✅ | Retry-After ✅     |

&lt;span class="gu"&gt;## The contract&lt;/span&gt;

429 + Retry-After is the signal a Lesson-6 client backs off on. Server
sets the pace; client respects it. Idempotency makes the eventual retry
safe.

&lt;span class="gu"&gt;## What's still deliberately missing (later lessons)&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**The client side of the handshake**&lt;/span&gt; — a client that honours Retry-After,
  retries with the same key, and ends with one order. Now covered in
  &lt;span class="gs"&gt;**Day 7 Part 2 — The Polite Client Obeys Retry-After**&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Distributed rate limiting**&lt;/span&gt; across replicas (shared counter / Redis) —
  belongs with the scale-out lessons. Today's limiter is per-instance.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Per-user / per-API-key limits, quotas, auth**&lt;/span&gt; — one global bucket for now.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Polly, circuit breaker**&lt;/span&gt; — Part 6.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Key TTL / expiry**&lt;/span&gt; — still parked for the caching lesson.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; both Session-07 reports are saved, the limiter + scenarios + session plan are committed, and &lt;code&gt;lesson-07-after&lt;/code&gt; is tagged.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Done When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Flood the undefended server → expose no backpressure&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;0 × 429, DB rows == accepted == total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Add the .NET 8 token-bucket limiter (429 + Retry-After)&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;td&gt;App rebuilds, endpoint has &lt;code&gt;[EnableRateLimiting]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Re-run the flood → witness load shedding&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;All 4 checks PASS (shed + correct + cooperative)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Connect 429 + Retry-After to Lesson 6 backoff&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;You can explain all three layers together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Save reports, commit, tag &lt;code&gt;lesson-07-after&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;Both reports saved + writeup committed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;blockquote&gt;
&lt;p&gt;🎓 &lt;strong&gt;The compounding effect — 7 lessons in.&lt;/strong&gt; Your order endpoint now survives &lt;strong&gt;restarts&lt;/strong&gt; and &lt;strong&gt;body tampering&lt;/strong&gt; (L4), &lt;strong&gt;concurrency&lt;/strong&gt; (L5), &lt;strong&gt;impolite-but-honest retries&lt;/strong&gt; (L6), and now &lt;strong&gt;floods and bots&lt;/strong&gt; (L7). Idempotency keeps it correct; backoff keeps the client gentle; rate limiting keeps the server standing. That trio — safe retries + client backoff + server backpressure — is exactly how production APIs survive a surge. Same endpoint, seven layers of deliberate, testable defence.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  ➡️ Next — Day 7 · Part 2: The Polite Client Obeys Retry-After
&lt;/h2&gt;

&lt;p&gt;The server now says "not now" with &lt;code&gt;429 + Retry-After&lt;/code&gt;. Part 2 teaches the client to listen: read &lt;code&gt;Retry-After&lt;/code&gt;, wait, and retry the same operation with the same Idempotency-Key until it completes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>ratelimiting</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Distributed Systems in .NET — Day 6</title>
      <dc:creator>mohamed Tayel</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:25:49 +0000</pubDate>
      <link>https://dev.to/moh_moh701/day-6-retrying-without-starting-a-fire-timeouts-retries-backoff-jitter-40o4</link>
      <guid>https://dev.to/moh_moh701/day-6-retrying-without-starting-a-fire-timeouts-retries-backoff-jitter-40o4</guid>
      <description>&lt;p&gt;&lt;em&gt;Session 5 made the server safe for retries. Today we move to the client side and learn to retry politely.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A retry is a real request — and a client that retries badly can bury a server that was about to recover. In this session we simulate several clients retrying one order the naive way (no waiting) and watch the attempts pile into one tiny window (a retry storm). Then we give them a bounded retry loop with exponential backoff and jitter, and watch the same number of attempts spread calmly over time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7o78n2qfppfuvkbsjtlp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7o78n2qfppfuvkbsjtlp.png" alt="Timeouts, retries, backoff &amp;amp; jitter" width="800" height="834"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📘 &lt;strong&gt;Where we left off in Lesson 5:&lt;/strong&gt; You made idempotency correct under genuine concurrency. 10 same-key POSTs arriving at the same instant now produce &lt;strong&gt;10 OK responses, 1 unique Order Id, 9 &lt;code&gt;replayed=true&lt;/code&gt;, and exactly 1 DB row&lt;/strong&gt; — no more HTTP 500s for the race losers. Tagged as &lt;code&gt;lesson-05-after&lt;/code&gt;. But every "retry" so far was &lt;em&gt;faked&lt;/em&gt; by us firing parallel requests. We never built the thing that actually retries in real life — &lt;strong&gt;the client&lt;/strong&gt;. The server is now a safe place to retry &lt;em&gt;into&lt;/em&gt;. Today we ask: &lt;em&gt;how should the client retry, so that being safe doesn't accidentally become being slow — or being an outage?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;🎯 &lt;strong&gt;The one idea to take away:&lt;/strong&gt; Idempotency makes retries safe for &lt;strong&gt;correctness&lt;/strong&gt;. Backoff &amp;amp; jitter make retries safe for &lt;strong&gt;system load&lt;/strong&gt;. Correctness is not the same as scalability. You need both, and they are solved in different places: one on the server (Lesson 5), one on the client (today).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What you'll reinforce and add
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;🔁 Reinforce — from Lessons 4 &amp;amp; 5:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Idempotency-Key = one key per business operation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;replayed=true&lt;/code&gt; means "I returned the existing result"&lt;/li&gt;
&lt;li&gt;DB row count as the authoritative dedup check&lt;/li&gt;
&lt;li&gt;Before/After discipline + the .NET scenario runner&lt;/li&gt;
&lt;li&gt;Reading a console table + saved report&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;✨ New — added today:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Seeing a retry as another real HTTP request&lt;/li&gt;
&lt;li&gt;Timeout vs retry vs backoff vs jitter&lt;/li&gt;
&lt;li&gt;The thundering-herd / retry-storm failure mode&lt;/li&gt;
&lt;li&gt;A hand-written exponential-backoff + jitter loop&lt;/li&gt;
&lt;li&gt;Why the SAME key must survive across retries&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🎯 &lt;strong&gt;Today's goal:&lt;/strong&gt; Simulate several clients that each retry one order. First let them retry the &lt;strong&gt;naive&lt;/strong&gt; way — no waiting — and watch all the retries pile into one tiny window (a retry storm). Then give them a &lt;strong&gt;bounded retry loop with exponential backoff and jitter&lt;/strong&gt;, reusing the same Idempotency-Key per operation, and watch the same number of attempts spread calmly over time. In both runs the database stays correct (one order per client) — proving the point: the fix here is not about correctness, it's about &lt;em&gt;load&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧠 Mental Model 1 — A retry is another real HTTP request
&lt;/h2&gt;

&lt;p&gt;The most common beginner mistake is to imagine a "retry" as some lightweight, free thing the framework does in the background. It is not. When a client retries, it opens a real connection and sends a real POST. The server does real work to answer it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;One logical operation: "Create my Koshary + Pizza order"

Attempt 1  ──►  POST /api/orders   (real request, real CPU, real DB hit)
   timeout / no answer in time
Attempt 2  ──►  POST /api/orders   (ANOTHER real request)
   still slow...
Attempt 3  ──►  POST /api/orders   (ANOTHER real request)

3 attempts  =  3 real requests the server must handle.
1 order      =  what the user actually wanted.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if 1,000 clients each retry 5 times, that is not 1,000 requests — it is &lt;strong&gt;up to 5,000&lt;/strong&gt;. Retries multiply load. Idempotency makes sure those 5,000 requests still create only 1,000 orders (correctness). It does &lt;em&gt;nothing&lt;/em&gt; to reduce the 5,000 (load). That gap is exactly what today is about.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Why does a client even retry?&lt;/strong&gt; Usually because of a &lt;em&gt;timeout&lt;/em&gt;: the request was sent, but no answer came back in time. Here is the cruel part — the client cannot tell the difference between "the server never got it" and "the server did the work but the reply got lost." So a careful client &lt;em&gt;must&lt;/em&gt; retry to be safe… which is only OK because the server is idempotent. The two halves depend on each other.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧠 Mental Model 2 — Timeout vs Retry vs Backoff vs Jitter
&lt;/h2&gt;

&lt;p&gt;Four words that beginners often blur together. They are four different decisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TIMEOUT  → "How long do I wait for one answer before I give up on it?"
            e.g. wait 2s; if no response, treat this attempt as failed.

RETRY    → "After a failed attempt, do I try again, and how many times?"
            e.g. try at most 5 times, then surface an error to the user.

BACKOFF  → "How long do I WAIT between retries?"
            naive  : 0ms, 0ms, 0ms ...      (hammer immediately)
            backoff: 250ms, 500ms, 1s, 2s   (wait longer each time)

JITTER   → "Do all clients wait the EXACT same amount?"
            no jitter: every client retries at t=250ms  → they sync up
            jitter   : each waits 250ms ± a random wobble → they spread out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put simply with a teaching analogy: imagine a door that jams. &lt;strong&gt;Timeout&lt;/strong&gt; is how long you push before letting go. &lt;strong&gt;Retry&lt;/strong&gt; is deciding to push again. &lt;strong&gt;Backoff&lt;/strong&gt; is waiting a bit longer before each push instead of slamming it nonstop. &lt;strong&gt;Jitter&lt;/strong&gt; is making sure you and everyone else in the crowd don't all shove at the exact same second — because that is what breaks the door.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Backoff without jitter is only half a fix.&lt;/strong&gt; If every client backs off by exactly 250ms, then 500ms, then 1s, they stay perfectly synchronized — the herd just stampedes in waves. Jitter is the small random offset that breaks the lockstep so the load smears out instead of arriving in spikes.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧪 Verification Discipline — same rule, new session
&lt;/h2&gt;

&lt;p&gt;As always, we do not prove this with throwaway PowerShell. Session 6 gets its own repeatable scenarios under &lt;code&gt;tools/Wassal.SessionTests&lt;/code&gt;, sharing the same engine and reporting as Session 5. Each scenario cleans the DB, fires the requests in a repeatable way, prints a readable console table, applies clear pass/fail rules, and saves a &lt;strong&gt;timestamped report&lt;/strong&gt; under &lt;code&gt;Reports/Session-06/&lt;/code&gt; that previous runs never overwrite — so the evidence is easy to attach to this writeup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One engine, two scenarios — different verdicts.&lt;/strong&gt; The shared mechanics (clean → simulate N clients × A attempts reusing one key per client → record when each attempt was sent → count rows) live once in &lt;code&gt;RetryStormCore&lt;/code&gt;. Each scenario reuses that engine but brings its own verdict. The &lt;strong&gt;only&lt;/strong&gt; behavioural difference between the two is the retry timing policy — naive (no wait) vs resilient (backoff + jitter). No server change is needed between them.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⚖️ Lesson 6 has TWO scenarios with different verdict logic
&lt;/h3&gt;

&lt;p&gt;Both simulate the same clients making the same number of attempts with the same reused key. What differs is what counts as success:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① BEFORE — Naive retry storm&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-06-naive-retry-storm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A diagnostic. Its job is to &lt;strong&gt;expose concentrated load&lt;/strong&gt;, so it is happy when the attempts pile up. &lt;strong&gt;PASS when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Attempts are crammed together (&lt;strong&gt;avg spacing &amp;lt; 50 ms&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Many attempts land in one short window (load concentration)&lt;/li&gt;
&lt;li&gt;Duplicates still avoided (DB rows = number of clients)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Meaning: idempotency kept it correct, but the retries arrived as one spike. Correctness ≠ scalability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② AFTER — Resilient retry&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-06-resilient-retry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short alias: &lt;code&gt;session-06&lt;/code&gt;. A proof. It is happy only when correctness &lt;em&gt;and&lt;/em&gt; spread both hold. &lt;strong&gt;PASS when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DB row count == clients, unique ids == clients&lt;/li&gt;
&lt;li&gt;Every client eventually succeeded&lt;/li&gt;
&lt;li&gt;Retries spread out (&lt;strong&gt;avg spacing ≥ 50 ms&lt;/strong&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Same input, opposite verdicts — on purpose.&lt;/strong&gt; The 50 ms boundary is the hinge: the naive run sits below it (a spike), the resilient run sits above it (spread out). Both keep the database correct — that is the whole lesson. The improvement you are proving is about &lt;em&gt;load&lt;/em&gt;, not &lt;em&gt;data&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Task 1 — Feel the Naive Retry Storm (BEFORE) &lt;em&gt;(10 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Make sure the Wassal API is running (the same idempotent server from Lesson 5 — no changes needed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/Wassal.Monolith &lt;span class="nt"&gt;--launch-profile&lt;/span&gt; https
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run the naive scenario. It simulates 5 clients, each retrying its order 5 times, with &lt;strong&gt;no wait&lt;/strong&gt; between attempts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From the repo root&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-06-naive-retry-storm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional overrides (defaults come from &lt;code&gt;appsettings.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-06-naive-retry-storm &lt;span class="sb"&gt;`&lt;/span&gt;
    &lt;span class="nt"&gt;--clients&lt;/span&gt; 5 &lt;span class="sb"&gt;`&lt;/span&gt;
    &lt;span class="nt"&gt;--attempts&lt;/span&gt; 5 &lt;span class="sb"&gt;`&lt;/span&gt;
    &lt;span class="nt"&gt;--cleanDb&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The console prints a per-client summary, an &lt;strong&gt;attempt timeline sorted by send time&lt;/strong&gt; (so you can literally see them stack up), and a metrics block. It then saves a report like &lt;code&gt;Reports/Session-06/Session-06-Naive-Retry-Storm-RunReport-2026-06-21-143012.txt&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🐛 &lt;strong&gt;The storm.&lt;/strong&gt; You'll see all 25 attempts land almost on top of each other — a tiny &lt;code&gt;avg spacing&lt;/code&gt; (single-digit ms) and a high &lt;code&gt;Max attempts in 200ms window&lt;/code&gt;. The database is still correct (5 orders, one per client, duplicates avoided) because each client reused its key. &lt;strong&gt;But the load was concentrated into one spike.&lt;/strong&gt; On a server that is busy or just recovering from a blip, that spike is the thundering herd that turns a hiccup into a full outage.&lt;/p&gt;

&lt;p&gt;💡 &lt;strong&gt;Read the verdict carefully.&lt;/strong&gt; This scenario &lt;em&gt;passes&lt;/em&gt; (exit 0) when the storm IS observed — that's the point of a BEFORE diagnostic. "Attempts too close together + duplicates still avoided" is the documented bad state we want to make visible before we fix it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; you've seen the attempts cram into one window, a tiny avg spacing, and DB rows = number of clients (duplicates avoided).&lt;/p&gt;




&lt;h2&gt;
  
  
  Task 2 — Write a Hand-Made Backoff + Jitter Loop &lt;em&gt;(15 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The fix is entirely on the client. We change &lt;strong&gt;one thing&lt;/strong&gt;: how long a client waits before each retry. We keep it hand-written on purpose — no Polly, no circuit breaker — so the mechanics stay visible. Here is the whole idea in a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the resilient retry policy (illustrative)&lt;/span&gt;

&lt;span class="c1"&gt;// One key for the whole operation — generated ONCE, reused on every retry.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;baseDelayMs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;250&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// first backoff gap&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// double the wait each time (exponential)&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// bounded — never retry forever&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1) WAIT before retrying (the first attempt waits 0).&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseDelayMs&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 250, 500, 1000, 2000&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0.6&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NextDouble&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;// random ×[0.6 .. 1.4)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;gap&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;                    &lt;span class="c1"&gt;// backoff + jitter&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 2) Send the SAME request with the SAME key.&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;PostOrderAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 3) Stop as soon as it works; otherwise loop and back off again.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Bounded: after maxAttempts we give up gracefully (surface a clean error,&lt;/span&gt;
&lt;span class="c1"&gt;// not an infinite stampede).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things make this "resilient" instead of "naive":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bounded&lt;/strong&gt; — &lt;code&gt;maxAttempts&lt;/code&gt; means we never retry forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exponential backoff&lt;/strong&gt; — each gap is roughly double the last (250ms → 500ms → 1s → 2s), so a struggling server gets breathing room that grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jitter&lt;/strong&gt; — the &lt;code&gt;±40%&lt;/code&gt; random multiplier means clients don't retry in lockstep, so the load smears out instead of arriving in synchronized waves.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;In this repo&lt;/strong&gt; the exact same policy already lives in the scenario class &lt;code&gt;Session06ResilientRetryScenario&lt;/code&gt; (method &lt;code&gt;ResilientDelay&lt;/code&gt;), and the naive one in &lt;code&gt;Session06NaiveRetryStormScenario&lt;/code&gt; (&lt;code&gt;NaiveDelay&lt;/code&gt; returns &lt;code&gt;0&lt;/code&gt;). The shared firing engine is &lt;code&gt;RetryStormCore&lt;/code&gt;. You don't have to edit the server at all — the difference between BEFORE and AFTER is purely this delay function.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; you can explain, in one sentence each, what bounded / backoff / jitter add — and why the key is created once, not per attempt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Task 3 — Prove Resilient Retry (AFTER) &lt;em&gt;(10 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Run the resilient scenario — same clients, same attempts, same per-client key, only the timing policy changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SAME runner, SAME engine — different policy, different verdict&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-06-resilient-retry

&lt;span class="c"&gt;# Short alias:&lt;/span&gt;
dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; tools/Wassal.SessionTests &lt;span class="nt"&gt;--&lt;/span&gt; session-06
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scenario applies &lt;strong&gt;5 explicit pass/fail checks&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DB row count == clients&lt;/code&gt; — one order per client&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Unique ids == clients&lt;/code&gt; — every client's retries point at its one order&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Duplicates avoided&lt;/code&gt; — idempotency held under retries&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;All clients succeeded&lt;/code&gt; — every operation eventually got a result&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Retries spread out&lt;/code&gt; — average spacing ≥ 50 ms (backoff actually applied)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;Both halves hold.&lt;/strong&gt; Read it against the timeline: the same 25 attempts are now smeared across a few seconds instead of one burst. &lt;code&gt;avg spacing&lt;/code&gt; jumps from single-digit ms to well over the 50 ms boundary, and &lt;code&gt;Max attempts in 200ms window&lt;/code&gt; drops sharply. Meanwhile the DB still shows one order per client. &lt;strong&gt;Idempotency kept it correct; backoff + jitter kept it gentle.&lt;/strong&gt; Exit code 0 means all five checks passed.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;If you ran the naive scenario after this, it would "fail" its storm check&lt;/strong&gt; — because there's no storm left to find. That failure is good news. Always match the scenario to what you're testing: naive to expose the spike, resilient to prove the spread.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; all 5 checks PASS — duplicates avoided AND retries spread out (avg spacing ≥ 50 ms).&lt;/p&gt;




&lt;h2&gt;
  
  
  Task 4 — Why the Same Idempotency-Key Must Survive Every Retry &lt;em&gt;(10 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;This is the single most important rule of client retries, and the easiest to get wrong. The Idempotency-Key belongs to the &lt;strong&gt;operation&lt;/strong&gt;, not to the &lt;strong&gt;attempt&lt;/strong&gt;. The client must generate it &lt;em&gt;once&lt;/em&gt;, before the first try, and reuse the exact same value on every retry of that operation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ CORRECT — key created once, reused on every retry

operation: "create my order"   →  key = ABC-123   (generated ONE time)
   attempt 1  →  POST  Idempotency-Key: ABC-123
   attempt 2  →  POST  Idempotency-Key: ABC-123     ← same key
   attempt 3  →  POST  Idempotency-Key: ABC-123     ← same key
Server: "I've seen ABC-123 → replay the existing order."  → 1 order ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the key is stable, the server recognizes attempts 2 and 3 as the &lt;em&gt;same intent&lt;/em&gt; and replays the first result. That is exactly the &lt;code&gt;replayed=true&lt;/code&gt; behaviour from Lessons 4 and 5 — and it's why our resilient scenario produces one order per client no matter how many times each client retries.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The anti-pattern: a new key per retry.&lt;/strong&gt; If the client generates a fresh key for each attempt, every retry looks like a brand-new operation to the server — and idempotency can't help, because dedup is keyed on… the key.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ WRONG — a new key every attempt

operation: "create my order"
   attempt 1  →  POST  Idempotency-Key: KEY-1   →  Server: new! → Order #55
   attempt 2  →  POST  Idempotency-Key: KEY-2   →  Server: new! → Order #56
   attempt 3  →  POST  Idempotency-Key: KEY-3   →  Server: new! → Order #57
Result: 1 user intent → 3 DUPLICATE orders (and 3 charges). 💥
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the customer wanted one Koshary + Pizza order and got billed three times. The server did nothing wrong — it was told, three times, "this is a new order." Generating a new key per retry doesn't just weaken idempotency; it &lt;strong&gt;completely defeats&lt;/strong&gt; it. All the work from Lessons 3–5 is undone by one client-side mistake.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Rule of thumb.&lt;/strong&gt; New key = new intent. Same retry = same key. If you can't point to where the key was generated &lt;em&gt;once&lt;/em&gt; and held across retries, your "idempotent" client isn't. In our runner, that's exactly why each simulated client builds one key (&lt;code&gt;…-client-N&lt;/code&gt;) and reuses it for all of its attempts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; you can state why reusing the key gives 1 order, and why a new key per retry gives N duplicates.&lt;/p&gt;




&lt;h2&gt;
  
  
  Task 5 — Capture the Evidence, Tag, Document &lt;em&gt;(5 min)&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Unlike Lesson 5, there is &lt;strong&gt;no server change&lt;/strong&gt; today — the BEFORE and AFTER are two client policies run against the same idempotent API. So the "before/after" lives in the two saved reports under &lt;code&gt;Reports/Session-06/&lt;/code&gt;, not in a controller diff. Attach both reports to the writeup as evidence.&lt;/p&gt;

&lt;p&gt;Commit the new scenarios + session plan, and tag the AFTER state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;D:&lt;span class="se"&gt;\b&lt;/span&gt;ooks&lt;span class="se"&gt;\d&lt;/span&gt;istributed-system&lt;span class="se"&gt;\W&lt;/span&gt;assal
git add tools/Wassal.SessionTests/ docs/sessions/Day-06-Timeouts-Retries-Backoff-Jitter.html
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"feat: Lesson 6 — client-side resilient retries (backoff + jitter) + scenarios"&lt;/span&gt;
git tag lesson-06-after
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suggested lesson writeup — &lt;code&gt;docs/lessons/lesson-06-timeouts-retries-backoff-jitter.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Lesson 6 — Timeouts, Retries, Backoff &amp;amp; Jitter&lt;/span&gt;

&lt;span class="gs"&gt;**Tags:**&lt;/span&gt; &lt;span class="sb"&gt;`lesson-06-before`&lt;/span&gt; (naive) → &lt;span class="sb"&gt;`lesson-06-after`&lt;/span&gt; (resilient)
&lt;span class="gs"&gt;**Builds on:**&lt;/span&gt; Lesson 5 (&lt;span class="sb"&gt;`lesson-05-after`&lt;/span&gt;)

&lt;span class="gu"&gt;## The pain (BEFORE)&lt;/span&gt;

Naive retries (no wait) from several clients pile every attempt into one
tiny window. Idempotency still keeps the DB correct — one order per client,
duplicates avoided — but the LOAD is concentrated into a spike: the
thundering herd. Correct is not the same as scalable.

&lt;span class="gu"&gt;## The fix (AFTER)&lt;/span&gt;

A bounded, hand-written retry loop with exponential backoff (250ms × 2 each
time) and ±40% jitter, reusing ONE Idempotency-Key per operation. Same
attempts, same correctness, but the load is spread over time instead of
stacked. Verified by &lt;span class="sb"&gt;`session-06-resilient-retry`&lt;/span&gt;: DB rows = clients,
unique ids = clients, every client succeeded, avg spacing ≥ 50ms.

| Scenario                       | Correctness        | Load                         |
|--------------------------------|--------------------|------------------------------|
| Naive retry (no wait)          | 1 order/client ✅  | concentrated spike ❌        |
| Resilient (backoff + jitter)   | 1 order/client ✅  | spread over time ✅          |

&lt;span class="gu"&gt;## The key rule&lt;/span&gt;

Generate the Idempotency-Key ONCE per operation and reuse it on every
retry. A new key per retry turns 1 intent into N duplicate orders and
defeats everything from Lessons 3–5.

&lt;span class="gu"&gt;## What's still deliberately missing (later lessons)&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**Polly / resilience libraries.**&lt;/span&gt; We hand-rolled the loop to see it.
  Polly comes later (Part 6, Circuit Breakers).
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Circuit breaker.**&lt;/span&gt; Stop calling a service that is clearly down. Later.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Server-side rate limiting / 429.**&lt;/span&gt; Defending the server FROM abusive
  clients is a separate lesson (Part 6).
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Key TTL / expiry.**&lt;/span&gt; Still parked for the caching lesson.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ Done when:&lt;/strong&gt; both Session-06 reports are saved, the scenarios + session plan are committed, and &lt;code&gt;lesson-06-after&lt;/code&gt; is tagged.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Done When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Run naive storm → expose concentrated load&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;Tiny avg spacing + DB rows = clients (duplicates avoided)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Understand + write the backoff + jitter loop&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;td&gt;You can explain bounded / backoff / jitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Run resilient retry → prove the fix&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;All 5 checks PASS (correct AND spread out)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Why reuse the key; why a new key per retry breaks it&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;Can explain 1 order vs N duplicates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Save reports, commit, tag &lt;code&gt;lesson-06-after&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;Both reports saved + writeup committed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;blockquote&gt;
&lt;p&gt;🎓 &lt;strong&gt;The compounding effect — 6 lessons in.&lt;/strong&gt; Your idempotent POST endpoint now survives &lt;strong&gt;app restarts&lt;/strong&gt; (Lesson 4), &lt;strong&gt;body tampering&lt;/strong&gt; (Lesson 4), and &lt;strong&gt;genuine concurrency&lt;/strong&gt; (Lesson 5) — and you finally have the &lt;strong&gt;client&lt;/strong&gt; that knows how to retry into it safely (Lesson 6). The server guarantees correctness; the client guarantees politeness. That two-sided contract — at-least-once delivery + idempotent handling + backoff — is the real shape of "exactly-once &lt;em&gt;effect&lt;/em&gt;" that production systems actually ship.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Part of the **Fundamentals of Distributed Systems&lt;/em&gt;* series — building Wassal, a distributed food-delivery lab, one concept at a time.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>distributedsystems</category>
      <category>resilience</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
