The default retry behavior most teams ship with Celery is max_retries=3 and nothing else β no backoff, no jitter, no distinction between “the downstream API is down for ten seconds” and “this task will never succeed no matter how many times you run it.” Then a dependency has a rough afternoon, every task hitting it fails, every one of those retries fires again within the same second, and the retry storm finishes the job the original outage started. The retries didn’t help. They amplified the problem into the thing that actually caused the incident.
The fix isn’t “retry less.” It’s retrying with intent: knowing which failures are worth retrying at all, spacing those retries out so they don’t all land in the same instant, and β critically β having somewhere for a task to go once it’s exhausted every reasonable attempt, instead of it just disappearing from a log line nobody was watching. That last part is the piece most Celery setups skip entirely, because Celery doesn’t hand you a dead-letter queue for free the way RabbitMQ-native consumers get one.
In short: exponential backoff spaces retries out with increasing delay after each failure instead of firing them all at once, and a dead-letter queue is the durable place a task goes once it’s exhausted every retry, so it can be inspected and reprocessed instead of just disappearing.
You’ll learn:
- How to distinguish transient failures worth retrying from permanent ones that never should be
- How to use
autoretry_forcorrectly, including the mistakes that make it retry the wrong things - How to configure celery retry exponential backoff with jitter so retries don’t stack into a thundering herd
- Why
max_retriesneeds a deliberate value, not just Celery’s default - How to build a working celery dead letter queue, since Celery has no built-in one
- How to route, alert on, and safely reprocess tasks that land in the dead-letter queue
Table of Contents
- Transient vs Permanent Failures
- Retrying with autoretry_for
- Exponential Backoff and Jitter
- Giving Up Gracefully with max_retries
- Setting Global Defaults vs Per-Task Overrides
- Building a Dead-Letter Queue in Celery
- Alerting and Reprocessing from the Dead-Letter Queue
- Common Pitfalls
- Common Questions
Transient vs Permanent Failures
Every retry strategy starts with a question most task code never actually answers: is this failure the kind that goes away if you wait, or the kind that’s still going to be there in an hour? Conflating the two is the single biggest source of bad retry behavior.
Transient failures are ones where the exact same input, retried a bit later, plausibly succeeds: a database connection pool exhausted for a moment, a downstream HTTP API returning a 503, a network timeout, a Redis connection blip. These are worth retrying, and worth retrying with increasing delay, because the underlying condition is often load- or time-based and tends to resolve on its own.
Permanent failures won’t be fixed by waiting: a ValidationError because the input was malformed, a 404 from an API because the resource genuinely doesn’t exist, a KeyError from a programming bug in the task itself. Retrying these doesn’t just waste worker capacity β it delays the point at which a human actually finds out something is broken, because the task looks “still working on it” for the duration of the retry schedule instead of immediately surfacing as failed.
The practical implication is that autoretry_for should almost never be a bare Exception. It should be a specific, deliberately chosen tuple of exception types that you’ve decided are actually transient for this particular task.
import requests
from celery import shared_task
class PermanentFailure(Exception):
"""Raised for errors that will never succeed on retry."""
@shared_task(bind=True)
def sync_customer_record(self, customer_id: int):
response = requests.get(f"https://crm.example.com/customers/{customer_id}", timeout=5)
if response.status_code == 404:
# The customer genuinely doesn't exist upstream β retrying won't change that.
raise PermanentFailure(f"customer {customer_id} not found in CRM")
response.raise_for_status() # raises for 5xx/connection errors β worth retrying
return response.json()
Deliberately raising a distinct exception type for the “don’t retry this” case, rather than letting every exception fall into the same bucket, is what makes the next section’s autoretry_for configuration actually mean something.
A Quick Reference: What’s Actually Transient
Classifying failures gets easier with a concrete reference table rather than reasoning about it fresh for every task:
| Failure | Usually transient? | Notes |
|---|---|---|
| Connection timeout / refused | Yes | Retry with backoff β the dependency may be under load or restarting |
| HTTP 503 / 429 | Yes | Explicit “back off” signals; respect a Retry-After header if present |
| HTTP 500 | Sometimes | Depends on whether it’s a transient server error or a consistent bug on their end β cap retries low and escalate if it repeats |
| HTTP 404 / 400 | No | The resource or the request itself is wrong; retrying doesn’t change the input |
IntegrityError (unique constraint) | No | The data conflict will still be there on retry β needs application logic, not a retry |
| Database deadlock | Yes | Genuinely transient by nature; short backoff, no need for a long delay |
KeyError / TypeError from task code | No | This is a bug, not an environmental failure β retrying just delays the bug report |
Redis ConnectionError | Yes | Broker or cache blip; retry, but investigate if it recurs frequently |
Treat this as a starting point, not a universal rule β the right classification depends on what your specific dependency actually does when it’s degraded, and it’s worth revisiting after every incident that surfaces a failure mode you hadn’t accounted for.
Retrying with autoretry_for
autoretry_for is Celery’s declarative retry mechanism β instead of manually catching exceptions and calling self.retry(), you list the exception types that should trigger an automatic retry, and Celery handles the rest.
from celery import shared_task
from requests.exceptions import ConnectionError, Timeout, HTTPError
@shared_task(
bind=True,
autoretry_for=(ConnectionError, Timeout, HTTPError),
retry_backoff=True,
retry_backoff_max=600,
retry_jitter=True,
max_retries=5,
)
def sync_customer_record(self, customer_id: int):
response = requests.get(f"https://crm.example.com/customers/{customer_id}", timeout=5)
response.raise_for_status()
return response.json()
The two mistakes that show up constantly with autoretry_for: listing exceptions too broadly (catching Exception itself means a genuine bug in your task code β a TypeError from a bad refactor β gets silently retried five times instead of failing loudly on the first run, burying a real defect inside what looks like normal retry noise), and forgetting that autoretry_for retries the entire task body from the top, not just the part that failed. If your task does two side effects in sequence and the second one raises, the retry re-executes the first one too β which is exactly the duplicate-execution problem that idempotent task design exists to solve, and it’s worth designing for from the start rather than discovering it mid-incident.
HTTPError deserves a specific callout: response.raise_for_status() raises it for both a 503 (transient, worth retrying) and a 404 (permanent, not worth retrying) β the same exception type covers both cases. Blanket-retrying HTTPError means you’re retrying 404s five times before giving up, which is wasted work and a delayed failure signal. The PermanentFailure pattern from the previous section β checking the status code and raising a distinct, non-retried exception for the permanent cases β is what actually fixes this; autoretry_for alone can’t distinguish between two failures that share an exception class.
Exponential Backoff and Jitter
Retrying immediately after a failure is close to the worst option for a transient, load-related failure β it fires again while the downstream system is still under exactly the pressure that caused the first failure. Exponential backoff spaces retries out with increasing delay: 2 seconds, then 4, then 8, then 16, giving the failing dependency room to actually recover between attempts.
@shared_task(
bind=True,
autoretry_for=(ConnectionError, Timeout),
retry_backoff=True, # enables exponential backoff
retry_backoff_max=600, # cap the delay at 10 minutes
retry_jitter=True, # randomize the delay to avoid a thundering herd
max_retries=6,
)
def call_flaky_api(self, payload: dict):
return flaky_api_client.post(payload)
retry_backoff=True uses Celery’s built-in formula β roughly 2 ** retry_count seconds by default, capped at retry_backoff_max β so the schedule for a task with max_retries=6 looks approximately like 1s, 2s, 4s, 8s, 16s, 32s (each capped at the max) rather than five retries fired back to back.
retry_jitter is the detail that’s easy to skip and expensive to skip in practice. Without it, every task instance that failed at the same moment β say, because a shared downstream dependency had a two-second outage β computes the exact same backoff delay and retries at the exact same moment, just shifted by a fixed offset. Hundreds or thousands of tasks all retrying in the same 100ms window is a self-inflicted thundering herd, and it can turn a brief blip into a sustained outage as the retry spike itself overwhelms the recovering service. Jitter randomizes each task’s actual delay within its backoff window, spreading that same herd out over seconds instead of concentrating it into one instant.
For manual retry logic (when autoretry_for doesn’t fit, e.g., you need to inspect the exception to decide the delay), the same formula applied by hand:
import random
@shared_task(bind=True, max_retries=6)
def call_flaky_api_manual(self, payload: dict):
try:
return flaky_api_client.post(payload)
except (ConnectionError, Timeout) as exc:
backoff = min(600, 2 ** self.request.retries)
jitter = random.uniform(0, backoff * 0.5)
raise self.retry(exc=exc, countdown=backoff + jitter)
Giving Up Gracefully with max_retries
Celery’s default max_retries is 3, applied silently if you never set it β a value chosen for no reason specific to your task’s actual failure characteristics. The right number depends on how long the failure mode you’re guarding against typically lasts: retrying a database connection blip five times over 30 seconds is reasonable; retrying an external API outage for the same 30 seconds when that provider’s outages typically last 20 minutes just guarantees you exhaust retries before the dependency recovers, and does it quietly, one task at a time, with no one noticing the pattern.
Set max_retries deliberately, and β this is the part that’s easy to skip β decide explicitly what happens when it’s exhausted:
from celery.exceptions import MaxRetriesExceededError
@shared_task(bind=True, max_retries=5)
def call_flaky_api(self, payload: dict):
try:
return flaky_api_client.post(payload)
except (ConnectionError, Timeout) as exc:
try:
backoff = min(600, 2 ** self.request.retries)
raise self.retry(exc=exc, countdown=backoff)
except MaxRetriesExceededError:
send_to_dead_letter_queue(task_name=self.name, args=[payload], error=str(exc))
raise
Letting a task simply fail after its last retry, with nothing downstream of that failure, is the default that leads to the “we didn’t notice for three days” class of incident β the task shows up as a red entry in Flower or a log line, and unless someone’s actively watching that surface, it’s effectively invisible. That’s the gap a dead-letter queue closes.
Setting Global Defaults vs Per-Task Overrides
Repeating retry_backoff=True, retry_jitter=True, retry_backoff_max=600 on every single task definition is easy to forget on the one task that matters most. Celery lets you set sane defaults globally and override only where a specific task genuinely needs different behavior:
# celeryconfig.py
task_annotations = {
"*": {
"retry_backoff": True,
"retry_backoff_max": 600,
"retry_jitter": True,
"max_retries": 5,
},
"myapp.tasks.charge_customer": {
"max_retries": 2, # payment retries should stay conservative
"retry_backoff_max": 60,
},
"myapp.tasks.sync_analytics_batch": {
"max_retries": 10, # low-stakes, safe to retry aggressively
"retry_backoff_max": 1800,
},
}
task_annotations applies the wildcard entry to every task, then layers task-specific overrides on top by name. This is worth setting up early, specifically because the failure mode of not having a global default isn’t an error β it’s silence. A newly added task that forgets to set backoff and jitter just quietly retries immediately, six times in a row, and nobody notices until it’s the task contributing to a retry storm during the next real outage. A global default makes safe behavior the path of least resistance instead of something every task author has to remember.
Building a Dead-Letter Queue in Celery
Unlike RabbitMQ consumers using AMQP directly, Celery doesn’t provide a first-class dead-letter queue out of the box β there’s no dead_letter_exchange config flag you flip. You build it yourself, and the shape of it is simpler than it sounds: a durable place to write the full context of a task that’s exhausted its retries, so it can be inspected, alerted on, and reprocessed instead of vanishing into the exhausted-retries void.
The simplest version is a database table:
class DeadLetterTask(db.Model):
__tablename__ = "dead_letter_tasks"
id = db.Column(db.Integer, primary_key=True)
task_name = db.Column(db.String(255), nullable=False)
task_args = db.Column(db.JSON, nullable=False)
task_kwargs = db.Column(db.JSON, nullable=False)
error_message = db.Column(db.Text, nullable=False)
retry_count = db.Column(db.Integer, nullable=False)
failed_at = db.Column(db.DateTime, default=datetime.utcnow)
reprocessed = db.Column(db.Boolean, default=False)
from celery.signals import task_failure
@task_failure.connect
def handle_task_failure(sender=None, task_id=None, exception=None, args=None,
kwargs=None, einfo=None, **extra):
# Only trap the final failure β Celery fires this signal once retries
# are genuinely exhausted, not on every intermediate retry attempt.
db.session.add(DeadLetterTask(
task_name=sender.name,
task_args=list(args or []),
task_kwargs=dict(kwargs or {}),
error_message=str(exception),
retry_count=sender.request.retries if sender.request else 0,
))
db.session.commit()
Hooking into the task_failure signal globally, rather than wrapping every task individually, means new tasks get dead-letter coverage automatically without each one needing its own boilerplate β the trade-off is that it fires for any task’s final failure, so you generally want a way to opt specific low-stakes tasks out rather than routing every failure into the same table indiscriminately.
If you’re running RabbitMQ as the broker rather than Redis, you get a lower-level alternative: configure a dedicated dead-letter exchange and queue at the broker level, and set x-dead-letter-exchange on your task queues so RabbitMQ itself routes rejected or expired messages there. This is more robust than the application-level signal approach because it survives even if your Celery worker process crashes before the task_failure handler runs, but it requires broker-level configuration outside Celery’s own settings and generally isn’t available at all if you’re on Redis β which is one more reason the broker choice discussed in the task queue comparison is worth getting right before you’re deep into production reliability work.
Alerting and Reprocessing from the Dead-Letter Queue
A dead-letter queue that nobody looks at is barely better than no dead-letter queue β it just changes “silently disappeared” into “silently accumulated in a table.” Two things make it actually useful:
Alerting. Fire a notification the moment something lands in the dead-letter store, not on a schedule β a daily digest email is fine for volume awareness, but the first occurrence of a new failure pattern deserves an immediate page or Slack message, since it’s often the earliest signal of an outage in a dependency you don’t directly monitor. Past a handful of these alerts, the same fan-out and delivery-guarantee questions show up that production notification systems have to solve at scale.
@task_failure.connect
def alert_on_task_failure(sender=None, task_id=None, exception=None, **extra):
if is_novel_failure(sender.name, str(exception)):
notify_slack(f"Task {sender.name} exhausted retries: {exception}")
Reprocessing. Once the underlying cause is fixed β the downstream API is back, the bad data was corrected β reprocessing dead-lettered tasks should be a deliberate, auditable action, not an automatic retry loop bolted back on. A small script that re-enqueues selected rows and marks them reprocessed=True keeps a clear record of what was retried and when, which matters when the same failure recurs and you need to know whether it’s a new instance or the old one coming back.
def reprocess_dead_letter_task(dead_letter_id: int):
row = DeadLetterTask.query.get(dead_letter_id)
task = celery_app.tasks[row.task_name]
task.apply_async(args=row.task_args, kwargs=row.task_kwargs)
row.reprocessed = True
db.session.commit()
Resist the temptation to auto-reprocess everything in the dead-letter queue on a cron schedule β that quietly reintroduces the exact retry-storm risk the backoff and jitter settings were designed to prevent, just on a longer time horizon, and it removes the human decision point that dead-lettering was supposed to create in the first place.
Common Pitfalls
Mistake: retrying Exception broadly instead of specific transient error types. This retries genuine bugs the same way it retries network blips, hiding real defects inside normal-looking retry noise. Solution: enumerate the specific exceptions that are actually transient for this task, and let everything else fail immediately.
Mistake: no jitter on backoff. A batch of tasks that failed together retries together, turning a brief outage into a self-inflicted retry storm. Solution: always set retry_jitter=True alongside retry_backoff=True.
Mistake: treating autoretry_for as retrying only the failed line. It re-runs the entire task body from the top. Solution: design tasks to be safely re-runnable in full, using the idempotency patterns covered separately, rather than assuming partial progress survives a retry.
Mistake: no dead-letter queue at all, just logs. Exhausted-retry failures become invisible unless someone is actively watching a dashboard. Solution: hook task_failure (or your broker’s native dead-lettering) to persist every exhausted failure somewhere queryable and alertable.
Mistake: auto-reprocessing the dead-letter queue on a timer. This silently reintroduces the retry storm risk you designed backoff to prevent, on a longer cycle. Solution: make reprocessing a deliberate, logged action taken after confirming the underlying cause is actually fixed.
Retries and dead-letter queues aren’t separate concerns bolted onto a task queue β they’re the two ends of the same design decision about what happens when something goes wrong. Retry the failures worth retrying, space them out enough that they don’t compound the problem, and give up loudly and visibly instead of quietly, into a table nobody queries. That’s the difference between a queue that degrades gracefully under a bad afternoon and one that turns a ten-second outage into the actual incident.
Common Questions
Does Celery have a built-in dead-letter queue?
No. Unlike RabbitMQ-native consumers, Celery doesn’t ship one out of the box β you build it yourself, typically by hooking the task_failure signal to persist exhausted-retry tasks somewhere queryable and alertable.
What’s a good default value for max_retries in Celery?
There’s no universal number β Celery’s own default of 3 is arbitrary. Set it based on how long the failure mode you’re guarding against typically lasts: a handful of retries over 30 seconds for a database blip, more and longer for a dependency with 20-minute outages.
Should retry_jitter always be enabled alongside retry_backoff?
Yes. Without jitter, every task that failed at the same moment computes the same delay and retries at the same instant, turning a brief outage into a self-inflicted retry storm.
Does autoretry_for retry only the part of a task that failed?
No β it re-runs the entire task body from the top, including any side effects that already succeeded. That’s why tasks need to be designed as safely re-runnable in full, not just retried.

