Voltar para o blog
RabbitMQMensageriaBackend

Dead letter queues no RabbitMQ: o que são e como usar de verdade

28 de maio de 20269 min de leiturapor Sávio Araújo

O problema sem dead letter queues

Sem uma dead letter queue (DLQ), mensagens que falham no processamento são descartadas ou ficam em loop infinito de retry, bloqueando a fila principal.

As duas alternativas ruins:

  1. Requeue eterno: a mensagem volta para a fila e bloqueia processamento de outras mensagens
  2. Descartar em caso de erro: você perde dados de produção silenciosamente

DLQs resolvem isso: mensagens que não conseguem ser processadas vão para uma fila separada, preservadas para investigação e reprocessamento.

Como funciona no RabbitMQ

Uma mensagem vai para a dead letter exchange (DLX) quando:

  1. É rejeitada (nack ou reject) com requeue: false
  2. Expira por TTL
  3. A fila atinge o limite máximo de mensagens
Producer → [main-queue] → Consumer
                              ↓ (nack ou TTL)
              [dlx-exchange] → [dead-letter-queue]

Configuração com @nestjs/microservices

Declarando as filas

// rabbitmq.setup.ts
import { Connection } from "amqplib";
 
export async function setupQueues(connection: Connection) {
  const channel = await connection.createChannel();
 
  // 1. Declara a dead letter exchange
  await channel.assertExchange("dlx", "direct", { durable: true });
 
  // 2. Declara a dead letter queue
  await channel.assertQueue("orders.dead-letter", {
    durable: true,
    arguments: {
      "x-message-ttl": 7 * 24 * 60 * 60 * 1000, // mensagens expiram em 7 dias
    },
  });
 
  // 3. Bind DLQ → DLX
  await channel.bindQueue("orders.dead-letter", "dlx", "orders");
 
  // 4. Declara a fila principal com referência ao DLX
  await channel.assertQueue("orders.processing", {
    durable: true,
    arguments: {
      "x-dead-letter-exchange": "dlx",         // onde vai quando falha
      "x-dead-letter-routing-key": "orders",   // routing key na DLX
      "x-message-ttl": 30_000,                 // TTL de 30s na fila principal
      "x-max-length": 10_000,                  // máximo de 10k mensagens
    },
  });
}

Consumer com tratamento de erro

@Injectable()
export class OrdersConsumer {
  private readonly logger = new Logger(OrdersConsumer.name);
 
  @RabbitSubscribe({
    exchange: "orders",
    routingKey: "orders.process",
    queue: "orders.processing",
    queueOptions: {
      durable: true,
      arguments: {
        "x-dead-letter-exchange": "dlx",
        "x-dead-letter-routing-key": "orders",
      },
    },
  })
  async handleOrder(
    @Payload() message: OrderMessage,
    @RabbitMQMessage() rawMessage: ConsumeMessage
  ) {
    try {
      await this.orderService.process(message);
      // Ack implícito em caso de sucesso
    } catch (error) {
      this.logger.error(
        `Falha ao processar order ${message.orderId}: ${error.message}`,
        { messageId: rawMessage.properties.messageId }
      );
 
      // Erros transientes: requeue para retry
      if (this.isTransient(error)) {
        throw error; // @golevelup/nestjs-rabbitmq faz nack + requeue
      }
 
      // Erros permanentes (dados inválidos, etc): vai para DLQ
      // Nack sem requeue → DLX
      throw new Nack(false);
    }
  }
 
  private isTransient(error: Error): boolean {
    return (
      error.message.includes("ECONNREFUSED") ||
      error.message.includes("timeout") ||
      error instanceof ServiceUnavailableException
    );
  }
}

Retry com backoff antes da DLQ

Em vez de ir direto para a DLQ, implemente filas de retry com TTL crescente:

[orders.processing] →(falha)→ [orders.retry.10s] →(TTL 10s)→ [orders.processing]
                                                                      ↓ (3ª falha)
                                                               [orders.dead-letter]
export async function setupQueuesWithRetry(channel: Channel) {
  // Retry exchange (envia de volta para a fila principal após TTL)
  await channel.assertExchange("retry-exchange", "direct", { durable: true });
 
  // Filas de retry com TTL crescente
  const retryDelays = [10_000, 30_000, 120_000]; // 10s, 30s, 2min
 
  for (const delay of retryDelays) {
    await channel.assertQueue(`orders.retry.${delay}ms`, {
      durable: true,
      arguments: {
        "x-message-ttl": delay,
        "x-dead-letter-exchange": "",           // default exchange
        "x-dead-letter-routing-key": "orders.processing", // volta para a fila principal
      },
    });
  }
}

Para implementar isso, use o header x-death que o RabbitMQ adiciona automaticamente:

async handleOrder(message: OrderMessage, rawMessage: ConsumeMessage) {
  const deathCount = this.getDeathCount(rawMessage);
 
  try {
    await this.orderService.process(message);
  } catch (error) {
    if (deathCount >= 3) {
      // Esgotou as tentativas: vai para DLQ
      throw new Nack(false);
    }
 
    // Envia para a fila de retry correspondente
    const delays = [10_000, 30_000, 120_000];
    const delay = delays[deathCount] ?? delays[delays.length - 1];
 
    await this.channel.publish("retry-exchange", `orders.retry.${delay}ms`, message, {
      headers: rawMessage.properties.headers,
    });
 
    // Ack para remover da fila principal (o retry cuida do resto)
    // Nack(false) iria para DLQ, não queremos isso aqui
  }
}
 
private getDeathCount(rawMessage: ConsumeMessage): number {
  const deaths = rawMessage.properties.headers?.["x-death"];
  if (!Array.isArray(deaths)) return 0;
  return deaths.reduce((acc, d) => acc + (d.count ?? 0), 0);
}

Consumer da DLQ

A DLQ precisa de atenção ativa. Configure alertas e um consumer de monitoramento:

@Injectable()
export class DeadLetterConsumer {
  private readonly logger = new Logger(DeadLetterConsumer.name);
 
  @RabbitSubscribe({
    exchange: "dlx",
    routingKey: "orders",
    queue: "orders.dead-letter",
  })
  async handleDeadLetter(
    @Payload() message: OrderMessage,
    @RabbitMQMessage() rawMessage: ConsumeMessage
  ) {
    const deaths = rawMessage.properties.headers?.["x-death"];
 
    this.logger.error("Mensagem na DLQ", {
      orderId: message.orderId,
      reason: deaths?.[0]?.reason,
      firstFailureAt: deaths?.[0]?.time,
      attempts: this.getDeathCount(rawMessage),
    });
 
    // Salva no banco para investigação
    await this.deadLetterRepo.save({
      payload: message,
      headers: rawMessage.properties.headers,
      reason: deaths?.[0]?.reason,
      failedAt: new Date(),
    });
 
    // Alerta via Slack/PagerDuty se for crítico
    if (this.isCritical(message)) {
      await this.alertService.notify(
        `Order ${message.orderId} na DLQ após ${this.getDeathCount(rawMessage)} tentativas`
      );
    }
  }
}

Endpoint de reprocessamento

Após investigar e corrigir o problema, você pode reprocessar mensagens da DLQ:

@Post("dead-letter/reprocess/:id")
@UseGuards(AdminGuard)
async reprocess(@Param("id") id: string) {
  const deadLetter = await this.deadLetterRepo.findById(id);
  if (!deadLetter) throw new NotFoundException();
 
  await this.ordersPublisher.publish(deadLetter.payload);
  await this.deadLetterRepo.markAsReprocessed(id);
 
  return { message: "Mensagem enviada para reprocessamento" };
}

Conclusão

Dead letter queues são a rede de segurança do sistema de mensageria. Sem elas, falhas silenciosas levam a perda de dados ou loops infinitos. Com elas, você tem visibilidade sobre o que falhou, por quê, e controle para reprocessar quando o problema for corrigido.

Configure DLQs desde o início: é muito mais difícil adicionar depois quando já tem mensagens em produção.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech