A DLQ that does not become an outage amplifier
Simulate a modelNeeds Python
Problem
A dead-letter queue is meant for records that can never succeed: malformed input, a schema the consumer cannot read. A naive policy (three attempts, then the DLQ) cannot tell those from records that failed because the downstream was briefly unavailable. During an outage every record fails three times, so good records pour into the DLQ, and when someone replays them later they are applied out of order.
Write a small consumer with a retry-then-DLQ policy. Feed it 10,000 records where (a) 0.5% are malformed, then (b) after record 5,000 the downstream function starts raising a timeout for every call for 30 seconds. Run it twice: with a naive policy (three attempts, then DLQ) and with a classified policy (transient errors retry with backoff and never go to the DLQ; malformed records go to the DLQ at once; if more than 2% of the last 1,000 records failed, pause and alert). Report the DLQ size and the number of records processed out of order in each run.
The question. How many good records does each policy send to the dead-letter queue during the outage, and how many end up applied out of order?
Predict
Expect the naive policy to move thousands of good records into the DLQ and the classified policy to dead-letter only the malformed 0.5% (about 50 records).
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
a-dlq-that-does-not-become.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """A DLQ that does not become a second data loss. 10,000 records, 0.5% malformed; after record 5,000 the downstream times out on every call for 30 seconds. A naive policy (three attempts, then the dead-letter queue) against a classified one (transient errors retry with backoff and never dead-letter; malformed records dead-letter at once; pause and alert when more than 2% of the last 1,000 failed). """ import argparse, json, random p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--records", type=int, default=10_000, help="Records.") p.add_argument("--malformed", type=float, default=0.005, help="Share of malformed records.") p.add_argument("--rate", type=float, default=100, help="Records per second offered.") p.add_argument("--outage", type=float, default=30, help="Seconds the downstream times out, starting at record 5,000.") p.add_argument("--fail-ms", type=float, default=1.0, help="Milliseconds a failing call takes to raise its timeout error.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) bad = set(random.sample(range(args.records), int(args.records * args.malformed))) start = 5_000 / args.rate # the outage starts when record 5,000 is due def run(policy): t, dlq, applied, recent, paused_s = 0.0, [], [], [], 0.0 for r in range(args.records): t = max(t, r / args.rate) while True: down = start <= t < start + args.outage if r in bad: if policy == "classified": dlq.append(r); break # malformed: no retry can fix it for attempt in range(3): t += 0.05 dlq.append(r); break if not down: applied.append(r); recent = (recent + [True])[-1000:]; break if policy == "naive": t += 3 * args.fail_ms / 1000 # three quick failures, then give up dlq.append(r); recent = (recent + [False])[-1000:]; break # classified: a transient error; back off and retry the same record (order kept) recent = (recent + [False])[-1000:] if recent.count(False) > 20: paused_s += 1.0 # > 2% of the last 1,000: pause, alert t += 1.0 out_of_order = sum(1 for a, b in zip(applied, applied[1:]) if b < a) late = len([x for x in dlq if x not in bad]) return {"policy": policy, "dlq": len(dlq), "good_records_in_dlq": late, "applied": len(applied), "applied_out_of_order": len(dlq) - len(bad) if policy == "naive" else out_of_order} rows = [run("naive"), run("classified")] for r in rows: print(f"{r['policy']:<10} DLQ {r['dlq']:>5} (good records in it: {r['good_records_in_dlq']:>5}) applied {r['applied']:>6}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"policies": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run a-dlq-that-does-not-become.py uv run a-dlq-that-does-not-become.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --records10,000 | Records. |
| --malformed0.005 | Share of malformed records. |
| --rate100 | Records per second offered. |
| --outage30 | Seconds the downstream times out, starting at record 5,000. |
| --fail-ms1 | Milliseconds a failing call takes to raise its timeout error. |
| --seed0 | Random seed. |
Our run
Dead-letter queue after a 30-second outage
| Policy | In the DLQ | Good records in it | Applied | Applied out of order |
|---|---|---|---|---|
| naive | 3.03K | 2.98K | 6.97K | 2.98K |
| classified | 50 | 0 | 9.95K | 0 |
naive DLQ 3033 (good records in it: 2983) applied 6967
classified DLQ 50 (good records in it: 0) applied 9950What we found
The naive policy dead-lettered 3.03K records, of which 2.98K were good: every record that arrived during the outage failed its three attempts and went to the queue with the malformed ones. Replaying the queue later would apply those 2.98K records out of order, behind records that arrived after them; the script counts them rather than replaying. The classified policy dead-lettered 50, all malformed, with 0 good records among them and 0 applied out of order, as expected.
Read it
A DLQ is for records that will never succeed, not for records that failed while something else was down. Classify the error before choosing the path: a malformed record goes to the DLQ at once, a timeout or an unavailable downstream is retried with backoff, and a failure rate above the threshold stops the consumer and pages someone.
Your DLQ grows by thousands during an incident and someone plans to "replay it later": that replay will apply those records out of order, after newer ones for the same keys. Treat a sudden jump in DLQ size as an outage signal, not as a queue to drain.