Retries and failures¶
potatoq delivers each task at least once. A task is removed from the broker only after it finishes, so a deploy, an OOM kill or a crashed machine never loses it. The flip side is that tasks must be idempotent: occasionally a task runs twice.
Retrying¶
Retries are opt-in
A task that raises fails: it's recorded as FAILURE and dead-lettered, and it
is not retried unless you ask for it, per task or
for every task. This is Celery's behaviour. Dramatiq and
Sidekiq retry every exception by default; to get that, set task_autoretry_for.
Retry from inside the task:
@app.task(bind=True, max_retries=5)
def charge(self, order_id):
try:
gateway.charge(order_id)
except gateway.Timeout as exc:
raise self.retry(exc=exc) # backoff: ~10 s, 20 s, 40 s ... max 10 min
Or let potatoq do it for some exceptions:
@app.task(autoretry_for=(gateway.Timeout,), dont_autoretry_for=(gateway.CardDeclined,), max_retries=5)
def charge(order_id):
gateway.charge(order_id)
- Retries are scheduled by the broker; nothing waits in worker memory.
- When retries run out, the task fails and is dead-lettered:
retry(exc=exc)andautoretry_forre-raise the exception, a bareretry()raisesMaxRetriesExceededError. - A retry keeps the task id, so
AsyncResultfollows it to its final state.
Retrying every task¶
task_autoretry_for gives every task an autoretry_for:
A task's own autoretry_for replaces it, and autoretry_for=() turns retries off for
one task (one that charges a card, say). dont_autoretry_for still applies. The worker's
banner shows the policy:
Only retry tasks that are safe to run again: a task that sends an email and then raises sends the email again on every retry.
How many times¶
max_retries counts retries after the first run: with the default of 10, a task runs at
most 11 times, over about 50 minutes. Set it per task, or for every task with task_max_retries
(None retries without a limit):
@app.task(autoretry_for=(Exception,), max_retries=10)
def sync_inventory(): ...
app.conf.task_max_retries = 5 # the default for tasks that don't set max_retries
self.retry(max_retries=...) overrides both for that one call.
Backoff¶
The delay before a retry grows exponentially:
With the defaults (task_retry_backoff = 10, task_retry_backoff_max = 600), the
delays are about 10 s, 20 s, 40 s, 80 s, 160 s, 320 s, then 10 minutes for every retry
after that: about 50 minutes for the 10 default retries, enough to ride out a
third-party outage, a database failover or a deploy. Change them per task or for every task:
@app.task(autoretry_for=(Exception,), retry_backoff=60, retry_backoff_max=3600)
def import_feed(): ... # 1 min, 2 min, 4 min ... capped at 1 hour
app.conf.task_retry_backoff = 5
app.conf.task_retry_backoff_max = 300
retry_backoff=True means a factor of 1 (1 s, 2 s, 4 s …), as in Celery. A fixed delay
replaces the backoff in three cases: retry_backoff=False (then default_retry_delay,
180 s by default via task_default_retry_delay, is used every time), a
default_retry_delay set on a task that doesn't set retry_backoff, and an explicit
self.retry(countdown=...) or eta=.
Jitter¶
Tasks that failed together, because a dependency went down, would otherwise retry together and hit it all at once again. Jitter spreads them out: each delay is a random value between half the computed delay and the full delay ("equal jitter"). The first retry of a task with the default backoff waits between 5 and 10 seconds, the second between 10 and 20, and so on. A retry never happens immediately, unlike Celery's full jitter (anywhere from 0 to the delay).
Turn it off with retry_jitter=False on a task, or task_retry_jitter = False for
every task, to get the exact delays.
Settings at a glance¶
| Task option | App setting | Default | |
|---|---|---|---|
autoretry_for |
task_autoretry_for |
() |
Exceptions that retry automatically |
dont_autoretry_for |
() |
Exceptions that never do | |
max_retries |
task_max_retries |
10 |
Retries after the first run; None (app setting): no limit |
retry_backoff |
task_retry_backoff |
10 |
First delay in seconds, doubled each retry; False: fixed delay |
retry_backoff_max |
task_retry_backoff_max |
600 |
Longest delay |
retry_jitter |
task_retry_jitter |
True |
Randomize each delay between half and all of it |
default_retry_delay |
task_default_retry_delay |
180 |
Fixed delay when backoff is off |
Time limits¶
Every task has a hard limit, 30 minutes by default:
@app.task(time_limit=600, soft_time_limit=570)
def export(report_id):
try:
build(report_id)
except SoftTimeLimitExceeded:
cleanup(report_id)
raise
The soft limit raises SoftTimeLimitExceeded inside the task, by default 30 seconds
before the hard limit. If the task is still running at the hard limit, the worker kills
the process, records a TimeLimitExceeded failure, and replaces the process.
When a worker process dies¶
If a process dies mid-task (OOM killer, segfault, kill -9), the supervisor notices
straight away and requeues the task. A task that keeps killing workers is a poison
message: after task_max_deliveries (5) crashed deliveries it is dead-lettered as
WorkerLostError instead of taking down the fleet. Tasks of a whole worker that
disappeared (a lost machine) are recovered once its heartbeat is worker_dead_after
(60 s) old.
A graceful shutdown never counts as a crash: on SIGTERM, running tasks get
worker_shutdown_timeout (25 s) to finish and are then requeued.
Dead letters¶
A task that fails for good is kept in a dead-letter store rather than discarded. That
covers a raised exception after retries, a killed time limit, too many crashes, an
unknown task name, and Reject(requeue=False).
$ potatoq -A proj dead list
3534bd8e-… proj.tasks.charge queue=default died=2026-10-08 00:20:27 gateway.Timeout: …
$ potatoq -A proj dead retry 3534bd8e-07ad-4d58-a9e7-f3eddb5354b1
Dead letters are kept for dead_letter_ttl (30 days), up to dead_letter_max (10,000).
Set task_dead_letter_failures = False to discard failed tasks as Celery does.