Handling Retries¶
Narad deliberately does not have a retry engine. It gives you three durable primitives (visibility leases, acks, and topics), and every retry policy you've ever wanted composes out of them in your consumer, where you can see it, log it, and change it without a broker upgrade. This page is the complete menu.
One rule before the menu: your handler must be idempotent. Crashes, timeouts, nacks, and network blips all cause redelivery: that's the at-least-once contract. Every pattern below assumes processing the same message twice is safe. (Deduplicate on a business ID in the payload if it isn't.)
Pattern 0: do nothing (the lease is already a retry)¶
If your consumer crashes, hangs, or just never acks, the message becomes visible again after the topic's visibility_timeout_ms (default 30s) and someone else gets it. You wrote zero lines of retry code and you already survive process death.
- Retry spacing: constant, = the visibility timeout.
- Attempts: unbounded; a permanently failing message loops forever until a human notices. Fine for prototypes; keep reading for production.
Pattern 1: sync retries (for blips)¶
The dependency timed out once? Just call it again, in-process, while you still hold the lease:
for attempt in 1..3:
if handle(msg) succeeds: ack; done
ack-with-extend or fall through to a requeue (below)
Cheap, immediate, and invisible to the broker. If your processing time approaches the visibility window, heartbeat while you retry: POST /ack?receipt_handle=$H&extend=true restarts your lease.
Sync retries solve transient failures. They do nothing for "this message will fail for the next ten minutes"; that needs one of the patterns below.
Pattern 2: nack (retry soon, on someone else)¶
POST /ack?receipt_handle=$H&extend=0 gives the message back immediately: the lease drops, the message is available again on the next consume. Use it when this worker is the problem (shutting down, missing a local dependency) rather than the message.
Same caveat as pattern 0: the redelivered message is byte-identical, so nothing counts attempts for you. Which brings us to the load-bearing pattern:
Pattern 3: the envelope (bounded retries, your way)¶
Wrap your payload at produce time:
{"payload": {"order_id": "ord_123"}, "delivery_count": 0}
On failure, produce a new copy with delivery_count + 1, then ack the original, in that order. A crash between the two steps yields a duplicate (idempotency absorbs it), never a loss. The reversed order can lose the message; don't reverse it.
on failure:
if msg.delivery_count >= MAX: produce to jobs-dlq; ack; done
produce {payload, delivery_count+1} to the same topic; ack
What this buys you:
- Bounded attempts: the counter travels with the message, no broker state needed.
- A dead-letter queue:
jobs-dlqis just a topic you create. Alert on its depth, inspect it with replay, re-produce from it to the main topic when the bug is fixed. No special broker feature, full API available on it. - No head-of-line blocking: the failed message re-enters at the tail; the partition's frontier moves on immediately.
- Evolvable policy: want per-error-type max attempts? Different DLQs per failure class? It's your JSON and your code.
This is the pattern the classic worker frameworks (Sidekiq, and the SQS redrive policy itself) implement: the counter just lives in the message instead of the broker.
Pattern 4: spaced backoff (the retry-topic pair)¶
Requeue-at-tail retries again immediately (an empty queue redelivers in milliseconds). When failures need breathing room (a rate-limited API, a database failing over), use Narad's native delay machinery as your backoff.
First, the rule that makes the shape below make sense: you cannot produce into a delayed child. Narad rejects it (409) because a direct write would bypass the delay the topic promises. A delayed child has exactly one source (its parent) and it receives everything the parent receives. So the parent is your write side, the child is your read side, and the parent must be a dedicated retry topic that carries nothing but retries:
jobs ← main topic: produce work here, consume from here
jobs-retry-30s ← tier PARENT: produce failed messages here…
└── jobs-retry-30s-run ← …its delayed child (fanout_delay_ms: 30000):
consume retries from here, 30s later
Your consumers consume jobs and jobs-retry-30s-run. On failure, produce the message (envelope counter incremented) to jobs-retry-30s; its copy becomes consumable exactly 30 seconds later: durably, surviving restarts of everything, no in-process timers holding your fate.
Want tiers? One parent + one child per tier, picked by delivery_count:
jobs-retry-30s └── jobs-retry-30s-run (delay 30s) ← delivery_count 1–2
jobs-retry-5m └── jobs-retry-5m-run (delay 5m) ← delivery_count 3–4
jobs-retry-30m └── jobs-retry-30m-run (delay 30m) ← delivery_count 5+
Do not attach several delay children to one retry parent to "get tiers": every child copies every parent message, so each failed message would come back once per tier. One tier, one pair. Fixed tiers, not arbitrary per-message delays; in practice two or three cover every real policy.
The one discipline that is not optional: retry your acks¶
An ack can fail: a node restarting, or a 503 because the partition's out-of-order ack window (max_acked_ahead_per_partition) is momentarily full. A failed ack is not a failed job. Retry the ack itself with a short backoff until it returns 204 or 410.
We say this from experience: our own long-running soak once ignored failed acks, and the visibility timeout dutifully redelivered 623,000 duplicates over seven hours. The broker was fine. The messages were fine. The harness just didn't retry a 503. Retry your acks.
(410 Gone on ack means the lease already lapsed and the message was handed to someone else; that's not an error to retry, it's Narad telling you the work may run twice. Idempotency, again.)
Choosing, quickly¶
| You want | Use |
|---|---|
| Survive crashes | Nothing (leases do it) |
| Ride out a blip | Sync retries (+ extend=true heartbeat) |
| "Not me, try another worker" | Nack (extend=0) |
| Bounded attempts + DLQ | Envelope counter + requeue-and-ack |
| Backoff between attempts | Retry-topic pair with delayed children |
| Recover after fixing a bug | Replay the DLQ, re-produce |
Compose freely: production setups typically run sync retries → envelope requeue with a 30s retry tier → DLQ after 5, and alert on DLQ depth. Total broker features required: the ones on this site already.