Kafka retry y dead letter topic con Spring Kafka

1. Descripción general

Un consumidor Kafka inevitablemente encontrará mensajes que fallan al procesarse. Hay errores temporales, por ejemplo, timeout en la pasarela de pagos. Hay errores permanentes, como datos con formato incorrecto. Si no los manejas correctamente, te enfrentarás a uno de dos problemas: pérdida de mensajes o un consumidor atascado en un mensaje que nunca tendrá éxito.

Este artículo te guía en cómo configurar reintentos en Spring Kafka de dos formas: bloqueante con DefaultErrorHandler y no bloqueante con @RetryableTopic. Los mensajes que siguen fallando después de agotar los reintentos se envían a un dead letter topic (DLT). Después de leerlo, sabrás cuándo elegir cada enfoque y escribirás tests para verificar el comportamiento.

2. Configuración

Añade la dependencia de Spring Kafka. Spring Boot 3.x gestiona la versión automáticamente, así que no necesitas declararla:

<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>

Configuración mínima en application.properties:

spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=order-service
spring.kafka.consumer.auto-offset-reset=earliest

Boot usa por defecto StringSerializer y StringDeserializer. Para mantener el ejemplo simple, el valor del mensaje es el ID de pedido como String.

3. Consumer que puede fallar

Este es un servicio que procesa pagos. Un ID de pedido con formato incorrecto es un error permanente. Una falla al llamar la pasarela de pagos es un error temporal:

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 de pedido inválido: " + orderId);
        }
        // Llamar pasarela de pagos; puede lanzar PaymentGatewayException en timeout
    }
}

El listener solo llama al servicio:

@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("Procesando pedido {}", orderId);
        paymentService.charge(orderId);
    }
}

Si no configuras nada, Spring Kafka usa DefaultErrorHandler con FixedBackOff(0, 9). El mensaje con error se reintenta 9 veces consecutivas, 10 en total. Después, el handler solo registra un log y descarta el mensaje. El mensaje se considera perdido. Por eso necesitas reconfigurar esto.

4. Retry bloqueante con DefaultErrorHandler

4.1. Configurar backoff y DLT

Cuando el listener lanza una excepción, DefaultErrorHandler vuelve al offset del mensaje con error para que el siguiente poll lo lea nuevamente. Cuando se agota el número de reintentos, el handler llama a un ConsumerRecordRecoverer. Aquí, el recoverer es 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;
    }
}

Explicación de cada parte:

  • DeadLetterPublishingRecoverer(template) envía el mensaje con error al topic <nombre-topic>.DLT, en la misma partición que el mensaje original. Para el topic orders, el DLT es orders.DLT.
  • ExponentialBackOffWithMaxRetries(3) reintenta un máximo de 3 veces. Los intervalos de espera son 1s, 2s, 4s respectivamente, sin exceder 10s.
  • addNotRetryableExceptions declara qué excepciones no necesitan reintento. IllegalArgumentException va directamente al DLT, porque reintentar datos malformados no los arreglará.

Spring Boot asigna automáticamente el bean CommonErrorHandler al container factory predeterminado. No necesitas crear ConcurrentKafkaListenerContainerFactory manualmente.

Cuando envías ORD-1 y la pasarela de pagos agota constantemente el timeout, el log se verá así:

10:00:00.010 INFO  Procesando pedido ORD-1
10:00:01.020 INFO  Procesando pedido ORD-1
10:00:03.030 INFO  Procesando pedido ORD-1
10:00:07.040 INFO  Procesando pedido ORD-1
10:00:07.060 ERROR Backoff ... exhausted for orders-0@42

Se procesa 4 veces en total: 1 inicial y 3 reintentos. Después el mensaje está en orders.DLT.

4.2. Headers en DLT

El mensaje en DLT conserva la clave y el valor originales. El recoverer añade headers para que investigues la 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 = ...

La excepción original está en kafka_dlt-exception-cause-fqcn. Si necesitas leer headers en el código, usa las constantes en KafkaHeaders, por ejemplo KafkaHeaders.DLT_ORIGINAL_TOPIC.

Nota: Como el recoverer envía a la misma partición por defecto, orders.DLT debe tener al menos el mismo número de particiones que orders. Si no, la publicación fallará. Puedes crear el DLT con un bean NewTopic con el mismo número de particiones. Otra opción es pasar un destination resolver que devuelva new TopicPartition(r.topic() + ".DLT", -1) para que Kafka elija la partición.

4.3. Cuándo es apropiado el retry bloqueante

El retry bloqueante preserva el orden de los mensajes en la partición. El siguiente mensaje solo se procesa cuando el anterior tiene éxito o está en DLT. Esta es la opción correcta cuando el orden importa, como en eventos OrderCreated → OrderPaid → OrderShipped del mismo pedido.

El precio es que toda la partición se bloquea mientras espera. Desde Spring Kafka 2.9 en adelante, el handler espera el backoff sin superar max.poll.interval.ms, gracias al mecanismo de seek y pausa. Aun así, si estableces un backoff total de varios minutos, el lag aumentará dramáticamente.

5. Retry no bloqueante con @RetryableTopic

5.1. Configurar con anotación

Con reintentos no bloqueantes, los mensajes con error se envían a un retry topic separado. Gracias a esto, el consumidor sigue procesando el siguiente mensaje de inmediato. Otro consumidor lee el retry topic después del backoff configurado:

@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("Procesando {} de {}", orderId, topic);
        paymentService.charge(orderId);
    }

    @DltHandler
    public void onDlt(String orderId,
                      @Header(KafkaHeaders.EXCEPTION_MESSAGE) String error) {
        log.error("Pedido {} en DLT: {}", orderId, error);
    }
}

Puntos clave:

  • attempts = "4" significa 4 intentos totales: 1 en el topic original y 3 en retry topics. El valor es String para soportar placeholders como "${app.retry.attempts}".
  • @Backoff viene del paquete org.springframework.retry.annotation. Spring Retry ya viene con spring-kafka.
  • exclude lista las excepciones que van directamente al DLT, equivalente a addNotRetryableExceptions.
  • @DltHandler está en la misma clase. Aquí registras logs, envías alertas o guardas en una tabla para procesamiento manual.

5.2. Topics que se crean

Con backoff incremental, por defecto cada reintento tiene su propio topic, con sufijo según el intervalo de delay:

payments
payments-retry-1000
payments-retry-2000
payments-retry-4000
payments-dlt

Spring Kafka crea estos topics automáticamente al iniciar (autoCreateTopics = "true" es el predeterminado). Los entornos de producción suelen desactivar auto-create en el broker. Entonces necesitas crear los topics con anticipación con los nombres exactos y número de particiones correcto. Si prefieres números en lugar de delays, establece topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE.

Nota los sufijos: @RetryableTopic usa -dlt, mientras que DeadLetterPublishingRecoverer usa .DLT. Recuerda esta diferencia al configurar monitoreo.

5.3. Tradeoffs del retry no bloqueante

El retry no bloqueante rompe el orden. Supongamos que ORD-1 falla y va al retry topic, mientras ORD-2 tiene éxito inmediatamente. Entonces ORD-2 se procesa antes que ORD-1. Si tu lógica de negocio depende del orden de los eventos, no uses este enfoque.

Además, el número de topics aumenta y también hay más consumidores. A cambio, un mensaje “problemático” no bloquea toda la partición.

6. Probar el flujo DLT

Este test usa @EmbeddedKafka. Envía un ID de pedido con formato incorrecto y verifica que aparezca en orders.DLT sin ser reintentado:

@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");
        }
    }
}

El test funciona así:

  • ${spring.embedded.kafka.brokers} apunta la aplicación al broker incrustado.
  • KafkaTestUtils.consumerProps crea la configuración del consumidor con auto.offset.reset=earliest, por lo que el test no pierde mensajes publicados antes de que el consumidor se suscriba.
  • Como IllegalArgumentException no es retryable, el mensaje llega al DLT casi instantáneamente. El test no tiene que esperar a que se agote la cadena de backoff.
  • Las aserciones verifican tres cosas: la clave y el valor se conservan, el header registra correctamente la excepción original y el header registra correctamente el topic fuente.

Si quieres probar el flujo de reintentos, extrae el backoff a properties. En el test, establece un intervalo de decenas de milisegundos para que el test se ejecute rápido.

7. Errores comunes

  • No clasificar excepciones. Reintentar NullPointerException o errores de validación solo desperdicia tiempo. Declara claramente qué excepciones deben reintentarse y cuáles van directamente al DLT. Errores de deserialización como DeserializationException ya son no-retryable por defecto. Pero para que el handler los reciba, necesitas envolver el deserializador con ErrorHandlingDeserializer.
  • Serializer del KafkaTemplate no coincide. El recoverer publica el valor ya deserializado. Si el consumidor lee JSON en un objeto pero el template usa StringSerializer, enviar al DLT fallará. Usa un template con el serializer adecuado. Para mensajes con error de deserialización, el valor es byte[], así que necesitas ByteArraySerializer.
  • Listener no es idempotente. Reintentar significa que el mismo mensaje puede procesarse varias veces. Si charge ya debitó dinero antes de fallar, un reintento debitará nuevamente. Protégete de duplicados usando una clave de idempotencia, por ejemplo guardando el orderId ya procesado.
  • Nadie lee el DLT. El DLT no es una papelera. Necesitas alertas cuando haya mensajes nuevos en el DLT y un proceso de replay después de corregir el error.
  • Backoff total demasiado largo con retry bloqueante. La partición se bloquea durante todo ese tiempo. Si el tiempo total de espera supera decenas de segundos, considera cambiar a non-bloqueante.

Nota: No uses @RetryableTopic para flujos que requieren orden estricto por clave. Por el contrario, no uses retry bloqueante con backoff largo para topics con alto throughput. Un mensaje con error puede aumentar el lag decenas de miles de posiciones.

8. Conclusión

Este artículo ha presentado dos formas de manejar mensajes con error en Spring Kafka. DefaultErrorHandler con DeadLetterPublishingRecoverer proporciona retry bloqueante, preserva el orden pero bloquea la partición. @RetryableTopic con @DltHandler proporciona retry no bloqueante, no detiene al consumidor pero sacrifica el orden.

Sea cual sea tu elección, debes hacer cuatro cosas: clasificar excepciones en retryable y no-retryable, asegurar que el listener sea idempotente, crear el DLT con número de particiones apropiado y monitorear el DLT regularmente. Un test con @EmbeddedKafka como el de la sección 6 te asegura que la configuración funciona correctamente antes de pasar a producción.

kafkaspring-kafkaspring-bootretrydead-letter-topic