DEV Community

mohamed Tayel
mohamed Tayel

Posted on

Day 16 — Retries, Backoff & Dead-Letter: Handling the Poison Message

Day 15 decided who 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.

Retries, backoff & the dead-letter exit

🙏 Credit where it's due — This series is my attempt to internalize and share what I learned from Mahmoud Youssef's excellent course, Fundamentals of Distributed Systems 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.

Where we are — after Days 13, 14 and 15

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

🎯 The one idea to take away: 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.


1. Why a leased, durable outbox can still loop forever

Everything so far assumed processing eventually succeeds. Real provider calls do not. They fail two ways:

  • Transient failure — a timeout, a blip, a temporary 503. Another try later will probably work.
  • Permanent failure — a malformed record, a rejected account, a bug. This message will never succeed. It is a poison message.

  • ✅ Day 15 solved — ownership: one worker owns a message at a time; a dead owner's lease expires and another worker takes over.

  • ❌ Day 16 solves — when to stop: a naive "retry until it works" loop never stops on a poison message — it re-claims and re-fails forever.

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


2. The BEFORE scenario — retried forever, and the state hides no lie but never ends

session-16-poison-message-retried-forever 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 Pending and try again — no limit, no backoff, no exit. One message, PAY-003, is a poison message: its provider status check fails every time.

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

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

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


3. The retry columns and the new terminal state

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 — Processed or DeadLettered:

Pending ─claim─▶ Processing ─success─▶ Processed        (terminal, good)
                     │
                     ├─fail, attempts < max ─▶ Pending + NextAttemptUtc  (backoff, retry later)
                     └─fail, attempts ≥ max ─▶ DeadLettered + LastError  (terminal, parked)
Enter fullscreen mode Exit fullscreen mode
Column Meaning
AttemptCount / MaxAttempts How many times we've tried, and the ceiling before giving up.
NextAttemptUtc Backoff gate — the message is not claimable again until this time.
LastError Why the last attempt failed — recorded for the human who inspects a dead letter.

Status is now Pending | Processing | Processed | DeadLettered. The DeadLettered state is terminal but not lost — the message is parked, out of the worker's way, with its error attached.


4. The claim, now gated by backoff

The Day 15 atomic claim gets one extra clause: a message is only claimable if its backoff has elapsed. The claim stays a single BEGIN IMMEDIATE transaction with a conditional update:

BEGIN IMMEDIATE;
  SELECT Id FROM OutboxMessages
   WHERE Status IN ('Pending','Processing')
     AND (Status='Pending' OR LockedUntilUtc < $now)          -- lease (Day 15)
     AND (NextAttemptUtc IS NULL OR NextAttemptUtc <= $now)   -- backoff (Day 16)
   ORDER BY CreatedAtUtc LIMIT 1;
  UPDATE OutboxMessages SET Status='Processing', LockedBy=$worker,
         LockedUntilUtc=$lease, AttemptCount = AttemptCount + 1
   WHERE Id=$id AND  (same conditions) ;
COMMIT;
Enter fullscreen mode Exit fullscreen mode

On failure the worker chooses one of two paths, purely from AttemptCount vs MaxAttempts:

if (AttemptCount < MaxAttempts)
    Status='Pending', NextAttemptUtc = now + backoff(AttemptCount), LastError=   // retry later
else
    Status='DeadLettered', LastError=                                            // give up, park it
Enter fullscreen mode Exit fullscreen mode

5. Backoff — and proving it without waiting

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

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

💡 Deterministic by design. 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.


6. Dead-letter — parked, not lost

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

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

💡 The payment is honestly still pending. Dead-lettering the message does not fake a payment outcome — PAY-003's payment row stays PendingConfirmation. We stopped the automatic retry; we did not invent a result.


7. The real implementation — proven by running it

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

  • BEFORE: Session16PoisonRetriedForeverScenario → command session-16-poison-message-retried-forever
  • AFTER: Session16RetryWithDeadLetterScenario → command session-16-retry-with-dead-letter

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

Real BEFORE vs AFTER metrics

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

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

Safety checks from the real AFTER report — all passing

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

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

Run it yourself

dotnet run --project tools/Wassal.SessionTests -- session-16-poison-message-retried-forever
dotnet run --project tools/Wassal.SessionTests -- session-16-retry-with-dead-letter
Enter fullscreen mode Exit fullscreen mode

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:

Reports/Session-16/Session-16-Poison-Message-Retried-Forever-RunReport-<timestamp>.txt
Reports/Session-16/Session-16-Retry-With-Dead-Letter-RunReport-<timestamp>.txt
Enter fullscreen mode Exit fullscreen mode

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


8. Final takeaway

  • A durable, atomic, leased message can still fail every time — leasing decides ownership, not when to stop.
  • Retry needs a limit (MaxAttempts), a backoff (NextAttemptUtc gating re-claim), and an exit (DeadLettered).
  • A dead-lettered message is parked, not lost: terminal, out of the worker's way, with its LastError for a human.
  • Dead-lettering the message does not fake the payment — the payment stays honestly PendingConfirmation.
  • 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.

The four days, side by side:

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.
Enter fullscreen mode Exit fullscreen mode

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


Part of the **Fundamentals of Distributed Systems* series — building Wassal, a distributed food-delivery lab, one concept at a time.*

Top comments (0)