Dead letters
A job lands in the dead letter queue when its retries are used up, when it raises a non-retryable error, or when its lease expired with no retry left (LeaseExpired). An SDK failure keeps what the worker reported: error_class, error_message, the stack frames and the raw trace, shown on the console's dead-letter page.
From the console or the API
| Route | Role | What it does |
|---|---|---|
GET /api/v1/dlq?queue&cursor&limit | viewer | Page through the dead letters, newest first |
POST /api/v1/dlq/redrive {jids} | operator | Put them back on their queues |
POST /api/v1/dlq/purge {jids} or {queue, all: true} | operator | Delete them |
GET /api/v1/export/dlq?format=csv|json&queue&limit | viewer | Download them (at most 10,000 entries and 10 MB of JSON; CSV has no payloads) |
Bulk calls answer {succeeded, missing, unredrivable, conflicted}, and are audited as dlq.redrive and dlq.purge. Payload values with secret-looking keys are masked in every answer, and sealed fields show as encrypted.


Retention
Dead letters are kept for ever unless JOOBQUI_DLQ_RETENTION_DAYS is set (1 to 36500); see Backups and retention.
In code
Every SDK's non-retryable error sends a job to the dead letter queue at once:
when "conf.retry"
raise "transient failure on attempt #{attempt}" if attempt < 3
Conf.report("retry", probe, {"attempt" => Conf.any(attempt)})
when "conf.dead"
Conf.report("dead_attempt", probe)
raise JoobQ::NonRetryableError.new("permanent failure #{probe}")@job("conf.retry", queue=QUEUE, retries=3)
def retry_job(payload, ctx):
if ctx.attempt < 3:
raise RuntimeError(f"transient failure on attempt {ctx.attempt}")
report(ctx, "retry", payload, attempt=ctx.attempt)
@job("conf.dead", queue=QUEUE)
def dead_job(payload, ctx):
report(ctx, "dead_attempt", payload)
raise NonRetryableError(f"permanent failure {payload['probe']}")defineJob<App>({ name: 'conf.retry', queue: QUEUE, retries: 3, perform: async (p, ctx) => {
if (ctx.attempt < 3) throw new Error(`transient failure on attempt ${ctx.attempt}`)
return report(ctx, 'retry', p, { attempt: ctx.attempt })
} }),
defineJob<App>({ name: 'conf.dead', queue: QUEUE, perform: async (p, ctx) => {
await report(ctx, 'dead_attempt', p)
throw new NonRetryableError(`permanent failure ${p.probe}`)
} }),class RetryJob
include JoobQ::Job
job_name "conf.retry"
queue QUEUE
retries 3
def self.perform(p, ctx)
raise "transient failure on attempt #{ctx.attempt}" if ctx.attempt < 3
report(ctx, "retry", p, attempt: ctx.attempt)
end
end
class DeadJob
include JoobQ::Job
job_name "conf.dead"
queue QUEUE
def self.perform(p, ctx)
report(ctx, "dead_attempt", p)
raise JoobQ::NonRetryableError, "permanent failure #{p['probe']}"
end
endjoobq.DefineJob[*App]("conf.retry", joobq.JobOptions{Queue: queue, Retries: 3}, func(p map[string]any, ec *EC) (any, error) {
if ec.Task.Attempt < 3 {
return nil, joobq.Errorf("transient failure on attempt %d", ec.Task.Attempt)
}
return report(ec, "retry", p, map[string]any{"attempt": ec.Task.Attempt})
}),
joobq.DefineJob[*App]("conf.dead", opts, func(p map[string]any, ec *EC) (any, error) {
_, _ = report(ec, "dead_attempt", p, nil)
return nil, joobq.NewNonRetryableError(fmt.Sprintf("permanent failure %v", p["probe"]))
}),JobDef::new("conf.retry", queue.clone(), |p: Value, ctx: &Ctx| {
if ctx.attempt() < 3 {
return Err(Error::new(format!("transient failure on attempt {}", ctx.attempt())));
}
report(ctx, "retry", &p, json!({"attempt": ctx.attempt()}))
}).retries(3),
JobDef::new("conf.dead", queue.clone(), |p: Value, ctx: &Ctx| report(ctx, "dead_attempt", &p, json!({})).and(Err(Error::non_retryable(format!("permanent failure {}", p["probe"].as_str().unwrap_or("")))))),JobDefinition.<App>of("conf.retry", QUEUE, 3, (p, ctx) -> {
if (ctx.attempt() < 3) throw new IllegalStateException("transient failure on attempt " + ctx.attempt());
return report(ctx, "retry", p, map("attempt", ctx.attempt()));
}),
JobDefinition.<App>of("conf.dead", QUEUE, 3, (p, ctx) -> {
report(ctx, "dead_attempt", p, Map.of());
throw new NonRetryableException("permanent failure " + m(p).get("probe"));
}),final class RetryJob extends ConfJob
{
public static int $retries = 3;
public static function jobName(): string { return 'conf.retry'; }
public static function perform(array $p, ExecutionContext $ctx): mixed
{
$attempt = (int)$ctx->task['attempt'];
if ($attempt < 3) {
throw new RuntimeException("transient failure on attempt {$attempt}");
}
return report($ctx, 'retry', $p, ['attempt' => $attempt]);
}
}
final class DeadJob extends ConfJob
{
public static function jobName(): string { return 'conf.dead'; }
public static function perform(array $p, ExecutionContext $ctx): mixed
{
report($ctx, 'dead_attempt', $p);
throw new NonRetryableException("permanent failure {$p['probe']}");
}
}JobDefinition<App>.Define("conf.retry", Queue, 3, (p, ctx) =>
{
if (ctx.Attempt < 3) throw new InvalidOperationException($"transient failure on attempt {ctx.Attempt}");
return Report(ctx, "retry", p, new() { ["attempt"] = ctx.Attempt });
}),
JobDefinition<App>.Define("conf.dead", Queue, 3, async (p, ctx) =>
{
await Report(ctx, "dead_attempt", p);
throw new NonRetryableException($"permanent failure {p?["probe"]}");
}),Tested against a real server in every language shown.