# Retries

> Give jobs an attempt budget and a persisted backoff policy that PostgreSQL applies consistently.

When a handler throws, the job usually is not finished — it has failed one attempt. PostgreSQL
records the failure evidence, checks whether budget remains, and moves the job back to
`scheduled` with a wake time before returning it to `ready`, or to a terminal failed outcome. This page covers who decides,
when the next attempt runs, and what survives in between.

## Set an attempt budget

`EnqueueOptions.maxAttempts` limits how many logical attempts one job may use. Each failure —
a thrown error or a crashed worker's expired lease — spends one. When the budget is gone,
the database deletes the runtime row and writes an immutable failed outcome.

<Tabs items={["TypeScript", "Python", "Go"]}>
  <Tab value="TypeScript">
    ```ts
    await queue.enqueue("provider.sync", { accountId }, { maxAttempts: 5 });
    ```
  </Tab>
  <Tab value="Python">
    ```python
    queue.enqueue(
        "provider.sync",
        {"accountId": account_id},
        EnqueueOptions(max_attempts=5),
    )
    ```
  </Tab>
  <Tab value="Go">
    ```go
    _, err := queue.Enqueue(ctx, "provider.sync", map[string]any{
        "accountId": accountID,
    }, workhorse.EnqueueOptions{MaxAttempts: 5})
    ```
  </Tab>
</Tabs>

The budget check happens in SQL, always. A worker override can change retry timing, but no
client configuration can create extra attempts. You cannot accidentally configure infinite
retries.

## Persist the delay policy with the job

Retrying instantly is usually wrong: if a provider is down, hammering it just burns attempts.
`EnqueueOptions.retryPolicy` stores a backoff shape with the immutable job definition, and
PostgreSQL validates it at enqueue time. Three shapes exist:

<Tabs items={["TypeScript", "Python", "Go"]}>
  <Tab value="TypeScript">
    ```ts
    const jitter = { type: "decorrelated-jitter", baseDelayMs: 1_000, maxDelayMs: 60_000 };
    await queue.enqueue("provider.sync", { accountId }, { retryPolicy: jitter, maxAttempts: 5 });
    ```
  </Tab>
  <Tab value="Python">
    ```python
    jitter = {"type": "decorrelated-jitter", "baseDelayMs": 1_000, "maxDelayMs": 60_000}
    queue.enqueue(
        "provider.sync", {"accountId": account_id},
        EnqueueOptions(retry_policy=jitter, max_attempts=5),
    )
    ```
  </Tab>
  <Tab value="Go">
    ```go
    jitter := map[string]any{"type": "decorrelated-jitter", "baseDelayMs": 1_000, "maxDelayMs": 60_000}
    _, err := queue.Enqueue(ctx, "provider.sync", map[string]any{
        "accountId": accountID,
    }, workhorse.EnqueueOptions{RetryPolicy: jitter, MaxAttempts: 5})
    ```
  </Tab>
</Tabs>

Fixed and exponential policies use the same JSON field names in every SDK because PostgreSQL owns
their validation and execution.

Prefer decorrelated jitter for anything that talks to an external service. When a thousand
jobs fail together, jitter spreads their wake times so the retry wave cannot knock the
recovering service over again.

Workhorse derives the jitter from stable job state — identity and attempt number — rather
than fresh randomness. Replaying the same transition therefore selects the same delay, which
keeps retry timing explainable after the fact.

## One policy for both failure paths

A job can need a retry for two different reasons: its handler threw, or its worker died and
the lease expired. An explicit policy applies to both paths, so a crashed process does not
get different backoff behavior from a failing one.

Jobs without a policy keep older compatibility behavior, which differs between the two paths:
a thrown error gets a legacy randomized backoff, and an expired lease retries immediately.
Set a persisted policy whenever consistent timing matters.

Overrides exist for the cases that need them. `Queue.fail` accepts an explicit delay, and
`WorkerOptions.retryDelayMs` takes a number or `(attempt, job) => number | undefined`. An
explicit value wins — including an explicit `0` for "retry immediately" — and `undefined`
defers to the persisted policy or the compatibility default.

## What survives another attempt

The job ID, payload, tags, policy, and every saved [checkpoint](/docs/durable-execution)
remain. PostgreSQL increments the attempt counter, and the next claim assigns a new fence
token so a stale previous owner cannot write over the new attempt.

A retry is the same job having another go. A redrive is different: it happens only after
terminal failure and creates a [new job identity](/docs/dead-letters).

`JobSnapshot.retryPolicy` shows the normalized policy, and timeline events record each
selected delay with its source. When an operator asks why a job is still `scheduled`, the
evidence answers.

## Next

- [Durable execution](/docs/durable-execution) — reuse completed work on another attempt
- [Dead letters](/docs/dead-letters) — create a fresh job after retries end
- [Deadlines and timeouts](/docs/deadlines) — end work before the attempt budget is gone

---

Exact policy shapes, bounds, precedence, and retry transitions:
[architecture reference](https://github.com/stablemates/workhorse/blob/main/docs/architecture.md#retry-and-recovery).
