Retries and error handling

Celery · Practical · 12 min

Tasks talk to flaky networks and APIs, and well-configured retries turn transient failures into eventual success.

A task that raises an exception ends in FAILURE by default. For transient errors (timeouts, 503s, deadlocks) you want retries.

The explicit way is a bound task: @app.task(bind=True, max_retries=5) passes the task instance as self, and inside an except block you call raise self.retry(exc=exc, countdown=30). retry() re-publishes the task with the same id and arguments and raises a Retry exception to end the current run; the state becomes RETRY. self.request.retries tells you which attempt this is, so you can compute your own delay.

The declarative way is autoretry_for=(ConnectionError, TimeoutError) on the decorator, combined with:

  • retry_backoff=True: exponential delays (1s, 2s, 4s, ...), or a number for the base factor.
  • retry_backoff_max=600: cap the delay.
  • retry_jitter=True (default): randomize delays so a thousand failing tasks don't retry in lockstep.
  • retry_kwargs={"max_retries": 5}.

max_retries defaults to 3; when exceeded, the original exception is raised and the task fails. max_retries=None retries forever, which is rarely wise.

Only retry errors that might succeed next time. Validation errors and missing records should fail fast. And since a retried task runs its body again, any side effects before the failure point run again too: make tasks idempotent.

Example

import random
from celery import Celery

app = Celery("tasks", broker="memory://")
app.conf.task_always_eager = True     # run inline for this demo

class Flaky(Exception):
    pass

@app.task(bind=True, autoretry_for=(Flaky,), retry_backoff=True,
          retry_backoff_max=60, retry_kwargs={"max_retries": 5})
def fetch_rate(self, currency):
    print("attempt", self.request.retries)
    if self.request.retries < 2:
        raise Flaky("upstream timeout")
    return {"currency": currency, "rate": 1.08}

print(fetch_rate.delay("EUR").get())
# attempt 0
# attempt 1
# attempt 2
# {'currency': 'EUR', 'rate': 1.08}

Remember

  • bind=True gives self; raise self.retry(exc=..., countdown=...)
  • autoretry_for + retry_backoff + jitter for declarative retries
  • max_retries defaults to 3; then the task fails with the original error
  • Retry only transient errors and keep tasks idempotent

Watch out for

  • Calling self.retry() without raise, or outside an except block, and losing the original error.

Check yourself

  1. Why is retry_jitter enabled by default?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.celeryq.dev/en/stable/userguide/tasks.html#retrying