Designing a Resilient Consumer
Everything, at once
Section titled “Everything, at once”Each reliability lesson gave you one safety net. A production consumer needs all of them working together. This capstone assembles them into a single design you can adapt.
The checklist
Section titled “The checklist”A consumer you can trust in production has these properties:
- Durable queue + persistent messages — so a broker restart doesn’t lose work.
- Manual acknowledgement — ack only after the work succeeds, so a crash mid-task redelivers.
- Sensible prefetch — a bounded
prefetch(say 10–50) so one consumer isn’t handed thousands of unacked messages. - Idempotent processing — because delivery is at-least-once, the same message can arrive twice; make handling it twice harmless (dedupe on a message id).
- Retry with backoff, then park — on failure,
nackwithout immediate requeue; route through a TTL + dead-letter retry queue; after N attempts, send to a parking (dead) queue for a human. - Connection recovery — reconnect automatically when the broker connection drops, and re-declare topology.
- Graceful shutdown — on SIGTERM, stop accepting new deliveries, finish in-flight work (and ack it), then close the channel and connection.
- Observability — log and emit metrics for processed, retried, and parked messages so you can see failures.
The flow, with every net in place
Section titled “The flow, with every net in place”flowchart TB
deliver["Message delivered
(prefetch-bounded)"] --> dedupe{"Seen this id?"}
dedupe -->|yes| ackdup["ack (idempotent skip)"]
dedupe -->|no| work["Process work"]
work -->|success| ack["ack"]
work -->|failure| retry{"Attempts < N?"}
retry -->|yes| ttl["nack -> retry queue (TTL) -> back to main"]
retry -->|no| park["route to parking queue
(alert a human)"] A robust consumer skeleton
Section titled “A robust consumer skeleton”The details differ per client, but the shape is the same everywhere: bounded prefetch, manual ack on success, dead-letter on repeated failure, and a shutdown hook.
import amqp from 'amqplib';
const conn = await amqp.connect('amqp://localhost');const channel = await conn.createChannel();
await channel.assertQueue('tasks', { durable: true });channel.prefetch(20); // bounded in-flight work
channel.consume('tasks', async (msg) => { if (!msg) return; const id = msg.properties.messageId; try { if (await alreadyProcessed(id)) { channel.ack(msg); return; } // idempotent await doWork(msg.content); await markProcessed(id); channel.ack(msg); // ack only after success } catch (err) { const attempts = (msg.properties.headers?.['x-attempts'] ?? 0) + 1; if (attempts >= 5) channel.nack(msg, false, false); // -> DLX/parking else republishToRetryQueue(channel, msg, attempts); // TTL + DLX retry channel.ack(msg); // remove the original }});
process.on('SIGTERM', async () => { // graceful shutdown await channel.close(); await conn.close(); process.exit(0);});import pika, signal
conn = pika.BlockingConnection(pika.ConnectionParameters("localhost"))channel = conn.channel()channel.queue_declare(queue="tasks", durable=True)channel.basic_qos(prefetch_count=20) # bounded in-flight work
def on_message(ch, method, props, body): msg_id = props.message_id try: if already_processed(msg_id): # idempotent ch.basic_ack(method.delivery_tag); return do_work(body) mark_processed(msg_id) ch.basic_ack(method.delivery_tag) # ack only after success except Exception: attempts = (props.headers or {}).get("x-attempts", 0) + 1 if attempts >= 5: ch.basic_nack(method.delivery_tag, requeue=False) # -> DLX/parking else: republish_to_retry_queue(ch, body, props, attempts) # TTL + DLX ch.basic_ack(method.delivery_tag)
channel.basic_consume(queue="tasks", on_message_callback=on_message)
def shutdown(*_): # graceful shutdown channel.stop_consuming(); conn.close()signal.signal(signal.SIGTERM, shutdown)channel.start_consuming()conn, _ := amqp.Dial("amqp://guest:guest@localhost:5672/")defer conn.Close()ch, _ := conn.Channel()defer ch.Close()
ch.QueueDeclare("tasks", true, false, false, false, nil) // durablech.Qos(20, 0, false) // bounded prefetchmsgs, _ := ch.Consume("tasks", "", false, false, false, false, nil)
go func() { for d := range msgs { id, _ := d.MessageId, d.Headers if alreadyProcessed(id) { d.Ack(false); continue } // idempotent if err := doWork(d.Body); err != nil { attempts := attemptCount(d.Headers) + 1 if attempts >= 5 { d.Nack(false, false) // -> DLX/parking } else { republishToRetryQueue(ch, d, attempts) // TTL + DLX d.Ack(false) } continue } markProcessed(id) d.Ack(false) // ack after success }}()
<-ctx.Done() // graceful shutdown: stop, finish in-flight, then closeThe habits that matter most
Section titled “The habits that matter most”If you remember only three things from this whole course, make them these: ack after success, never before (it’s the difference between losing work and not); make processing idempotent (because at-least-once guarantees duplicates); and never requeue a poison message in a tight loop (use TTL + dead-letter retry and a parking queue). Everything else is refinement on those three.