Runbook de Dead Letter Queue
Runbook para handlear y replayear dead letter queue messages en Kafka y RabbitMQ: DLQ setup, inspection procedures, root cause analysis, replay strategies, monitoring alerts y automation scripts para failed message recovery.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Overview
Este runbook cubre procedures para handlear y replayear dead letter queue (DLQ) messages en Kafka y RabbitMQ. DLQs capturean messages que consumers failearon de procesar — debido a deserialization errors, business logic failures o downstream service unavailability. Este documento cubre DLQ setup, inspection, root cause analysis, replay strategies, monitoring y automation.
1. DLQ Setup
1.1 RabbitMQ DLQ Configuration
import pika
connection = pika.BlockingConnection(pika.ConnectionParameters('rabbitmq-prod'))
channel = connection.channel()
# Declara dead letter exchange
channel.exchange_declare(
exchange='prod.orders.dlx.direct',
exchange_type='direct',
durable=True,
)
# Declara dead letter queue con 7-day retention
channel.queue_declare(
queue='prod.orders.dlq.failed-messages',
durable=True,
arguments={
'x-message-ttl': 604800000, # 7 days
},
)
# Bindea DLQ a DLX
channel.queue_bind(
exchange='prod.orders.dlx.direct',
queue='prod.orders.dlq.failed-messages',
routing_key='order-processing-failed',
)
# Configura main queue para usar DLX
channel.queue_declare(
queue='prod.orders.payment-service.process-payment',
durable=True,
arguments={
'x-dead-letter-exchange': 'prod.orders.dlx.direct',
'x-dead-letter-routing-key': 'order-processing-failed',
'x-max-priority': 10,
},
)
1.2 Kafka DLQ Topic Setup
# Crea DLQ topic
kafka-topics.sh --bootstrap-server kafka:9092 \
--create \
--topic prod.orders.order-created.dlq \
--partitions 6 \
--replication-factor 3 \
--config retention.ms=604800000 \
--config cleanup.policy=delete
1.3 Kafka Consumer DLQ Producer
from confluent_kafka import Consumer, Producer, KafkaError
import json
consumer = Consumer({
'bootstrap.servers': 'kafka:9092',
'group.id': 'payment-service',
'auto.offset.reset': 'earliest',
'enable.auto.commit': False,
})
dlq_producer = Producer({'bootstrap.servers': 'kafka:9092'})
consumer.subscribe(['prod.orders.order-created.v1'])
MAX_RETRIES = 3
def process_with_dlq():
while True:
msg = consumer.poll(1.0)
if msg is None:
continue
if msg.error():
if msg.error().code() == KafkaError._PARTITION_EOF:
continue
logger.error(f"Consumer error: {msg.error()}")
continue
try:
process_message(msg.value())
consumer.commit(msg)
except Exception as e:
headers = dict(msg.headers() or [])
retry_count = int(headers.get('retry-count', b'0')) + 1
if retry_count <= MAX_RETRIES:
# Retry — requeue con incremented retry count
dlq_producer.produce(
topic='prod.orders.order-created.v1',
key=msg.key(),
value=msg.value(),
headers=[('retry-count', str(retry_count).encode())],
)
else:
# Send a DLQ despues de max retries
dlq_producer.produce(
topic='prod.orders.order-created.dlq',
key=msg.key(),
value=msg.value(),
headers=[
('retry-count', str(retry_count).encode()),
('error', str(e).encode()),
('original-topic', msg.topic().encode()),
('original-partition', str(msg.partition()).encode()),
('original-offset', str(msg.offset()).encode()),
('failed-at', datetime.utcnow().isoformat().encode()),
],
)
consumer.commit(msg)
2. DLQ Inspection
2.1 RabbitMQ DLQ Inspection
# Checkea DLQ message count
rabbitmqctl list_queues name messages messages_ready messages_unacknowledged \
| grep dlq
# Inspecta DLQ messages via management API
curl -u admin:password \
http://rabbitmq-prod:15672/api/queues/%2F/prod.orders.dlq.failed-messages/get \
-H "content-type: application/json" \
--data '{"count": 5, "ackmode": "ack_requeue_true", "encoding": "auto"}'
2.2 Kafka DLQ Inspection
# Consume de DLQ para inspection (sin committing offsets)
kafka-console-consumer.sh --bootstrap-server kafka:9092 \
--topic prod.orders.order-created.dlq \
--from-beginning \
--max-messages 10 \
--property print.headers=true \
--property print.partition=true \
--property print.offset=true
# Cuenta messages en DLQ
kafka-run-class.sh kafka.tools.GetOffsetShell \
--broker-list kafka:9092 \
--topic prod.orders.order-created.dlq \
--time -1
2.3 DLQ Message Analysis Script
import json
from confluent_kafka import Consumer
consumer = Consumer({
'bootstrap.servers': 'kafka:9092',
'group.id': 'dlq-inspector',
'auto.offset.reset': 'earliest',
'enable.auto.commit': False,
})
consumer.subscribe(['prod.orders.order-created.dlq'])
error_categories = {}
total_messages = 0
while True:
msg = consumer.poll(1.0)
if msg is None():
if total_messages > 0:
break
continue
headers = dict(msg.headers() or [])
error = headers.get('error', b'unknown').decode()
original_topic = headers.get('original-topic', b'unknown').decode()
# Categoriza errors
if 'ConnectionError' in error:
category = 'downstream_unavailable'
elif 'JSONDecodeError' in error:
category = 'deserialization_error'
elif 'ValidationError' in error:
category = 'schema_validation_error'
elif 'TimeoutError' in error:
category = 'timeout'
else:
category = 'business_logic_error'
error_categories[category] = error_categories.get(category, 0) + 1
total_messages += 1
print(f"Total DLQ messages: {total_messages}")
for category, count in sorted(error_categories.items(), key=lambda x: -x[1]):
print(f" {category}: {count} ({count * 100 / total_messages:.1f}%)")
3. Root Cause Analysis
3.1 Common DLQ Causes
Cause | Frequency | Fix
──────────────────────────┼───────────┼──────────────────────────────────────
Downstream service down | High | Fix downstream, replay DLQ
Schema mismatch | Medium | Fix consumer deserialization, replay
Invalid message format | Medium | Fix producer, discard o transform
Business rule violation | Low | Fix data, replay o discard
Timeout | Medium | Increase timeout, optimize handler
Deserialization error | Medium | Fix schema, deploy, replay
Null pointer / bug | Low | Fix code, deploy, replay
3.2 RCA Template
Incident: <DLQ alert name>
Date: <YYYY-MM-DD HH:MM>
DLQ: <queue o topic name>
Message count: <N>
Error category: <category from analysis>
Root cause:
<Describe que causo que los messages faileran>
Affected messages:
- Count: <N>
- Time range: <start> to <end>
- Original topic/queue: <name>
Resolution:
- [ ] Fix applied: <description>
- [ ] Fix deployed: <version>
- [ ] DLQ replayed: <yes/no>
- [ ] DLQ cleared: <yes/no>
- [ ] Monitoring confirmed: <yes/no>
Prevention:
- <Que hacer para prevenir recurrence>
4. Replay Strategies
4.1 RabbitMQ Replay
import pika
connection = pika.BlockingConnection(pika.ConnectionParameters('rabbitmq-prod'))
channel = connection.channel()
# Replayea messages de DLQ back a main exchange
def replay_dlq(source_queue, target_exchange, routing_key, batch_size=100):
for _ in range(batch_size):
method, properties, body = channel.basic_get(
queue=source_queue,
auto_ack=False,
)
if method is None:
print("DLQ empty — replay complete")
break
# Clear dead-letter headers para prevenir loops
if properties.headers:
properties.headers.pop('x-death', None)
properties.headers.pop('x-first-death-exchange', None)
properties.headers.pop('x-first-death-queue', None)
properties.headers.pop('x-first-death-reason', None)
# Republisha a original exchange
channel.basic_publish(
exchange=target_exchange,
routing_key=routing_key,
body=body,
properties=properties,
)
# Acknowledge el DLQ message
channel.basic_ack(delivery_tag=method.delivery_tag)
print(f"Replayed message {method.delivery_tag}")
replay_dlq(
source_queue='prod.orders.dlq.failed-messages',
target_exchange='prod.orders.order-created.direct',
routing_key='order.payment.required',
)
4.2 Kafka Replay
from confluent_kafka import Consumer, Producer
import json
consumer = Consumer({
'bootstrap.servers': 'kafka:9092',
'group.id': 'dlq-replayer',
'auto.offset.reset': 'earliest',
'enable.auto.commit': False,
})
producer = Producer({'bootstrap.servers': 'kafka:9092'})
consumer.subscribe(['prod.orders.order-created.dlq'])
def replay_dlq(target_topic, max_messages=None):
count = 0
while True:
msg = consumer.poll(1.0)
if msg is None():
if count > 0:
break
continue
if msg.error():
continue
# Extracta original message de DLQ
headers = dict(msg.headers() or [])
original_topic = headers.get('original-topic', msg.topic().encode()).decode()
# Remove DLQ metadata headers
clean_headers = [
(k, v) for k, v in msg.headers()
if k not in ('retry-count', 'error', 'original-topic',
'original-partition', 'original-offset', 'failed-at')
]
# Reproducea a original topic
producer.produce(
topic=target_topic,
key=msg.key(),
value=msg.value(),
headers=clean_headers,
)
consumer.commit(msg)
count += 1
if max_messages and count >= max_messages:
break
producer.flush()
print(f"Replayed {count} messages to {target_topic}")
replay_dlq(target_topic='prod.orders.order-created.v1')
5. Monitoring and Alerts
5.1 Alert Rules
# Prometheus alert rules para DLQ
groups:
- name: dlq-alerts
rules:
- alert: RabbitMQDLQMessages
expr: rabbitmq_queue_messages_ready{queue=~".*dlq.*"} > 0
for: 5m
labels:
severity: warning
annotations:
summary: "DLQ has messages: {{ $labels.queue }}"
description: "DLQ {{ $labels.queue }} has {{ $value }} messages"
- alert: KafkaDLQMessages
expr: kafka_topic_partition_current_offset{topic=~".*dlq"} > 0
for: 5m
labels:
severity: warning
annotations:
summary: "Kafka DLQ has messages: {{ $labels.topic }}"
- alert: DLQRateIncreasing
expr: rate(rabbitmq_queue_messages_ready{queue=~".*dlq.*"}[10m]) > 1
for: 10m
labels:
severity: critical
annotations:
summary: "DLQ message rate is increasing: {{ $labels.queue }}"
5.2 Dashboard Queries
# DLQ message count by queue
rabbitmq_queue_messages_ready{queue=~".*dlq.*"}
# DLQ age (oldest message)
rabbitmq_queue_messages_ready{queue=~".*dlq.*"} * 60
# DLQ rate (messages per minute)
rate(rabbitmq_queue_messages_ready{queue=~".*dlq.*"}[5m]) * 60
# Consumer error rate (messages entering DLQ)
rate(rabbitmq_queue_messages{queue=~".*dlq.*"}[5m])
Preguntas Frecuentes
¿Por cuanto tiempo deberia retener messages en el DLQ?
Retene DLQ messages por 7 days en production. Esto da a engineers enough time para investigate, fixear el root cause y replayear messages. Para audit o compliance topics, extend a 30 days. Setea un TTL en RabbitMQ DLQs y retention en Kafka DLQ topics. Monitora DLQ age y alerta cuando messages son older que 24 hours — stale messages son harder de replayear successfully.
¿Cuando deberia discardar DLQ messages en vez de replayearlos?
Discarda messages cuando el data itself es invalid y no puede ser fixed (e.g., corrupt payload, missing required fields que no pueden ser reconstructed). Tambien discarda si el business event ya no es relevant (e.g., un cancelled order’s payment event). Documenta el discard decision en el RCA. Nunca discards sin confirmar que el message es truly unrecoverable — checkea si un producer fix podia regenerar el correct data.
¿Cómo prevengo infinite retry loops?
Setea un maximum retry count (tipicamente 3) en message headers. Despues de max retries, routea a DLQ en vez de requeueing. En RabbitMQ, usa basic_reject(requeue=False) despues de max retries. En Kafka, checkea el retry-count header y produce a DLQ cuando exceeded. Siempre setea un TTL en el DLQ para que messages expiren si nadie los processea. Monitora para messages cycling entre main queue y DLQ.
¿Deberia usar un separate DLQ per consumer o per topic?
Usa un separate DLQ per consumer service, no per topic. Esto da a cada team visibility en sus own failures y previene que un service’s errors drowneen out another’s. Nombra el DLQ con el consumer service name (e.g., prod.orders.payment-service.dlq). Para Kafka, usa un DLQ topic per source topic (e.g., prod.orders.order-created.dlq) ya que Kafka consumers son tipicamente per-topic.
¿Cómo automatizo DLQ replay?
Buildea un replay service que read de DLQ, checkea si el fix ha sido deployed (via health check o version check) y replayea messages al original topic. Include un dry-run mode que inspecta messages sin replayear. Add rate limiting para avoid overwhelming downstream services. Loggea cada replayed message con su original metadata para auditability. Corre el replay service como un Kubernetes Job o Lambda function triggered manually despues de un fix deployed.
See Also
Recursos Relacionados
Kafka Topic Naming Convention Template
Template for standardizing Kafka topic names across teams: naming patterns, environment prefixes, domain segmentation, event type suffixes, partition count rules, and retention policies with examples.
DocRabbitMQ Queue Design Template
Template for documenting RabbitMQ queue, exchange, and binding design: exchange types, queue properties, binding rules, dead letter handling, TTL policies, and capacity planning with code examples.
DocMessage Schema Evolution Policy
Policy for evolving message schemas safely in event-driven systems: backward and forward compatibility rules, schema registry usage, versioning strategies, migration procedures, and breaking change handling with Avro, Protobuf, and JSON examples.