?

Day 0442 minPreface+60 XP

Dead Letters and Poison Pills.

2 AM. PagerDuty goes off. The order processing system is frozen.

4 stages27 code

watch it first · newThe whole lesson in a 19-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

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.

The Poison Pill Death Spiral
  • 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:

js6 lines
{
  "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.

ExplainerThe whole lesson: dead letters and poison pills

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 · Stage 1 of 4 · 9 min

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.

EVERYDAY ANALOGY: The Post Office

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:

  1. Try to figure out the intended recipient
  2. Return to sender if possible
  3. Hold for investigation if suspicious
  4. Eventually destroy if unsalvageable

Your DLQ serves the same purpose for messages.

Your turn · checkpoint 1not answered

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?

Think it through, or jot it down. Then compare.

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:

FAILURE TAXONOMY

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#

DiagramMessage flow with DLQ
%% 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
  1. The producer's message reaches the consumer through the main queue
  2. Normal path: processing succeeds
  3. Failure path: the retry logic decides what happens next
  4. Below the retry limit, the message goes back to the queue and is tried again
  5. At the limit it moves to the dead letter queue
  6. Someone investigates why it failed
  7. Then fixes the cause and replays it, or discards it
ExplainerPoison pills and the dead letter queue

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.

DiagramSimple DLQ architecture
%% 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:

json8 lines
{
  "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 turn · checkpoint 2not answered

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?

Think it through, or jot it down. Then compare.

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.

DiagramCategorized DLQ architecture
%% 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"]]:::bad

Benefits 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.

DiagramDelayed retry architecture
%% 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"]]:::bad

Implementation 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:
    1. Sleeps for the delay duration
    2. Republishes to main topic
    3. Or sends to DLQ if max retries exceeded

2.4 Pattern 4: Error-Specific Handling#

Different error types get different treatment.

pythonErrorCategory · 70 lines
# 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.UNKNOWN

2.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#

Your turn · checkpoint 3not answered

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?

Think it through, or jot it down. Then compare.

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#

HOW MANY RETRIES?
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
pythonAdaptiveRetryPolicy · 9 lines
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#

HOW LONG TO KEEP DLQ MESSAGES?
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
  1. Keep hot DLQ for 7 days (fast access)
  2. Archive to cold storage (S3) for 90 days
  3. 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#

When NOT to use a DLQ

SKIP THE DLQ WHEN

  1. Messages are fully idempotent AND retriable — Just retry forever with backoff. Eventually it will work or alert.
  2. Data loss is acceptable — Metrics, logs, analytics. Drop and move on.
  3. Real-time requirements — If it fails, it's too late anyway. Log the error, move on.
  4. You have a better recovery mechanism — Event sourcing: rebuild from event log. CQRS: reproject from source.

USE DLQ WHEN

  1. Every message must eventually succeed — Orders, payments, critical workflows
  2. You need visibility into failures — Debugging, compliance, auditing
  3. Manual intervention is expected — Data correction, customer support
  4. Replay is part of your recovery plan — Fix bug, replay failed messages

3.4 Decision Framework#

DiagramShould this message go to DLQ?
%% 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)"]]:::bad
Quick check4 questions · +5 XP each

Poison pills and DLQ strategy

  1. Q1/4 A message with a malformed items field 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.

  2. 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.

  3. 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.

  4. 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 · Stage 2 of 4 · 17 min

Part II: Implementation#

Chapter 4: Basic Implementation#

4.1 Simple DLQ Consumer#

pythonDeadLetter · 136 lines
# 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#

DLQ MESSAGE LIFECYCLE
  1. Producer sends message
    • Topic: orders
    • Message: {"order_id": "123", "items": "invalid"}
  2. Consumer receives message
    • Attempt 1: ValueError - items not a list
  3. Consumer retries (message republished)
    • Attempt 2: ValueError - same error
  4. Consumer retries twice more
    • Attempts 3 and 4: ValueError - still failing
  5. Max retries exceeded → DLQ
    • Topic: orders.dlq
    • Message:
      json8 lines
      {
        "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
      }
  6. Manual investigation
    • Engineer sees message in DLQ
    • Identifies bug in producer
    • Fixes producer
  7. Replay from DLQ
    • Fix the message format
    • Republish to orders topic
    • Message processes successfully

Chapter 5: Production-Ready Implementation#

5.1 Requirements for Production#

  1. Error classification — Different errors need different handling
  2. Exponential backoff — Don't hammer failing systems
  3. Circuit breaker integration — Stop retrying during outages
  4. Metrics and alerting — Know when DLQ is growing
  5. Replay tooling — Easy to investigate and replay
  6. Poison pill detection — Catch crashes before they loop

5.2 Complete Production Implementation#

pythonDLQConfig · 543 lines
# 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
WorksheetThe 2 AM poison pill with this handler (arrival rate and revenue rate derived from the preface; even spread assumed)
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 31 s × $3,500/min ≈ **$1,800**, delayed rather than lost

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#

pythonDLQMonitor · 57 lines
# 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#

Your turn · checkpoint 4not answered

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?

Think it through, or jot it down. Then compare.

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#

SCENARIO: Message sent to DLQ, but DLQ consumer 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

pythondlq_consumer · 4 lines
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

python6 lines
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#

SCENARIO: Replay causes new errors
  1. Message fails with bug A
  2. Bug A is fixed
  3. Message replayed
  4. 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

python6 lines
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

pythonmigrate_message · 4 lines
def migrate_message(msg: dict) -> dict:
    if 'price' not in msg:
        msg['price'] = calculate_legacy_price(msg)
    return msg

3. 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#

SCENARIO: Message keeps retrying forever
  1. Consumer A receives message → Failure, retry → Republish to queue
  2. Consumer B receives same message → Failure, retry → Republish to queue
  3. 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#

SCENARIO: Same message appears in DLQ multiple times

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:

pythonsend_to_dlq · 13 lines
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
Quick check4 questions · +5 XP each

Building the DLQ pipeline

  1. 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.

  2. 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.

  3. 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.

  4. 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 · Stage 3 of 4 · 6 min

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
DiagramUber trip event DLQ system
%% 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 service

Key innovations:

  1. 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)
  2. Financial reconciliation — DLQ'd trip events trigger reconciliation job. Compares actual charges vs expected. Ensures no money is lost.
  3. 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
DiagramStripe webhook delivery (simplified)
%% 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 bad

Key design decisions:

  1. Long retry window — Retry for up to 3 days. Merchant might fix their server.
  2. 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
  3. 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).
  4. Self-service recovery — Merchants can resend events from the Dashboard and list recent events (about 30 days) via the Events API.
  5. 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
DiagramNetflix encoding pipeline
%% 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 service

DLQ categories:

  1. Transient failures — Encoder crashed; resource contention; network issues → Automatic retry
  2. Permanent failures — Unsupported codec (expected for some content); corrupted source file → Skip, use lower quality fallback
  3. 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#

No DLQ at all

WRONG: Ignoring failures

pythonprocess · 6 lines
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

pythonprocess · 6 lines
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 recovery

8.2 Mistake 2: Retrying Poison Pills#

Retrying poison pills

WRONG: Unlimited retries

python7 lines
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

pythonsafe_process · 19 lines
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#

Not monitoring DLQ

WRONG: DLQ as a black hole

python4 lines
# Send to DLQ and forget
await producer.send("orders.dlq", msg)
# No monitoring, no alerts
# DLQ grows to 1M messages unnoticed

CORRECT: Monitor and alert on DLQ

python3 lines
# Send to DLQ with metrics
await producer.send("orders.dlq", msg)
metrics.increment("dlq.messages.total", tags={"topic": "orders"})
yaml10 lines
# Alert configuration
alerts:
  - name: "DLQ Warning"
    condition: dlq.depth > 100
    severity: warning

  - name: "DLQ Critical"
    condition: dlq.depth > 1000
    severity: critical
    page_oncall: true

8.4 Mistake 4: No Replay Strategy#

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

pythonDLQTools · 15 lines
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#

Same processing for retries

WRONG: Retry with same logic that failed

python10 lines
# 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 time

CORRECT: Adaptive retry behavior

pythonAdaptiveProcessor · 19 lines
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:

Before deploying DLQ handling, verify0/8 ready
  • 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
Quick check2 questions · +5 XP each

DLQs in practice

  1. 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.

  2. 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 · Stage 4 of 4 · 9 min

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
❯ Say this

9.2 Key Phrases to Use#

Key phrases
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:

  1. Where would you place DLQs in this pipeline?
  2. How would you handle validation vs payment failures differently?
  3. 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:

DiagramDLQ 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 bad

Error Handling by Stage:

  1. Validation failures

    • No retry (bad data won't fix itself)
    • DLQ immediately
    • Notify customer of invalid order
    • Agent can fix and resubmit
  2. Inventory failures

    • Retry 3x (might be race condition)
    • If item truly out of stock → notify customer
    • Don't retry indefinitely (item won't reappear)
  3. 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
  4. 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:

  1. How do you handle provider failures?
  2. What's your DLQ strategy for time-sensitive vs marketing notifications?
  3. 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:

DiagramNotification priority routing
%% 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 queue

Provider Isolation:

DiagramProvider 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 bad

Time-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:

  1. How do you ensure no transaction is lost?
  2. How do you prevent double-processing on replay?
  3. 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:

DiagramZero-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")]:::bad

Idempotency:

pythonprocess_transaction · 15 lines
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:

sql13 lines
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:

  1. Written to audit table
  2. Kept for 7 years (regulatory)
  3. Includes who replayed and when

Chapter 11: Mock Interview Dialogue#

Scenario: Design DLQ for Payment Processing#

Transcript
Interviewer

"We're building a payment processing system. How would you handle failures and ensure no payment is lost?"

You

"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:

DiagramPayment processing with DLQs
%% 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 bad

For 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."

Interviewer

"What information would you store in the DLQ message?"

You

"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:

json20 lines
{
  "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."

Interviewer

"How would you handle replaying these failed payments?"

You

"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:

pythonreplay_payment · 13 lines
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."

Interviewer

"What metrics would you monitor?"

You

"For DLQ health, I'd track:

  1. DLQ depth — number of messages waiting. Alert at 100 (warning) and 1000 (critical).

  2. DLQ growth rate — messages added per minute. Spike detection for sudden failures.

  3. DLQ age — oldest message in queue. If > 24 hours, we're not investigating fast enough.

  4. Error category distribution — ratio of validation vs transient vs poison. Spike in poison pills means we have a bug.

  5. 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#

DAY 4 KEY TAKEAWAYS
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#

Engineering Blogs#

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=all plus errors.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.

End of Day 04 · Dead Letters and Poison Pills
Finish Day 4Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~8 more min of reading.

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.

UberBlog post · ~10 min

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.
taught here: Retry topics and DLQ
AmazonDocs · ~4 min

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.
taught here: Dead letter queue
ConfluentBlog post · ~16 min

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.
taught here: Poison pills

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…