skip to content

In Celery, when should a task raise `Reject` or `Ignore` instead of calling `self.retry()`, and what happens to the message and its stored state?

level: seniorimportance: should knowfreq 24%

answer

  1. exceptions that steer the worker
  2. transient, permanent, or moot
  3. reject needs a late ack
  4. no record left behind

basics

~20 s

Use self.retry() for transient failures: a new message runs the task again. Raise Reject for a message that should leave the queue, dead-lettered or requeued, which needs acks_late. Raise Ignore when the work is moot: it acks and records nothing.

solid answer

~40 s

All three are exceptions the worker treats as signals rather than failures. `self.retry()` raises `Retry` after publishing a new message and stores `RETRY`; it suits transient failures such as a label API timing out. `raise Reject(reason, requeue=False)` rejects the message at the broker, but only with `acks_late=True`, because otherwise the message was acknowledged before the task ran. Without requeue, a RabbitMQ queue with a dead-letter exchange receives it for inspection — right for a permanent failure like an address the carrier refuses. `requeue=True` redelivers it at once and can loop forever. `raise Ignore()` acknowledges the message and stores no state, which suits a shipment cancelled after the task was queued. Because neither `Reject` nor `Ignore` writes to the result backend, the caller's `AsyncResult` keeps its last state, usually `PENDING`.

code

python · 20 lines
python
from celery import Celery
from celery.exceptions import Ignore, Reject

from shipping.client import CarrierRejectedAddress, CarrierUnavailable, LabelClient
from shipping.models import Shipment

app = Celery("shop", broker="amqp://guest@localhost//")


@app.task(bind=True, acks_late=True, max_retries=6)
def create_shipping_label(self, shipment_id):
    shipment = Shipment.load(shipment_id)
    if shipment.cancelled:
        raise Ignore()                     # ack, store no state
    try:
        return LabelClient().create(shipment)
    except CarrierRejectedAddress as exc:
        raise Reject(exc, requeue=False)   # dead-lettered if the queue has a DLX
    except CarrierUnavailable as exc:
        raise self.retry(exc=exc, countdown=60)

go deeper

for a junior

Recall the three signals: retry for try-again-later, Reject for drop or dead-letter, Ignore for skip without a record.

for a middle

Explain what each does to the message and to the result backend, and why Reject only has an effect with acks_late.

for a senior

Show you classify failures as transient, permanent or moot, route permanent ones to a dead-letter queue, and give callers a timeout because Reject and Ignore store no state.

for a principal

Decide who owns the dead-letter queue: who is alerted, how rejected labels are replayed, and how long a message may sit there before it is resolved.

## Three exceptions that are signals, not errors `celery.exceptions` defines a family of exceptions a task raises to steer the worker rather than to report a bug. Three matter for error handling: `Retry`, `Reject` and `Ignore`. The worker catches each one, settles the message in a specific way, and does not record the task as failed. | Raised | What happens to the message | What the result backend stores | Needs `acks_late` | |---|---|---|---| | `self.retry()` → `Retry` | a new message is published; the original is acknowledged | `RETRY`, with the exception | no | | `Reject(reason, requeue=False)` | rejected; the broker drops it or dead-letters it | nothing | yes | | `Reject(reason, requeue=True)` | rejected and put back for redelivery | nothing | yes | | `Ignore()` | acknowledged | nothing | no | ## `Retry` for transient failures A carrier API that times out or answers with a temporary error will probably succeed later. `raise self.retry(exc=exc, countdown=60)` publishes a new message for the same task id with that delay, stores `RETRY`, and ends the current run. The limit is `max_retries`, 3 by default; past it, the original exception is re-raised and the task ends in `FAILURE`. If publishing the retry message itself fails, `retry()` raises `Reject(exc, requeue=False)` instead of reporting a retry that was never scheduled. ## `Reject` for messages that should leave the queue A rejection is the broker-level "no" — Celery uses the AMQP `basic_reject` operation. With `requeue=False` the message leaves the queue without being processed. On RabbitMQ, a queue configured with a dead-letter exchange routes it there, where someone can inspect it; without one, it is discarded. That fits a **permanent** failure: the carrier refuses the destination address, and no number of retries will change its answer. Typical cases for `requeue=False`: - the carrier refuses the address or the parcel dimensions; - the payload fails validation and needs a person to correct it; - the retry budget is spent and the message should be kept for replay rather than dropped — check `self.request.retries >= self.max_retries` and reject instead of calling `retry()` once more, since an exhausted `retry()` ends in `FAILURE` and an acknowledged message. `Reject` only works when the task sets `acks_late=True`. With the default early ack the message was acknowledged when the task started, so the worker skips the reject and the message is simply gone. `requeue=True` puts the message straight back. There is no delay and no counter, so a failure that repeats turns into a tight loop; the Celery docs warn it "can easily result in an infinite message loop". The docs' own example guards it with `self.request.delivery_info['redelivered']`, requeuing only on the first delivery. ## `Ignore` for work that no longer matters `raise Ignore()` tells the worker to acknowledge the message and record **no state**. It suits a task that finds, on starting, that its work is moot — the shipment was cancelled after the label task was queued. It also serves custom result handling: a task that stores its own outcome with `self.update_state()` and then raises `Ignore` so Celery does not overwrite it. `autoretry_for` never retries `Ignore`, even when a broad exception list would match it. ## What the caller sees Because neither `Reject` nor `Ignore` writes to the result backend, the caller's `AsyncResult` keeps the last state stored for that id — usually `PENDING`, or `STARTED` if `task_track_started` is on. A client polling for the label waits until its own timeout. When the worker sends events, a monitor such as Flower still receives a `task-rejected` event for a rejection, so it is visible there even though the backend shows nothing. ## Choosing for the shipping-label task 1. Check preconditions first; if the shipment is cancelled, `raise Ignore()`. 2. Call the carrier. On a timeout or a 5xx-mapped error, `raise self.retry(exc=exc, countdown=60)`. 3. On a permanent refusal, `raise Reject(exc, requeue=False)` so the message reaches the dead-letter queue. 4. Let unexpected exceptions propagate: they end in `FAILURE`, and the message is acknowledged. ## Pitfalls - **`Reject` without `acks_late`** does nothing to the message; it was acknowledged already. - **`Reject(requeue=True)` as a retry** has no delay and no limit. - **Expecting `FAILURE` after a `Reject`**: the backend has no record of it, and from the backend alone a caller cannot tell a rejected task from a queued one. - **A broad `except Exception`** around the task body swallows these signals, because `Retry`, `Reject` and `Ignore` all subclass `Exception`.

  • What happens when a Celery task that does not set `acks_late` raises `Reject`?
    Nothing useful. The worker acknowledged the message when the task started, so its reject call is skipped because the message is already settled, and no state is stored either. The caller sees `PENDING` and nothing reaches a dead-letter queue. Any task that relies on `Reject` needs `acks_late=True`.
  • Why is `Reject(requeue=True)` dangerous in a Celery task, and when is it acceptable?
    It returns the same message for immediate redelivery with no delay and no counter, so a failure that repeats becomes a tight loop that can monopolise workers. It is acceptable only with a guard that ends the loop, such as checking `self.request.delivery_info['redelivered']` as the Celery docs show. When a pause is needed, `self.retry()` with a countdown is the right tool.

saying these in an interview costs you the question

  • Reject works the same whether or not the task sets acks_late.
  • Reject(requeue=True) is a convenient way to retry a task after a failure.
  • A task that raised Reject shows FAILURE in the result backend.
  • Ignore marks the task as SUCCESS with an empty result.
  • A permanent error such as a refused address should go through self.retry() like any other.