Email copiado — support@tuurt.com
Cargando experiencia
aws · 27 de septiembre de 2026 · 7 min

Dead-letter queues en SQS: cómo diseñamos el reintento antes de perder un mensaje

Un mensaje que falla en SQS no desaparece: vuelve a entregarse hasta bloquear la cola o agotar reintentos. Vemos cómo ajustamos visibility timeout, maxReceiveCount y el monitoreo de la DLQ para que eso no pase.

Por Equipo Tuurt

Dead-letter queues en SQS: cómo diseñamos el reintento antes de perder un mensaje

Cuando integramos SQS con Lambda para procesar eventos de forma asíncrona, la primera versión casi siempre funciona bien en las pruebas y falla de una forma muy específica en producción: un mensaje que no se puede procesar —por un payload mal formado, un servicio downstream caído, una excepción que no contemplamos— no desaparece. SQS lo vuelve a entregar, la función vuelve a fallar, y el ciclo se repite indefinidamente. En una cola FIFO ese mensaje puede bloquear todo el grupo de mensajería detrás de él; en una cola estándar no bloquea el orden, pero sigue consumiendo invocaciones, cuota de concurrencia y presupuesto de reintentos que deberían estar disponibles para mensajes sanos. La dead-letter queue (DLQ) no evita que el mensaje falle. Evita que un mensaje que falla de forma permanente compita para siempre con los que sí se pueden procesar.

El ajuste que hay que sincronizar primero: visibility timeout

Antes de tocar la DLQ, hay un ajuste previo que si queda mal configurado hace que todo lo demás falle de forma confusa: el visibility timeout de la cola. Cuando Lambda recibe un mensaje, SQS lo oculta del resto de consumidores durante ese tiempo, esperando a que la función confirme el procesamiento borrándolo. Si el visibility timeout es más corto que el tiempo que tarda la función en procesar el mensaje, SQS lo vuelve a hacer visible antes de que la ejecución original termine, y una segunda invocación puede recoger el mismo mensaje mientras la primera todavía está corriendo. AWS recomienda fijar el visibility timeout de la cola en al menos seis veces el timeout configurado en la función Lambda, para dejar margen a los reintentos internos del event source mapping sin que se solapen invocaciones sobre el mismo mensaje.

const queue = new sqs.Queue(this, 'PedidosQueue', {
  visibilityTimeout: cdk.Duration.seconds(180), // Lambda timeout: 30s
  deadLetterQueue: {
    queue: dlq,
    maxReceiveCount: 5,
  },
});

maxReceiveCount define cuándo un mensaje deja de ser un reintento y pasa a ser un problema

La política de redirección hacia la DLQ se define con maxReceiveCount: el número de veces que SQS entrega un mensaje sin que sea borrado antes de moverlo automáticamente a la cola secundaria. Elegir ese número no es un detalle menor. Un valor bajo (uno o dos) manda a la DLQ mensajes que habrían tenido éxito en el tercer intento, por ejemplo cuando la falla es un timeout transitorio contra una API externa que se recupera sola en segundos. Un valor alto (más de diez) mantiene mensajes rotos circulando en la cola principal durante mucho tiempo, disparando alarmas de error repetidamente por el mismo mensaje.

La distinción que usamos para decidir el número es si la causa de la falla es transitoria o determinística. Un timeout de red, una limitación de tasa del proveedor downstream, un servicio que está reiniciando: esas fallas suelen resolverse solas y justifican varios reintentos con backoff. Un payload que no cumple el esquema esperado, un identificador que no existe en la base de datos, una excepción de programación: esas fallas no se resuelven reintentando, así que insistir muchas veces solo retrasa lo inevitable y complica encontrar la causa cuando el mensaje finalmente llega a la DLQ rodeado de otros cien intentos idénticos.

batchItemFailures: no dejar que un mensaje malo tire todo el lote

Cuando el event source mapping de Lambda usa un tamaño de lote mayor a uno, un error sin manejar en cualquier mensaje del lote hace que Lambda reporte el lote entero como fallido, y SQS reintenta todos los mensajes del lote, incluidos los que sí se procesaron correctamente. Reportar fallas parciales evita ese efecto:

def handler(event, context):
    failures = []
    for record in event['Records']:
        try:
            procesar(record['body'])
        except Exception:
            failures.append({'itemIdentifier': record['messageId']})
    return {'batchItemFailures': failures}

Con functionResponseType configurado como ReportBatchItemFailures en el event source mapping, SQS solo vuelve a entregar los mensajes cuyo messageId aparece en failures. Los demás se dan por procesados. Sin esto, un solo mensaje problemático dentro de un lote de diez provoca que los otros nueve se reprocesen también, con el riesgo de efectos duplicados si esos nueve no son idempotentes.

La idempotencia no es opcional, aunque no haya fallas

SQS estándar entrega mensajes al menos una vez, no exactamente una vez. Incluso sin ningún error de aplicación, es posible que el mismo mensaje se procese dos veces: si la función tarda cerca del límite del visibility timeout, o si hay un fallo de red entre Lambda y SQS justo después de procesar pero antes de confirmar el borrado. Diseñar el handler para que procesar el mismo mensaje dos veces no duplique el efecto —guardando el identificador del mensaje ya procesado antes de ejecutar la operación de negocio, o usando una clave de idempotencia propia del dominio cuando el messageId de SQS no es suficiente porque el productor reenvía el mismo evento con un messageId distinto— deja de ser una optimización y pasa a ser un requisito, porque la garantía de entrega de la cola ya viene con esa condición incluida.

Qué monitoreamos sobre la DLQ

Una DLQ sin monitoreo es un mensaje perdido con otro nombre: nadie se entera hasta que un cliente reclama que algo no pasó. Lo mínimo que configuramos sobre cada DLQ es una alarma de CloudWatch sobre ApproximateNumberOfMessagesVisible con umbral mayor a cero, porque en una DLQ correctamente aislada cualquier mensaje presente ya es una señal de que algo necesita revisión humana, no un valor que se espera ver crecer con tráfico normal. Esa alarma dispara una notificación a SNS que llega al canal de guardia, no un dashboard que alguien tiene que recordar revisar.

Una vez identificada la causa y corregido el bug o restablecido el servicio downstream, usamos la función de redrive de SQS para devolver los mensajes de la DLQ a la cola principal sin tener que escribir un script de migración a mano, especificando cuántos mensajes mover y desde qué momento, lo cual es más seguro que vaciar la DLQ entera de una sola vez cuando solo una parte de los mensajes corresponde al bug ya resuelto.

Lo que no funcionó: reintentos manuales con backoff dentro del handler

En una integración temprana intentamos evitar la complejidad del visibility timeout implementando nuestro propio backoff dentro de la función: si la llamada al servicio downstream fallaba, el handler esperaba unos segundos y reintentaba internamente antes de devolver el control a Lambda. La intención era tener control fino sobre el backoff sin depender de la configuración de la cola. No funcionó por dos razones. La función mantenía el slot de concurrencia ocupado durante toda la espera, así que bajo una ráfaga de fallas downstream el backoff manual multiplicaba el tiempo de ejecución facturable de cada invocación en vez de liberar el slot para otros mensajes. Y como el reintento vivía dentro de la misma invocación, cualquier reinicio en frío o timeout de la función durante la espera perdía el estado del reintento sin que SQS supiera que debía reprogramarlo, al contrario de lo que pasa cuando el reintento lo maneja la cola. Volvimos a apoyarnos en el visibility timeout y el maxReceiveCount de SQS para el backoff entre reintentos, y dejamos la lógica del handler únicamente para decidir si una falla es reintentable o debe fallar de inmediato hacia la DLQ.

La pregunta que nos queda antes de cerrar la integración

Antes de dar por terminada una integración con SQS y Lambda revisamos cuatro cosas: que el visibility timeout tenga margen suficiente sobre el timeout de la función, que maxReceiveCount refleje si las fallas esperadas son transitorias o determinísticas, que el handler reporte fallas parciales de lote en vez de todo o nada, y que exista una alarma sobre la DLQ que llegue a una persona y no solo a un log. Cuando las cuatro están resueltas, un mensaje que falla deja de ser un riesgo de pérdida silenciosa y pasa a ser, en el peor caso, una alerta que alguien atiende con el mensaje todavía disponible para reprocesar.

aws sqs lambda serverless
← Volver al blog