Skip to content

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 ​

RouteRoleWhat it does
GET /api/v1/dlq?queue&cursor&limitviewerPage through the dead letters, newest first
POST /api/v1/dlq/redrive {jids}operatorPut them back on their queues
POST /api/v1/dlq/purge {jids} or {queue, all: true}operatorDelete them
GET /api/v1/export/dlq?format=csv|json&queue&limitviewerDownload 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.

The console's Dead letters page: a list of failed jobs with their queue, error class, error message and when they failed.The console's Dead letters page: a list of failed jobs with their queue, error class, error message and when they failed.
The dead-letter page: each job's error class and message; a row opens the stack trace (mock data).

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:

crystal
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}")
python
@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']}")
ts
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}`)
} }),
ruby
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
end
go
joobq.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"]))
}),
rust
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("")))))),
java
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"));
}),
php
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']}");
    }
}
csharp
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.

JoobQ Core is MIT licensed. JoobQ Pro needs a licence key.