Core concepts
Retries and backoff
Failures are normal: networks drop, partners rate-limit, deploys restart workers. Halyard retries failed attempts with exponential backoff and keeps what still fails in a dead-letter queue.
On this page
How retries work
When an attempt fails, Halyard checks two things: is the error retryable, and are attempts left? If both are true the job returns to queued with a run_at in the future. Otherwise it moves to failed and into the dead-letter queue.
A retry is the same job with the same ID and payload. Each attempt is recorded with its own start time, duration, logs and error, so you can see exactly how a job got where it is.
Exponential backoff
The delay doubles after each failure, so a brief outage costs a few quick retries while a long one doesn't hammer the service that is down. The default policy starts at 10 seconds and caps at one hour.
delay(n) = min(initial × factor^(n − 1), max_delay)actual = delay(n) × random(1 − jitter, 1 + jitter) # defaults: initial = 10s, factor = 2, max_delay = 1h, jitter = 0.25Delay before each retry
base ±25% jitter- Retry 110s (7.5s–12.5s)
- Retry 220s (15s–25s)
- Retry 340s (30s–50s)
- Retry 41m 20s (1m–1m 40s)
- Retry 52m 40s (2m–3m 20s)
- Retry 65m 20s (4m–6m 40s)
- Retry 710m 40s (8m–13m 20s)
| Attempt | Waits before it | Jitter window | Time since first attempt |
|---|---|---|---|
| 1 | — | — | 0s |
| 2 | 10s | 7.5s–12.5s | ≈ 10s |
| 3 | 20s | 15s–25s | ≈ 30s |
| 4 | 40s | 30s–50s | ≈ 1m 10s |
| 5 | 1m 20s | 1m–1m 40s | ≈ 2m 30s |
| 6 | 2m 40s | 2m–3m 20s | ≈ 5m 10s |
| 7 | 5m 20s | 4m–6m 40s | ≈ 10m 30s |
| 8 | 10m 40s | 8m–13m 20s | ≈ 21m 10s |
Why jitter
If a dependency fails for a minute, every job that hit it fails at about the same moment. Without jitter they would all retry at the same moment too, and knock it over again. Jitter spreads each delay randomly by ±25%, so retries arrive as a smooth trickle instead of a wave.
Maximum attempts
max_attempts counts every attempt, including the first. The default is 8; the allowed range is 1 to 50. Set it to 1 to never retry. Attempts lost because a worker disappeared (no heartbeat) are retried without counting, up to 5 times, because the handler may never have run.
Which errors retry
Any error a handler throws is retried unless you mark it as permanent.
| Outcome | Retries | Notes |
|---|---|---|
| Handler threw / returned an error | Yes | The default for any exception. |
| Attempt timed out | Yes | Lease expired after the queue's timeout. |
| Worker lost (no heartbeat) | Yes | Does not count toward max_attempts. |
| NonRetryableError | No | Goes straight to failed and the dead-letter queue. |
| Payload failed validation | No | Rejected at creation with 422, never queued. |
| Job cancelled | No | Moves to cancelled; no further attempts. |
Throw a non-retryable error when trying again can't possibly help, such as a missing record or invalid input:
import { NonRetryableError } from "@halyard/node"; worker.task("send-welcome-email", async (job) => { const user = await db.users.find(job.payload.user_id); if (!user) { // Retrying won't make the user exist throw new NonRetryableError(`User ${job.payload.user_id} not found`); } await sendEmail({ to: user.email, template: "welcome" });});from halyard import NonRetryableError @worker.task("send-welcome-email")def send_welcome_email(job): user = db.users.find(job.payload["user_id"]) if user is None: # Retrying won't make the user exist raise NonRetryableError(f"User {job.payload['user_id']} not found") send_email(to=user.email, template="welcome")w.Handle("send-welcome-email", func(ctx context.Context, job *halyard.Job) (any, error) { user, err := db.FindUser(ctx, job.Payload["user_id"].(string)) if errors.Is(err, sql.ErrNoRows) { // Retrying won't make the user exist return nil, halyard.Permanent(err) } if err != nil { return nil, err // retried with backoff } return nil, sendEmail(ctx, user.Email, "welcome")})Custom retry policies
Policies resolve from the most specific level: a value on the job wins over the task, the task over the queue, and the queue over the project default. Set a policy on the task when it reflects how that work behaves, such as a partner API with strict rate limits.
worker.task( "sync-crm-contact", { retry: { max_attempts: 12, initial: "30s", factor: 3, max_delay: "6h", jitter: 0.3, }, }, async (job) => { const res = await crm.upsertContact(job.payload); if (res.status === 429) { // Honour the partner's Retry-After instead of the policy throw job.retryIn(res.headers["retry-after"] + "s"); } },);@worker.task( "sync-crm-contact", retry={ "max_attempts": 12, "initial": "30s", "factor": 3, "max_delay": "6h", "jitter": 0.3, },)def sync_crm_contact(job): res = crm.upsert_contact(job.payload) if res.status_code == 429: # Honour the partner's Retry-After instead of the policy raise job.retry_in(f"{res.headers['Retry-After']}s")job.retryIn overrides the computed delay for this one retry and still counts as an attempt. Override a single job when you create it:
await halyard.jobs.create({ queue: "media", task: "resize-image", payload: { image_id: "img_2Lq8" }, retry: { max_attempts: 3 }, // overrides task and queue defaults});Dead-letter queue
Jobs that fail for good are kept in the dead-letter queue of their queue for 30 days with their payload, every attempt and the last error. Halyard publishes a job.failed event for each one, so you can alert on it with a subscription.
Once you've fixed the cause, retry dead letters one at a time or in bulk. The job keeps its ID and history and starts again with a full set of attempts.
# List dead letters on the emails queuecurl "https://api.halyard.example/v3/jobs?queue=emails&status=failed&dead_letter=true" \ -H "Authorization: Bearer hy_test_51Hq8Zx2rTmV9cKpL3d" # Retry one with a fresh set of attemptscurl -X POST https://api.halyard.example/v3/jobs/job_8Vn4Hc2Lp6/retry \ -H "Authorization: Bearer hy_test_51Hq8Zx2rTmV9cKpL3d"const dead = await halyard.jobs.list({ queue: "emails", status: "failed", dead_letter: true,}); for (const job of dead.data) { await halyard.jobs.retry(job.id); // new attempts, same job ID}dead = halyard.jobs.list(queue="emails", status="failed", dead_letter=True) for job in dead.data: halyard.jobs.retry(job.id) # new attempts, same job ID