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.