El comportamiento de reintento por defecto con el que la mayoría de los equipos despliegan Celery es max_retries=3 y nada más — sin backoff, sin jitter, sin distinguir entre “la API downstream está caída durante diez segundos” y “esta tarea nunca va a tener éxito por más veces que la ejecutes”. Entonces una dependencia tiene una tarde complicada, todas las tareas que la usan fallan, cada uno de esos reintentos se dispara de nuevo en el mismo segundo, y la estampida de reintentos termina el trabajo que la interrupción original había empezado. Los reintentos no ayudaron. Amplificaron el problema hasta convertirlo en lo que realmente causó el incidente.
La solución no es “reintentar menos”. Es reintentar con intención: saber qué fallos merece la pena reintentar, espaciar esos reintentos para que no caigan todos en el mismo instante, y — de forma crítica — tener un lugar al que enviar una tarea una vez que ha agotado todos los intentos razonables, en lugar de que simplemente desaparezca en una línea de log que nadie estaba mirando. Esa última parte es la pieza que la mayoría de las configuraciones de Celery se saltan por completo, porque Celery no te da una dead-letter queue gratis como sí ocurre con los consumidores nativos de RabbitMQ.
En resumen: el backoff exponencial espacia los reintentos con un retraso creciente después de cada fallo en lugar de dispararlos todos a la vez, y una dead-letter queue es el lugar duradero al que va una tarea una vez que ha agotado todos sus reintentos, para que pueda inspeccionarse y reprocesarse en lugar de simplemente desaparecer.
Aprenderás:
- Cómo distinguir los fallos transitorios que merece la pena reintentar de los permanentes que nunca deberían reintentarse
- Cómo usar
autoretry_forcorrectamente, incluyendo los errores que hacen que reintente las cosas equivocadas - Cómo configurar el backoff exponencial de reintentos en Celery con jitter para que los reintentos no se acumulen en una estampida
- Por qué
max_retriesnecesita un valor deliberado, no simplemente el valor por defecto de Celery - Cómo construir una dead-letter queue funcional en Celery, ya que Celery no trae una integrada
- Cómo enrutar, alertar y reprocesar de forma segura las tareas que terminan en la dead-letter queue
Tabla de contenido
- Fallos transitorios vs permanentes
- Reintentos con autoretry_for
- Backoff exponencial y jitter
- Cómo rendirse con elegancia usando max_retries
- Configurar valores globales por defecto vs overrides por tarea
- Cómo construir una dead-letter queue en Celery
- Alertas y reprocesamiento desde la dead-letter queue
- Errores comunes
- Preguntas frecuentes
Fallos transitorios vs permanentes
Toda estrategia de reintentos empieza con una pregunta que la mayoría del código de tareas nunca llega a responder realmente: ¿este fallo es del tipo que desaparece si esperas, o del tipo que va a seguir ahí dentro de una hora? Confundir los dos es la mayor fuente de mal comportamiento en los reintentos.
Los fallos transitorios son aquellos en los que la misma entrada exacta, reintentada un poco más tarde, probablemente tenga éxito: un pool de conexiones a base de datos agotado por un momento, una API HTTP downstream devolviendo un 503, un timeout de red, un blip de conexión de Redis. Estos merece la pena reintentarlos, y reintentarlos con un retraso creciente, porque la condición subyacente suele estar ligada a carga o a tiempo y tiende a resolverse por sí sola.
Los fallos permanentes no se arreglan esperando: un ValidationError porque la entrada estaba mal formada, un 404 de una API porque el recurso genuinamente no existe, un KeyError por un bug de programación en la propia tarea. Reintentar estos no solo desperdicia capacidad de los workers — retrasa el momento en que un humano realmente se entera de que algo está roto, porque la tarea parece “seguir trabajando en ello” durante todo el calendario de reintentos en lugar de mostrarse de inmediato como fallida.
La implicación práctica es que autoretry_for casi nunca debería ser un Exception a secas. Debería ser una tupla específica y deliberadamente elegida de tipos de excepción que hayas decidido que son realmente transitorios para esta tarea en concreto.
import requests
from celery import shared_task
class PermanentFailure(Exception):
"""Raised for errors that will never succeed on retry."""
@shared_task(bind=True)
def sync_customer_record(self, customer_id: int):
response = requests.get(f"https://crm.example.com/customers/{customer_id}", timeout=5)
if response.status_code == 404:
# The customer genuinely doesn't exist upstream — retrying won't change that.
raise PermanentFailure(f"customer {customer_id} not found in CRM")
response.raise_for_status() # raises for 5xx/connection errors — worth retrying
return response.json()
Lanzar deliberadamente un tipo de excepción distinto para el caso “no reintentar esto”, en lugar de dejar que toda excepción caiga en el mismo saco, es lo que hace que la configuración de autoretry_for de la siguiente sección realmente signifique algo.
Una referencia rápida: qué es realmente transitorio
Clasificar los fallos resulta más fácil con una tabla de referencia concreta en lugar de razonarlo desde cero para cada tarea:
| Fallo | ¿Suele ser transitorio? | Notas |
|---|---|---|
| Timeout de conexión / conexión rechazada | Sí | Reintentar con backoff — la dependencia puede estar bajo carga o reiniciándose |
| HTTP 503 / 429 | Sí | Señales explícitas de “dar un paso atrás”; respeta un header Retry-After si está presente |
| HTTP 500 | A veces | Depende de si es un error transitorio del servidor o un bug consistente en su lado — limita los reintentos y escala si se repite |
| HTTP 404 / 400 | No | El recurso o la propia petición está mal; reintentar no cambia la entrada |
IntegrityError (restricción única) | No | El conflicto de datos seguirá ahí en el reintento — necesita lógica de aplicación, no un reintento |
| Deadlock de base de datos | Sí | Genuinamente transitorio por naturaleza; backoff corto, no hace falta un retraso largo |
KeyError / TypeError desde el código de la tarea | No | Esto es un bug, no un fallo del entorno — reintentar solo retrasa el reporte del bug |
ConnectionError de Redis | Sí | Blip del broker o de la caché; reintenta, pero investiga si se repite con frecuencia |
Trata esto como un punto de partida, no como una regla universal — la clasificación correcta depende de qué hace realmente tu dependencia específica cuando está degradada, y vale la pena revisarla después de cada incidente que saque a la luz un modo de fallo que no habías contemplado.
Reintentos con autoretry_for
autoretry_for es el mecanismo declarativo de reintento de Celery — en lugar de capturar excepciones manualmente y llamar a self.retry(), listas los tipos de excepción que deberían disparar un reintento automático, y Celery se encarga del resto.
from celery import shared_task
from requests.exceptions import ConnectionError, Timeout, HTTPError
@shared_task(
bind=True,
autoretry_for=(ConnectionError, Timeout, HTTPError),
retry_backoff=True,
retry_backoff_max=600,
retry_jitter=True,
max_retries=5,
)
def sync_customer_record(self, customer_id: int):
response = requests.get(f"https://crm.example.com/customers/{customer_id}", timeout=5)
response.raise_for_status()
return response.json()
Los dos errores que aparecen constantemente con autoretry_for: listar excepciones de forma demasiado amplia (capturar Exception en sí misma significa que un bug genuino en el código de tu tarea — un TypeError por un refactor mal hecho — se reintenta en silencio cinco veces en lugar de fallar de forma ruidosa en la primera ejecución, enterrando un defecto real dentro de lo que parece ruido normal de reintentos), y olvidar que autoretry_for reintenta todo el cuerpo de la tarea desde el principio, no solo la parte que falló. Si tu tarea ejecuta dos efectos secundarios en secuencia y el segundo lanza una excepción, el reintento vuelve a ejecutar también el primero — que es exactamente el problema de la ejecución duplicada que el diseño de tareas idempotentes existe para resolver, y vale la pena diseñarlo desde el principio en lugar de descubrirlo en medio de un incidente.
HTTPError merece una mención específica: response.raise_for_status() lo lanza tanto para un 503 (transitorio, merece reintento) como para un 404 (permanente, no merece reintento) — el mismo tipo de excepción cubre ambos casos. Reintentar HTTPError de forma indiscriminada significa que reintentas 404 cinco veces antes de rendirte, lo cual es trabajo desperdiciado y una señal de fallo retrasada. El patrón PermanentFailure de la sección anterior — comprobar el código de estado y lanzar una excepción distinta y no reintentable para los casos permanentes — es lo que realmente arregla esto; autoretry_for por sí solo no puede distinguir entre dos fallos que comparten una misma clase de excepción.
Backoff exponencial y jitter
Reintentar inmediatamente después de un fallo es casi la peor opción para un fallo transitorio relacionado con carga — se dispara de nuevo mientras el sistema downstream todavía está bajo exactamente la misma presión que causó el primer fallo. El backoff exponencial espacia los reintentos con un retraso creciente: 2 segundos, luego 4, luego 8, luego 16, dando a la dependencia que falla espacio real para recuperarse entre intentos.
@shared_task(
bind=True,
autoretry_for=(ConnectionError, Timeout),
retry_backoff=True, # enables exponential backoff
retry_backoff_max=600, # cap the delay at 10 minutes
retry_jitter=True, # randomize the delay to avoid a thundering herd
max_retries=6,
)
def call_flaky_api(self, payload: dict):
return flaky_api_client.post(payload)
retry_backoff=True usa la fórmula incorporada de Celery — aproximadamente 2 ** retry_count segundos por defecto, con un tope en retry_backoff_max — así que el calendario para una tarea con max_retries=6 se parece aproximadamente a 1s, 2s, 4s, 8s, 16s, 32s (cada uno topado en el máximo) en lugar de seis reintentos disparados uno detrás de otro.
retry_jitter es el detalle que es fácil saltarse y caro de saltarse en la práctica. Sin él, cada instancia de tarea que falló en el mismo momento — digamos, porque una dependencia downstream compartida tuvo una interrupción de dos segundos — calcula exactamente el mismo retraso de backoff y reintenta exactamente en el mismo momento, solo desplazado por un offset fijo. Cientos o miles de tareas reintentando todas en la misma ventana de 100ms es una estampida autoinfligida, y puede convertir una interrupción breve en una caída sostenida, ya que el propio pico de reintentos termina sobrecargando al servicio que se estaba recuperando. El jitter aleatoriza el retraso real de cada tarea dentro de su ventana de backoff, repartiendo esa misma estampida a lo largo de varios segundos en lugar de concentrarla en un instante.
Para lógica de reintento manual (cuando autoretry_for no encaja, por ejemplo cuando necesitas inspeccionar la excepción para decidir el retraso), la misma fórmula aplicada a mano:
import random
@shared_task(bind=True, max_retries=6)
def call_flaky_api_manual(self, payload: dict):
try:
return flaky_api_client.post(payload)
except (ConnectionError, Timeout) as exc:
backoff = min(600, 2 ** self.request.retries)
jitter = random.uniform(0, backoff * 0.5)
raise self.retry(exc=exc, countdown=backoff + jitter)
Cómo rendirse con elegancia usando max_retries
El max_retries por defecto de Celery es 3, aplicado en silencio si nunca lo configuras — un valor elegido sin ninguna razón específica relacionada con las características reales de fallo de tu tarea. El número correcto depende de cuánto suele durar el modo de fallo contra el que te estás protegiendo: reintentar un blip de conexión a base de datos cinco veces en 30 segundos es razonable; reintentar la caída de una API externa durante esos mismos 30 segundos, cuando las caídas de ese proveedor suelen durar 20 minutos, simplemente garantiza que agotes los reintentos antes de que la dependencia se recupere, y lo hace en silencio, una tarea a la vez, sin que nadie note el patrón.
Configura max_retries de forma deliberada, y — esta es la parte que es fácil saltarse — decide explícitamente qué pasa cuando se agota:
from celery.exceptions import MaxRetriesExceededError
@shared_task(bind=True, max_retries=5)
def call_flaky_api(self, payload: dict):
try:
return flaky_api_client.post(payload)
except (ConnectionError, Timeout) as exc:
try:
backoff = min(600, 2 ** self.request.retries)
raise self.retry(exc=exc, countdown=backoff)
except MaxRetriesExceededError:
send_to_dead_letter_queue(task_name=self.name, args=[payload], error=str(exc))
raise
Dejar que una tarea simplemente falle después de su último reintento, sin nada más abajo de ese fallo, es el comportamiento por defecto que lleva a la clase de incidente de “no lo notamos durante tres días” — la tarea aparece como una entrada roja en Flower o una línea de log, y a menos que alguien esté vigilando activamente esa superficie, es efectivamente invisible. Ese es el hueco que cierra una dead-letter queue.
Configurar valores globales por defecto vs overrides por tarea
Repetir retry_backoff=True, retry_jitter=True, retry_backoff_max=600 en cada definición de tarea es fácil de olvidar precisamente en la única tarea que más importa. Celery te permite fijar valores por defecto sensatos de forma global y sobreescribirlos solo donde una tarea específica realmente necesita un comportamiento distinto:
# celeryconfig.py
task_annotations = {
"*": {
"retry_backoff": True,
"retry_backoff_max": 600,
"retry_jitter": True,
"max_retries": 5,
},
"myapp.tasks.charge_customer": {
"max_retries": 2, # payment retries should stay conservative
"retry_backoff_max": 60,
},
"myapp.tasks.sync_analytics_batch": {
"max_retries": 10, # low-stakes, safe to retry aggressively
"retry_backoff_max": 1800,
},
}
task_annotations aplica la entrada comodín a todas las tareas, y luego superpone overrides específicos por nombre de tarea. Vale la pena configurar esto pronto, precisamente porque el modo de fallo de no tener un valor global por defecto no es un error — es silencio. Una tarea recién añadida que se olvida de configurar el backoff y el jitter simplemente reintenta de inmediato, seis veces seguidas, y nadie se da cuenta hasta que es la tarea que contribuye a una estampida de reintentos durante la próxima caída real. Un valor global por defecto convierte el comportamiento seguro en el camino de menor resistencia en lugar de algo que cada autor de tareas tiene que recordar.
Cómo construir una dead-letter queue en Celery
A diferencia de los consumidores de RabbitMQ que usan AMQP directamente, Celery no ofrece una dead-letter queue de primera clase lista para usar — no hay un flag de configuración dead_letter_exchange que simplemente actives. Tienes que construirla tú mismo, y su forma es más simple de lo que suena: un lugar durable donde escribir el contexto completo de una tarea que ha agotado sus reintentos, para que pueda inspeccionarse, generar alertas y reprocesarse en lugar de desvanecerse en el vacío de los reintentos agotados.
La versión más simple es una tabla de base de datos:
class DeadLetterTask(db.Model):
__tablename__ = "dead_letter_tasks"
id = db.Column(db.Integer, primary_key=True)
task_name = db.Column(db.String(255), nullable=False)
task_args = db.Column(db.JSON, nullable=False)
task_kwargs = db.Column(db.JSON, nullable=False)
error_message = db.Column(db.Text, nullable=False)
retry_count = db.Column(db.Integer, nullable=False)
failed_at = db.Column(db.DateTime, default=datetime.utcnow)
reprocessed = db.Column(db.Boolean, default=False)
from celery.signals import task_failure
@task_failure.connect
def handle_task_failure(sender=None, task_id=None, exception=None, args=None,
kwargs=None, einfo=None, **extra):
# Only trap the final failure — Celery fires this signal once retries
# are genuinely exhausted, not on every intermediate retry attempt.
db.session.add(DeadLetterTask(
task_name=sender.name,
task_args=list(args or []),
task_kwargs=dict(kwargs or {}),
error_message=str(exception),
retry_count=sender.request.retries if sender.request else 0,
))
db.session.commit()
Engancharse a la señal task_failure de forma global, en lugar de envolver cada tarea individualmente, significa que las tareas nuevas obtienen cobertura de dead-letter automáticamente sin que cada una necesite su propio boilerplate — la contrapartida es que se dispara para el fallo final de cualquier tarea, así que en general quieres una forma de excluir tareas específicas de bajo riesgo en lugar de enrutar cada fallo indiscriminadamente a la misma tabla.
Si estás usando RabbitMQ como broker en lugar de Redis, tienes una alternativa de más bajo nivel: configura un exchange y una queue de dead-letter dedicados a nivel de broker, y fija x-dead-letter-exchange en tus colas de tareas para que el propio RabbitMQ enrute allí los mensajes rechazados o expirados. Esto es más robusto que el enfoque de señal a nivel de aplicación porque sobrevive incluso si tu proceso worker de Celery se cae antes de que se ejecute el handler de task_failure, pero requiere configuración a nivel de broker fuera de la propia configuración de Celery y, en general, no está disponible en absoluto si estás en Redis — lo cual es una razón más por la que vale la pena acertar con la elección de broker discutida en la comparación de colas de tareas antes de meterte de lleno en el trabajo de fiabilidad en producción.
Alertas y reprocesamiento desde la dead-letter queue
Una dead-letter queue que nadie mira es apenas mejor que no tener ninguna — simplemente cambia “desapareció en silencio” por “se acumuló en silencio en una tabla”. Dos cosas la hacen realmente útil:
Alertas. Dispara una notificación en el momento en que algo llega al almacén de dead-letter, no en un horario programado — un digest diario por email está bien para tener consciencia de volumen, pero la primera aparición de un nuevo patrón de fallo merece un aviso o un mensaje de Slack inmediato, ya que suele ser la señal más temprana de una caída en una dependencia que no monitorizas directamente. Pasado un puñado de estas alertas, aparecen las mismas preguntas de fan-out y garantía de entrega que los sistemas de notificaciones en producción tienen que resolver a gran escala.
@task_failure.connect
def alert_on_task_failure(sender=None, task_id=None, exception=None, **extra):
if is_novel_failure(sender.name, str(exception)):
notify_slack(f"Task {sender.name} exhausted retries: {exception}")
Reprocesamiento. Una vez que la causa subyacente está arreglada — la API downstream volvió, se corrigió el dato incorrecto —, reprocesar las tareas en la dead-letter queue debería ser una acción deliberada y auditable, no un bucle de reintento automático vuelto a montar. Un pequeño script que vuelve a encolar filas seleccionadas y las marca como reprocessed=True mantiene un registro claro de qué se reintentó y cuándo, lo cual importa cuando el mismo fallo reaparece y necesitas saber si es una instancia nueva o el mismo fallo de antes que ha vuelto.
def reprocess_dead_letter_task(dead_letter_id: int):
row = DeadLetterTask.query.get(dead_letter_id)
task = celery_app.tasks[row.task_name]
task.apply_async(args=row.task_args, kwargs=row.task_kwargs)
row.reprocessed = True
db.session.commit()
Resiste la tentación de reprocesar automáticamente todo lo que hay en la dead-letter queue con un cron programado — eso reintroduce en silencio exactamente el riesgo de estampida de reintentos que las configuraciones de backoff y jitter estaban diseñadas para prevenir, solo que en un horizonte temporal más largo, y elimina el punto de decisión humana que el dead-lettering se suponía que debía crear en primer lugar.
Errores comunes
Error: reintentar Exception de forma amplia en lugar de tipos de error transitorios específicos. Esto reintenta bugs genuinos de la misma forma que reintenta blips de red, ocultando defectos reales dentro de ruido de reintentos que parece normal. Solución: enumera las excepciones específicas que son realmente transitorias para esta tarea, y deja que todo lo demás falle de inmediato.
Error: sin jitter en el backoff. Un lote de tareas que fallaron juntas reintenta junto, convirtiendo una caída breve en una estampida de reintentos autoinfligida. Solución: configura siempre retry_jitter=True junto con retry_backoff=True.
Error: tratar autoretry_for como si solo reintentara la línea que falló. Vuelve a ejecutar todo el cuerpo de la tarea desde el principio. Solución: diseña las tareas para que se puedan volver a ejecutar por completo de forma segura, usando los patrones de idempotencia tratados aparte, en lugar de asumir que el progreso parcial sobrevive a un reintento.
Error: ninguna dead-letter queue en absoluto, solo logs. Los fallos de reintento agotado se vuelven invisibles a menos que alguien esté vigilando activamente un dashboard. Solución: engancha task_failure (o el dead-lettering nativo de tu broker) para persistir cada fallo agotado en algún lugar consultable y con capacidad de alertar.
Error: reprocesar automáticamente la dead-letter queue con un temporizador. Esto reintroduce en silencio el riesgo de estampida de reintentos que el backoff estaba diseñado para prevenir, en un ciclo más largo. Solución: convierte el reprocesamiento en una acción deliberada y registrada, tomada después de confirmar que la causa subyacente realmente está arreglada.
Los reintentos y las dead-letter queues no son asuntos separados atornillados a una cola de tareas — son los dos extremos de la misma decisión de diseño sobre qué pasa cuando algo sale mal. Reintenta los fallos que merece la pena reintentar, espácialos lo suficiente para que no agraven el problema, y ríndete de forma ruidosa y visible en lugar de en silencio, en una tabla que nadie consulta. Esa es la diferencia entre una cola que se degrada con elegancia en una mala tarde y una que convierte una caída de diez segundos en el incidente real.
Preguntas frecuentes
¿Celery tiene una dead-letter queue integrada?
No. A diferencia de los consumidores nativos de RabbitMQ, Celery no trae una lista para usar — la construyes tú mismo, normalmente enganchándote a la señal task_failure para persistir las tareas con reintentos agotados en algún lugar consultable y con alertas.
¿Cuál es un buen valor por defecto para max_retries en Celery?
No hay un número universal — el propio valor por defecto de Celery, 3, es arbitrario. Fíjalo según cuánto suele durar el modo de fallo del que te estás protegiendo: un puñado de reintentos en 30 segundos para un bache de base de datos, más reintentos y más tiempo para una dependencia con caídas de 20 minutos.
¿retry_jitter debería estar siempre activado junto a retry_backoff?
Sí. Sin jitter, cada tarea que falló en el mismo momento calcula el mismo retraso y reintenta en el mismo instante exacto, convirtiendo una interrupción breve en una estampida de reintentos autoinfligida.
¿autoretry_for reintenta solo la parte de la tarea que falló?
No — vuelve a ejecutar todo el cuerpo de la tarea desde el principio, incluyendo cualquier efecto secundario que ya hubiera tenido éxito. Por eso las tareas deben diseñarse para poder reejecutarse por completo de forma segura, no asumiendo que solo se repite la parte fallida.

