---
title: "Failures and the dead-letter queue"
description: "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."
---

> Documentation Index
> Fetch the complete documentation index at: https://queenmq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Failures and the dead-letter queue

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.

```js
// 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.

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

## Reading the dead-letter queue

```http
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.

> **Caution**
>
> Those four are the only parameters the handler reads. The JavaScript SDK's DLQ builder also sends `partition`, `from` and `to`; the broker ignores them, so a filtered call returns the unfiltered page. Filter on the client side, or narrow with `queue` and `consumerGroup`.

```json
{
  "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:

```js
// 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

```http
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:

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

> **Caution**
>
> The Go SDK's `Admin.RetryMessage` does not call this route: it returns a hard-coded error saying the endpoint is not implemented server-side, referencing the retired C++ broker's route file. The JavaScript (`Admin.retryMessage`) and Python (`Admin.retry_message`) clients do call it. From Go, issue the `POST` yourself or use the manual path below.

### 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.

> **Note**
>
> The JavaScript and Python `Admin` clients also expose a `moveMessageToDLQ` helper that posts to `/api/v1/messages/{partitionId}/{transactionId}/dlq`. The broker registers no such route, so the call is answered by the catch-all with HTTP 404 `{"error":"Not Found","code":"no_such_route"}`. To force a message into the dead-letter queue, ack it with status `dlq`.

## 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

- [Errors and backpressure](/use/errors) — Status codes, the 429 contract, and which failures a client should not retry.
- [Replaying live messages](/use/examples/replay) — Moving a group's cursor backwards, which the DLQ path cannot do.

Source: https://queenmq.com/use/examples/dlq/index.mdx
