Skip to content

Retry failed messages ​

Retry is configured in two places: the queue declares the policy, the handler classifies the failure. The retry model explains why. This page is the configuration.

Decide whether a failure should retry ​

In the handler, map the underlying cause to one of two errors:

typescript
import { NonRetryableError, RetryableError } from "@amqp-contract/worker";
import { fromPromise } from "unthrown";

processOrder: ({ input: { payload } }) =>
  fromPromise(chargeCard(payload), (cause) => {
    // A declined card will be declined again. Don't spend retries on it.
    if (cause instanceof CardDeclined) {
      return new NonRetryableError("card declined", cause);
    }
    // A 503 might not happen next time.
    return new RetryableError("gateway unavailable", cause);
  }).map(() => undefined),

The test: if the same input hit the same code tomorrow, would it fail the same way? If yes, it is non-retryable.

When a whole operation falls on one side, use the prebuilt mappers:

typescript
import { qualifyNonRetryable, qualifyRetryable } from "@amqp-contract/worker";

fromPromise(callApi(payload), qualifyRetryable("API unavailable"));
fromPromise(validateExternally(payload), qualifyNonRetryable("rejected by provider"));

Outside a fromPromise boundary, construct the errors directly: new RetryableError(message, cause) / new NonRetryableError(message, cause).

Retry immediately ​

For failures that resolve in milliseconds:

typescript
const orderQueue = defineQueue("order-processing", {
  deadLetter: { exchange: dlx },
  retry: { mode: "immediate-requeue", maxRetries: 3 },
});

After maxRetries, the message is dead-lettered.

dlx here needs a queue bound to it. defineContract rejects a dead-letter exchange with nothing bound, because RabbitMQ discards a message routed to zero queues — a bare DLX loses every retry-exhausted message exactly as silently as no DLX at all. See route dead letters for the declaration; every deadLetter on this page assumes it is in place.

On quorum queues the count comes from RabbitMQ's native x-delivery-count; on classic queues the worker maintains x-retry-count by republishing.

Quorum delivery limit

Quorum queues also cap redeliveries on the broker side with x-delivery-limit — 20 by default in RabbitMQ 4 — and dead-letter past it on their own. defineQueue sets it to maxRetries + 1 on every quorum queue with immediate-requeue retry, so the worker's retry budget is always the one that runs out first, and you need not set it.

An explicit x-delivery-limit in arguments is kept as long as it leaves room for maxRetries (at least maxRetries + 1, or -1 for unlimited); a lower one is rejected at define time. A delivery limit applied through a broker policy is invisible to the contract — check it does not undercut maxRetries.

Retry with exponential backoff ​

For failures that take seconds or minutes to clear:

typescript
const orderQueue = defineQueue("order-processing", {
  deadLetter: { exchange: dlx },
  retry: {
    mode: "ttl-backoff",
    maxRetries: 5,
    initialDelayMs: 1_000,
    maxDelayMs: 60_000,
    backoffMultiplier: 2,
    jitter: true,
  },
});

The delay is initialDelayMs * backoffMultiplier ^ attempt, capped at maxDelayMs. With these values: 1s, 2s, 4s, 8s, 16s.

Keep jitter: true unless you have a reason not to. Without it, messages that failed together retry together and hit the recovering dependency simultaneously.

The supporting topology is derived for you at channel-setup time — one wait queue per distinct delay in the schedule (order-processing-wait-1000ms, order-processing-wait-2000ms, …), each dead-lettering back to the main queue when its TTL expires. You do not wire or declare any of it, and none of it appears in the contract.

Per-delay wait queues are what make the schedule hold: RabbitMQ only expires messages at the head of a queue, so a single shared wait queue would let a parked 16s retry block a later 1s retry. With one queue per delay, a message's wait is bounded by its own tier — within a tier the skew is at most the jitter spread (jitter draws the actual delay from [0.5x, 1.5x] of the base, and the tier's queue-level TTL backstop is the jitter ceiling), and with jitter: false it is zero.

One consequence of the retry hop: retried deliveries re-enter the main queue via the default exchange, so raw.fields.routingKey — raw being the delivery on the handler's helpers record — is the queue name from the second attempt on. The routing key of the first delivery is preserved in the x-original-routing-key header.

Turn retries off ​

none is the default, so this is only worth writing down explicitly:

typescript
retry: { mode: "none" },

A RetryableError then behaves like a NonRetryableError. Use it when retries happen elsewhere — an outbox that replays from the source, or an upstream scheduler — because stacking two retry loops multiplies attempts.

Pick a mode ​

SituationModemaxRetries
Network blips, lock contentionimmediate-requeue3–5
Rate-limited downstreamttl-backoff5–10
Degraded service, recovers in secondsttl-backoff5
Idempotent and cheap to retryimmediate-requeue5–10
Retries owned upstream (outbox)none—

Use different policies for different work ​

Retry is a property of the queue, not the handler. To run two policies, use two queues:

typescript
const fastQueue = defineQueue("orders-fast", {
  deadLetter: { exchange: dlx },
  retry: { mode: "immediate-requeue", maxRetries: 3 },
});

const slowQueue = defineQueue("orders-slow", {
  deadLetter: { exchange: dlx },
  retry: { mode: "ttl-backoff", maxRetries: 8, initialDelayMs: 5_000 },
});

Inspect retry state ​

Diagnostic headers are stamped only on paths that republish the message — classic queues under immediate-requeue, and any queue under ttl-backoff.

HeaderMeaningSet on
x-delivery-countBroker-native attempt countQuorum queues, by RabbitMQ
x-retry-countWorker-managed attempt countRepublish paths only
x-last-errorMost recent failure message (first 1024 characters)Republish paths only
x-first-failure-timestampEpoch ms of the first failureRepublish paths only
x-original-routing-keyRouting key of the first deliveryRepublish paths only

Direct-nack paths — a NonRetryableError, a validation failure, a quorum queue exhausting immediate-requeue — do not republish, so the dead-lettered message arrives byte-identical to what the broker delivered, with no failure context. Error details are in the worker's logs instead.

If you need context on the message itself, use ttl-backoff (which always republishes), or set maxRetries: 1 so a single republish stamps the headers before the message is dead-lettered.

Know the edges of the retry path ​

  • A retry publish that times out or loses its channel requeues the original. When the retry copy's publish fails with PublishError timeout or channel-closed, the worker nacks the original with requeue: true instead of acking it. Its retry headers are unchanged, so the budget is intact, and nothing is dead-lettered for a broker hiccup. The log line is Publish for retry failed; requeueing the original for redelivery. After a timeout the copy may still have reached the wait queue, so the handler can run twice.
  • A retry copy the broker nacks dead-letters the original. A nacked PublishError is the broker refusing the copy — typically a wait queue at x-max-length with x-overflow: reject-publish — and it would refuse the next one too. Requeueing would loop: the original comes back with the same retry count, and on a classic queue nothing counts those redeliveries. The worker nacks it with requeue: false instead; the log line is Publish for retry was nacked by the broker; dead-lettering the original.
  • RPC requests never retry. A RetryableError from an RPC handler dead-letters the request even when its queue has a retry config: the caller waits on a timeoutMs far shorter than most backoffs, so a retry would re-run the handler for nobody.
  • Malformed retry headers count as zero. x-retry-count and x-delivery-count are read as non-negative integers; anything else — a string, a negative, a fraction — counts as 0 rather than bypassing the budget. A malformed x-first-failure-timestamp or x-original-routing-key is replaced, and a retry is only ever published to a wait queue the topology declares; anything else is dead-lettered with the reason logged.

Avoid the common traps ​

No dead-letter exchange. nack(requeue=false) with no deadLetter configured discards the message. The worker warns; the body is gone. Configure deadLetter if poison messages matter.

A dead-letter exchange with nothing bound to it. Same outcome, no warning: RabbitMQ discards a message routed to zero queues, while the worker logs a reassuring Sending message to DLQ. defineContract now rejects this at definition time — bind a queue to the DLX, or set externalConsumers: true on the deadLetter config when another service owns that queue.

Retrying non-idempotent work. Retries are delivery attempts, not exactly-once semantics. A handler that charges a card then fails will charge again. The library never sets AMQP's messageId for you, so a dedupe table keyed on it starts out keyed on undefined — set one yourself first, or use a business key from the payload. Delivery guarantees covers the id and the three ways to spend it.

Tuning maxRetries by intuition. The number that matters is how long the dependency actually takes to recover. That lives in your dead-letter queue and your metrics, not in your head.

Throwing instead of returning. A thrown error reaches the worker's safety net and is dead-lettered, having lost the classification that would have let it retry.

Where next ​

Released under the MIT License.