# Worker Error Handling, Retries and Backoff

How bunqueue treats a throwing processor: attempts, exponential backoff, per-job timeouts, permanent failure into the DLQ, and errors outside the processor.

Canonical: https://bunqueue.dev/guide/worker/errors/

---

import { Tabs, TabItem } from '@astrojs/starlight/components';

<div class="bq-wrap bq-hero">
  <span class="bq-eyebrow">guide · worker</span>
  <h1 class="bq-hero-h1 bq-bench-h1">When the processor <em>throws.</em></h1>
  <p class="bq-hero-sub">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.</p>
</div>

## Handle errors

Throwing inside the processor fails the current attempt; bunqueue retries with backoff (a growing wait between attempts) until `attempts` is exhausted:

<Tabs syncKey="lang">
<TabItem label="Bun">

```typescript
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);
});
```

</TabItem>
<TabItem label="Node.js / Deno">

```typescript
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);
});
```

</TabItem>
<TabItem label="Python">

```python
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)
```

</TabItem>
<TabItem label="PHP">

```php
$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);
});
```

</TabItem>
<TabItem label="Go">

```go
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])
})
```

</TabItem>
<TabItem label="Rust">

```rust
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(),
);
```

</TabItem>
<TabItem label="Elixir">

```elixir
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)
```

</TabItem>
</Tabs>

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](/guide/sdks/#worker-events).

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.

## Where to go next

| Guide | What it covers |
|---|---|
| [Worker](/guide/worker/) | Create a worker and process your first job |
| [Worker Concurrency and Batch Pulling](/guide/worker/concurrency/) | Run jobs in parallel and pull them in batches |
| [The Job Object Inside a Worker Processor](/guide/worker/job-object/) | Everything the processor receives and can do |
| [Worker Events](/guide/worker/events/) | completed, failed, stalled and the rest |
| [Worker Lifecycle](/guide/worker/lifecycle/) | Pause, resume and shut down without losing work |
| [Heartbeats, Stall Detection and Lock Ownership](/guide/worker/stalls/) | Heartbeats, stall recovery and lock ownership |
| [SandboxedWorker](/guide/worker/sandboxed/) | Experimental isolation for CPU-heavy handlers |
| [WorkerOptions Reference](/guide/worker/options/) | Every WorkerOptions field, with defaults |