O cenário costuma ser este. Um consumidor Java recebe uma mensagem da fila pedidos-source. O payload está quebrado e o handler lança exceção. O catch espera alguns segundos e tenta de novo, três vezes. Enquanto isso, a visibility timeout da mensagem expira, o SQS a entrega para outra instância, e essa instância repete o mesmo ritual. A DLQ existe, foi criada com carinho e continua vazia. Na fila de origem, o poison message nunca falta a uma rodada.
O SQS já sabe tirar de circulação uma mensagem que falha sempre. Só que ele conta recebimentos, não exceções. O retry dentro da aplicação, junto com a ausência de um maxReceiveCount configurado, impede essa contagem de chegar ao limite.
A seguir: o contrato da RedrivePolicy conforme o Amazon SQS Developer Guide, o motivo de o retry dentro do processo não avançar o contador, um consumidor que faz uma tentativa por receive e um script Python que valida a configuração em JSON local, sem conta AWS e sem SDK.
O contrato: RedrivePolicy é uma string dentro do mapa de atributos
A ligação entre a fila de origem e a DLQ é feita pelo atributo RedrivePolicy da fila de origem, enviado pelo SetQueueAttributes. A API reference descreve esse valor como uma string que contém um objeto JSON com duas chaves:
deadLetterTargetArn: o ARN da DLQ para onde o SQS move a mensagem depois que o valor demaxReceiveCounté excedido;maxReceiveCount: quantas vezes a mensagem é entregue pela fila de origem antes de ir para a DLQ. O default documentado, dentro da política, é 10.
Esta é a fixture local usada no resto do texto. A conta 123456789012, a região sa-east-1 e os nomes de fila são placeholders, não saída de um GetQueueAttributes real:
{
"QueueUrl": "https://sqs.sa-east-1.amazonaws.com/123456789012/pedidos-source",
"Attributes": {
"VisibilityTimeout": "90",
"MessageRetentionPeriod": "345600",
"ReceiveMessageWaitTimeSeconds": "20",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:sa-east-1:123456789012:pedidos-dlq\",\"maxReceiveCount\":\"5\"}"
}
}
Desserializada, a RedrivePolicy fica assim:
{
"deadLetterTargetArn": "arn:aws:sqs:sa-east-1:123456789012:pedidos-dlq",
"maxReceiveCount": "5"
}
As aspas escapadas são intencionais. No mapa de atributos, RedrivePolicy é uma string, igual a todos os outros valores, inclusive os números. O exemplo da própria API manda maxReceiveCount entre aspas. Quem monta o mapa à mão e coloca um objeto aninhado no lugar da string está enviando outra coisa.
A fixture da DLQ não tem RedrivePolicy e guarda mensagens por mais tempo:
{
"QueueUrl": "https://sqs.sa-east-1.amazonaws.com/123456789012/pedidos-dlq",
"Attributes": {
"VisibilityTimeout": "30",
"MessageRetentionPeriod": "1209600"
}
}
O guia de dead-letter queues e o de configuração acrescentam algumas regras:
- o SQS não cria a DLQ. Você cria a fila e depois a aponta na política da origem;
- a DLQ precisa estar na mesma conta e na mesma região da origem, e ser do mesmo tipo: standard com standard, FIFO com FIFO. Em FIFO, a DLQ quebra a ordem estrita, e o guia avisa isso explicitamente;
- no console, "Maximum receives" aceita de 1 a 1.000;
- em fila standard, o timestamp original de enfileiramento continua o mesmo quando a mensagem vai para a DLQ. Por isso a recomendação é dar à DLQ uma retenção maior que a da origem. Na fixture, são 14 dias contra 4;
- segundo o
SetQueueAttributes, uma mudança de atributo pode levar até 60 segundos para propagar. Testar a política dois segundos depois de aplicá-la não prova nada.
O que o maxReceiveCount conta (e o que não conta)
O guia define maxReceiveCount como o número de vezes que um consumidor pode receber a mensagem da fila de origem antes que ela seja movida. Exceções não entram na conta. Iterações de for também não. A unidade é o receive.
O ciclo de um receive, segundo o guia de visibility timeout, é este: a mensagem é entregue e fica invisível na fila. Se ninguém chamar DeleteMessage antes do fim da timeout (default de 30 segundos), ela volta a ficar visível e pode ser recebida de novo. É esse novo receive que conta para o limite.
Com isso, o comportamento do handler decide o destino do poison message:
| O handler faz | O que o SQS vê | Destino do poison message |
|---|---|---|
Retry com sleep dentro do mesmo receive e termina antes da timeout | Um receive comprido | O contador não anda durante o loop. Cada rodada da fila custa um receive e várias tentativas |
Retry com sleep que ultrapassa a timeout | A mensagem fica visível no meio do loop e outro consumidor a recebe | Duas instâncias processam a mesma mensagem e o contador sobe enquanto a primeira ainda insiste |
catch seguido de DeleteMessage | Receive concluído com sucesso | Some. Nunca chega à DLQ |
| Falha sem delete | Receive não concluído; a mensagem volta após a timeout | Gasta um receive por rodada até o limite e vai para a DLQ |
Qualquer coisa, sem RedrivePolicy na origem | Nenhum destino configurado | Fica circulando até expirar o MessageRetentionPeriod (4 dias por default) |
Também não adianta pôr uma anotação de retry ou uma biblioteca de resiliência em volta do handler: para o SQS, o efeito é o mesmo do for. O retry das chamadas do SDK cobre as chamadas à API da fila, não o seu processamento.
Um detalhe documentado das filas standard: com redrive policy e maxReceiveCount acima de 3, uma mensagem recebida 3 ou mais vezes sem delete vai para o fim da fila. Ela não desaparece, só perde a vez.
O consumidor teimoso
Para não amarrar o exemplo a uma versão de SDK ou framework, o código usa uma porta mínima. Na implementação real, apagar chama DeleteMessage e mudarVisibilidade chama ChangeMessageVisibility:
package academy.devdojo.pedidos;
record Mensagem(String messageId, String receiptHandle, String body) {}
interface FilaSqs {
void apagar(String receiptHandle); // DeleteMessage
void mudarVisibilidade(String receiptHandle, int segundos); // ChangeMessageVisibility
}
interface ProcessadorPedido {
void processar(String body);
}
class PayloadInvalido extends RuntimeException {
PayloadInvalido(String motivo) {
super(motivo);
}
}
O anti-padrão, com os dois erros clássicos juntos:
package academy.devdojo.pedidos;
class ConsumidorTeimoso {
private final FilaSqs fila;
private final ProcessadorPedido processador;
ConsumidorTeimoso(FilaSqs fila, ProcessadorPedido processador) {
this.fila = fila;
this.processador = processador;
}
void onMessage(Mensagem msg) throws InterruptedException {
for (int tentativa = 1; tentativa <= 3; tentativa++) {
try {
processador.processar(msg.body());
fila.apagar(msg.receiptHandle());
return;
} catch (Exception e) {
Thread.sleep(30_000);
}
}
fila.apagar(msg.receiptHandle()); // "pra não travar a fila"
}
}
Com a VisibilityTimeout de 90 segundos da fixture, três tentativas mais três pausas de 30 segundos passam da timeout. A mensagem reaparece enquanto o loop ainda está rodando, e outra instância começa o mesmo loop. No fim, o apagar da última linha encerra o assunto de fato: a fila não trava, a DLQ fica vazia e o pedido quebrado some sem deixar rastro. O comentário pelo menos é honesto quanto à intenção.
Uma tentativa por receive
A regra prática separa dois tipos de falha:
- permanente (payload inválido, contrato quebrado, erro de negócio que não vai mudar): não espere nem repita. Devolva a mensagem sem apagar para que o próximo receive consuma o
maxReceiveCounte ela chegue à DLQ; - transitória (timeout de dependência, throttling): também não use
sleep. Não apague e deixe a visibility timeout funcionar como o relógio do retry. O guia de visibility timeout recomenda essa combinação: timeout para a nova tentativa, DLQ para a falha persistente.
package academy.devdojo.pedidos;
import java.lang.System.Logger;
import java.lang.System.Logger.Level;
class ConsumidorPedidos {
private static final Logger LOG = System.getLogger(ConsumidorPedidos.class.getName());
private final FilaSqs fila;
private final ProcessadorPedido processador;
ConsumidorPedidos(FilaSqs fila, ProcessadorPedido processador) {
this.fila = fila;
this.processador = processador;
}
void onMessage(Mensagem msg) {
try {
processador.processar(msg.body());
} catch (PayloadInvalido e) {
LOG.log(Level.WARNING, "payload inválido messageId=" + msg.messageId(), e);
// permanente: visível de novo agora; o próximo receive gasta mais um do limite
fila.mudarVisibilidade(msg.receiptHandle(), 0);
return;
} catch (RuntimeException e) {
LOG.log(Level.WARNING, "falha transitória messageId=" + msg.messageId(), e);
// transitória: sem delete e sem sleep; a visibility timeout decide quando tentar de novo
return;
}
fila.apagar(msg.receiptHandle());
}
}
O DeleteMessage só acontece no caminho de sucesso. ChangeMessageVisibility com 0 encerra a timeout e deixa a mensagem disponível na hora, conforme o guia. Na falha permanente, isso só acelera a ida para a DLQ. Se o seu listener apaga a mensagem quando o método retorna normalmente e não apaga quando ele lança exceção, relançar a exceção faz o mesmo papel dos return acima.
Esse redelivery torna a entrega pelo menos uma vez também do lado do consumidor. A DLQ cuida da mensagem que nunca processa, mas não impede que uma mensagem processada duas vezes cobre duas vezes. Isso fica com a idempotência, como no artigo de idempotência e outbox.
Visibility timeout contra a duração do handler
Duas situações deixam o problema de pé mesmo com o código certo.
A primeira é a falta de maxReceiveCount. Sem RedrivePolicy na origem, não existe deadLetterTargetArn, e nenhum número de recebimentos move a mensagem. Ela fica até a retenção expirar. O default 10 só vale dentro de uma política que existe.
A segunda é uma timeout curta demais para o tempo de processamento. Se o handler saudável demora mais que a VisibilityTimeout, a mensagem volta a ficar visível antes do delete, outra instância a recebe e o contador sobe sem nenhuma falha. Com um maxReceiveCount baixo, uma mensagem boa pode acabar na DLQ só por ser lenta. O guia recomenda partir do tempo máximo típico de processamento. Para trabalho longo, a saída é estender a visibilidade com ChangeMessageVisibility enquanto o handler ainda está trabalhando. A extensão não cria um novo receive, e o teto é de 12 horas contadas desde o primeiro receive, sem reinício a cada extensão.
Para calibrar maxReceiveCount e timeout juntos, uma conta didática ajuda: com 5 receives e 90 segundos, o poison message passa pelo menos uns 5 × 90 s circulando antes de mudar de fila, mais o tempo de espera entre receives. Isso é aritmética de configuração, não um SLA medido. O ponto exato em que a mensagem muda de fila (no receive N ou no N+1) também não está aqui como fato: a API diz "após exceder", e um exemplo do guia diz que maxReceiveCount 1 move na primeira falha. Isso se confirma num teste de integração.
Verificando a configuração sem conta AWS
O script abaixo lê as duas fixtures só com json. Ele confirma que RedrivePolicy é string, que a política tem exatamente as duas chaves, que a DLQ está na mesma conta e região, que maxReceiveCount está na faixa do console e que a DLQ guarda mensagens por mais tempo que a origem:
import json
import sys
with open(sys.argv[1]) as f:
origem = json.load(f)
with open(sys.argv[2]) as f:
dlq = json.load(f)
attrs = origem["Attributes"]
bruto = attrs["RedrivePolicy"]
assert isinstance(bruto, str), "RedrivePolicy deve ser string JSON, não objeto"
politica = json.loads(bruto)
assert set(politica) == {"deadLetterTargetArn", "maxReceiveCount"}, sorted(politica)
arn = politica["deadLetterTargetArn"]
_, _, servico, regiao, conta, nome = arn.split(":")
assert servico == "sqs", arn
# DLQ precisa estar na mesma conta e região da fila de origem
assert origem["QueueUrl"].startswith(f"https://sqs.{regiao}.amazonaws.com/{conta}/"), arn
assert dlq["QueueUrl"].endswith(f"/{conta}/{nome}"), dlq["QueueUrl"]
# o exemplo da API manda o número entre aspas
assert isinstance(politica["maxReceiveCount"], (str, int)), politica["maxReceiveCount"]
max_receives = int(politica["maxReceiveCount"])
assert 1 <= max_receives <= 1000, max_receives
assert "RedrivePolicy" not in dlq["Attributes"]
assert int(dlq["Attributes"]["MessageRetentionPeriod"]) > int(attrs["MessageRetentionPeriod"])
print(f"DLQ={nome} maxReceiveCount={max_receives} VisibilityTimeout={attrs['VisibilityTimeout']}s")
python3 check_redrive.py sqs-source-queue-attributes.json sqs-dlq-attributes.json
Saída esperada com as fixtures acima:
DLQ=pedidos-dlq maxReceiveCount=5 VisibilityTimeout=90s
Esse é o limite do recibo, e vale dizer uma vez só: o JSON prova nomes, tipos e aninhamento. Ele não prova que o handler deixa de apagar na falha, não mostra em qual receive a mensagem muda de fila e não mede quanto tempo ela leva até a DLQ. Isso exige um teste de integração contra uma fila de verdade.
O que observar em produção
A documentação oferece dois sinais, um de cada lado.
Na mensagem, peça o atributo de sistema ApproximateReceiveCount em MessageSystemAttributeNames no ReceiveMessage (o parâmetro antigo AttributeNames está marcado como deprecated). Registre esse valor junto com o messageId em cada falha. Se o valor cresce de um receive para o outro, a fila está fazendo o trabalho dela. Se o log mostra três tentativas para cada receive, o loop interno ainda está lá. Se ele sobe sem que nenhuma falha tenha sido registrada, o handler está passando da visibility timeout.
Na DLQ, o que importa é que ela pare de estar vazia. ApproximateNumberOfMessages no GetQueueAttributes dá a contagem aproximada de mensagens disponíveis. Para alerta, o guia de DLQ aponta alarmes do CloudWatch sobre a métrica de mensagens visíveis da fila, ApproximateNumberOfMessagesVisible. Um alarme com limite acima de zero na DLQ fecha o ciclo: o poison message sai da fila de origem e alguém fica sabendo.
Para amanhã de manhã
- Exporte os atributos da sua fila de origem para um arquivo e rode o script. Se
RedrivePolicynão existir, o problema já está explicado. - Confira se o
maxReceiveCounté pequeno o bastante para a mensagem sair logo e grande o bastante para absorver as falhas transitórias, e se a retenção da DLQ é maior que a da origem. - Procure
sleepe loops de retry em volta do handler eDeleteMessagedentro decatch. - Compare o tempo máximo do handler com a
VisibilityTimeout. Se ele passar perto, estenda a visibilidade durante o processamento em vez de torcer para dar tempo.