Todos os artigos

// Knowledge.log — 技術記事

Redrive policy no SQS: o poison message que o retry da aplicação nunca larga

Por que o retry dentro do consumidor Java segura o poison message fora da DLQ do SQS, e como validar RedrivePolicy e maxReceiveCount num JSON local.

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 de maxReceiveCount é 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 fazO que o SQS vêDestino do poison message
Retry com sleep dentro do mesmo receive e termina antes da timeoutUm receive compridoO contador não anda durante o loop. Cada rodada da fila custa um receive e várias tentativas
Retry com sleep que ultrapassa a timeoutA mensagem fica visível no meio do loop e outro consumidor a recebeDuas instâncias processam a mesma mensagem e o contador sobe enquanto a primeira ainda insiste
catch seguido de DeleteMessageReceive concluído com sucessoSome. Nunca chega à DLQ
Falha sem deleteReceive não concluído; a mensagem volta após a timeoutGasta um receive por rodada até o limite e vai para a DLQ
Qualquer coisa, sem RedrivePolicy na origemNenhum destino configuradoFica 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 maxReceiveCount e 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ã

  1. Exporte os atributos da sua fila de origem para um arquivo e rode o script. Se RedrivePolicy não existir, o problema já está explicado.
  2. 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.
  3. Procure sleep e loops de retry em volta do handler e DeleteMessage dentro de catch.
  4. 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.
awsjava

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos