Skip to content

Route to the DLQ

The dead-letter queue ({chasqui:<queue>}:dlq) is just another Redis Stream. The engine routes entries there in five distinct cases. Knowing which is firing is the first step in any debugging session.

When you know a job will never succeed (bad input, blocked address, payload schema mismatch), throw UnrecoverableError to bypass the retry budget.

import { Worker, UnrecoverableError } from "chasquimq";
new Worker("emails", async (job) => {
if (!job.data.to.includes("@")) {
throw new UnrecoverableError(`invalid address: ${job.data.to}`);
}
await sendEmail(job.data);
}, { connection });

The native binding maps any error whose class name is UnrecoverableError to HandlerError::unrecoverable(...) on the Rust side. Subclasses work — class PoisonPill extends UnrecoverableError {} (Node) or class PoisonPill(UnrecoverableError) (Python) get the same routing.

The engine writes a reason field on every DLQ entry. chasqui dlq peek renders a histogram. The five values:

ReasonWhen it firesHandler ran?
retries_exhaustedHandler returned Err / threw, attempt + 1 >= max_attemptsYes
unrecoverableHandler threw UnrecoverableErrorYes (once)
decode_failReader couldn’t msgpack-decode the entry payloadNo
malformedStream entry missing required fields, or unparseableNo
oversizePayload exceeded ConsumerConfig::max_payload_bytesNo

The reader-side reasons (decode_fail, malformed, oversize) carry attempt: 0 because the handler never ran. Useful when triaging: a high decode_fail rate means a producer is writing in a different schema, not a handler bug.

Terminal window
chasqui dlq peek emails --limit 50

Renders the histogram plus the most recent entries with source_id, reason, attempt, dispatch name, and the raw payload bytes.

In code:

const entries = await queue.peekDlq(50);
for (const e of entries) {
console.log(e.dlqId, e.reason, e.detail);
}

The DLQ stream is capped via ConsumerConfig::dlq_max_stream_len (default 100,000). The engine uses XADD MAXLEN ~ N so the cap is approximate — a runaway error rate may overshoot temporarily but won’t grow unboundedly.

  • Panics route to DLQ but with DlqReason::Panic on the metrics path. A handler that throws an uncaught exception does not retry. Treat it as a code bug.
  • UnrecoverableError is per-handler-call. If the worker crashes mid-handler before the engine sees the throw, idle-pending CLAIM re-delivers the entry on the next read and the handler runs again.
  • The DLQ keeps the dispatch name. When Queue.add(name, data) set n on the wire, the DLQ entry preserves it. chasqui dlq peek and the metrics path surface it for filtering.

For the architecture: DLQ and recovery. For replay: Replay the DLQ.