Celery + RabbitMQ — reliable tasks, retries, DLQ
Build async task processing where Celery is the worker/dispatch layer and RabbitMQ
is the durable transport — and the application code never speaks AMQP directly. Producers
call a task by name; consumers run handlers; failures retry and then land in a dead-letter
queue you can inspect and replay.
Mental model — keep three layers separate
| Layer |
Responsibility |
Where it lives |
| Transport |
durable delivery, exchanges, DLQ |
RabbitMQ (broker URL + queue declarations) |
| Worker |
pull, execute, retry, ack/reject |
Celery worker process |
| Dispatch |
"for this event type, call this function" |
a HANDLERS dict inside one task |
The goal the user asked for — "broker is a detail" — is achieved by: callers use
task.delay(...) / app.send_task("name", ...) and never touch channels or exchanges;
all AMQP wiring (exchanges, routing keys, DLX) is declared once in the Celery config.
The one thing everyone gets wrong (read this first)
A DLQ does not fill up by default. Two independent reasons:
- Celery's default
acks_late=False acks the message on receipt, before execution — a
crash mid-task loses the message and nothing is dead-lettered.
- Celery's retry (
autoretry_for / self.retry) re-publishes a new message to the
same queue. When retries are exhausted the task is marked FAILURE in the result backend,
but the broker message was already acked — it is never routed to the dead-letter exchange.
To make RabbitMQ dead-letter a failed task you must let the broker reject the in-flight
delivery (basic.reject / basic.nack with requeue=false). The clean native way:
task_acks_late = True # ack only AFTER the task returns
task_acks_on_failure_or_timeout = False # on final failure -> REJECT (requeue=False) -> DLX
task_reject_on_worker_lost = True # worker died -> reject -> DLX (don't silently drop)
Combine with a queue that declares x-dead-letter-exchange, and exhausted retries flow to the
DLQ automatically — no manual Reject needed. Full mechanics, plus the manual-Reject variant
for selective dead-lettering, are in references/retry-and-dlq.md.
Workflow
Ground the versions. Check what's installed before writing config — celery, kombu,
and the RabbitMQ server version each change available features (quorum queues, delivery-limit).
Run pip show celery kombu 2>/dev/null | grep -E 'Name|Version' (or read the lockfile) and,
for any uncertain API, pull current docs via context7 (/websites/celeryq_dev_en_stable).
Match the project's existing style (config location, settings source, async vs sync DB).
Declare the topology — main exchange/queue with a x-dead-letter-exchange, plus the DLX
and dead-letter queue. The terminal DLQ won't be created by listing it in task_queues
(Celery only declares queues the worker consumes) — declare it explicitly at boot or
dead-letters drop silently. → references/retry-and-dlq.md §topology and §terminal-DLQ
gotcha. Start from assets/templates/celery_app.py.tpl.
Wire reliability flags — acks_late, acks_on_failure_or_timeout=False,
reject_on_worker_lost, prefetch_multiplier=1 for long tasks, and per-task
autoretry_for / max_retries / retry_backoff. → references/retry-and-dlq.md.
Write the dispatch task + handlers — one process_event task that reads an envelope
{type, payload, idempotency_key} and calls HANDLERS[type]. Make handlers idempotent
(acks_late guarantees occasional double delivery). → references/publish-consume.md §dispatch.
Start from assets/templates/tasks.py.tpl.
Producing — keep the broker invisible. Callers use task.delay() /
app.send_task("events.process", args=[envelope]) and task_routes maps the name to a
queue. No queue/exchange in business code. → references/publish-consume.md §producing.
Consuming & scheduling — run the worker (celery -A app.worker worker -Q main), and
if cron is needed add Celery Beat as a single process (never N replicas, or it
double-fires). → references/publish-consume.md §consuming and §beat.
Async app? mind the gotcha. If the project is asyncio (asyncpg / SQLAlchemy async),
Celery is sync — wrap handlers with asyncio.run(...) and build the async engine per
worker in the worker_process_init signal with a NullPool (asyncpg does not survive the
prefork). → references/setup-ops.md §async.
Monitor & replay — expose stuck-message counts from the RabbitMQ Management API
(GET /api/queues/%2F/<queue> → messages); replay the DLQ with a shovel or a small
republish task. → references/setup-ops.md §monitoring.
Verify. Send a task that always raises and confirm: it retries max_retries times, then
the message appears in the dead queue (check the count). A green path that never
dead-letters means the reliability flags are wrong — re-read §the-one-thing.
References (open only what the step needs)
| Open when you need to… |
Read |
| wire retries and make the DLQ actually fill |
references/retry-and-dlq.md |
| publish by name, consume, dispatch-by-type, Beat cron |
references/publish-consume.md |
| app/broker config, async-in-Celery, quorum queues, monitoring, DLQ replay |
references/setup-ops.md |
| copy a working Celery app / tasks skeleton |
assets/templates/ |
Guardrails
- Never declare infra (the RabbitMQ host, security groups, credentials) ad hoc if the
project manages infra as code — add it there and keep broker secrets in the project's secret
store, not in source. Restrict the AMQP (5672) and management (15672) ports to the app's
network, never public.
- Idempotency is mandatory with
acks_late. If a handler is not safe to run twice, add a
processed-key guard (dedup table on idempotency_key) — see references/publish-consume.md.
- Don't put secrets or large blobs in the task payload; pass IDs and refetch.
- Don't block a DLQ forever: alert when
dead queue depth crosses a threshold.
1---2name: celery-rabbitmq3description: Implement reliable async task processing with Celery on a RabbitMQ broker — native retries, dead-letter queues (DLQ), publishing and consuming, with the broker kept as an implementation detail. Use when the user wants to "add Celery", "set up a task queue", "process events/jobs async", "configure RabbitMQ with Celery", "add retries", "set up a dead letter queue / DLQ", "handle failed tasks", "publish a message and consume it", "Celery beat / cron", or move fire-and-forget background work (asyncio.create_task, BackgroundTasks, threads) onto a durable broker. Covers the acks_late + acks_on_failure_or_timeout=False pattern that actually routes failures to the DLX, the dispatch-by-event-type handler pattern, producing tasks by name, and monitoring stuck messages. Not for Redis-only queues (arq/taskiq), raw pika/aio-pika without Celery, or Kafka.4license: MIT5---67# Celery + RabbitMQ — reliable tasks, retries, DLQ89Build async task processing where **Celery is the worker/dispatch layer** and **RabbitMQ10is the durable transport** — and the application code never speaks AMQP directly. Producers11call a task by name; consumers run handlers; failures retry and then land in a dead-letter12queue you can inspect and replay.1314## Mental model — keep three layers separate1516| Layer | Responsibility | Where it lives |17|---|---|---|18| **Transport** | durable delivery, exchanges, DLQ | RabbitMQ (broker URL + queue declarations) |19| **Worker** | pull, execute, retry, ack/reject | Celery worker process |20| **Dispatch** | "for this event type, call this function" | a `HANDLERS` dict inside one task |2122The goal the user asked for — *"broker is a detail"* — is achieved by: callers use23`task.delay(...)` / `app.send_task("name", ...)` and never touch channels or exchanges;24all AMQP wiring (exchanges, routing keys, DLX) is declared **once** in the Celery config.2526## The one thing everyone gets wrong (read this first)2728**A DLQ does not fill up by default.** Two independent reasons:29301. Celery's default `acks_late=False` acks the message *on receipt*, before execution — a31 crash mid-task loses the message and nothing is dead-lettered.322. Celery's **retry** (`autoretry_for` / `self.retry`) **re-publishes a new message** to the33 *same* queue. When retries are exhausted the task is marked FAILURE in the result backend,34 but the broker message was already acked — it is **never** routed to the dead-letter exchange.3536To make RabbitMQ dead-letter a failed task you must let the broker *reject* the in-flight37delivery (`basic.reject` / `basic.nack` with `requeue=false`). The clean native way:3839```python40task_acks_late = True # ack only AFTER the task returns41task_acks_on_failure_or_timeout = False # on final failure -> REJECT (requeue=False) -> DLX42task_reject_on_worker_lost = True # worker died -> reject -> DLX (don't silently drop)43```4445Combine with a queue that declares `x-dead-letter-exchange`, and exhausted retries flow to the46DLQ automatically — no manual `Reject` needed. Full mechanics, plus the manual-`Reject` variant47for selective dead-lettering, are in `references/retry-and-dlq.md`.4849## Workflow50511. **Ground the versions.** Check what's installed before writing config — `celery`, `kombu`,52 and the RabbitMQ server version each change available features (quorum queues, delivery-limit).53 Run `pip show celery kombu 2>/dev/null | grep -E 'Name|Version'` (or read the lockfile) and,54 for any uncertain API, pull current docs via context7 (`/websites/celeryq_dev_en_stable`).55 Match the project's existing style (config location, settings source, async vs sync DB).56572. **Declare the topology** — main exchange/queue with a `x-dead-letter-exchange`, plus the DLX58 and dead-letter queue. The terminal DLQ won't be created by listing it in `task_queues`59 (Celery only declares queues the worker *consumes*) — declare it explicitly at boot or60 dead-letters drop silently. → `references/retry-and-dlq.md` §topology and §terminal-DLQ61 gotcha. Start from `assets/templates/celery_app.py.tpl`.62633. **Wire reliability flags** — `acks_late`, `acks_on_failure_or_timeout=False`,64 `reject_on_worker_lost`, `prefetch_multiplier=1` for long tasks, and per-task65 `autoretry_for` / `max_retries` / `retry_backoff`. → `references/retry-and-dlq.md`.66674. **Write the dispatch task + handlers** — one `process_event` task that reads an envelope68 `{type, payload, idempotency_key}` and calls `HANDLERS[type]`. Make handlers **idempotent**69 (acks_late guarantees occasional double delivery). → `references/publish-consume.md` §dispatch.70 Start from `assets/templates/tasks.py.tpl`.71725. **Producing — keep the broker invisible.** Callers use `task.delay()` /73 `app.send_task("events.process", args=[envelope])` and `task_routes` maps the name to a74 queue. No queue/exchange in business code. → `references/publish-consume.md` §producing.75766. **Consuming & scheduling** — run the worker (`celery -A app.worker worker -Q main`), and77 if cron is needed add Celery **Beat as a single process** (never N replicas, or it78 double-fires). → `references/publish-consume.md` §consuming and §beat.79807. **Async app? mind the gotcha.** If the project is asyncio (asyncpg / SQLAlchemy async),81 Celery is sync — wrap handlers with `asyncio.run(...)` and build the async engine **per82 worker** in the `worker_process_init` signal with a NullPool (asyncpg does not survive the83 prefork). → `references/setup-ops.md` §async.84858. **Monitor & replay** — expose stuck-message counts from the RabbitMQ Management API86 (`GET /api/queues/%2F/<queue>` → `messages`); replay the DLQ with a shovel or a small87 republish task. → `references/setup-ops.md` §monitoring.88899. **Verify.** Send a task that always raises and confirm: it retries `max_retries` times, then90 the message appears in the **dead** queue (check the count). A green path that never91 dead-letters means the reliability flags are wrong — re-read §the-one-thing.9293## References (open only what the step needs)9495| Open when you need to… | Read |96|---|---|97| wire retries and make the DLQ actually fill | `references/retry-and-dlq.md` |98| publish by name, consume, dispatch-by-type, Beat cron | `references/publish-consume.md` |99| app/broker config, async-in-Celery, quorum queues, monitoring, DLQ replay | `references/setup-ops.md` |100| copy a working Celery app / tasks skeleton | `assets/templates/` |101102## Guardrails103104- **Never declare infra (the RabbitMQ host, security groups, credentials) ad hoc** if the105 project manages infra as code — add it there and keep broker secrets in the project's secret106 store, not in source. Restrict the AMQP (5672) and management (15672) ports to the app's107 network, never public.108- **Idempotency is mandatory** with `acks_late`. If a handler is not safe to run twice, add a109 processed-key guard (dedup table on `idempotency_key`) — see `references/publish-consume.md`.110- Don't put secrets or large blobs in the task payload; pass IDs and refetch.111- Don't block a DLQ forever: alert when `dead` queue depth crosses a threshold.