Kafka retry e dead letter topic com Spring Kafka
1. Visão geral
Um consumer Kafka certamente encontrará mensagens com erro no processamento. Existem erros temporários, como timeout de gateway de pagamento. Existem erros permanentes, como dados com formato inválido. Se não tratado corretamente, você enfrentará uma de duas situações: perder a mensagem ou o consumer ficar travado em uma mensagem que nunca terá sucesso.
Este artigo orienta você sobre como configurar retry no Spring Kafka de duas formas: blocking com DefaultErrorHandler e non-blocking com @RetryableTopic. Mensagens que continuam falhando após esgotar as tentativas de retry são encaminhadas para o dead letter topic (DLT). Ao finalizar, você saberá quando escolher cada abordagem e como escrever testes para verificar o comportamento.
2. Configuração
Adicione a dependência Spring Kafka. O Spring Boot 3.x gerencia automaticamente a versão, então você não precisa declará-la:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
Configuração mínima em application.properties:
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=order-service
spring.kafka.consumer.auto-offset-reset=earliest
Boot usa StringSerializer e StringDeserializer por padrão. Para simplificar o exemplo, o valor da mensagem é o ID da ordem em formato String.
3. Consumer Propenso a Erros
Este é um serviço de processamento de pagamento. ID de ordem com formato inválido é um erro permanente. Erro ao chamar o gateway de pagamento é um erro temporário:
public class PaymentGatewayException extends RuntimeException {
public PaymentGatewayException(String message) {
super(message);
}
}
@Service
public class PaymentService {
public void charge(String orderId) {
if (orderId == null || !orderId.startsWith("ORD-")) {
throw new IllegalArgumentException("ID da ordem inválida: " + orderId);
}
// Chamar o gateway de pagamento; pode lançar PaymentGatewayException quando timeout
}
}
O listener apenas chama o serviço:
@Component
public class OrderListener {
private static final Logger log = LoggerFactory.getLogger(OrderListener.class);
private final PaymentService paymentService;
public OrderListener(PaymentService paymentService) {
this.paymentService = paymentService;
}
@KafkaListener(topics = "orders")
public void onOrder(String orderId) {
log.info("Processando ordem {}", orderId);
paymentService.charge(orderId);
}
}
Se você não configurar nada, Spring Kafka usa DefaultErrorHandler com FixedBackOff(0, 9). A mensagem com erro é retentada 9 vezes consecutivas, num total de 10 tentativas. Depois disso, o handler apenas registra em log e descarta. A mensagem é perdida. Por isso você precisa reconfigurar.
4. Retry Blocking com DefaultErrorHandler
4.1. Configurar Backoff e DLT
Quando o listener lança uma exception, DefaultErrorHandler faz seek para o offset da mensagem com erro para que a próxima sondagem a leia novamente. Após esgotar as tentativas de retry, o handler chama um ConsumerRecordRecoverer. Aqui, o recoverer é DeadLetterPublishingRecoverer:
@Configuration
public class KafkaErrorConfig {
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template);
var backOff = new ExponentialBackOffWithMaxRetries(3);
backOff.setInitialInterval(1_000L);
backOff.setMultiplier(2.0);
backOff.setMaxInterval(10_000L);
var handler = new DefaultErrorHandler(recoverer, backOff);
handler.addNotRetryableExceptions(IllegalArgumentException.class);
return handler;
}
}
Explicação de cada parte:
DeadLetterPublishingRecoverer(template)envia a mensagem com erro para o topic<nome-do-topic>.DLT, na mesma partition da mensagem original. Para o topicorders, o DLT éorders.DLT.ExponentialBackOffWithMaxRetries(3)faz retry no máximo 3 vezes. Os intervalos de espera são 1s, 2s, 4s e não excedem 10s.addNotRetryableExceptionsdeclara as exceptions que não devem ser retentadas.IllegalArgumentExceptionvai direto para o DLT, pois tentar novamente não ajudará se os dados estão mal formatados.
Spring Boot automaticamente atribui o bean CommonErrorHandler à factory padrão do container. Você não precisa criar manualmente ConcurrentKafkaListenerContainerFactory.
Quando envia ORD-1 e o gateway de pagamento sempre faz timeout, o log parecerá assim:
10:00:00.010 INFO Processando ordem ORD-1
10:00:01.020 INFO Processando ordem ORD-1
10:00:03.030 INFO Processando ordem ORD-1
10:00:07.040 INFO Processando ordem ORD-1
10:00:07.060 ERROR Backoff ... exhausted for orders-0@42
No total, há 4 processamentos: 1 inicial e 3 retries. Depois disso, a mensagem fica em orders.DLT.
4.2. Headers no DLT
A mensagem no DLT mantém sua key e value originais. O recoverer adiciona headers para você investigar a causa:
kafka_dlt-original-topic = orders
kafka_dlt-original-partition = 0
kafka_dlt-original-offset = 42
kafka_dlt-exception-fqcn = org.springframework.kafka.listener.ListenerExecutionFailedException
kafka_dlt-exception-cause-fqcn = com.example.PaymentGatewayException
kafka_dlt-exception-message = Listener method ... threw exception
kafka_dlt-exception-stacktrace = ...
Sua exception original está em kafka_dlt-exception-cause-fqcn. Se precisar ler headers via código, use as constantes em KafkaHeaders, por exemplo KafkaHeaders.DLT_ORIGINAL_TOPIC.
Nota: Como o recoverer padrão envia para a mesma partition,
orders.DLTdeve ter pelo menos o mesmo número de partitions queorders. Se não, a publicação falhará. Você pode criar o DLT usando um beanNewTopiccom o mesmo número de partitions. Outra forma é passar um destination resolver que retornanew TopicPartition(r.topic() + ".DLT", -1)para deixar o Kafka escolher a partition automaticamente.
4.3. Quando o Retry Blocking é Apropriado
Retry blocking mantém a ordem das mensagens na partition. Uma mensagem posterior é processada apenas quando a anterior foi bem-sucedida ou encaminhada para o DLT. Esta é a escolha correta quando a ordem é importante, por exemplo, os eventos OrderCreated → OrderPaid → OrderShipped do mesmo pedido.
O preço a pagar é que toda a partition fica bloqueada durante a espera. A partir do Spring Kafka 2.9, o handler aguarda o backoff sem exceder max.poll.interval.ms, graças aos mecanismos de seek e pause. Mesmo assim, se você definir o backoff total por alguns minutos, o lag aumentará significativamente.
5. Retry Non-Blocking com @RetryableTopic
5.1. Configuração com Annotation
Com retry non-blocking, mensagens com erro são encaminhadas para um retry topic separado. Assim, o consumer continua processando a próxima mensagem imediatamente. Um outro consumer lê o retry topic após o intervalo de backoff definido:
@Component
public class OrderRetryListener {
private static final Logger log = LoggerFactory.getLogger(OrderRetryListener.class);
private final PaymentService paymentService;
public OrderRetryListener(PaymentService paymentService) {
this.paymentService = paymentService;
}
@RetryableTopic(
attempts = "4",
backoff = @Backoff(delay = 1_000, multiplier = 2.0, maxDelay = 10_000),
exclude = IllegalArgumentException.class)
@KafkaListener(topics = "payments")
public void onPayment(String orderId,
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
log.info("Processando {} de {}", orderId, topic);
paymentService.charge(orderId);
}
@DltHandler
public void onDlt(String orderId,
@Header(KafkaHeaders.EXCEPTION_MESSAGE) String error) {
log.error("Ordem {} para DLT: {}", orderId, error);
}
}
Os pontos principais:
attempts = "4"significa um total de 4 processamentos: 1 no topic original e 3 no retry topic. O valor é umaStringpara suportar placeholders como"${app.retry.attempts}".@Backoffé do packageorg.springframework.retry.annotation. Spring Retry já vem comspring-kafka.excludelista as exceptions que vão direto para o DLT, equivalente aaddNotRetryableExceptions.@DltHandleré colocado na mesma classe. Este é o lugar para registrar em log, enviar alertas ou salvar em uma tabela para processamento manual.
5.2. Topics Criados
Com backoff crescente, por padrão cada tentativa de retry tem um topic separado, com sufixo baseado no intervalo de delay:
payments
payments-retry-1000
payments-retry-2000
payments-retry-4000
payments-dlt
Spring Kafka cria automaticamente esses topics na inicialização (autoCreateTopics = "true" é o padrão). Ambientes de produção normalmente desabilitam auto-create no broker. Nesses casos, você precisa criar antecipadamente os topics com os nomes e números de partitions corretos. Se deseja usar um índice em vez do delay, defina topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE.
Atenção com os sufixos: @RetryableTopic usa -dlt, enquanto DeadLetterPublishingRecoverer usa .DLT. Lembre-se disso ao configurar monitoramento.
5.3. Compensações do Non-Blocking
Retry non-blocking quebra a ordem. Suponha que ORD-1 falha e é encaminhado para o retry topic, enquanto ORD-2 tem sucesso imediatamente. Nesse caso, ORD-2 é processado antes de ORD-1. Se o fluxo de negócio depender da ordem dos eventos, não use essa abordagem.
Além disso, o número de topics aumenta e também há mais consumers. Em compensação, uma mensagem problemática não bloqueia toda a partition.
6. Testando o Fluxo DLT
O teste abaixo usa @EmbeddedKafka. O teste envia um ID de ordem com formato inválido e verifica se a mensagem aparece em orders.DLT sem ser retentada:
@SpringBootTest(properties = "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}")
@EmbeddedKafka(partitions = 1, topics = {"orders", "orders.DLT"})
class OrderDltTest {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Autowired
private EmbeddedKafkaBroker broker;
@Test
void givenInvalidOrderId_whenConsumed_thenSentToDltWithoutRetry() {
kafkaTemplate.send("orders", "order-key", "INVALID-99");
Map<String, Object> props = KafkaTestUtils.consumerProps("dlt-test", "false", broker);
try (Consumer<String, String> consumer = new DefaultKafkaConsumerFactory<>(
props, new StringDeserializer(), new StringDeserializer()).createConsumer()) {
broker.consumeFromAnEmbeddedTopic(consumer, "orders.DLT");
ConsumerRecord<String, String> record =
KafkaTestUtils.getSingleRecord(consumer, "orders.DLT", Duration.ofSeconds(10));
assertThat(record.value()).isEqualTo("INVALID-99");
assertThat(record.key()).isEqualTo("order-key");
String cause = new String(record.headers()
.lastHeader(KafkaHeaders.DLT_EXCEPTION_CAUSE_FQCN).value());
assertThat(cause).isEqualTo(IllegalArgumentException.class.getName());
String originalTopic = new String(record.headers()
.lastHeader(KafkaHeaders.DLT_ORIGINAL_TOPIC).value());
assertThat(originalTopic).isEqualTo("orders");
}
}
}
O teste funciona da seguinte forma:
${spring.embedded.kafka.brokers}aponta a aplicação para o broker incorporado.KafkaTestUtils.consumerPropscria configuração do consumer comauto.offset.reset=earliest, então o teste não perde mensagens publicadas antes de o consumer se inscrever.- Como
IllegalArgumentExceptioné non-retryable, a mensagem vai para o DLT quase imediatamente. O teste não precisa esperar toda a sequência de backoff. - As asserções verificam três coisas: a key e value são preservadas, o header registra corretamente a exception original, e o header registra corretamente o topic de origem.
Se desejar testar o fluxo de retry, mova o valor de backoff para properties. No teste, defina o intervalo de espera para alguns milissegundos para o teste executar rapidamente.
7. Erros Comuns
- Não classificar exceptions. Fazer retry de
NullPointerExceptionou erros de validação apenas desperdiça tempo. Declare claramente qual exception é retentada e qual vai direto para o DLT. Erros de desserialização comoDeserializationExceptionjá são non-retryable por padrão. Mas para o handler receber esse erro, você precisa envolver o deserializer comErrorHandlingDeserializer. - Serializer de
KafkaTemplatenão corresponde. O recoverer publica novamente o value já desserializado. Se o consumer lê JSON como objeto mas o template usaStringSerializer, enviar para o DLT falhará. Use um template com um serializer apropriado. Para mensagens com erro de desserialização, o value ébyte[], então você precisa deByteArraySerializer. - Listener não é idempotente. Retry significa que a mesma mensagem pode ser processada várias vezes. Se
chargejá debitou e depois fez timeout, o retry debitará novamente. Defenda-se contra duplicatas usando uma idempotency key, por exemplo salvando oorderIdjá processado. - Ninguém lê o DLT. DLT não é uma lixeira. Você precisa alertar quando há novas mensagens no DLT e precisar de um processo de replay após corrigir o erro.
- Backoff total muito longo com retry blocking. A partition fica bloqueada durante esse tempo. Se o tempo total de espera exceder dezenas de segundos, considere mudar para non-blocking.
Nota: Não use
@RetryableTopicpara fluxos que exigem ordem rigorosa por key. Por outro lado, não use retry blocking com backoff longo para topics com alto throughput. Uma mensagem com erro pode fazer o lag aumentar em dezenas de milhares.
8. Conclusão
Este artigo apresentou duas abordagens para tratamento de mensagens com erro no Spring Kafka. DefaultErrorHandler combinado com DeadLetterPublishingRecoverer para retry blocking, preserva ordem mas bloqueia a partition. @RetryableTopic junto com @DltHandler para retry non-blocking, não bloqueia o consumer mas sacrifica a ordem.
Qualquer que seja sua escolha, você deve fazer quatro coisas: classificar exceptions retentáveis e non-retentáveis, garantir que o listener seja idempotente, criar DLT com um número apropriado de partitions e monitorar o DLT regularmente. Um teste com @EmbeddedKafka como na seção 6 ajuda você a ter certeza de que a configuração funciona corretamente antes de ir para produção.