Preface#
2 AM. PagerDuty goes off. The order processing system is frozen.
Investigation reveals: a single malformed order event has been crashing the consumer for the past 4 hours. Each time the consumer restarts, it fetches the same message, crashes, restarts, crashes again—an infinite loop of suffering.
- 00:00 — Consumer processes message #4,521,892
- 00:00 — Message contains invalid JSON in 'items' field
- 00:00 — Consumer throws exception, crashes
- 00:01 — Kubernetes restarts consumer
- 00:01 — Consumer fetches message #4,521,892 (same one!)
- 00:01 — Crash again
- 00:02 — Restart again
- 00:02 — Crash again
- ...
- 04:00 — ~50 crash-loop restarts later (Kubernetes backs off to one restart every 5 minutes), on-call engineer wakes up
- 04:00 — 50,000 messages stuck behind the poison pill
- 04:00 — Customers can't check out
- 04:00 — Revenue loss: $847,000
The message that killed a system:
{
"order_id": "ord_123",
"user_id": "usr_456",
"items": "INVALID - should be array, got string", // ← The poison
"total": 99.99
}Yesterday, we learned about backpressure—handling too many messages. Today, we face a different problem:
What do you do with messages that simply cannot be processed?
These are poison pills—messages that crash your consumers no matter how many times you retry. And the solution is the dead letter queue (DLQ)—a quarantine zone for failed messages.
Today, we'll learn how to implement DLQs, debug poison pills, and build systems that fail gracefully instead of catastrophically.
One malformed message crash-looped a consumer for 4 hours, leaving 50,000 messages stuck and $847,000 lost. Transient failures retry with backoff, permanent ones go straight to a DLQ, and poison pills are quarantined at once. A DLQ message carries its full context so an engineer can fix the cause and replay it idempotently; retry counts live in headers, old messages are versioned for replay, and depth alerts at 100 and 1,000.
Part I: Foundations#
Chapter 1: What Are Dead Letters and Poison Pills?#
1.1 The Simple Definitions#
Poison Pill A message that cannot be successfully processed, no matter how many times you retry. It "poisons" your consumer by causing repeated failures.
Dead Letter Queue (DLQ) A special queue where failed messages go after exceeding retry limits. It's a quarantine zone—messages can be inspected, fixed, and replayed later.
Imagine the postal system:
- Normal delivery: Letter → Sorting → Delivery → Recipient (ok)
But some letters can't be delivered:
- Wrong address (validation error)
- Recipient moved (transient failure)
- House doesn't exist (permanent failure)
- Explosive device (poison pill!)
What happens to undeliverable mail? It goes to the DEAD LETTER OFFICE. There, postal workers:
- Try to figure out the intended recipient
- Return to sender if possible
- Hold for investigation if suspicious
- Eventually destroy if unsalvageable
Your DLQ serves the same purpose for messages.
Your call: retry it or quarantine it?
Four messages fail. (1) The database is briefly unavailable. (2) The items field holds a string instead of a list. (3) An order has a negative price. (4) A message makes the consumer run out of memory every time. Which would you retry, which go to the DLQ, and how fast?
One strong answer
- Retry with backoff: (1). It's transient; the database will likely be back in a moment.
- DLQ without retrying: (2) and (3). Bad format and broken business rules fail identically every time; someone has to fix the data or the code.
- DLQ immediately and alert: (4). It's a poison pill. Every retry crashes the consumer again and blocks everything behind it.
1.2 Why Messages Fail#
Messages can fail for many reasons. Understanding the failure type determines how to handle it:
TRANSIENT FAILURES — retry will help
- Database temporarily unavailable
- Network timeout
- Rate limit exceeded
- Downstream service restarting
→ Solution: Retry with exponential backoff
PERMANENT FAILURES — retry won't help
- Invalid message format (bad JSON)
- Schema mismatch (missing required field)
- Business rule violation (negative price)
- Referenced entity doesn't exist
→ Solution: Send to DLQ, fix the data or code
POISON PILLS — crashes the consumer
- Causes unhandled exception
- Triggers infinite loop
- Exhausts memory
- Corrupts consumer state
→ Solution: IMMEDIATELY send to DLQ, investigate
1.3 The Dead Letter Queue Pattern#
%% title: Message flow with DLQ
%% steps:
%% P --> MQ, MQ --> C: The producer's message reaches the consumer through the main queue
%% C --> S1: Normal path: processing succeeds
%% C --> F, F --> RL: Failure path: the retry logic decides what happens next
%% RL --> BQ, BQ --> C2, C2 --> EV: Below the retry limit, the message goes back to the queue and is tried again
%% RL --> DLQ: At the limit it moves to the dead letter queue
%% DLQ --> MI: Someone investigates why it failed
%% MI --> FR, MI --> DI: Then fixes the cause and replays it, or discards it
flowchart TD
P["Producer"]:::client --> MQ[["Main Queue"]]:::queue
MQ --> C["Consumer"]:::core
C -->|"Normal path"| S1(["Success ok"]):::ok
C -->|"Failure path"| F["Failure!"]:::bad
F --> RL{"Retry Logic"}
RL -->|"Retry < Max"| BQ[["Back to Queue"]]:::queue
BQ --> C2["Consumer"]:::core
C2 --> EV["Eventually: Success ok<br/>or DLQ if exhausted"]:::service
RL -->|"Retry >= Max"| DLQ[["Dead Letter Queue"]]:::bad
DLQ --> MI["Manual Investigation"]:::service
MI --> FR["Fix & Replay"]:::ok
MI --> DI["Discard"]:::bad- The producer's message reaches the consumer through the main queue
- Normal path: processing succeeds
- Failure path: the retry logic decides what happens next
- Below the retry limit, the message goes back to the queue and is tried again
- At the limit it moves to the dead letter queue
- Someone investigates why it failed
- Then fixes the cause and replays it, or discards it
A message whose items field is a string crashes the consumer on every restart, so the offset never moves and 50,000 messages pile up behind it. With a DLQ the consumer catches the ValueError, retries up to 3 times, moves the message to orders.dlq with its error and retry count, and keeps processing; after a fix the message is replayed.
1.4 Key Terminology#
| Term | Definition |
|---|---|
| Dead Letter Queue (DLQ) | Queue holding messages that couldn't be processed |
| Poison Pill | Message that crashes or hangs the consumer |
| Retry Count | Number of times processing has been attempted |
| Max Retries | Threshold before sending to DLQ |
| Visibility Timeout | Time message is hidden after being fetched (SQS) |
| Redelivery | Re-sending a message for another processing attempt |
| Replay | Manually reprocessing messages from DLQ |
Chapter 2: DLQ Patterns and Strategies#
2.1 Pattern 1: Simple DLQ (Catch-All)#
The simplest approach: one DLQ for all failures.
%% title: Simple DLQ architecture flowchart TD MQ[["Main Queue: orders"]]:::queue --> C["Consumer"]:::core C -->|"Success"| CO["Commit offset"]:::ok C -->|"Failure"| R["Retry up to 3 times"]:::service R -->|"Still failing"| DLQ[["orders.dlq"]]:::bad
DLQ Schema:
{
"original_message": { ... },
"error": "ValueError: Invalid item quantity",
"failed_at": "2024-01-15T10:30:00Z",
"retry_count": 3,
"consumer_id": "orders-consumer-1",
"stack_trace": "..."
}Pros:
- ✓ Simple to implement
- ✓ All failures in one place
- ✓ Easy to monitor (one queue depth metric)
Cons:
- ✗ Different failure types mixed together
- ✗ Replay logic must handle all error types
- ✗ Can't prioritize critical vs non-critical failures
Your design: one DLQ or several?
Your order consumer sends every failure to a single orders.dlq. It now holds a mix: fixable bad addresses, timeouts that would probably succeed if replayed, and a few errors nobody understands. What does mixing them cost you, and how would you split it?
One strong answer
- The cost: one queue means one replay policy and one alert level for very different problems, so you can't prioritise the scary ones.
- Split by failure type: a validation DLQ for fixable data (a correction UI, auto-fixes), a transient DLQ that retries itself after a delay, and an unknown DLQ that alerts engineers first.
- The price: more queues to run and monitor.
2.2 Pattern 2: Categorized DLQs#
Separate DLQs based on failure type or severity.
%% title: Categorized DLQ architecture
flowchart TD
MQ[["Main Queue: orders"]]:::queue --> C["Consumer"]:::core
C -->|"Success"| CO["Commit"]:::ok
C -->|"Failure"| CL{"Classify error"}
CL -->|"Validation Error"| D1[["orders.dlq<br/>.validation"]]:::bad
CL -->|"Transient Error"| D2[["orders.dlq<br/>.transient"]]:::bad
CL -->|"Unknown Error"| D3[["orders.dlq<br/>.unknown"]]:::badBenefits of categorization:
- Validation DLQ:
- Contains fixable data issues
- Can build UI for manual correction
- Auto-fix common issues (trim whitespace, etc.)
- Transient DLQ:
- Automatic retry after delay
- Often self-resolves
- Alert if persists > 1 hour
- Unknown DLQ:
- Requires engineering investigation
- Highest priority alerts
- May indicate bugs in consumer code
2.3 Pattern 3: Delayed Retry Queue#
Instead of immediate DLQ, use delayed reprocessing.
%% title: Delayed retry architecture
flowchart TD
MQ[["Main Queue"]]:::queue --> C["Consumer"]:::core
C --> F["Failure!"]:::bad
F --> RC{"Retry Counter"}
RC -->|"Retry 1 · Wait 1s"| DQ1[["Delay Queue<br/>(1 second)"]]:::queue
RC -->|"Retry 2 · Wait 10s"| DQ2[["Delay Queue<br/>(10 seconds)"]]:::queue
RC -->|"Retry 3+ · Wait 60s"| DQ3[["Delay Queue<br/>(60 seconds)"]]:::queue
DQ1 & DQ2 & DQ3 --> BM[["Back to Main Queue"]]:::queue
BM --> SF{"Still failing after<br/>5 retries?"}
SF --> DLQ[["DLQ"]]:::badImplementation with Kafka:
- Retry topics:
orders.retry.1(1 second delay)orders.retry.2(10 second delay)orders.retry.3(60 second delay)
- Each retry topic has consumer that:
- Sleeps for the delay duration
- Republishes to main topic
- Or sends to DLQ if max retries exceeded
2.4 Pattern 4: Error-Specific Handling#
Different error types get different treatment.
# Error Classification and Routing
from enum import Enum
from typing import Optional
import json
class ErrorCategory(Enum):
VALIDATION = "validation" # Bad data, won't retry
TRANSIENT = "transient" # Might succeed later
DEPENDENCY = "dependency" # External service issue
POISON = "poison" # Crashes consumer
UNKNOWN = "unknown" # Unclassified
ERROR_ROUTING = {
ErrorCategory.VALIDATION: {
"retry": False,
"dlq": "orders.dlq.validation",
"alert": False, # Expected errors
},
ErrorCategory.TRANSIENT: {
"retry": True,
"max_retries": 5,
"backoff": "exponential",
"dlq": "orders.dlq.transient",
"alert": True, # Alert if DLQ'd
},
ErrorCategory.DEPENDENCY: {
"retry": True,
"max_retries": 10,
"backoff": "linear",
"dlq": "orders.dlq.dependency",
"alert": True,
},
ErrorCategory.POISON: {
"retry": False, # Never retry poison
"dlq": "orders.dlq.poison",
"alert": True, # Immediate alert
"page_oncall": True, # Wake someone up
},
ErrorCategory.UNKNOWN: {
"retry": True,
"max_retries": 3,
"dlq": "orders.dlq.unknown",
"alert": True,
},
}
def classify_error(error: Exception) -> ErrorCategory:
"""Classify an exception into an error category."""
# Validation errors
if isinstance(error, (ValueError, json.JSONDecodeError, KeyError)):
return ErrorCategory.VALIDATION
# Transient errors
if isinstance(error, (TimeoutError, ConnectionError)):
return ErrorCategory.TRANSIENT
# Dependency errors
if "503" in str(error) or "rate limit" in str(error).lower():
return ErrorCategory.DEPENDENCY
# Poison indicators
if isinstance(error, (MemoryError, RecursionError)):
return ErrorCategory.POISON
return ErrorCategory.UNKNOWN2.5 Pattern Comparison#
| Pattern | Complexity | Best For | Drawback |
|---|---|---|---|
| Simple DLQ | Low | Small systems, getting started | All errors mixed |
| Categorized | Medium | Mature systems | More queues to manage |
| Delayed Retry | Medium | High transient failure rate | Delay adds latency |
| Error-Specific | High | Complex systems | Requires good classification |
Chapter 3: Trade-offs and Considerations#
Estimate: what does "15 retries to be safe" cost?
A poison pill sits at the head of a partition that receives 50 messages a second. Backoff starts at 1 s, doubles each retry and caps at 300 s. How long is the partition blocked with 5 retries, and with 15? How many messages pile up behind it in each case?
One strong answer
- 5 retries: 1 + 2 + 4 + 8 + 16 = 31 s of backoff, so about 50 × 31 ≈ 1,550 messages wait.
- 15 retries: 1 + 2 + … + 256 = 511 s, plus six capped waits of 300 s = 1,800 s: ~2,300 s, about 38 minutes, and roughly 115,000 messages wait.
- So extra retries only help transient errors. For a message that will never succeed, each one is pure delay for everything behind it.
3.1 Retry Count Trade-offs#
Too few retries (1-2)
- Transient errors go to DLQ unnecessarily
- DLQ fills up with recoverable messages
- More manual work to replay
Too many retries (10+)
- Poison pills block processing longer
- Resource waste on hopeless retries
- Delays detection of real problems
Sweet spot depends on: Your transient failure rate · Time to recover from transient issues · Cost of DLQ investigation
COMMON CONFIGURATIONS
| System | Retries | Backoff |
|---|---|---|
| Low-latency systems | 2-3 retries | fast backoff |
| High-reliability | 5-7 retries | longer backoff |
| Batch processing | 10+ retries | very long backoff |
ADAPTIVE RETRIES
class AdaptiveRetryPolicy:
def get_max_retries(self, error_category: ErrorCategory) -> int:
return {
ErrorCategory.VALIDATION: 0, # Never retry
ErrorCategory.TRANSIENT: 5,
ErrorCategory.DEPENDENCY: 10,
ErrorCategory.POISON: 0, # Never retry
ErrorCategory.UNKNOWN: 3,
}[error_category]3.2 DLQ Retention Trade-offs#
| Retention | Pros | Cons |
|---|---|---|
| Short (1-7 days) | Less storage cost · Forces prompt investigation | May lose data if team is slow · No historical analysis |
| Long (30-90 days) | Time to investigate complex issues · Historical pattern analysis | Storage costs · Stale messages may not replay correctly |
| Infinite | Never lose data | Growing storage forever · Old messages become irrelevant |
RECOMMENDED APPROACH
- Keep hot DLQ for 7 days (fast access)
- Archive to cold storage (S3) for 90 days
- Delete after 90 days unless flagged
Schema evolution consideration: Messages from 90 days ago may not match current schema. Include schema version in DLQ messages.
3.3 When NOT to Use a DLQ#
SKIP THE DLQ WHEN
- Messages are fully idempotent AND retriable — Just retry forever with backoff. Eventually it will work or alert.
- Data loss is acceptable — Metrics, logs, analytics. Drop and move on.
- Real-time requirements — If it fails, it's too late anyway. Log the error, move on.
- You have a better recovery mechanism — Event sourcing: rebuild from event log. CQRS: reproject from source.
USE DLQ WHEN
- Every message must eventually succeed — Orders, payments, critical workflows
- You need visibility into failures — Debugging, compliance, auditing
- Manual intervention is expected — Data correction, customer support
- Replay is part of your recovery plan — Fix bug, replay failed messages
3.4 Decision Framework#
%% title: Should this message go to DLQ?
flowchart TD
S(["Start: Message processing failed"]) --> Q1{"Is it a poison pill<br/>(crashes consumer)?"}
Q1 -->|"YES"| D1[["DLQ immediately,<br/>alert on-call"]]:::bad
Q1 -->|"NO"| Q2{"Is it a validation error?"}
Q2 -->|"YES"| Q3{"Can it be auto-fixed?"}
Q3 -->|"YES"| FR["Fix & Retry"]:::ok
Q3 -->|"NO"| D2[["DLQ<br/>(validation)"]]:::bad
Q2 -->|"NO"| Q4{"Retry count < max?"}
Q4 -->|"YES"| RB["Retry<br/>w/backoff"]:::service
Q4 -->|"NO"| D3[["DLQ<br/>(exhausted)"]]:::badPoison pills and DLQ strategy
Q1/4 A message with a malformed
itemsfield crashes the consumer. Kubernetes restarts it, it fetches the same message, and crashes again for hours. Why is everything behind it stuck too?Answer & explanation
Answer: C
A poison pill blocks its whole partition because the consumer keeps restarting at the same offset. The fix is to catch the failure and quarantine the message in a DLQ so the consumer can move on.
Q2/4 Which failure should go straight to the DLQ with no retries?
Answer & explanation
Answer: A
Validation errors fail identically every time, so retrying just delays everything behind them. Timeouts and 503s are transient: retry with backoff, and only DLQ them once retries run out.
Q3/4 You raise max retries from 3 to 15 "to be safe". What is the main downside?
Answer & explanation
Answer: B
More retries help messages that are genuinely transient, but every unfixable message now burns 15 attempts. Too few retries has the opposite problem: recoverable messages land in the DLQ.
Q4/4 A DLQ message from 90 days ago fails when replayed because the consumer now expects a newer message format. What practice from the lesson helps here?
Answer & explanation
Answer: A
Messages outlive the code that produced them. Recording the schema version lets replay tooling apply versioned processing or a migration instead of failing blindly.
Part II: Implementation#
Chapter 4: Basic Implementation#
4.1 Simple DLQ Consumer#
# Basic DLQ Implementation
# WARNING: Not production-ready - for learning only
import asyncio
import json
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from dataclasses import dataclass, asdict
@dataclass
class DeadLetter:
"""A message that failed processing."""
original_message: Dict[str, Any]
error_type: str
error_message: str
stack_trace: str
failed_at: str
retry_count: int
consumer_id: str
topic: str
partition: Optional[int] = None
offset: Optional[int] = None
class SimpleDLQConsumer:
"""
Consumer with basic dead letter queue support.
"""
def __init__(
self,
kafka_consumer,
kafka_producer,
dlq_topic: str,
max_retries: int = 3,
consumer_id: str = "consumer-1"
):
self.consumer = kafka_consumer
self.producer = kafka_producer
self.dlq_topic = dlq_topic
self.max_retries = max_retries
self.consumer_id = consumer_id
async def process_message(self, message: Dict[str, Any]) -> None:
"""Override this to implement your processing logic."""
raise NotImplementedError
async def run(self):
"""Main consumer loop with DLQ handling."""
async for msg in self.consumer:
# Kafka headers are a list of (str, bytes) tuples
retry_count = int(dict(msg.headers or []).get('retry_count', b'0'))
try:
# Parse message
value = json.loads(msg.value)
# Process
await self.process_message(value)
# Commit on success
await self.consumer.commit()
except Exception as e:
await self._handle_failure(msg, e, retry_count)
async def _handle_failure(self, msg, error: Exception, retry_count: int):
"""Handle a processing failure."""
import traceback
if retry_count < self.max_retries:
# Retry: republish with incremented count
print(f"Retrying message (attempt {retry_count + 1}/{self.max_retries})")
await self._retry_message(msg, retry_count + 1)
else:
# Max retries exceeded: send to DLQ
print(f"Max retries exceeded, sending to DLQ: {error}")
await self._send_to_dlq(msg, error, retry_count)
# Commit to move past this message
await self.consumer.commit()
async def _retry_message(self, msg, new_retry_count: int):
"""Republish message for retry."""
headers = dict(msg.headers) if msg.headers else {}
headers['retry_count'] = str(new_retry_count).encode()
await self.producer.send(
msg.topic,
value=msg.value,
key=msg.key,
headers=list(headers.items())
)
async def _send_to_dlq(self, msg, error: Exception, retry_count: int):
"""Send failed message to dead letter queue."""
import traceback
try:
original = json.loads(msg.value)
except ValueError:
# The failure may BE the bad JSON; don't crash while quarantining it
original = {"raw": msg.value.decode('utf-8', errors='replace')}
dead_letter = DeadLetter(
original_message=original,
error_type=type(error).__name__,
error_message=str(error),
stack_trace=traceback.format_exc(),
failed_at=datetime.now(timezone.utc).isoformat(),
retry_count=retry_count,
consumer_id=self.consumer_id,
topic=msg.topic,
partition=msg.partition,
offset=msg.offset
)
await self.producer.send(
self.dlq_topic,
value=json.dumps(asdict(dead_letter)).encode(),
key=msg.key
)
# Usage example
class OrderConsumer(SimpleDLQConsumer):
async def process_message(self, message: Dict[str, Any]) -> None:
order_id = message['order_id']
items = message['items'] # May raise KeyError
if not isinstance(items, list):
raise ValueError(f"Items must be a list, got {type(items)}")
# Process order...
print(f"Processing order {order_id} with {len(items)} items")4.2 Understanding the Flow#
- Producer sends message
- Topic:
orders - Message:
{"order_id": "123", "items": "invalid"}
- Topic:
- Consumer receives message
- Attempt 1: ValueError - items not a list
- Consumer retries (message republished)
- Attempt 2: ValueError - same error
- Consumer retries twice more
- Attempts 3 and 4: ValueError - still failing
- Max retries exceeded → DLQ
- Topic:
orders.dlq - Message:
json
{ "original_message": {"order_id": "123", "items": "invalid"}, "error_type": "ValueError", "error_message": "Items must be a list, got <class 'str'>", "stack_trace": "...", "failed_at": "2024-01-15T10:30:00Z", "retry_count": 3 }
- Topic:
- Manual investigation
- Engineer sees message in DLQ
- Identifies bug in producer
- Fixes producer
- Replay from DLQ
- Fix the message format
- Republish to orders topic
- Message processes successfully
Chapter 5: Production-Ready Implementation#
5.1 Requirements for Production#
- Error classification — Different errors need different handling
- Exponential backoff — Don't hammer failing systems
- Circuit breaker integration — Stop retrying during outages
- Metrics and alerting — Know when DLQ is growing
- Replay tooling — Easy to investigate and replay
- Poison pill detection — Catch crashes before they loop
5.2 Complete Production Implementation#
# Production-Ready Dead Letter Queue Implementation
import asyncio
import json
import traceback
import hashlib
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List, Callable, Set
from enum import Enum
import logging
import time
from aiokafka import TopicPartition
logger = logging.getLogger(__name__)
# =============================================================================
# Configuration
# =============================================================================
@dataclass
class DLQConfig:
"""Configuration for dead letter queue handling."""
# Retry configuration
max_retries: int = 5 # the simple consumer in 4.1 used 3; tune per error category
base_backoff_seconds: float = 1.0
max_backoff_seconds: float = 300.0
backoff_multiplier: float = 2.0
# DLQ topics
dlq_topic_suffix: str = ".dlq"
validation_dlq_suffix: str = ".dlq.validation"
poison_dlq_suffix: str = ".dlq.poison"
# Poison pill detection
poison_detection_enabled: bool = True
poison_timeout_seconds: float = 30.0
# Alerting thresholds
dlq_alert_threshold: int = 100
dlq_critical_threshold: int = 1000
class ErrorCategory(Enum):
VALIDATION = "validation"
TRANSIENT = "transient"
DEPENDENCY = "dependency"
POISON = "poison"
UNKNOWN = "unknown"
@dataclass
class DeadLetter:
"""A message that failed processing."""
id: str
original_topic: str
original_partition: int
original_offset: int
original_key: Optional[str]
original_value: Dict[str, Any]
original_headers: Dict[str, str]
error_category: str
error_type: str
error_message: str
stack_trace: str
first_failed_at: str
last_failed_at: str
retry_count: int
consumer_id: str
consumer_version: str
# For replay tracking
replayed: bool = False
replayed_at: Optional[str] = None
replay_result: Optional[str] = None
# =============================================================================
# Error Classification
# =============================================================================
class ErrorClassifier:
"""Classifies exceptions into error categories."""
# Exceptions that indicate validation errors
VALIDATION_ERRORS = (
ValueError,
KeyError,
TypeError,
json.JSONDecodeError,
)
# Exceptions that indicate transient errors
TRANSIENT_ERRORS = (
TimeoutError,
ConnectionError,
ConnectionResetError,
)
# Error message patterns
TRANSIENT_PATTERNS = [
"timeout",
"connection refused",
"temporarily unavailable",
"try again",
"rate limit",
"503",
"504",
]
POISON_PATTERNS = [
"out of memory",
"recursion",
"stack overflow",
"segmentation fault",
]
@classmethod
def classify(cls, error: Exception) -> ErrorCategory:
"""Classify an exception."""
error_str = str(error).lower()
# Check for poison indicators
if isinstance(error, (MemoryError, RecursionError, PoisonPillError)):
return ErrorCategory.POISON
for pattern in cls.POISON_PATTERNS:
if pattern in error_str:
return ErrorCategory.POISON
# Check for validation errors
if isinstance(error, cls.VALIDATION_ERRORS):
return ErrorCategory.VALIDATION
# Check for transient errors
if isinstance(error, cls.TRANSIENT_ERRORS):
return ErrorCategory.TRANSIENT
for pattern in cls.TRANSIENT_PATTERNS:
if pattern in error_str:
return ErrorCategory.TRANSIENT
return ErrorCategory.UNKNOWN
# =============================================================================
# Core DLQ Handler
# =============================================================================
class DLQHandler:
"""
Production-ready dead letter queue handler.
Features:
- Error classification and routing
- Exponential backoff
- Poison pill detection
- Metrics and alerting
- Replay support
"""
def __init__(
self,
config: DLQConfig,
kafka_producer,
metrics_client,
consumer_id: str,
consumer_version: str = "1.0.0"
):
self.config = config
self.producer = kafka_producer
self.metrics = metrics_client
self.consumer_id = consumer_id
self.consumer_version = consumer_version
# Track retry attempts per message
self._retry_counts: Dict[str, int] = {}
self._first_failures: Dict[str, datetime] = {}
# Metrics
self.messages_retried = 0
self.messages_dlqd = 0
self.poison_pills_detected = 0
def get_message_id(self, topic: str, partition: int, offset: int) -> str:
"""Generate unique ID for a message."""
content = f"{topic}:{partition}:{offset}"
return hashlib.md5(content.encode()).hexdigest()[:16]
async def handle_failure(
self,
topic: str,
partition: int,
offset: int,
key: Optional[bytes],
value: bytes,
headers: Dict[str, str],
error: Exception
) -> None:
"""
Handle a message processing failure.
Decides whether to retry or send to DLQ based on error type
and retry count.
"""
msg_id = self.get_message_id(topic, partition, offset)
# Classify error
category = ErrorClassifier.classify(error)
# Track first failure time
if msg_id not in self._first_failures:
self._first_failures[msg_id] = datetime.now(timezone.utc)
# Get current retry count
retry_count = self._retry_counts.get(msg_id, 0)
# Determine action based on category
if category == ErrorCategory.POISON:
# Never retry poison pills
logger.error(f"Poison pill detected: {error}")
self.poison_pills_detected += 1
await self._send_to_dlq(
topic, partition, offset, key, value, headers,
error, category, retry_count,
dlq_suffix=self.config.poison_dlq_suffix
)
self._cleanup_tracking(msg_id)
elif category == ErrorCategory.VALIDATION:
# Don't retry validation errors
logger.warning(f"Validation error, sending to DLQ: {error}")
await self._send_to_dlq(
topic, partition, offset, key, value, headers,
error, category, retry_count,
dlq_suffix=self.config.validation_dlq_suffix
)
self._cleanup_tracking(msg_id)
elif retry_count < self.config.max_retries:
# Retry with backoff
backoff = self._calculate_backoff(retry_count)
logger.info(
f"Retrying message (attempt {retry_count + 1}/{self.config.max_retries}), "
f"backoff: {backoff:.1f}s"
)
self._retry_counts[msg_id] = retry_count + 1
self.messages_retried += 1
# Wait for backoff
await asyncio.sleep(backoff)
# Raise to trigger reprocessing
raise RetryableError(f"Retry attempt {retry_count + 1}")
else:
# Max retries exceeded
logger.warning(f"Max retries exceeded, sending to DLQ: {error}")
await self._send_to_dlq(
topic, partition, offset, key, value, headers,
error, category, retry_count
)
self._cleanup_tracking(msg_id)
def _calculate_backoff(self, retry_count: int) -> float:
"""Calculate exponential backoff duration."""
backoff = self.config.base_backoff_seconds * (
self.config.backoff_multiplier ** retry_count
)
return min(backoff, self.config.max_backoff_seconds)
async def _send_to_dlq(
self,
topic: str,
partition: int,
offset: int,
key: Optional[bytes],
value: bytes,
headers: Dict[str, str],
error: Exception,
category: ErrorCategory,
retry_count: int,
dlq_suffix: Optional[str] = None
) -> None:
"""Send a failed message to the dead letter queue."""
# Determine DLQ topic
suffix = dlq_suffix or self.config.dlq_topic_suffix
dlq_topic = f"{topic}{suffix}"
# Parse original value
try:
original_value = json.loads(value.decode('utf-8'))
except:
original_value = {"raw": value.decode('utf-8', errors='replace')}
# Build dead letter
msg_id = self.get_message_id(topic, partition, offset)
first_failed = self._first_failures.get(msg_id, datetime.now(timezone.utc))
dead_letter = DeadLetter(
id=msg_id,
original_topic=topic,
original_partition=partition,
original_offset=offset,
original_key=key.decode('utf-8') if key else None,
original_value=original_value,
original_headers=headers,
error_category=category.value,
error_type=type(error).__name__,
error_message=str(error)[:1000], # Truncate long messages
stack_trace=traceback.format_exc()[:5000], # Truncate
first_failed_at=first_failed.isoformat(),
last_failed_at=datetime.now(timezone.utc).isoformat(),
retry_count=retry_count,
consumer_id=self.consumer_id,
consumer_version=self.consumer_version
)
# Send to DLQ
await self.producer.send(
dlq_topic,
value=json.dumps(asdict(dead_letter)).encode('utf-8'),
key=key
)
# Update metrics
self.messages_dlqd += 1
self.metrics.increment('dlq.messages.total', tags={'topic': topic, 'category': category.value})
logger.info(f"Message sent to DLQ: {dlq_topic}")
def _cleanup_tracking(self, msg_id: str) -> None:
"""Clean up tracking state for a message."""
self._retry_counts.pop(msg_id, None)
self._first_failures.pop(msg_id, None)
class RetryableError(Exception):
"""Raised to trigger message reprocessing."""
pass
# =============================================================================
# Consumer with DLQ Support
# =============================================================================
class DLQConsumer:
"""
Kafka consumer with integrated dead letter queue support.
"""
def __init__(
self,
kafka_consumer,
kafka_producer,
dlq_handler: DLQHandler,
process_fn: Callable[[Dict[str, Any]], Any],
config: DLQConfig
):
self.consumer = kafka_consumer
self.producer = kafka_producer
self.dlq_handler = dlq_handler
self.process_fn = process_fn
self.config = config
self._running = False
async def start(self):
"""Start consuming messages."""
self._running = True
logger.info("DLQ consumer started")
async for msg in self.consumer:
if not self._running:
break
await self._process_message(msg)
async def stop(self):
"""Stop the consumer."""
self._running = False
async def _process_message(self, msg) -> None:
"""Process a single message with DLQ handling."""
headers = {k: v.decode('utf-8', 'replace') for k, v in (msg.headers or [])}
try:
# Parse message
value = json.loads(msg.value.decode('utf-8'))
# Detect poison pills with timeout
if self.config.poison_detection_enabled:
try:
await asyncio.wait_for(
self._process_with_timeout(value),
timeout=self.config.poison_timeout_seconds
)
except asyncio.TimeoutError:
raise PoisonPillError(
f"Processing timed out after {self.config.poison_timeout_seconds}s"
)
else:
await self.process_fn(value)
# Success - commit
await self.consumer.commit()
except RetryableError:
# Not committing is NOT enough: the consumer's position has already
# moved past this message. Seek back so it is fetched again.
self.consumer.seek(TopicPartition(msg.topic, msg.partition), msg.offset)
except Exception as e:
# Handle failure
try:
await self.dlq_handler.handle_failure(
topic=msg.topic,
partition=msg.partition,
offset=msg.offset,
key=msg.key,
value=msg.value,
headers=headers,
error=e
)
# Commit to move past this message
await self.consumer.commit()
except RetryableError:
# Seek back so the same message is fetched and retried
self.consumer.seek(TopicPartition(msg.topic, msg.partition), msg.offset)
async def _process_with_timeout(self, value: Dict[str, Any]) -> None:
"""Process with timeout for poison pill detection."""
await self.process_fn(value)
class PoisonPillError(Exception):
"""Indicates a poison pill message."""
pass
# =============================================================================
# DLQ Replay Tool
# =============================================================================
class DLQReplayTool:
"""
Tool for investigating and replaying DLQ messages.
"""
def __init__(self, kafka_consumer, kafka_producer, db_client=None):
self.consumer = kafka_consumer
self.producer = kafka_producer
self.db = db_client # Optional: for tracking replays
async def list_messages(
self,
dlq_topic: str,
limit: int = 100,
error_category: Optional[str] = None
) -> List[DeadLetter]:
"""List messages in the DLQ."""
messages = []
async for msg in self.consumer:
if len(messages) >= limit:
break
dead_letter = DeadLetter(**json.loads(msg.value))
if error_category and dead_letter.error_category != error_category:
continue
messages.append(dead_letter)
return messages
async def replay_message(
self,
dead_letter: DeadLetter,
fix_fn: Optional[Callable[[Dict], Dict]] = None
) -> bool:
"""
Replay a single message from the DLQ.
Args:
dead_letter: The dead letter to replay
fix_fn: Optional function to fix the message before replay
"""
value = dead_letter.original_value
# Apply fix if provided
if fix_fn:
try:
value = fix_fn(value)
except Exception as e:
logger.error(f"Fix function failed: {e}")
return False
# Republish to original topic
try:
await self.producer.send(
dead_letter.original_topic,
value=json.dumps(value).encode('utf-8'),
key=dead_letter.original_key.encode() if dead_letter.original_key else None
)
logger.info(f"Replayed message {dead_letter.id} to {dead_letter.original_topic}")
return True
except Exception as e:
logger.error(f"Replay failed: {e}")
return False
async def replay_batch(
self,
dead_letters: List[DeadLetter],
fix_fn: Optional[Callable[[Dict], Dict]] = None,
dry_run: bool = False
) -> Dict[str, int]:
"""Replay multiple messages."""
results = {"success": 0, "failed": 0, "skipped": 0}
for dl in dead_letters:
if dry_run:
logger.info(f"[DRY RUN] Would replay: {dl.id}")
results["skipped"] += 1
continue
success = await self.replay_message(dl, fix_fn)
if success:
results["success"] += 1
else:
results["failed"] += 1
return results| Value | |
|---|---|
| Messages stuck behind the pill (preface) | 50,000 in 4 hours → ~3.5/s into that partition |
| Revenue lost (preface), spread evenly | $847,000 ÷ 240 min ≈ $3,500 a minute |
ValueError (items is a string) with this handler |
VALIDATION: no retries, straight to orders.dlq.validation |
| Worst case if it were classified UNKNOWN instead | 5 retries: 1 + 2 + 4 + 8 + 16 = 31 s of backoff |
| Messages delayed in that worst case | 3.5 × 31 ≈ ~110, each by at most ~31 s |
| Checkouts delayed in that worst case |
Conclusion: the same bad message costs a 4-hour outage without a DLQ and at most half a minute with one; classified as a validation error it costs almost nothing. What decides the damage isn't the bad data, it's how fast the consumer gets it out of the way.
5.3 Monitoring and Alerting#
# DLQ Monitoring
class DLQMonitor:
"""
Monitors dead letter queue health and triggers alerts.
"""
def __init__(
self,
metrics_client,
alerting_client,
dlq_topics: List[str],
config: DLQConfig
):
self.metrics = metrics_client
self.alerting = alerting_client
self.dlq_topics = dlq_topics
self.config = config
async def check_health(self) -> Dict[str, Any]:
"""Check DLQ health across all topics."""
results = {}
for topic in self.dlq_topics:
depth = await self.metrics.get_queue_depth(topic)
growth_rate = await self.metrics.get_growth_rate(topic)
results[topic] = {
"depth": depth,
"growth_rate": growth_rate,
"status": self._determine_status(depth)
}
# Trigger alerts
if depth >= self.config.dlq_critical_threshold:
await self.alerting.send_alert(
severity="critical",
title=f"DLQ Critical: {topic}",
message=f"DLQ depth is {depth:,}, threshold is {self.config.dlq_critical_threshold:,}",
tags={"topic": topic}
)
elif depth >= self.config.dlq_alert_threshold:
await self.alerting.send_alert(
severity="warning",
title=f"DLQ Warning: {topic}",
message=f"DLQ depth is {depth:,}, threshold is {self.config.dlq_alert_threshold:,}",
tags={"topic": topic}
)
return results
def _determine_status(self, depth: int) -> str:
if depth >= self.config.dlq_critical_threshold:
return "critical"
elif depth >= self.config.dlq_alert_threshold:
return "warning"
return "healthy"Chapter 6: Edge Cases and Error Handling#
What breaks when the DLQ's own consumer fails?
Failed orders go to orders.dlq, and a DLQ consumer reads them and tries to fix and reprocess each one. Now that consumer starts failing too. Do you add a DLQ for the DLQ? What would you design instead?
One strong answer
- A DLQ for the DLQ just moves the problem one level down; it can fail the same way.
- Make the DLQ consumer boring: store the message and alert, nothing more. Replay is a separate, deliberate step.
- If even that fails, stop and page a human, and as a last resort write the message to local disk so it isn't lost.
6.1 Edge Case 1: DLQ Consumer Also Fails#
- Main Queue: orders → Consumer → Failure → DLQ:
orders.dlq - Then: DLQ:
orders.dlq→ DLQ Consumer → Also Fails!
What now? DLQ for the DLQ?
SOLUTIONS
1. DLQ CONSUMERS SHOULD BE SIMPLER — Don't process—just store and alert
async def dlq_consumer(msg):
# Just save to database, don't process
await db.insert("dlq_messages", msg)
await alert("New DLQ message", msg)2. MANUAL INTERVENTION FLAG — If DLQ consumer fails, stop and alert. Require human to investigate.
3. FALLBACK TO FILE STORAGE — If all else fails, write to local disk
try:
await send_to_dlq(msg)
except:
with open("emergency_dlq.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
alert_critical("DLQ send failed!")6.2 Edge Case 2: Replay Causes New Errors#
- Message fails with bug A
- Bug A is fixed
- Message replayed
- Message fails with bug B!
TIMELINE
- Original:
{"items": [{"qty": "5"}]}← qty should be int - Bug A: Consumer expects qty to be int
- Fix A: Consumer now converts string to int
- Replay: Still fails!
- Bug B: Consumer expects "price" field (new requirement)
Message format evolved, old messages no longer valid.
SOLUTIONS
1. VERSIONED PROCESSING — Check message version, apply appropriate logic
if msg.get('version', 1) < 2:
# Old format processing
process_v1(msg)
else:
# New format processing
process_v2(msg)2. MESSAGE MIGRATION ON REPLAY — Transform old messages to new format
def migrate_message(msg: dict) -> dict:
if 'price' not in msg:
msg['price'] = calculate_legacy_price(msg)
return msg3. DISCARD STALE MESSAGES — After N days, messages may be too old to replay. Archive and close them out.
6.3 Edge Case 3: Infinite Retry Loop#
- Consumer A receives message → Failure, retry → Republish to queue
- Consumer B receives same message → Failure, retry → Republish to queue
- Consumer A receives same message again...
ROOT CAUSE: Retry count in headers not being preserved
| WRONG | RIGHT |
|---|---|
Each consumer starts retry count at 0: retry_count = 0 |
Read retry count from message headers: retry_count = int(dict(msg.headers or []).get('retry_count', b'0')) (Kafka headers are a list of (key, bytes) pairs) |
ALSO CHECK
- Headers being preserved on republish
- Retry count being incremented
- Max retries actually being enforced
6.4 Edge Case 4: Duplicate DLQ Messages#
Consumer crashes AFTER sending to DLQ but BEFORE committing. Consumer restarts, reprocesses same message. Sends to DLQ again.
SOLUTION: Idempotent DLQ writes
Use message ID as deduplication key:
async def send_to_dlq(msg):
msg_id = get_message_id(msg.topic, msg.partition, msg.offset)
# Check if already in DLQ
if await dlq_store.exists(msg_id):
logger.info(f"Message {msg_id} already in DLQ, skipping")
return
# Send to DLQ
await producer.send(dlq_topic, msg)
# Record in DLQ store
await dlq_store.add(msg_id, ttl=timedelta(days=7))6.5 Error Handling Matrix#
| Error | Retry? | DLQ? | Alert? | Action |
|---|---|---|---|---|
| JSON parse error | No | Yes (validation) | No | Fix producer |
| Missing field | No | Yes (validation) | No | Fix producer/schema |
| Database timeout | Yes (3x) | Yes if exhausted | If DLQ'd | Wait for DB |
| Rate limit (429) | Yes (10x) | Yes if exhausted | Yes | Check quotas |
| Consumer crash | No | Yes (poison) | Yes (page) | Investigate |
| Out of memory | No | Yes (poison) | Yes (page) | Fix consumer |
| Unknown error | Yes (3x) | Yes if exhausted | Yes | Investigate |
Building the DLQ pipeline
Q1/4 A message bounces between consumers forever, and each one logs "retry 1 of 3". What is the bug?
Answer & explanation
Answer: C
Retry state has to travel with the message. If the header is not preserved and incremented on republish, the max-retries check never triggers.
Q2/4 A consumer sends a message to the DLQ, then crashes before committing its offset. After the restart the same message reaches the DLQ again. How do you prevent that?
Answer & explanation
Answer: B
The DLQ write is just another at-least-once side effect. Checking a dedup key before writing turns the redelivery into a no-op; committing first would risk losing the message instead.
Q3/4 Why does the production consumer wrap processing in a 30-second timeout and treat a timeout as a possible poison pill?
Answer & explanation
Answer: B
Infinite loops and stuck calls do not throw, so without a deadline the consumer just stops making progress. The timeout turns a hang into a failure that can be quarantined.
Q4/4 Messages already in the DLQ fail again when the DLQ's own consumer processes them. What does the lesson recommend?
Answer & explanation
Answer: A
The more logic the DLQ consumer runs, the more ways it can fail. Storing and alerting is hard to break; replay is a separate, deliberate step.
Part III: Real-World Application#
Chapter 7: How Big Tech Does It#
7.1 Case Study: Uber — Retry Topics and a DLQ#
Uber Engineering described tiered retry topics plus a dead letter topic in "Building Reliable Reprocessing and Dead Letter Queues with Kafka" (2018). The trip-event version below, including the auto-fix layer and the numbers, is an illustrative extension of that design.
Scale:
- Very high trip volume
- Events: trip_started, trip_updated, trip_completed
- Each event updates pricing, driver earnings, rider charges
Challenge:
- Trip events must never be lost (financial implications)
- But system must stay responsive during spikes
- Some events have data issues from client bugs
%% title: Uber trip event DLQ system
flowchart TD
TE@{ icon: "sd:car", label: "Trip Events" } --> K@{ icon: "tech:kafka", label: "Kafka" }
K --> TP["Trip Processor"]:::core
TP -->|"Success"| CM["Commit"]:::ok
TP -->|"Failure"| CL{"Classify"}
CL -->|"Retryable"| RT@{ icon: "sd:stream", label: "Retry Topic" }
RT --> MT@{ icon: "sd:stream", label: "Main Topic" }
CL -->|"Validation"| AF@{ icon: "sd:queue", label: "DLQ with<br/>Auto-fix Attempt" }
AF --> FX{"Fixed?"}
FX -->|"YES"| RP["Replay"]:::ok
FX -->|"NO"| MR@{ icon: "sd:user", label: "Manual Review" }
CL -->|"Poison"| IA@{ icon: "sd:alert", label: "Immediate Alert" }
class TE client
class K,RT,MT queue
class AF,IA bad
class MR serviceKey innovations:
- Auto-fix layer — Common validation errors have automatic fixes
- Missing timezone → default to trip city timezone
- Invalid currency → lookup from trip region
- Negative amount → take absolute value (with flag)
- Financial reconciliation — DLQ'd trip events trigger reconciliation job. Compares actual charges vs expected. Ensures no money is lost.
- SLA-based alerting — Trip must be processed within 15 minutes. DLQ messages > 15 min old → page on-call.
Targets (illustrative):
- The vast majority of events processed on the first attempt
- A tiny fraction reaching the DLQ
- Most DLQ'd validation errors auto-fixed and replayed
- No lost trip data
7.2 Case Study: Stripe — Webhook DLQ#
Challenge:
- Very high webhook volume
- Merchant endpoints vary wildly in reliability
- Must deliver OR tell merchant they missed events
%% title: Stripe webhook delivery (simplified)
flowchart TD
EC@{ icon: "sd:feed", label: "Event Created" } --> WQ@{ icon: "sd:queue", label: "Webhook Queue" }
WQ --> DA["Delivery Attempt"]:::core
DA -->|"2xx"| DN(["Done"]):::ok
DA -->|"Non-2xx or timeout"| RQ@{ icon: "sd:queue", label: "Retry Queue" }
RQ -->|"Exponential backoff<br/>for up to 3 days"| SF{"Still failing?"}
SF --> AC@{ icon: "sd:mail", label: "Email merchant<br/>Disable endpoint<br/>Event stays in Events API" }
class EC client
class WQ,RQ queue
class AC badKey design decisions:
- Long retry window — Retry for up to 3 days. Merchant might fix their server.
- Simple failure rule
- Any non-2xx response (4xx included) or timeout counts as a failure and is retried
- Stripe doesn't give up early on 4xx: the merchant may fix a broken handler within the window
- Endpoint health tracking — A good sender surfaces persistent endpoint failures to the merchant and can eventually stop delivering to an endpoint that never recovers (check the provider's current docs for exactly what it does).
- Self-service recovery — Merchants can resend events from the Dashboard and list recent events (about 30 days) via the Events API.
- No true DLQ for merchants — After the retry window:
- Events are not automatically retried
- The Events API is the merchant's recovery path
7.3 Case Study: Netflix — DLQ for Encoding Jobs (simplified)#
Netflix's encoding platform is described publicly only at a high level; the categories and process below are an illustrative simplification.
Challenge:
- Some videos fail to encode (corrupted source, unsupported format)
- Can't retry indefinitely (expensive compute)
- Need human review for edge cases
%% title: Netflix encoding pipeline
flowchart TD
UP@{ icon: "sd:video", label: "Upload" } --> VA["Validation"]:::service
VA --> EQ@{ icon: "sd:queue", label: "Encoding Queue" }
EQ --> EN["Encoder"]:::core
EN -->|"Success"| CP(["Complete"]):::ok
EN -->|"Failure"| CL{"Classify"}
CL -->|"Transient (retry)"| RQ@{ icon: "sd:queue", label: "Retry Queue" }
CL -->|"Permanent (skip)"| SA@{ icon: "sd:alert", label: "Skip & Alert" }
CL -->|"Unknown (review)"| RV@{ icon: "sd:queue", label: "Review Queue" }
RV --> HR@{ icon: "sd:user", label: "Human Review" }
HR --> FR["Fix & Retry"]:::ok
HR --> AK["Accept & Skip"]:::service
HR --> RB["Report Bug"]:::bad
class UP client
class EQ,RQ,RV queue
class SA bad
class HR serviceDLQ categories:
- Transient failures — Encoder crashed; resource contention; network issues → Automatic retry
- Permanent failures — Unsupported codec (expected for some content); corrupted source file → Skip, use lower quality fallback
- Unknown failures — New error types; unexpected behaviors → Human review queue
Operational excellence — Daily DLQ review meeting:
- Review new error types
- Categorize unknowns → transient or permanent
- Update classification rules
- Track trends
7.4 Summary: Industry Patterns#
| Company | System | DLQ Strategy | Key Innovation |
|---|---|---|---|
| Uber | Kafka consumers | Tiered retry topics + DLQ topic | Delayed retries without blocking the main topic |
| Stripe | Webhooks | Long retry window | 3-day retry window, Events API for recovery |
| Netflix | Encoding | Categorized + human review | Human triage of unknown errors |
Chapter 8: Common Mistakes to Avoid#
8.1 Mistake 1: No DLQ at All#
WRONG: Ignoring failures
async def process(msg):
try:
await do_work(msg)
except Exception as e:
logger.error(f"Failed: {e}")
# Message is lost forever!Problems:
- No way to recover failed messages
- No visibility into failure patterns
- Silent data loss
CORRECT: Always have a DLQ
async def process(msg):
try:
await do_work(msg)
except Exception as e:
logger.error(f"Failed: {e}")
await send_to_dlq(msg, e) # Preserve for recovery8.2 Mistake 2: Retrying Poison Pills#
WRONG: Unlimited retries
while True:
try:
process(msg)
break
except:
time.sleep(1)
# Retry forever!If msg causes crash or infinite loop:
- Consumer stuck on one message
- Queue backs up
- System fails
CORRECT: Detect and quarantine poison pills
MAX_RETRIES = 3
MAX_PROCESSING_TIME = 30
async def safe_process(msg):
retry_count = get_retry_count(msg)
if retry_count >= MAX_RETRIES:
await send_to_dlq(msg, "Max retries exceeded")
return
try:
await asyncio.wait_for(
process(msg),
timeout=MAX_PROCESSING_TIME
)
except asyncio.TimeoutError:
await send_to_dlq(msg, "Processing timeout - possible poison pill")
except Exception as e:
await retry_or_dlq(msg, e, retry_count)8.3 Mistake 3: Not Monitoring DLQ#
WRONG: DLQ as a black hole
# Send to DLQ and forget
await producer.send("orders.dlq", msg)
# No monitoring, no alerts
# DLQ grows to 1M messages unnoticedCORRECT: Monitor and alert on DLQ
# Send to DLQ with metrics
await producer.send("orders.dlq", msg)
metrics.increment("dlq.messages.total", tags={"topic": "orders"})# Alert configuration
alerts:
- name: "DLQ Warning"
condition: dlq.depth > 100
severity: warning
- name: "DLQ Critical"
condition: dlq.depth > 1000
severity: critical
page_oncall: true8.4 Mistake 4: No Replay Strategy#
WRONG: DLQ with no way to replay
Messages in DLQ, but:
- No tooling to view them
- No way to fix and replay
- No process for handling them
Result:
- DLQ becomes graveyard
- Messages rot forever
- Data effectively lost
CORRECT: Build replay tooling from day one
class DLQTools:
async def list_messages(self, topic, limit=100):
"""View messages in DLQ."""
async def replay_single(self, msg_id):
"""Replay one message."""
async def replay_batch(self, filter, fix_fn=None):
"""Replay multiple messages with optional fix."""
async def discard(self, msg_id, reason):
"""Mark message as unrecoverable."""
async def export(self, topic, format="json"):
"""Export DLQ for analysis."""8.5 Mistake 5: Same Processing for Retries#
WRONG: Retry with same logic that failed
# First attempt:
call_external_api(msg) # Timeout
# Second attempt:
call_external_api(msg) # Same timeout!
# Third attempt:
call_external_api(msg) # Still timing out!
# Learned nothing, just wasted timeCORRECT: Adaptive retry behavior
class AdaptiveProcessor:
async def process(self, msg):
retry_count = get_retry_count(msg)
if retry_count == 0:
# First attempt: normal timeout
timeout = 5.0
elif retry_count == 1:
# Second attempt: longer timeout
timeout = 30.0
else:
# Third+ attempt: very long timeout, simplified processing
timeout = 120.0
msg = simplify_message(msg) # Remove optional fields
await asyncio.wait_for(
self._do_process(msg),
timeout=timeout
)8.6 Mistake Checklist#
Before deploying DLQ handling, verify:
- DLQ exists — Every queue has a corresponding DLQ
- Retry limits set — Max retries defined and enforced
- Poison detection — Timeout on processing to catch hangs
- Error classification — Different errors handled differently
- Monitoring active — Alerts on DLQ depth and growth
- Replay tooling built — Can view, fix, and replay messages
- Runbook written — Steps for handling DLQ alerts
- Retention policy set — Know how long to keep DLQ messages
DLQs in practice
Q1/2 A payment consumer stops retrying when the gateway returns 4xx, but keeps retrying on 5xx and timeouts. Why?
Answer & explanation
Answer: C
Classifying failures decides between retrying and giving up. Retrying a client error only adds load; server errors and timeouts often clear up.
Q2/2 A DLQ exists, but it has no monitoring and no replay tooling. What does it effectively become?
Answer & explanation
Answer: A
A DLQ is only useful if someone sees it growing and can act on it. Alert on depth and growth, and build list, fix, replay and discard tools from day one.
Part IV: Interview Preparation#
Chapter 9: Interview Tips and Phrases#
9.1 When to Bring Up DLQs#
Bring up dead letter queues when:
- Designing any queue-based system
- Discussing error handling and resilience
- Interviewer asks "what if processing fails?"
- System has critical messages that can't be lost
- Discussing operational aspects of async systems
9.2 Key Phrases to Use#
INTRODUCING DLQs
"For error handling, I'd implement a dead letter queue pattern. Messages that fail processing after N retries get moved to a separate queue for investigation. This prevents one bad message from blocking the entire pipeline."
EXPLAINING CLASSIFICATION
"Not all failures are equal. I'd classify errors into categories: validation errors go straight to DLQ since retrying won't help, transient errors get retried with exponential backoff, and anything that crashes or hangs the consumer—a poison pill—gets immediately quarantined."
DISCUSSING RETRY STRATEGY
"For retries, I'd use exponential backoff starting at 1 second, doubling each time up to a max of 5 minutes. After 5 retries, the message goes to the DLQ. The key is distinguishing between transient failures worth retrying and permanent failures that should go straight to DLQ."
ADDRESSING OPERATIONS
"The DLQ isn't just a dumping ground—it's an operational tool. We need monitoring on DLQ depth, alerting when it grows, and tooling to inspect, fix, and replay messages. I'd also have runbooks for common DLQ scenarios."
EXPLAINING POISON PILLS
"A poison pill is a message that crashes the consumer every time it's processed. Without detection, it creates an infinite loop: process, crash, restart, process same message, crash again. I'd add processing timeouts and crash detection to immediately quarantine these before they block the queue."
9.3 Questions to Ask Interviewer#
- "What's the criticality of messages? Can any be lost, or must all be processed?"
- "Are there different message types with different failure characteristics?"
- "What's the expected failure rate? That affects DLQ sizing and monitoring thresholds."
- "Is there a team to investigate DLQ messages, or should we automate recovery?"
9.4 Common Follow-up Questions#
| Question | Good Answer |
|---|---|
| "How do you prevent poison pills?" | "Processing timeout—if a message takes more than 30 seconds, it's likely stuck. Also track crash patterns; if the same message causes repeated restarts, quarantine it immediately." |
| "How do you decide retry count?" | "Balance between giving transient failures time to recover and not wasting resources. Usually 3-5 retries with exponential backoff. Validation errors get 0 retries—they go straight to DLQ." |
| "What's in a DLQ message?" | "The original message, error details including stack trace, timestamp of first and last failure, retry count, and consumer ID. Enough context to debug without accessing other systems." |
| "How do you handle DLQ growth?" | "Monitor depth, alert at thresholds. Daily triage to categorize errors. Build auto-fix for common issues. Clear SLA: DLQ messages addressed within 24 hours." |
Chapter 10: Practice Problems#
Practice · problem 1
Problem 1: E-commerce Order Processing#
Setup: You're designing the order processing system. Orders flow through: validation → inventory reservation → payment → fulfillment.
Requirements:
- No order can be lost
- System handles 1000 orders/minute
- Some orders have invalid data (wrong address format, etc.)
- Payment gateway occasionally times out
Questions:
- Where would you place DLQs in this pipeline?
- How would you handle validation vs payment failures differently?
- What's your replay strategy for failed orders?
Click for hints
- Different stages fail for different reasons
- Validation failures are permanent, payment failures are transient
- Consider: should a failed order ever be auto-replayed?
Click for solution outline
DLQ Placement:
%% title: DLQ placement
flowchart TD
O@{ icon: "sd:cart", label: "Orders" } --> V["Validation"]:::service
V --> I["Inventory"]:::service
I --> P["Payment"]:::service
P --> F["Fulfillment"]:::service
V --> VD@{ icon: "sd:queue", label: "validation.dlq" }
I --> ID@{ icon: "sd:queue", label: "inventory.dlq" }
P --> PD@{ icon: "sd:queue", label: "payment.dlq" }
F --> FD@{ icon: "sd:queue", label: "fulfillment.dlq" }
class O client
class VD,ID,PD,FD badError Handling by Stage:
-
Validation failures
- No retry (bad data won't fix itself)
- DLQ immediately
- Notify customer of invalid order
- Agent can fix and resubmit
-
Inventory failures
- Retry 3x (might be race condition)
- If item truly out of stock → notify customer
- Don't retry indefinitely (item won't reappear)
-
Payment failures
- Retry 5x with longer backoff (gateway might recover)
- Check error code: 4xx = don't retry, 5xx = retry
- After DLQ: manual review, contact customer
-
Fulfillment failures
- Retry aggressively (critical to complete)
- Alert if DLQ'd (money already charged!)
- Manual fulfillment as backup
Replay Strategy:
- Validation DLQ: Customer support fixes, resubmits new order
- Payment DLQ: Retry after gateway recovery, or refund
- Never auto-replay without human approval (avoid double-charging)
Practice · problem 2
Problem 2: Real-time Notification System#
Setup: You're building a notification system that sends push notifications, emails, and SMS.
Requirements:
- 100K notifications/hour
- External providers (Twilio, SendGrid) have rate limits and outages
- Some notifications are time-sensitive (2FA codes)
Questions:
- How do you handle provider failures?
- What's your DLQ strategy for time-sensitive vs marketing notifications?
- How do you prevent one provider's issues from blocking others?
Click for hints
- Time-sensitive notifications need fast failure, not long retry
- Provider-specific DLQs prevent cross-contamination
- Consider fallback providers
Click for solution outline
Architecture:
%% title: Notification priority routing
flowchart TD
N@{ icon: "sd:bell", label: "Notifications" } --> PR["Priority Router"]:::core
PR --> CR@{ icon: "sd:queue", label: "Critical<br/>(2FA)" }
PR --> NO@{ icon: "sd:queue", label: "Normal<br/>(transactional)" }
PR --> BU@{ icon: "sd:queue", label: "Bulk<br/>(marketing)" }
CR --> FF["Fast fail<br/>+ fallback"]:::service
NO --> SR["Standard<br/>retry"]:::service
BU --> LRT["Long retry<br/>+ DLQ"]:::service
class N client
class CR,NO,BU queueProvider Isolation:
%% title: Provider isolation
flowchart TD
EQ@{ icon: "sd:queue", label: "Email Queue" } --> EC["Email Consumer"]:::core
EC --> SG@{ icon: "tech:sendgrid", label: "SendGrid" }
EC -->|"Failure"| ED@{ icon: "sd:queue", label: "email.dlq" }
SQ@{ icon: "sd:queue", label: "SMS Queue" } --> SC["SMS Consumer"]:::core
SC --> TW@{ icon: "sd:sms", label: "Twilio" }
SC -->|"Failure"| SD@{ icon: "sd:queue", label: "sms.dlq" }
PQ@{ icon: "sd:queue", label: "Push Queue" } --> PC["Push Consumer"]:::core
PC --> FB@{ icon: "tech:firebase", label: "Firebase" }
PC -->|"Failure"| PD@{ icon: "sd:queue", label: "push.dlq" }
class EQ,SQ,PQ queue
class SG,TW,FB external
class ED,SD,PD badTime-Sensitive Handling:
- 2FA codes: 1 retry, then try fallback provider, then DLQ
- Don't retry for minutes—the code expires
- DLQ for logging, not replay
Bulk Notifications:
- Long retry window (24 hours)
- Rate limit retries to not overwhelm provider
- DLQ only after extended failure
Practice · problem 3
Problem 3: Financial Transaction Processing#
Setup: You're processing financial transactions that must be applied exactly once.
Requirements:
- Zero tolerance for lost transactions
- Transactions must be idempotent (no double-processing)
- Regulatory requirement: all failures must be auditable
Questions:
- How do you ensure no transaction is lost?
- How do you prevent double-processing on replay?
- What audit trail do you maintain?
Click for hints
- Idempotency keys are critical
- DLQ must be durable
- Consider: what if DLQ write fails?
Click for solution outline
Zero-Loss Guarantee:
%% title: Zero-loss guarantee
flowchart TD
T["Transaction"]:::client --> P["Process"]:::core
P -->|"Success"| CM["Commit"]:::ok
P -->|"Failure"| DLQ[["DLQ<br/>(with acknowledgment)"]]:::queue
DLQ -->|"DLQ write fails?"| FB[("Fallback to database table<br/>Alert immediately")]:::badIdempotency:
async def process_transaction(txn):
# Check if already processed
if await db.exists("processed_txns", txn.id):
logger.info(f"Transaction {txn.id} already processed, skipping")
return
# Process
result = await apply_transaction(txn)
# Mark as processed
await db.insert("processed_txns", {
"txn_id": txn.id,
"processed_at": now(),
"result": result
})Audit Trail:
CREATE TABLE dlq_audit (
id SERIAL PRIMARY KEY,
txn_id VARCHAR(64) NOT NULL,
original_message JSONB NOT NULL,
error_type VARCHAR(100) NOT NULL,
error_message TEXT,
failed_at TIMESTAMP NOT NULL,
retry_count INT NOT NULL,
replayed_at TIMESTAMP,
replayed_by VARCHAR(100),
replay_result VARCHAR(50),
CONSTRAINT dlq_audit_txn_unique UNIQUE (txn_id, failed_at)
);Every DLQ message is:
- Written to audit table
- Kept for 7 years (regulatory)
- Includes who replayed and when
Chapter 11: Mock Interview Dialogue#
Scenario: Design DLQ for Payment Processing#
"We're building a payment processing system. How would you handle failures and ensure no payment is lost?"
"Great question. Let me first understand the failure modes we need to handle, then design the DLQ strategy.
For payment processing, I see three categories of failures:
First, validation failures — invalid card number, expired card, insufficient fields. These won't succeed on retry, so they should go directly to DLQ with no retries. We'd notify the customer to fix their payment method.
Second, transient failures — network timeouts, rate limits from the payment processor, temporary unavailability. These are worth retrying because they often self-resolve.
Third, poison pills — malformed messages that crash our processor, infinite loops, memory exhaustion. These need immediate quarantine.
For the architecture, I'd set it up like this:
%% title: Payment processing with DLQs
flowchart TD
PR@{ icon: "sd:card", label: "Payment Request" } --> V["Validation"]:::service
V --> PQ@{ icon: "sd:queue", label: "Payment Queue" }
PQ --> PC["Processor"]:::core
V --> VD@{ icon: "sd:queue", label: "validation.dlq" }
PC -->|"Success"| DN(["Done"]):::ok
PC -->|"Failure"| CL{"Classify"}
CL -->|"Transient"| RQ@{ icon: "sd:queue", label: "Retry Queue" }
CL -->|"Permanent"| PD@{ icon: "sd:queue", label: "payment<br/>.dlq" }
CL -->|"Poison"| IA@{ icon: "sd:alert", label: "Immediate<br/>Alert" }
class PR client
class PQ,RQ queue
class VD,PD,IA badFor transient failures, I'd use exponential backoff: 1 second, then 2, 4, 8, up to 60 seconds max. After 5 retries (1+2+4+8+16 = 31 seconds of backoff, plus the time each attempt takes), we'd give up and DLQ the message."
"What information would you store in the DLQ message?"
"The DLQ message needs enough context to debug and potentially replay without accessing other systems.
I'd include:
- Original payment request — full message including amount, customer, card token
- Error details — exception type, message, full stack trace
- Processing context — which processor attempted it, retry count, timestamps
- Kafka metadata — original topic, partition, offset for traceability
Something like:
{
"id": "dlq_abc123",
"original_message": {
"payment_id": "pay_789",
"amount": 99.99,
"customer_id": "cust_456",
"card_token": "tok_xxx"
},
"error": {
"type": "TimeoutError",
"message": "Payment processor timeout after 30s",
"stack_trace": "..."
},
"context": {
"processor_id": "processor-3",
"retry_count": 5,
"first_failed_at": "2024-01-15T10:00:00Z",
"last_failed_at": "2024-01-15T10:03:01Z"
}
}This lets someone investigating the DLQ understand what happened without querying multiple systems."
"How would you handle replaying these failed payments?"
"Replay is tricky for payments because we absolutely cannot double-charge customers.
First, idempotency is critical. Every payment request has a unique payment_id. Before processing any replay, we check if that payment_id was already successfully processed. If yes, skip it.
Second, we'd have explicit replay tooling, not just re-publishing to the queue. Something like:
async def replay_payment(dlq_message):
payment_id = dlq_message.original_message['payment_id']
# Check if already succeeded
if await payment_db.is_processed(payment_id):
return "Already processed, skipping"
# Check if customer still wants this payment
if dlq_message.age > timedelta(hours=24):
return "Too old, requires manual review"
# Replay with fresh attempt
return await process_payment(dlq_message.original_message)Third, for payments specifically, older DLQ messages need human review. If a payment failed 3 days ago, maybe the customer already bought elsewhere. We wouldn't auto-replay—we'd flag for customer support to reach out.
Finally, every replay is logged. Who replayed, when, what was the result. This gives us an audit trail for compliance."
"What metrics would you monitor?"
"For DLQ health, I'd track:
-
DLQ depth — number of messages waiting. Alert at 100 (warning) and 1000 (critical).
-
DLQ growth rate — messages added per minute. Spike detection for sudden failures.
-
DLQ age — oldest message in queue. If > 24 hours, we're not investigating fast enough.
-
Error category distribution — ratio of validation vs transient vs poison. Spike in poison pills means we have a bug.
-
Replay success rate — when we replay, how often does it succeed? Low success rate means our retry strategy is wrong.
Dashboard would show these per payment type, per processor, and aggregate. On-call gets paged for any poison pill or if DLQ depth exceeds critical threshold."
Summary#
CORE CONCEPT
- Poison pill: message that can never be processed
- Dead letter queue: quarantine for failed messages
- Goal: graceful failure, not infinite retry loops
ERROR CLASSIFICATION
- Validation errors → No retry, DLQ immediately
- Transient errors → Retry with backoff, then DLQ
- Poison pills → Never retry, immediate quarantine
IMPLEMENTATION
- Exponential backoff: 1s, 2s, 4s, 8s... up to max
- Processing timeout to detect poison pills
- Preserve full context in DLQ messages
- Build replay tooling from day one
OPERATIONS
- Monitor DLQ depth and growth rate
- Alert before DLQ becomes a crisis
- Daily triage of new error patterns
- Clear SLA for DLQ investigation
INTERVIEW TIPS
- Mention DLQ when designing any async system
- Classify errors: validation vs transient vs poison
- Always discuss replay and idempotency together
- Don't forget monitoring and operational aspects
DEFAULT CHOICE
- 3-5 retries with exponential backoff
- Categorized DLQs (validation, transient, poison)
- Process timeout = 2x normal processing time
Further Reading#
Official Documentation#
- Docs
AWS SQS Dead-Letter Queues: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- Docs
RabbitMQ Dead Letter Exchanges: https://www.rabbitmq.com/docs/dlx
- Docs
Kafka Error Handling: https://kafka.apache.org/documentation/#consumerconfigs
Engineering Blogs#
- Blog
Uber Engineering: "Building Reliable Reprocessing and Dead Letter Queues with Kafka" — https://www.uber.com/blog/reliable-reprocessing/
- Docs
Stripe Docs: "Webhooks" (retry behaviour, disabling endpoints) — https://docs.stripe.com/webhooks
- Blog
Netflix Tech Blog: "Evolution of the Netflix Data Pipeline" — https://netflixtechblog.com/
Books#
- Book
"Release It!" by Michael Nygard — Chapters on stability patterns
- Book
"Enterprise Integration Patterns" by Hohpe & Woolf — Dead Letter Channel pattern
Tools#
- Read
Kafka Connect: sink connectors can route failed records to a DLQ topic (
errors.tolerance=allpluserrors.deadletterqueue.topic.name, KIP-298) - Read
Spring Retry: Retry and DLQ patterns for Java
- Read
Celery: Task queue with retry and dead letter support
this lesson in shorts · 3
play them all →One idea from this lesson in about a minute each, made for your phone.
Go deeper · reading trail
all reading →Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.
Building Reliable Reprocessing and Dead Letter Queues with Apache Kafka
Ning Xia · 2018- What
- How Uber retries failed Kafka messages without blocking the main topic.
- Key idea
- Failed messages move to a chain of retry topics with growing delays and finally to a dead letter queue, so one bad message never stalls a partition.
- Why here
- The DLQ pattern of this lesson built on Kafka, where you cannot simply leave a poison pill at the head of the partition.
Using dead-letter queues in Amazon SQS
- What
- The SQS reference for dead-letter queues.
- Key idea
- A redrive policy moves a message to the DLQ after maxReceiveCount failed receives, and messages can later be redriven back to the source queue.
- Why here
- The managed-queue version of this lesson's DLQ: receive count as the poison-pill detector and redrive as the recovery path.
Kafka Connect Deep Dive – Error Handling and Dead Letter Queues
Robin Moffatt · 2019- What
- How Kafka Connect deals with records it cannot process.
- Key idea
- Choose between failing fast and tolerating errors; tolerated bad records go to a DLQ topic with headers describing the error for later inspection.
- Why here
- Shows the fail-fast vs skip-and-park decision this lesson asks you to make, and why error context must travel with the dead letter.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.


