Your Celery task failed after max_retries. Where did it go?
A task calls something outside your process: a payment provider, a model API,
an email service. Tonight that dependency is down for forty minutes. Every task
that runs in the window fails, retries, fails again, and after the third retry
Celery gives up — max_retries defaults to 3, and once it is exceeded the
current exception is re-raised (or MaxRetriesExceededError, when retry()
was called without one). The task's state becomes FAILURE. The worker
acknowledges the message. The next task starts.
Here is what Celery keeps of that task:
- With a result backend (and
ignore_resultoff): theFAILUREstate and the traceback, filed under the task id. Not the arguments — and you need the id to look it up. - Without a result backend, or with
ignore_result=True: nothing. - In the broker: nothing either. The message that carried the arguments was acknowledged, and an acknowledged message is gone.
So the list of what you lost this morning is your log file, if you logged the
arguments. grep MaxRetriesExceededError gives you a count, not a list.
Why retry did not cover it
Retry is for blips. retry_backoff is off by default, so the three attempts
land within seconds of each other; turn it on and retry_backoff_max caps the
wait at 600 seconds, so the whole budget still fits inside half an hour. A
forty-minute outage outlasts every task's retries — and each task spends them
alone. Two hundred queued tasks make six hundred calls into an API that is
down, and then two hundred tasks are gone. Retry answers "was that a blip?" It
has no answer to "the dependency is down; hold this and run it later."
What Celery gives you for this, honestly
Rejectand a dead-letter exchange. RaiseReject(requeue=False)and a RabbitMQ broker with a dead-letter exchange configured will file the message there. That is broker-level, RabbitMQ-only (a Redis broker has no equivalent), and it takes you as far as "the message is somewhere" — replay is still re-publishing by hand.acks_lateandtask_reject_on_worker_lost. These protect a task whose worker died mid-run. A task that raised is acknowledged either way.- The result backend. A record that it failed, not a copy you can re-run.
- Building it yourself. An
on_failurehook that writes the task name, args and kwargs to a table; a management command that re-enqueues them; and some way of deciding that the provider is back, so the replay does not run into the same outage. It is a weekend of work, and it is the part of the codebase that never gets a test.
Capturing every task that fails for good
One call in the module that builds your Celery app:
from celery import Celery
from baldur.adapters.celery import setup_baldur_signals
app = Celery("myproject")
setup_baldur_signals(
app=app,
task_domain_mapping={
"myproject.tasks.send_invoice": "billing",
"myproject.tasks.summarize_document": "llm",
},
)
That connects Baldur to Celery's task signals. From then on, a failure that
arrives after the retries are spent (retries >= max_retries, or from a task
with no retry budget at all) is captured with the task name and id, its
args and kwargs, the exception type and message, the traceback, a
failure-type classification read off the exception (NETWORK_ERROR,
TIMEOUT, RATE_LIMITED, EXTERNAL_SERVICE_ERROR, VALIDATION_ERROR,
AUTH_ERROR, …), and a recommended action. Intermediate retry failures are
not captured — only the attempt Celery gave up on. Nothing changes per task;
@app.task stays as it is.
The same signals feed a circuit breaker per domain, so billing has a live
state you can read. The signal path records; it does not stop tasks from
running while the breaker is open. For the calls you want held back as well,
put @baldur.protected("billing") under @app.task — the
Celery quickstart shows the two together.
Seeing what you lost
baldur dlq list --pending
baldur dlq list --domain billing --json
Or open the built-in web console's dead-letter panel. Either way, each entry is the task, the arguments it was called with, why it failed, and what to do about it — the list the log file could not give you.
Getting it back
Baldur captured the work; only your code knows how to re-run a task. A replay handler for a Celery domain re-enqueues the task from the captured request:
from baldur.services.replay_service import (
ReplayHandler,
ReplayResult,
register_replay_handler,
)
from myproject.tasks import send_invoice
class BillingReplayHandler(ReplayHandler):
@property
def domain(self) -> str:
return "billing"
def can_replay(self, failed_op) -> tuple[bool, str]:
return True, ""
def replay(self, failed_op) -> ReplayResult:
request = failed_op.request_data or {}
send_invoice.apply_async(
args=request.get("args", []),
kwargs=request.get("kwargs", {}),
)
return ReplayResult.succeeded(failed_op.id, "re-enqueued")
register_replay_handler(BillingReplayHandler())
Register it in a module the worker imports — registration is per process, and the worker is where replay runs. "Succeeded" here means re-enqueued; from there the task's outcome is Celery's again, retries included.
With the handler in place, two ways to drain the queue:
-
By hand, once you know the provider is back:
baldur dlq replay --domain billingor the per-entry Retry action in the console.
-
Automatically, when the dependency recovers. Tell Baldur which failure types the outage produced for the domain, and run a worker on the
dlq_processingqueue the replay is dispatched to:export BALDUR_REPLAY_AUTOMATION_SERVICE_FAILURE_TYPE_MAP='{"billing": ["NETWORK_ERROR", "TIMEOUT", "EXTERNAL_SERVICE_ERROR"]}' celery -A myproject worker -Q celery,dlq_processingWhen the
billingbreaker closes again, the captured entries with those failure types go back through your handler. The console's dead-letter panel reports whether the loop is armed and, if it is not, names the prerequisite that is missing — a handler, the map, or a worker on the queue.
What this is not
It is not "replay everything." A task that failed because its input was
bad will fail the same way tomorrow. That is what can_replay is for, and why
the map lists the failure types an outage produces rather than every type
there is; a VALIDATION_ERROR entry waits for a person.
Replay is a bet that the first attempt did nothing. For a task that charged a card and then timed out, that bet is not free — the charge may have gone through and the confirmation never came back. That is a different mechanism: an idempotency key on the protected call, so the second run returns the first run's result instead of charging again. It has its own page: Idempotency keys for Python services.
Shared means shared storage. Out of the box the queue lives in process
memory, and Celery almost always runs more than one process. Point Baldur at
Redis before you start the second worker — BALDUR_REDIS_URL is the one
variable — or the entries one worker captured are invisible to the others. The
Celery quickstart has the
production checklist.
Where to go next
- Celery quickstart —
pip install baldur-framework[celery], the signal setup, and Baldur's maintenance on your beat - DLQ + replay — how capture, the outbox, and the on-recovery sweep work end to end
- Idempotency keys for Python services — for the tasks a replay must not run twice
- Circuit breaker for Python — the state behind "the dependency is back"