RabbitMQ + Python: o que acontece quando o worker falha antes do ack
Uma API que responde 201 antes de saber se o email foi enviado, e uma linha de código, requeue=False, que decide o destino de toda mensagem que falha.
Depois do estudo sobre cache, puxei outro assunto que aparece toda hora em sistemas maiores: filas de mensagem e comunicação assíncrona entre serviços.
O cenário
Montei um cenário direto: uma API em Node.js simula o registro de um usuário e, em vez de enviar o email ali mesmo, publica uma mensagem numa fila do RabbitMQ. Um worker em Python, separado e rodando de forma independente, fica ouvindo essa fila e é quem de fato dispara o email — usando o Resend.
Cliente -> API (Node.js) -> RabbitMQ -> Worker (Python + Pika) -> Resend (Email)
Duas linguagens diferentes conversando através de uma fila, sem uma conhecer a outra diretamente. Isso já era um primeiro ponto interessante: o RabbitMQ não se importa em qual linguagem o produtor ou o consumidor foram escritos, contanto que ambos falem o protocolo AMQP.
O lado que publica
./src/config/rabbitMQ.js
const rabbit = {
send: async (queue, message) => {
try {
const connection = await amqp.connect(process.env.RABBIT_URL)
const channel = await connection.createChannel()
await channel.assertQueue(queue, { durable: true })
channel.sendToQueue(
queue,
Buffer.from(JSON.stringify(message)),
{ persistent: true }
)
await new Promise(resolve => setTimeout(resolve, 500))
await channel.close()
await connection.close()
return true
} catch (error) {
console.error(error)
throw error
}
}
}
E o controller que usa isso:
./src/controller/userController.js
const user_register = {
register: async function (req, res) {
const { email } = req.body;
try {
const message = { email }
await send('register', message)
return res.status(201).json({ 'message': "Usuário criado com sucesso!" })
} catch (error) {
return res.status(500).json({ 'error': error.message })
}
}
}
O ponto central: a API responde 201 assim que a mensagem é publicada na fila — ela não espera o email ser enviado, nem sabe se o worker está de pé, nem sabe se o Resend vai aceitar o envio. Publicar na fila e mandar o email pro usuário são dois eventos completamente desacoplados no tempo. É basicamente a definição de comunicação assíncrona, mas só bateu de verdade quando eu vi a resposta 201 chegando instantânea mesmo com o worker desligado — a mensagem simplesmente fica esperando na fila (durable: true garante isso mesmo se o RabbitMQ reiniciar).
O lado que consome
./python-worker.py
channel.queue_declare(queue="register", durable=True)
def callback(ch, method, properties, body):
try:
data = json.loads(body)
enviar_email(data['email'])
ch.basic_ack(delivery_tag=method.delivery_tag)
except Exception as e:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue="register", on_message_callback=callback, auto_ack=False)
O comportamento em caso de falha depende de uma flag: auto_ack=False. Com ack manual, a mensagem só sai da fila quando o worker chama basic_ack explicitamente, depois de processar com sucesso — não quando o RabbitMQ simplesmente entrega ela. Isso existe pra cobrir o caso do worker morrer no meio do processamento: se ele cair antes do ack, o RabbitMQ devolve a mensagem pra fila pra outro consumidor tentar de novo.
O prefetch_count=1 também faz sentido nesse contexto: ele diz pro RabbitMQ “não me manda uma mensagem nova enquanto eu não confirmar a anterior”, o que evita um worker travado acumulando um monte de mensagens que ele nunca vai processar.
requeue=False: a linha que descarta mensagens
No except do callback, o código chama basic_nack(..., requeue=False). Se enviar_email() falhar por qualquer motivo — Resend fora do ar, chave de API inválida, o que for — a mensagem é descartada: não volta pra fila, não é tentada de novo, não vai pra lugar nenhum.
durable e persistent protegem a mensagem contra o RabbitMQ cair ou reiniciar — mas não protegem contra uma falha de negócio no consumidor. Isso é uma decisão que cabe a quem escreve o worker, e nesse projeto ela foi tomada priorizando simplicidade: falhou, descarta, segue o baile.
Limitações que ficaram evidentes
- Não existe dead letter queue configurada. Uma fila de mensagens mortas seria o lugar natural pra onde essas mensagens que falharam deveriam ir, em vez de serem descartadas silenciosamente.
- Sem retry: uma falha temporária no Resend (um timeout, por exemplo) tem o mesmo destino que um erro permanente — a mensagem morre igual.
- Cada chamada a
send()abre uma conexão AMQP nova comamqp.connect()e fecha tudo no fim, com uma pausa fixa de 500ms antes de fechar. Isso funciona, mas é caro: numa API com volume real, o ideal seria manter uma conexão/canal já abertos e reutilizá-los entre requisições, em vez de recriar isso a cada publicação.
Próximo passo
O caminho natural aqui seria configurar uma dead letter exchange pra capturar essas mensagens que falharam, em vez de descartá-las — e daí decidir o que fazer com elas (retry com backoff, alerta manual, etc).