Skip to content

Failures and the dead-letter queue

Nack, the retry budget, what lands in the dead-letter queue, how to read it, and the two ways to get a message back out.

Updated View as Markdown

A handler that throws is not an exceptional case, it is a design input. Queen’s failure path has three deliberate outcomes (redeliver, dead-letter, drop) and which one you get is decided by one counter and two queue options. This page walks the whole path, then the two ways back out.

Signalling failure

An ack carries a status. Four values reach the broker:

Status Effect on the cursor Retry budget Lease
completed (also success, acked, ok, or absent) Advances to this offset Untouched Released when the batch end is reached
failed Clamps below this offset Charged once, while budget remains Released
retry Clamps below this offset Untouched Released
dlq Clamps below this offset Bypassed entirely Held for the hand-off, then released

Anything else the broker does not recognise is treated as failed.

In the SDKs, a boolean maps onto the first two: true becomes completed, false becomes failed. The consume loop nacks automatically when the handler throws.

// Illustrative, not extracted from a test.
await client
  .queue('orders')
  .group('billing')
  .each()
  .consume(async msg => {
    // Throwing nacks this message: status 'failed'.
    await bill(msg.data)
  })

Two consequences of ack-as-cursor-commit are worth restating here, because they surprise people in exactly this situation:

  • A failure signal is never skipped by a later success in the same call. If one ack call marks offset 5 failed and offset 9 completed, the cursor clamps below 5. Offsets 6 to 9 redeliver. The JavaScript consume loop knows this and abandons the rest of the batch after a nack rather than pretending the later acks took effect.
  • retry is the budget-free option. It releases the lease and leaves the counter alone, which is what you want for “not my turn yet”: a dependency that is briefly down, a rate limit you are respecting. Use failed for “this message is wrong”.

The retry budget

The counter is batch_retry_count on the (partition, consumer group) row. It is:

  • charged once per explicit failed ack, while budget remains;
  • compared against the queue’s retryLimit, default 3;
  • reset to zero when a batch completes, and also when a message is dead-lettered, so the next batch starts fresh;
  • never charged by a lease expiry, or by a plain release, or by retry.

That last point is the one to design around. A handler that is slow rather than broken (it hangs, it is killed, the pod is evicted) loses its lease without consuming any budget. The span redelivers forever and never reaches the dead-letter queue. If you want a stuck message to end up somewhere visible, your handler has to decide it has failed and say so with failed or dlq; the broker will not decide it for you.

With the default retryLimit of 3, a message that is nacked with failed every time is redelivered after the first, second and third nack. The fourth failed ack finds the budget exhausted and takes the terminal branch.

The terminal branch

What “exhausted” leads to depends on two options, both defaulting to true: deadLetterQueue and dlqAfterMaxRetries. The broker enables dead-lettering when either is true, so a queue that was never configured always dead-letters.

Dead-letter enabled. The broker snapshots the poison frame (message id, transaction id, payload, the error text from the nack, and the retry count consumed) into queen.log_dlq, advances the cursor past that one frame, resets the retry and redelivery state, and releases the lease. The ack response item comes back with dlq: true. Consumption then continues with the next message; one poison message does not wedge the partition.

The snapshot is decoupled by design: queen.log_dlq has no foreign key into the segment tables, so a dead-letter row survives retention deleting the segment it came from. Retention never purges the dead-letter queue. It grows until you delete from it.

Dead-letter disabled (both options explicitly false). The exhausted nack drops the message: the cursor advances past it, the counter resets, the lease is released, and the ack response carries dropped: true. The payload is gone. This is the right setting only when losing a malformed message is preferable to storing it.

Forcing it immediately. Acking with status dlq skips the budget entirely and dead-letters on the first try. Use it when the handler can tell that retrying is pointless: a schema violation, a reference to an entity that will never exist.

// Illustrative, not extracted from a test.
await client.ack(msg, 'dlq', { error: 'unknown payload version' })

Reading the dead-letter queue

GET /api/v1/dlq?queue=orders&consumerGroup=billing&limit=100&offset=0

queue and consumerGroup are both optional filters; limit defaults to 100 and offset to 0. Rows come back newest failure first, alongside a pagination object and a total count of the rows in this page.

{
  "messages": [
    {
      "id": "b2f0...",
      "transactionId": "order:8812:v3",
      "partitionId": "6f1c...",
      "queue": "orders",
      "partition": "8812",
      "consumerGroup": "billing",
      "errorMessage": "unknown payload version",
      "retryCount": 3,
      "data": {"orderId": 8812},
      "producerSub": null,
      "createdAt": "2026-07-30T09:41:02.517Z",
      "failedAt": "2026-07-30T09:41:02.517Z"
    }
  ],
  "pagination": {"limit": 100, "offset": 0},
  "total": 1
}

Three fields need reading carefully:

  • createdAt is the failure time, not the enqueue time. The original per-message enqueue time is not recoverable from a compressed segment blob once the row is a snapshot, so both timestamps report failed_at. Use failedAt and treat createdAt as its alias here.
  • producerSub is always null on a dead-letter row. The authenticated producer identity is not carried into the snapshot.
  • retryCount is the budget consumed at the moment of dead-lettering, snapshotted, not a live counter.

If the queue has at-rest encryption enabled, the stored snapshot is the encryption envelope; the read path decrypts it and flags the row isEncrypted: true so you can distinguish a decrypted payload from one that was never encrypted. A payload that fails to decrypt (wrong or rotated key) is returned exactly as stored rather than replaced with an invented value.

The SDKs wrap the same call:

// Illustrative, not extracted from a test.
const { messages, total } = await client.queue('orders').dlq('billing').limit(50).get()

Getting a message back out

The replay endpoint

POST /api/v1/messages/{partitionId}/{transactionId}/retry

This re-publishes the dead-letter snapshot to its own queue and partition, then removes the dead-letter row. Its semantics are specific:

  • It exists only for dead-lettered addresses. A live message has nothing to replay and the call answers HTTP 404 with No dead-letter row for this address.
  • The replayed message gets a fresh transactionId, minted from its new message id. Reusing the original would be seen by the deduplication window as a duplicate and silently dropped, so the replay would appear to succeed and write nothing.
  • The dead-letter row is deleted only after the push is accepted. If the push fails, the message stays in the dead-letter queue and the response says so.
  • If the cleanup delete fails after a successful push, the response reports replayed: true with dlqRowRemoved: false. The message now exists twice, live and dead-lettered, and you must delete the row yourself. It is reported rather than swallowed.
  • On an encrypted queue the snapshot is decrypted before the re-push, so the payload is not double-encrypted.

A success looks like:

{
  "success": true,
  "queue": "orders",
  "partition": "8812",
  "replayedAs": {"index":0,"message_id":"0199...","transaction_id":"0199...","queueName":"orders","status":"queued"},
  "dlqRowRemoved": true
}

The manual path

The replay endpoint is a convenience over three calls you can make yourself, and doing it by hand is the right choice when you want to fix the payload before republishing:

  1. Read the rows with GET /api/v1/dlq and pick the ones to reprocess.

  2. Push a corrected payload with a fresh transactionId: a new message, on the queue and partition of your choosing.

  3. Delete the dead-letter row with DELETE /api/v1/messages/{partitionId}/{transactionId}.

The delete only ever removes dead-letter rows: live messages live in immutable segments and cannot be deleted at all. An address with no dead-letter row answers HTTP 404 with No dead-letter row for this address rather than HTTP 200 with a false success, so a caller that ignores the body cannot read a no-op as a deletion.

What to expect

Situation Result
Handler throws, budget remains Cursor clamps below the message, lease released, budget charged once, message redelivers
Handler throws, budget exhausted, dead-lettering on Snapshot filed, cursor advances past the message, budget reset, dlq: true on the ack item
Handler throws, budget exhausted, both DLQ options false Message dropped, cursor advances, dropped: true
Ack with dlq Dead-lettered immediately, budget ignored
Ack with retry Redelivers, budget untouched
Handler hangs and the lease expires Redelivers forever; budget never charged; never reaches the DLQ
Retention window passes on the queue Segments deleted; dead-letter rows kept
Replay endpoint on a live message HTTP 404
Replay endpoint, push succeeds, delete fails replayed: true, dlqRowRemoved: false (delete the row yourself)

Next

Navigation

Type to search…

↑↓ navigate↵ selectEsc close