Poison messages on the fulfillment queue
How one malformed message stops blocking the queue, where it goes, and who replays it.
A poison message is a message that fails on every delivery. Before this design, the fulfillment-worker crashed on such a message, the broker redelivered it to the next worker, and every message behind it waited. The fix has two halves. The worker moves a failing message out of the way after a bounded number of tries. The broker does the same when the worker itself dies.
Assumptions
orders.fulfillment, a RabbitMQ quorum queue. The consumer is fulfillment-worker (3 replicas). The producer is the Orders API, which publishes order.placed. Retry delay uses a second queue, orders.fulfillment.retry, with a per-message TTL. Names and numbers below are the current production values; change them in one place, the spec block, and carry the change into the other blocks.Where a poison message goes
The worker never requeues a failed message to the head of orders.fulfillment. A transient failure goes to the retry queue, which returns it to the main queue after the backoff expires. A permanent failure, or the fifth failure of any kind, goes to orders.fulfillment.dlq. The main queue therefore never holds a message that already failed, and the messages behind it keep flowing.
Two paths lead to the DLQ, and they cover different failures. The worker path handles errors the handler can see and classify. The broker path (delivery-limit: 5 on the quorum queue) handles a worker that dies before it can ack or nack, for example an out-of-memory kill. The broker counts each redelivery in x-delivery-count. It dead-letters the message itself on the sixth attempt. Without the broker path, a crash that happens before any handler code runs would loop forever.
What the worker decides on each delivery
The worker acks the original message in every branch. In the transient branch it acks only after the copy is safely in the retry queue. An ack is what frees the queue head. The order of the checks matters. Schema goes first because an invalid body cannot be classified further. The retry count goes last because it applies to every error class.
One delivery, from receipt to ack
Failure classes
The handler maps every exception to one class. The class decides the branch in the flow above and the team that gets the alert. An exception with no mapping is unknown, which retries and then dead-letters, so an unclassified error can delay a message but never block the queue.
Failure class, action, and owner
| Class | Detected by | Permanent? | Action | Owner |
|---|---|---|---|---|
| schema_invalid | JSON schema check before the handler runs | yes | DLQ at once, reason in x-death-reason | Orders API team |
| unknown_sku | Catalog lookup returns 404 | yes | DLQ at once | Catalog team |
| duplicate_order | Idempotency key already processed | yes | Ack and drop, log at info | Fulfillment team |
| downstream_timeout | Warehouse API call exceeds 5 s | no | Retry with backoff, then DLQ | Fulfillment team |
| db_conflict | Postgres serialization failure or deadlock | no | Retry with backoff, then DLQ | Fulfillment team |
| unknown | Any exception without a mapping | no | Retry with backoff, then DLQ | Fulfillment on-call |
| worker_crash | Broker: no ack within the delivery limit | n/a | Broker dead-letters on the 6th delivery | Fulfillment on-call |
duplicate_order is the one class that never reaches the DLQ. A replayed message can arrive after a retry of the same message already succeeded. The idempotency key catches that case, and dropping it is the correct result.
Retry and dead-letter numbers
These values are the contract between the worker, the broker policy, and the alert. The same limit of 5 appears in the worker header check and in the broker delivery-limit policy. Keep them equal. If they differ, one path dead-letters earlier than the other and the reason header lies.
Retry and DLQ policy
Replay a message from the DLQ
Replay is manual on purpose. A message in the DLQ failed five times, or failed in a way that a retry cannot fix. A replay without a fix repeats the failure. Fix the cause first, then replay.
Replay after a fix
- 1Read the failed messages and their reasons
The command peeks without consuming. Group the output by x-death-reason before you decide anything.
bashfulfillment-cli dlq peek --queue orders.fulfillment.dlq --limit 50 - 2Fix the cause for that class
For schema_invalid, the Orders API team ships a producer fix. For unknown_sku, the Catalog team adds the SKU. For downstream_timeout, wait until the Warehouse API is healthy.
Do not replay before the fix is in production. A replay without a fix returns the same message to the DLQ with x-retry-count reset to 0.
- 3Replay one message and watch it
Replay by message id first. The worker logs the outcome with the same id within one backoff window.
bashfulfillment-cli dlq replay --queue orders.fulfillment.dlq --id 7f3c1a --wait - 4Replay the rest of that class
Replay by reason so messages from other classes stay in the DLQ.
bashfulfillment-cli dlq replay --queue orders.fulfillment.dlq --reason unknown_sku - 5Confirm the queue is clear
DLQ depth must return to 0 and the PagerDuty alert must resolve on its own. If a message returns to the DLQ, its x-retry-count is 5 again and the fix was incomplete.
bashfulfillment-cli dlq depth --queue orders.fulfillment.dlq - 6Purge only what is truly dead
Purge by id only a message that no fix can repair, for example a test order from a deleted tenant. Record the reason in the incident ticket.
bashfulfillment-cli dlq purge --queue orders.fulfillment.dlq --id 7f3c1a --ticket INC-2291Purge is irreversible. Retention deletes the rest after 14 days.