2025, Oct 24 19:00

Celery Retries and RabbitMQ Dead-Letter Queues: Understanding acks_late, Reject, and getting failed tasks to the DLQ

Learn why Celery retries make RabbitMQ dead-lettering fail, and how acks_late and Reject ensure failed tasks land in your DLQ. Step-by-step fixes and examples.

When Celery tasks are wired to dead-letter queues in RabbitMQ, plain failures are easy to route. The confusion starts when retries enter the picture: tasks retry as expected, then ultimately fail, yet the message never appears on the dead-letter queue. Below is a concise walkthrough of what’s happening and how to make retries and dead-lettering play well together.

Reproducing the issue

The following example shows a task that fails, triggers retries, and after exhausting them raises Reject to push the message to a dead-letter queue. However, without the right acknowledgment behavior, the message won’t be dead-lettered at the end.

from celery import Celery, Task
from celery.exceptions import Reject

app = Celery("dlq_retry_demo")

class DlqAwareTask(Task):
    # acks_late is intentionally not set here
    def on_failure(self, exc, task_id, args, kwargs, einfo):
        # After retries are exhausted we want to send to DLQ
        raise Reject()

@app.task(bind=True, base=DlqAwareTask, autoretry_for=(RuntimeError,), retry_kwargs={"max_retries": 3, "countdown": 1})
def flaky_work(self):
    # Simulate a failure that triggers autoretry
    raise RuntimeError("boom")

Why it happens

With retries enabled, Celery acknowledges the task just before execution. If the task then fails, the broker already considers the message handled. Raising Reject at that point doesn’t route the message to the dead-letter queue because the broker doesn’t have an unacked message to reject or dead-letter.

Looks like you need to use acks_late. When retying, the task is acknowledged immediately prior to execution, even if the task fails with an exception. See the FAQ. More specific docs on acks_late.

Working fix

Enable late acknowledgements so the message stays unacked until the task completes successfully. With late acks in place, explicit errors or Reject can be used to push the message to your dead-letter queue after retries are exhausted.

from celery import Celery, Task
from celery.exceptions import Reject

app = Celery("dlq_retry_demo")

# Ensure late acknowledgements are used
app.conf.task_acks_late = True

class DlqAwareTask(Task):
    # Alternatively, set per-task
    acks_late = True

    def on_failure(self, exc, task_id, args, kwargs, einfo):
        # Once retries are done, this will route to DLQ
        raise Reject()

# Automatic retries work with late acks
@app.task(bind=True, base=DlqAwareTask, autoretry_for=(RuntimeError,), retry_kwargs={"max_retries": 3, "countdown": 1})
def flaky_work(self):
    raise RuntimeError("boom")

# Explicit retry also works
@app.task(bind=True, base=DlqAwareTask)
def flaky_manual(self):
    try:
        raise RuntimeError("oops")
    except RuntimeError as exc:
        if self.request.retries < 3:
            raise self.retry(exc=exc, countdown=1)
        # After final retry, dead-letter the message
        raise Reject()

There is an important nuance when mixing on_failure and retries. If on_failure itself triggers a retry, Reject in that path is not handled the same way and the message does not get routed to the dead-letter queue. In other words, use on_failure to Reject after retries are exhausted, but avoid initiating a retry from within on_failure if you expect the same dead-lettering behavior.

class RetryInFailureTask(Task):
    acks_late = True

    def on_failure(self, exc, task_id, args, kwargs, einfo):
        # Triggering a retry here changes how Reject is handled
        if self.request.retries < 3:
            raise self.retry(exc=exc, countdown=1)
        # After retries are done, this Reject won't be handled the same way
        raise Reject()

Why this matters

Retries are standard in task execution, but they shouldn’t make failed messages disappear from your dead-letter workflow. Ensuring that acks_late is enabled keeps the message lifecycle aligned with your failure handling, so terminal errors are visible where you expect them to be.

Practical takeaways

Configure tasks to acknowledge late so the broker can dead-letter terminal failures after retries. Use Reject either inside the task or in on_failure once retries are exhausted. If you choose to trigger a retry from on_failure, be aware that Reject won’t route the same way, and the message won’t land on the dead-letter queue.

The article is based on a question from StackOverflow by grahamlyons and an answer by grahamlyons.