# Dead Letter Queue Pattern

## Problem

When a message consumer encounters a message it cannot process — due to malformed data, missing dependencies, or persistent errors — retrying indefinitely blocks the queue. The "poison message" prevents all subsequent messages from being processed, and the system grinds to a halt.

## Solution

After a configured number of retry attempts, route the failed message to a **dead letter queue (DLQ)**. The DLQ stores unprocessable messages along with error metadata (reason, timestamps, attempt count). This unblocks the main queue while preserving failed messages for later inspection, manual correction, or automated reprocessing.

## When to Use

- Any message queue where processing can fail
- When poison messages can block consumer progress
- When you need to audit and investigate failures
- Systems with strict processing SLAs (can't let one bad message delay everything)
- Compliance environments where no message can be silently dropped

## When to Avoid

- When all failures are truly transient (retry is sufficient)
- When messages are disposable (logging, metrics) and can be dropped
- In-process event buses where failure handling is synchronous
- When the system has no mechanism to inspect or reprocess DLQ messages

## Pseudocode

```
class Consumer:
    max_retries = 3

    function process_message(message):
        for attempt in 1..max_retries:
            try:
                handle(message)
                acknowledge(message)
                return
            catch error:
                record_error(message, error)

        # All retries exhausted
        dead_letter_queue.send(message, errors)
        acknowledge(message)  # Remove from main queue
```

## Python Implementation

```python
import time
from dataclasses import dataclass, field
from typing import Any
from collections import deque


@dataclass
class Message:
    id: int
    body: Any
    attempts: int = 0
    errors: list[str] = field(default_factory=list)


@dataclass
class DeadLetter:
    original_message: Message
    reason: str
    failed_at: float
    attempts: int


class SimpleQueue:
    def __init__(self, name: str):
        self.name = name
        self._items: deque[Any] = deque()

    def put(self, item: Any):
        self._items.append(item)

    def get(self):
        if self._items:
            return self._items.popleft()
        return None

    @property
    def size(self):
        return len(self._items)

    def peek_all(self) -> list:
        return list(self._items)


class DeadLetterQueueProcessor:
    def __init__(
        self,
        main_queue: SimpleQueue,
        dlq: SimpleQueue,
        handler,
        max_retries: int = 3,
    ):
        self.main_queue = main_queue
        self.dlq = dlq
        self.handler = handler
        self.max_retries = max_retries
        self.processed = 0
        self.dead_lettered = 0

    def process_next(self) -> bool:
        msg = self.main_queue.get()
        if msg is None:
            return False

        last_error = "unknown"
        for attempt in range(1, self.max_retries + 1):
            msg.attempts = attempt
            try:
                print(f"  [Consumer] Processing msg#{msg.id} (attempt {attempt}): {msg.body}")
                self.handler(msg)
                self.processed += 1
                print(f"  [Consumer] msg#{msg.id} succeeded")
                return True
            except Exception as exc:
                last_error = str(exc)
                msg.errors.append(f"Attempt {attempt}: {last_error}")
                print(f"  [Consumer] msg#{msg.id} failed: {last_error}")

        # All retries exhausted — send to DLQ
        dead_letter = DeadLetter(
            original_message=msg,
            reason=last_error,
            failed_at=time.time(),
            attempts=msg.attempts,
        )
        self.dlq.put(dead_letter)
        self.dead_lettered += 1
        print(f"  [DLQ] msg#{msg.id} moved to dead letter queue after {msg.attempts} attempts")
        return True

    def process_all(self):
        while self.process_next():
            pass


# --- Demo ---
main_q = SimpleQueue("main")
dlq = SimpleQueue("dead-letters")

# Handler that fails on certain messages
def order_handler(msg: Message):
    if "invalid" in str(msg.body):
        raise ValueError(f"Invalid order data: {msg.body}")
    if "timeout" in str(msg.body) and msg.attempts < 3:
        raise TimeoutError(f"Service timeout processing {msg.body}")


# Enqueue messages
messages = [
    Message(1, {"order": "ORD-001", "total": 29.99}),
    Message(2, {"order": "invalid-data"}),           # Will always fail → DLQ
    Message(3, {"order": "ORD-003", "total": 15.00}),
    Message(4, {"order": "timeout-prone"}),            # Fails twice, succeeds on 3rd
    Message(5, {"order": "ORD-005", "total": 42.00}),
]

print("=== Enqueuing messages ===")
for msg in messages:
    main_q.put(msg)
    print(f"  Enqueued msg#{msg.id}: {msg.body}")

print()
print("=== Processing ===")
processor = DeadLetterQueueProcessor(main_q, dlq, order_handler, max_retries=3)
processor.process_all()

print()
print("=== Results ===")
print(f"  Processed successfully: {processor.processed}")
print(f"  Sent to DLQ: {processor.dead_lettered}")
print(f"  Main queue remaining: {main_q.size}")
print(f"  DLQ size: {dlq.size}")

print()
print("=== Dead Letter Queue Contents ===")
for dl in dlq.peek_all():
    msg = dl.original_message
    print(f"  msg#{msg.id}: {msg.body}")
    print(f"    Reason: {dl.reason}")
    print(f"    Attempts: {dl.attempts}")
    for err in msg.errors:
        print(f"    - {err}")
```

**Output:**
```
=== Enqueuing messages ===
  Enqueued msg#1: {'order': 'ORD-001', 'total': 29.99}
  Enqueued msg#2: {'order': 'invalid-data'}
  Enqueued msg#3: {'order': 'ORD-003', 'total': 15.0}
  Enqueued msg#4: {'order': 'timeout-prone'}
  Enqueued msg#5: {'order': 'ORD-005', 'total': 42.0}

=== Processing ===
  [Consumer] Processing msg#1 (attempt 1): {'order': 'ORD-001', 'total': 29.99}
  [Consumer] msg#1 succeeded
  [Consumer] Processing msg#2 (attempt 1): {'order': 'invalid-data'}
  [Consumer] msg#2 failed: Invalid order data: {'order': 'invalid-data'}
  [Consumer] Processing msg#2 (attempt 2): {'order': 'invalid-data'}
  [Consumer] msg#2 failed: Invalid order data: {'order': 'invalid-data'}
  [Consumer] Processing msg#2 (attempt 3): {'order': 'invalid-data'}
  [Consumer] msg#2 failed: Invalid order data: {'order': 'invalid-data'}
  [DLQ] msg#2 moved to dead letter queue after 3 attempts
  [Consumer] Processing msg#3 (attempt 1): {'order': 'ORD-003', 'total': 15.0}
  [Consumer] msg#3 succeeded
  [Consumer] Processing msg#4 (attempt 1): {'order': 'timeout-prone'}
  [Consumer] msg#4 failed: Service timeout processing {'order': 'timeout-prone'}
  [Consumer] Processing msg#4 (attempt 2): {'order': 'timeout-prone'}
  [Consumer] msg#4 failed: Service timeout processing {'order': 'timeout-prone'}
  [Consumer] Processing msg#4 (attempt 3): {'order': 'timeout-prone'}
  [Consumer] msg#4 succeeded
  [Consumer] Processing msg#5 (attempt 1): {'order': 'ORD-005', 'total': 42.0}
  [Consumer] msg#5 succeeded

=== Results ===
  Processed successfully: 4
  Sent to DLQ: 1
  Main queue remaining: 0
  DLQ size: 1

=== Dead Letter Queue Contents ===
  msg#2: {'order': 'invalid-data'}
    Reason: Invalid order data: {'order': 'invalid-data'}
    Attempts: 3
    - Attempt 1: Invalid order data: {'order': 'invalid-data'}
    - Attempt 2: Invalid order data: {'order': 'invalid-data'}
    - Attempt 3: Invalid order data: {'order': 'invalid-data'}
```

## JavaScript Implementation

```javascript
class SimpleQueue {
  constructor(name) {
    this.name = name;
    this._items = [];
  }

  put(item) { this._items.push(item); }
  get() { return this._items.shift() || null; }
  get size() { return this._items.length; }
  peekAll() { return [...this._items]; }
}

class DeadLetterQueueProcessor {
  constructor(mainQueue, dlq, handler, maxRetries = 3) {
    this.mainQueue = mainQueue;
    this.dlq = dlq;
    this.handler = handler;
    this.maxRetries = maxRetries;
    this.processed = 0;
    this.deadLettered = 0;
  }

  processNext() {
    const msg = this.mainQueue.get();
    if (!msg) return false;

    let lastError = "unknown";
    for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
      msg.attempts = attempt;
      try {
        console.log(`  [Consumer] Processing msg#${msg.id} (attempt ${attempt}): ${JSON.stringify(msg.body)}`);
        this.handler(msg);
        this.processed++;
        console.log(`  [Consumer] msg#${msg.id} succeeded`);
        return true;
      } catch (err) {
        lastError = err.message;
        msg.errors.push(`Attempt ${attempt}: ${lastError}`);
        console.log(`  [Consumer] msg#${msg.id} failed: ${lastError}`);
      }
    }

    // DLQ
    this.dlq.put({
      originalMessage: msg,
      reason: lastError,
      failedAt: Date.now(),
      attempts: msg.attempts,
    });
    this.deadLettered++;
    console.log(`  [DLQ] msg#${msg.id} moved to DLQ after ${msg.attempts} attempts`);
    return true;
  }

  processAll() {
    while (this.processNext()) {}
  }
}

// --- Demo ---
const mainQ = new SimpleQueue("main");
const dlq = new SimpleQueue("dead-letters");

function orderHandler(msg) {
  if (JSON.stringify(msg.body).includes("invalid")) {
    throw new Error(`Invalid order data: ${JSON.stringify(msg.body)}`);
  }
  if (JSON.stringify(msg.body).includes("timeout") && msg.attempts < 3) {
    throw new Error(`Service timeout processing ${JSON.stringify(msg.body)}`);
  }
}

const messages = [
  { id: 1, body: { order: "ORD-001", total: 29.99 }, attempts: 0, errors: [] },
  { id: 2, body: { order: "invalid-data" }, attempts: 0, errors: [] },
  { id: 3, body: { order: "ORD-003", total: 15.0 }, attempts: 0, errors: [] },
  { id: 4, body: { order: "timeout-prone" }, attempts: 0, errors: [] },
  { id: 5, body: { order: "ORD-005", total: 42.0 }, attempts: 0, errors: [] },
];

console.log("=== Enqueuing messages ===");
for (const msg of messages) {
  mainQ.put(msg);
  console.log(`  Enqueued msg#${msg.id}: ${JSON.stringify(msg.body)}`);
}

console.log("\n=== Processing ===");
const processor = new DeadLetterQueueProcessor(mainQ, dlq, orderHandler, 3);
processor.processAll();

console.log("\n=== Results ===");
console.log(`  Processed: ${processor.processed}`);
console.log(`  Dead-lettered: ${processor.deadLettered}`);
console.log(`  DLQ size: ${dlq.size}`);

console.log("\n=== DLQ Contents ===");
for (const dl of dlq.peekAll()) {
  const msg = dl.originalMessage;
  console.log(`  msg#${msg.id}: ${JSON.stringify(msg.body)}`);
  console.log(`    Reason: ${dl.reason}`);
  console.log(`    Attempts: ${dl.attempts}`);
  msg.errors.forEach((e) => console.log(`    - ${e}`));
}
```

## Related Patterns

- **Retry** — the DLQ is the safety net after all retries are exhausted; retry and DLQ work together.
- **Message Queue** — the DLQ is a specialised message queue that receives unprocessable messages from the main queue.
- **Observer** — alert systems subscribe to DLQ arrivals via observer/event notifications to trigger investigations.
- **Chain of Responsibility** — each retry attempt is a handler in a chain; the DLQ is the end-of-chain handler of last resort.
