Building a Dead-Letter Queue Replay Tool
When a channel-manager sync fails past its retries, the payload lands in a dead-letter queue — but a DLQ that no one can inspect, filter, or safely replay is just a slow-motion data-loss incident, because the rate and availability updates sitting in it are exactly the ones that never reached booking_com or expedia. This page builds a command-line tool that lists what is stuck, filters it down to the records you actually want to retry, and replays them back through the pipeline without double-applying, by preserving each message’s original idempotency key. It is the operational counterpart to the classification rules in error categorization and retry logic that decided these records belonged in the DLQ in the first place.
The reader is the on-call engineer at 8am after an OTA had a two-hour outage: a few thousand availability pushes dead-lettered, and they need to replay them safely before the day’s bookings come in — without re-sending anything that actually succeeded, and without letting one genuinely poisonous message block the batch.
Prerequisites & environment
The tool reads from the DLQ, re-validates each payload, and re-submits it through the same code path a live sync uses. Pin these:
- Python 3.11+ — for
tomllib-freeargparseCLI wiring and modern type hints. redis5.x (redis-py) — the reference DLQ here is a Redis Stream (sync:dlq), read withXRANGEand acknowledged withXDEL; the same design maps cleanly onto an SQS DLQ.pydantic2.6+ — every dead-lettered payload is re-validated against the canonical model before replay, so a schema-invalid record is caught, not blindly resent.polars1.x — the inspect/summary view aggregates the DLQ by channel, error class, and property with a vectorized group-by rather than a Python loop.structlog24.x — every replay decision logs as key=value keyed byidempotency_keyandproperty_id.- Read/write access to the DLQ and the same sync endpoint the live worker uses, authenticated via the usual OAuth2 token refresh strategies.
The one non-negotiable prerequisite is that the original producer stamped a stable idempotency key onto each message before it was dead-lettered. If the key is missing, a replay cannot be made safe after the fact — the fix is upstream, in the producer, not in this tool.
Step-by-step implementation
Four steps: model the envelope so the idempotency key travels with the payload, build an inspect view that summarizes the DLQ before you touch it, replay with the preserved key under a dry-run flag, and quarantine poison messages so one bad record never stalls the batch.
Step 1 — Model the dead-letter envelope
A dead-lettered message is more than its payload: it carries the original idempotency key, the failure reason that sent it here, and an attempt count. Model the envelope so the key is a first-class field that replay cannot lose.
from pydantic import BaseModel, Field
class DeadLetter(BaseModel):
stream_id: str # Redis Stream entry id, for XDEL after success
idempotency_key: str = Field(min_length=8) # MUST survive into the replay
channel: str
endpoint: str
property_id: str
payload: dict # the original sync body, replayed verbatim
error_class: str # e.g. "ota_5xx", "timeout", "schema_invalid"
attempts: int = Field(ge=1)
def replay_headers(self) -> dict[str, str]:
# the original key — NOT a fresh one — is what makes replay safe
return {"Idempotency-Key": self.idempotency_key}
Reconstructing the original idempotency key into the replay headers is the single choice that makes the whole tool safe: mint a fresh key here and the OTA treats the replay as a brand-new update, double-applying a rate change that may have actually succeeded before the failure that dead-lettered it.
Step 2 — Inspect before you touch anything
Never replay blind. Load the DLQ into Polars and summarize it by channel, error class, and property, so the operator can see whether they are looking at one OTA’s outage or a systemic schema bug before deciding what to replay.
import polars as pl
import redis
def load_dlq(r: redis.Redis, stream: str = "sync:dlq") -> list[DeadLetter]:
entries = r.xrange(stream) # [(id, {field: value}), ...]
out = []
for entry_id, fields in entries:
out.append(DeadLetter(stream_id=entry_id.decode(), **_decode(fields)))
return out
def summarize(letters: list[DeadLetter]) -> pl.DataFrame:
return (
pl.DataFrame([l.model_dump(exclude={"payload"}) for l in letters])
.group_by(["channel", "error_class"])
.agg(pl.len().alias("count"),
pl.col("property_id").n_unique().alias("properties"))
.sort("count", descending=True)
)
Grouping by error_class up front is what turns a scary undifferentiated backlog into a decision: a pile of ota_5xx from one channel is a safe bulk replay, whereas a pile of schema_invalid means the producer is broken and replaying would just re-fail every record — a distinction drawn straight from categorizing 4xx vs 5xx sync errors.
Step 3 — Replay with the preserved key, behind a dry-run flag
Default to dry-run: report exactly what would be replayed and change nothing. Only with --apply does the tool re-submit — and it deletes a message from the DLQ only after a confirmed success, so a replay that fails again stays put for the next attempt.
import httpx
import structlog
log = structlog.get_logger()
def replay(r: redis.Redis, client: httpx.Client, letters: list[DeadLetter],
apply: bool) -> dict[str, int]:
stats = {"replayed": 0, "skipped_dry_run": 0, "failed": 0}
for dl in letters:
if not apply:
log.info("would_replay", idempotency_key=dl.idempotency_key,
property_id=dl.property_id, channel=dl.channel)
stats["skipped_dry_run"] += 1
continue
resp = client.post(f"/sync/{dl.endpoint}", json=dl.payload,
headers=dl.replay_headers())
# 200/201 = applied now; 409/200-dedup = already applied → still success
if resp.status_code in (200, 201, 409):
r.xdel("sync:dlq", dl.stream_id) # remove ONLY after confirmed success
stats["replayed"] += 1
else:
log.warning("replay_failed", idempotency_key=dl.idempotency_key,
status=resp.status_code)
stats["failed"] += 1
return stats
Treating a 409 Conflict (or a dedup-hit 200) as success, not failure, is what closes the double-apply gap end to end: because the original key was preserved, a record that actually applied before dead-lettering is recognized server-side and the replay becomes a safe no-op that still clears the message from the queue.
Step 4 — Quarantine poison messages so one bad record can’t block the batch
A message that fails re-validation, or fails replay repeatedly, is poison — replaying it forever just stalls everything behind it. Route it to a separate quarantine stream and remove it from the main DLQ, so the good records keep flowing.
POISON_STREAM = "sync:dlq:poison"
def quarantine_poison(r: redis.Redis, raw_id: str, raw_fields: dict,
reason: str) -> None:
# copy the raw entry verbatim into the poison stream for human review
r.xadd(POISON_STREAM, {**raw_fields, "quarantine_reason": reason})
r.xdel("sync:dlq", raw_id) # unblock the batch; do not lose the data
log.error("dlq_poison_quarantined", stream_id=raw_id, reason=reason)
def safe_load(r: redis.Redis, stream: str = "sync:dlq") -> list[DeadLetter]:
letters = []
for entry_id, fields in r.xrange(stream):
try:
letters.append(DeadLetter(stream_id=entry_id.decode(), **_decode(fields)))
except Exception as exc: # unparseable envelope → poison, not a crash
quarantine_poison(r, entry_id.decode(), _decode(fields), str(exc))
return letters
Copying poison to a dedicated stream rather than deleting it outright preserves the evidence for a human while still unblocking the batch — the payload is never lost, but a single malformed record can no longer wedge the head of the queue and starve every valid message behind it.
Gotchas & production notes
- A fresh idempotency key turns a safe replay into a double-charge. The most damaging mistake is regenerating the key on replay. Availability decrements and rate updates are not idempotent by nature — only the preserved key makes them so. Assert the key is non-empty before every replay and refuse to send without it.
- Order matters when replaying overlapping updates for one property. If the DLQ holds three availability pushes for the same
property_idand date, replaying them out of order can leave the older value winning. Replay per-property in the original stream order (Redis Stream IDs are time-ordered), or collapse to the latest state per key before sending, so a stale replay never clobbers a newer truth. - Delete from the DLQ only after a confirmed 2xx — never before. Deleting on send-attempt rather than send-success is silent data loss: if the replay POST times out after the OTA already rejected it, an at-least-once delete leaves nothing to retry. Acknowledge (
XDEL) strictly after a success response, accepting that a crash mid-replay may re-attempt one already-applied record — which the preserved key makes harmless. - Rate-limit the replay itself. A backlog of ten thousand messages dumped at an OTA in seconds will simply re-trip the limit that may have caused the backlog. Pace the replay loop through the same distributed token bucket the live workers use, so a recovery replay does not become a second incident.
Verification snippet
The behaviour worth proving is the safety property: replaying a record whose update already applied must not double-apply, and must still clear it from the DLQ. Stub the sync endpoint to return 409 (server-side dedup) and assert the message is removed cleanly.
import fakeredis
def test_replay_is_idempotent_and_clears_dlq() -> None:
r = fakeredis.FakeStrictRedis()
dl = DeadLetter(
stream_id="1-0", idempotency_key="idem-booking-8842-2026-07-20",
channel="booking_com", endpoint="availability", property_id="LON-STJ-01",
payload={"property_id": "LON-STJ-01", "date": "2026-07-20", "avail": 3},
error_class="ota_5xx", attempts=3)
r.xadd("sync:dlq", {"x": "1"}, id="1-0") # placeholder entry to be XDEL'd
class DedupClient: # OTA already applied this key
def post(self, url, json, headers):
assert headers["Idempotency-Key"] == "idem-booking-8842-2026-07-20"
class Resp: status_code = 409 # dedup hit, not a real conflict
return Resp()
stats = replay(r, DedupClient(), [dl], apply=True)
assert stats["replayed"] == 1 # dedup 409 counts as success
assert r.xlen("sync:dlq") == 0 # message cleared, not re-queued
test_replay_is_idempotent_and_clears_dlq()
A green run confirms the tool’s core guarantee: a record whose update had in fact already reached the OTA is replayed with its original key, recognized as a duplicate server-side, counted as a success, and removed from the DLQ — so an operator can bulk-replay a recovered backlog without any fear of double-applying a rate or availability change.
Related
- Error Categorization & Retry Logic — the parent guide whose classification decides what dead-letters in the first place
- Categorizing 4xx vs 5xx Sync Errors — the retryable-versus-terminal split that tells you which DLQ records are safe to replay
- Distributing Token Buckets Across Async Workers — pace the replay through the same shared budget so recovery isn’t a second incident
- Building Batch Reconciliation Scripts for Daily Syncs — the nightly pass that catches state a failed-then-replayed push left inconsistent
- OAuth2 Token Refresh Strategies — how the replay tool authenticates to re-submit against the live endpoint
← Back to Error Categorization & Retry Logic