Celery MaxRetriesExceededError (tested)
Celery raises MaxRetriesExceededError: Can't retry tasks.x[...] when self.retry() is called after max_retries retries (default 3). If you pass exc=, the final retry re-raises that exception instead.
Setup used in every block
To show the retry logic without a broker, common.py sets task_always_eager, which runs each task and its retries inline in the same process:
from celery import Celery
app = Celery('tasks')
app.conf.task_always_eager = True # run inline: no broker or worker needed here
calls = []
1. Reproduce it: retry() without exc
from common import *
@app.task(bind=True, max_retries=2, default_retry_delay=0)
def fetch(self):
calls.append(self.request.retries)
raise self.retry()
try:
fetch.delay().get()
except Exception as e:
print(f'{type(e).__module__}.{type(e).__name__}: {e}')
print('attempts (request.retries):', calls)
Output:
celery.exceptions.MaxRetriesExceededError: Can't retry tasks.fetch[ce3ff0ab-4d49-41b5-a3ca-81ea561129df] args:() kwargs:{}
attempts (request.retries): [0, 1, 2]
max_retries=2 means 3 attempts in total (request.retries 0, 1, 2). On the last one, self.retry() raises celery.exceptions.MaxRetriesExceededError: Can't retry tasks.fetch[...] and the task fails with it. The default max_retries is 3.
2. With retry(exc=...), you get your own exception back
from common import *
@app.task(bind=True, max_retries=2, default_retry_delay=0)
def fetch(self):
calls.append(self.request.retries)
try:
raise ConnectionError('upstream down')
except ConnectionError as exc:
raise self.retry(exc=exc)
try:
fetch.delay().get()
except Exception as e:
print(f'{type(e).__name__}: {e}')
print('attempts:', calls)
Output:
ConnectionError: upstream down
attempts: [0, 1, 2]
When you pass exc=, the final retry() re-raises that exception instead of MaxRetriesExceededError, so the task fails with ConnectionError: upstream down. This is why an except MaxRetriesExceededError around retry(exc=exc) never runs.
3. Give up cleanly, or retry without a limit
from common import *
from celery.exceptions import MaxRetriesExceededError
@app.task(bind=True, max_retries=2, default_retry_delay=0)
def fetch(self):
calls.append(self.request.retries)
try:
raise ConnectionError('upstream down')
except ConnectionError as exc:
try:
raise self.retry(exc=exc)
except MaxRetriesExceededError:
return 'gave up' # never reached: retry re-raises exc instead
except ConnectionError:
if self.request.retries >= self.max_retries:
return 'gave up cleanly'
raise
print(fetch.delay().get(), calls)
@app.task(bind=True, autoretry_for=(ConnectionError,), max_retries=None, retry_backoff=False, default_retry_delay=0)
def forever(self):
if self.request.retries < 5:
raise ConnectionError('flaky')
return f'ok after {self.request.retries} retries'
print(forever.delay().get())
Output:
gave up cleanly [0, 1, 2]
ok after 5 retries
To handle the final failure yourself, check self.request.retries >= self.max_retries before (or instead of) calling retry(); the except MaxRetriesExceededError branch above is dead code because exc was passed. For a task that should keep retrying, set max_retries=None; autoretry_for retries on the listed exceptions without an explicit retry() call. In production, use a real default_retry_delay or retry_backoff=True instead of 0.
Related: Celery Error 111 Connection refused, asyncio Task exception was never retrieved.
Frequently asked questions
How many times does a Celery task run with max_retries=2?
Three times: the first attempt plus two retries (request.retries 0, 1, 2).
Why is MaxRetriesExceededError not raised when I use self.retry(exc=exc)?
With exc= set, the final retry re-raises your exception instead. Check self.request.retries >= self.max_retries to handle the last attempt.
Written with AI assistance. Measured on 2026-10-23 with Celery 5.6.3 and Python 3.14.7 (python:3-alpine in Docker, task_always_eager). Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; Python and Celery are free.