- Docs
- Worker
- Errors & Retries
When the processor throws.
A failed attempt is not a lost job. bunqueue counts the attempt, waits a widening backoff, tries again, and only after the budget runs out does the job become permanently dead.
Handle errors
Section titled “Handle errors”Throwing inside the processor fails the current attempt; bunqueue retries with backoff (a growing wait between attempts) until attempts is exhausted:
const worker = new Worker('queue', async (job) => { await riskyOperation(); // Just let errors throw, retries are automatic}, { embedded: true });
worker.on('failed', (job, error) => { console.warn(`Attempt ${job.attemptsMade + 1} failed`, error);});const worker = new Worker('queue', async (job) => { await riskyOperation(); // Just let errors throw, retries are automatic}, { embedded: false });
worker.on('failed', (job, error) => { console.warn(`Attempt ${job.attemptsMade + 1} failed`, error);});def process(job): risky_operation() # Just let exceptions raise, retries are automatic
worker = Worker("queue", process)
def on_failed(job, error): final_attempt = job.attempts + 1 >= job.max_attempts if final_attempt: alert_ops(job, error)
worker.on("failed", on_failed)$worker = new Worker('queue', function (Bunqueue\Job $job) { riskyOperation(); // Just let exceptions throw, retries are automatic});
$worker->on('failed', function ($job, $error) { // This event reports every failed attempt; inspect the DLQ for terminal jobs. logAttemptFailure($job, $error);});worker := bunqueue.NewWorker("queue", func(job *bunqueue.Job) (any, error) { return nil, riskyOperation() // Return an error, retries are automatic}, bunqueue.WorkerOptions{})
worker.On("failed", func(args ...any) { job := args[0].(*bunqueue.Job) // This event reports every failed attempt; inspect the DLQ for terminal jobs. logAttemptFailure(job, args[1])})use bunqueue_client::{ProcessError, Worker, WorkerOptions};
let worker = Worker::new( "queue", |job| { risky_operation(&job) .map_err(|error| ProcessError::retryable(error.to_string())) // ProcessError::unrecoverable(...) skips retries -> DLQ }, WorkerOptions::default(),);worker = Bunqueue.Worker.new("queue", fn job -> # Return {:error, reason} (or raise), retries are automatic. # Raising Bunqueue.UnrecoverableError skips retries -> DLQ. risky_operation(job) end)The failed listener fires after each broker-confirmed failed attempt, not only
after retry exhaustion. The job snapshot was pulled before that failure, so the
attempt that just failed is attemptsMade + 1 (both TypeScript packages),
attempts + 1 (Python). PHP and Go do not expose maxAttempts on their Job view;
use the queue’s DLQ operations for authoritative terminal-failure alerts. Rust
and Elixir record per-job outcomes in normal processor control flow and expose
transport/retry failures through telemetry; see the
SDK guide.
A processing timeout can win while user code is still running. In that race,
the broker has already recorded the failed attempt. Both TypeScript Workers treat the
late processor result or exception as an acknowledged no-op: it emits neither
a second completed/failed event nor a Worker error, and a newer retry lease
remains free to finish normally.
Postpone a job with DelayedError
Section titled “Postpone a job with DelayedError”Throw DelayedError from a TypeScript Worker processor when a job cannot run
yet but has not failed. The Worker moves the job back to delayed: the attempt
is not counted and no failed event fires.
import { DelayedError, Queue, Worker } from 'bunqueue/client';
const queue = new Queue('reports', { embedded: true });
const worker = new Worker('reports', async (job) => { if (!(await upstreamReady())) throw new DelayedError('upstream not ready'); return buildReport(job.data);}, { embedded: true });
// Postponed by at most 5 seconds, not by the 60-second base delay.await queue.add('daily', { day: '2026-10-01' }, { backoff: { type: 'fixed', delay: 60_000, maxDelay: 5_000 },});The wait is the job’s base backoff (the numeric backoff, or backoff.delay
for the object form) with no exponential growth and no jitter; a base of 0 (or
a negative one) falls back to 1000 ms. It is then capped at backoff.maxDelay,
or at 1 hour when no cap is set. Embedded and TCP Workers compute the same wait.
The wait is never zero. maxDelay: 0 makes failed attempts retry immediately,
but it does not apply to DelayedError: the job is not failing and no attempt
is counted, so a zero wait would let a processor that keeps throwing
DelayedError run the job again in a tight loop. A job with maxDelay: 0 waits
its base backoff, capped at 1 hour.
Where to go next
Section titled “Where to go next”| Guide | What it covers |
|---|---|
| Worker | Create a worker and process your first job |
| Worker Concurrency and Batch Pulling | Run jobs in parallel and pull them in batches |
| The Job Object Inside a Worker Processor | Everything the processor receives and can do |
| Worker Events | completed, failed, stalled and the rest |
| Worker Lifecycle | Pause, resume and shut down without losing work |
| Heartbeats, Stall Detection and Lock Ownership | Heartbeats, stall recovery and lock ownership |
| SandboxedWorker | Experimental isolation for CPU-heavy handlers |
| WorkerOptions Reference | Every WorkerOptions field, with defaults |