Route dead letters
A dead-letter exchange (DLX) catches messages the system gave up on. Without one they are silently discarded, so configure it on any queue whose failures you care about.
Configure a dead-letter exchange
const ordersDlx = defineExchange("orders-dlx");
const orderProcessingQueue = defineQueue("order-processing", {
deadLetter: {
exchange: ordersDlx,
routingKey: "order.failed", // optional
},
});The DLX is extracted into the contract automatically — you do not list it in defineContract yourself. What is not automatic is a queue bound to it: defineContract rejects a dead-letter exchange with nothing bound, because RabbitMQ discards a message routed to zero queues. Bind one of the two ways below, or set externalConsumers: true on the deadLetter config when another service owns the dead-letter queue.
The binding key depends on the exchange type. On a topic DLX, # catches every dead letter. On a direct DLX there is no wildcard: # and * are matched literally, so a # binding receives nothing — defineQueueBinding rejects a # or * segment on a direct exchange at define time. Bind the exact key the dead letter carries: the queue's deadLetter.routingKey, which is why setting one is worth it on a direct DLX.
If routingKey is omitted, the message keeps its original routing key. Setting one is usually clearer, because it lets a single DLX distinguish sources.
Consume dead-lettered messages
The dead-letter queue is an ordinary queue, so consuming it is ordinary too. Define a publisher against the DLX to describe what lands there, then bind a consumer:
import {
defineContract,
defineEventConsumer,
defineEventPublisher,
defineExchange,
defineMessage,
defineQueue,
} from "@amqp-contract/contract";
import { z } from "zod";
const ordersExchange = defineExchange("orders");
const ordersDlx = defineExchange("orders-dlx");
const orderMessage = defineMessage(z.object({ orderId: z.string() }));
const orderProcessingQueue = defineQueue("order-processing", {
deadLetter: { exchange: ordersDlx, routingKey: "order.failed" },
});
const orderCreated = defineEventPublisher(ordersExchange, orderMessage, {
routingKey: "order.created",
});
// Consuming a dead-letter queue makes it subject to the poison-loss check, and
// a DLQ cannot dead-letter to itself. Declare the drop: a message the DLQ
// handler also rejects has nowhere left to go.
const ordersDlxQueue = defineQueue("orders-dlx-queue", { onPoison: "drop" });
const failedOrder = defineEventPublisher(ordersDlx, orderMessage, {
routingKey: "order.failed",
});
export const contract = defineContract({
publishers: { orderCreated },
consumers: {
processOrder: defineEventConsumer(orderCreated, orderProcessingQueue),
handleFailedOrders: defineEventConsumer(failedOrder, ordersDlxQueue),
},
});failedOrder exists to give the dead-letter consumer a payload type and a binding. Nothing publishes through it directly — the broker does the routing.
handleFailedOrders: ({ raw, input: { payload } }) => {
logger.error(
{ orderId: payload.orderId, death: raw.properties.headers?.["x-death"] },
"order dead-lettered",
);
return OkAsync(undefined);
},The broker's x-death header records why the message arrived and how many times.
WARNING
Give the dead-letter queue a retry: { mode: "none" } policy, or none at all, and be careful what its handler can fail on. A dead-letter consumer that dead-letters its own messages needs somewhere for those to go.
Declare a dead-letter queue nobody consumes here
If no consumer in this service drains the DLQ — an operator replays from it, or another process owns it — declare it as standalone topology instead of inventing a consumer. defineDeadLetterQueue(dlx, name, options?) builds the queue and its binding in one call and returns them as { queue, binding }:
import {
defineContract,
defineDeadLetterQueue,
defineEventConsumer,
defineEventPublisher,
defineExchange,
defineMessage,
defineQueue,
} from "@amqp-contract/contract";
import { z } from "zod";
const ordersExchange = defineExchange("orders");
const ordersDlx = defineExchange("orders-dlx");
const orderMessage = defineMessage(z.object({ orderId: z.string() }));
// No dead-letter routing key here, so a dead letter keeps the key it arrived
// with — and `orders-dlx` is topic, where the helper's `#` catches all of them.
const orderProcessingQueue = defineQueue("order-processing", {
deadLetter: { exchange: ordersDlx },
});
const ordersDlq = defineDeadLetterQueue(ordersDlx, "orders-dlx-queue");
const orderCreated = defineEventPublisher(ordersExchange, orderMessage, {
routingKey: "order.created",
});
export const contract = defineContract({
consumers: {
processOrder: defineEventConsumer(orderCreated, orderProcessingQueue),
},
queues: { ordersDlq: ordersDlq.queue },
bindings: { ordersDlq: ordersDlq.binding },
});What the helper binds follows the exchange type: # on a topic DLX (override it with routingKey), no key on fanout or headers, and on a direct DLX a routingKey you must pass — the type requires it, because there is no catch-all to default to:
import { defineDeadLetterQueue, defineExchange, defineQueue } from "@amqp-contract/contract";
const paymentsDlx = defineExchange("payments-dlx", { type: "direct" });
const paymentsQueue = defineQueue("payments", {
deadLetter: { exchange: paymentsDlx, routingKey: "payments.dead" },
});
// The key the dead letters carry — the queue's deadLetter.routingKey.
const paymentsDlq = defineDeadLetterQueue(paymentsDlx, "payments-dlq", {
routingKey: "payments.dead",
});Pass queue: { … } for the dead-letter queue's own defineQueue options. The queue and binding are asserted at setup like any other, so dead-lettered messages land somewhere durable even before anything consumes them. See declare standalone topology.
Know what triggers dead-lettering
RabbitMQ routes a message to the DLX when:
- it is rejected with
nackandrequeue: false— which is what the worker does for aNonRetryableError, a validation failure, or an exhausted retry budget; - its TTL expires;
- the queue hits a length limit.
Expire old messages automatically
const orderProcessingQueue = defineQueue("order-processing", {
deadLetter: { exchange: ordersDlx, routingKey: "order.expired" },
arguments: {
"x-message-ttl": 86_400_000, // 24 hours
},
});Anything unconsumed for 24 hours moves to the DLX rather than sitting in the queue forever. Route expiry to its own key so the dead-letter consumer can tell "too slow" from "failed".
Separate poison messages from expired ones
Use distinct routing keys and distinct dead-letter queues:
const failedOrder = defineEventPublisher(ordersDlx, orderMessage, {
routingKey: "order.failed",
});
const expiredOrder = defineEventPublisher(ordersDlx, orderMessage, {
routingKey: "order.expired",
});consumers: {
handleFailedOrders: defineEventConsumer(failedOrder, failedQueue),
handleExpiredOrders: defineEventConsumer(expiredOrder, expiredQueue),
},They usually want different responses — a failure needs investigating, an expiry usually needs more capacity.
Replay a dead-lettered message
There is no built-in replay. Consume from the dead-letter queue and publish back through the normal publisher once you have fixed the cause:
import { NonRetryableError, PublishError, RetryableError } from "@amqp-contract/worker";
import { OkAsync, P } from "unthrown";
handleFailedOrders: ({ input: { payload } }) =>
shouldReplay(payload)
? client.publish("orderCreated", payload).mapErrCases((matcher) =>
matcher
.with(
P.tag("@amqp-contract/MessageValidationError"),
(error) => new NonRetryableError("replay rejected", error),
)
.with(P.tag(PublishError.tag), (error) => new RetryableError("replay failed", error)),
)
: OkAsync(undefined),publish models both failures, and they want opposite answers: a payload that fails its schema will fail again, so it is non-retryable; a broker hiccup (PublishError: timeout, nack, closed channel) is transient, so it is retryable — give the queue this handler consumes a retry config, or a RetryableError is dead-lettered like any other. Only a failure core cannot classify arrives as a defect, which dead-letters the message like any bug.
Do this deliberately, not automatically. A replay loop that re-dead-letters is an infinite loop with extra steps — gate it on a fix having shipped, or on an attempt counter you control.
Never replay an RPC request. client.call() publishes it with expiration = timeoutMs, so one no worker picked up in time is dead-lettered with an x-death reason of expired — and by then the caller has its RpcTimeoutError and no reply consumer. A replayed request would run the handler for nobody. Filter them out of any replay by the x-death reason expired or by a replyTo on the message (use request/reply).
Find out why a message died
What you get depends on how it arrived, and this catches people out.
Messages that reached the DLX via a republish path carry x-last-error and x-first-failure-timestamp. Messages nacked directly — the most common case — carry only the broker's x-death and no application-level reason. The reason is in the worker's logs.
Retry failed messages has the full matrix.
Where next
- Retry failed messages — exhausting retries is the main road here.
- Add logging — where the failure reason actually lives.
- Topology options —
deadLetterandargumentsin full.