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
failedand offset 9completed, 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. retryis 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. Usefailedfor “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
failedack, 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=0queue 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:
createdAtis 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 reportfailed_at. UsefailedAtand treatcreatedAtas its alias here.producerSubis always null on a dead-letter row. The authenticated producer identity is not carried into the snapshot.retryCountis 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}/retryThis 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: truewithdlqRowRemoved: 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:
-
Read the rows with
GET /api/v1/dlqand pick the ones to reprocess. -
Push a corrected payload with a fresh
transactionId: a new message, on the queue and partition of your choosing. -
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) |