← Back to list

60-Day Kafka 4 Learning Plan · Week 3 — Day 18 of 60

Day 18 — Error Handling: DLT & Retry Templates

Eric Anicet · 2026-07-19 06:20 · 0 claps · 6.6 min read paywalled
#kafka #apache-kafka #messaging #spring-boot #dead-letter-queue
Open on Medium ↗
Wiki topics: EDU · Education & Learning

60-Day Kafka 4 Learning Plan · Week 3 — Day 18 of 60

Day 18 — Error Handling: DLT & Retry Templates

If you are not a Medium member, then click here to read for free.

· Goal · 1. Error flow — retry then route to DLT · 2. DefaultErrorHandler with exponential backoff · 3. Retryable vs non-retryable exceptionsRetryable (transient — worth retrying)Non-retryable (fatal — skip straight to DLT) · 4. Wire error handler into listener container factory · 5. Observe the DLTReplay DLT messages after a bug fix · 6. Non-blocking retry with @RetryableTopic · 7. Blocking vs non-blocking retry — which to use · 8. Monitoring error handling health · 9. Testing error handler behavior · 10. Common pitfalls · Key Takeaways · Next · Resources

60-Day Kafka 4 Learning Plan · Week 3 — Spring Boot Integration Sources: Kafka: The Definitive Guide Ch.5 · docs.spring.io/spring-kafka/reference

Goal

Build a production-grade error handling pipeline in Spring Kafka: retry with exponential backoff (blocking and non-blocking), route unrecoverable messages to a dead-letter topic (DLT), distinguish retryable from non-retryable exceptions, and monitor/test the whole pipeline.

1. Error flow — retry then route to DLT

Message arrives
    → @KafkaListener throws exception
    → DefaultErrorHandler retries N times with exponential backoff
    → All retries exhausted
    → DeadLetterPublishingRecoverer publishes to {topic}.DLT

DLT naming convention: ordersorders.DLT

DLT message headers (set automatically by DeadLetterPublishingRecoverer):

  • kafka_dlt-original-topic — source topic name
  • kafka_dlt-original-partition — source partition
  • kafka_dlt-original-offset — source offset
  • kafka_dlt-exception-fqcn — fully qualified exception class name
  • kafka_dlt-exception-message — exception message

2. DefaultErrorHandler with exponential backoff

@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<?, ?> kafkaTemplate) {

    // 1. Recoverer: publish failed record to {topic}.DLT
    var recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate,
        (record, exception) -> new TopicPartition(
            record.topic() + ".DLT",
            record.partition()
        )
    );

    // 2. Exponential backoff: 1s → 2s → 4s (max 3 retries)
    var backoff = new ExponentialBackOffWithMaxRetries(3);
    backoff.setInitialInterval(1_000L);   // 1 second
    backoff.setMultiplier(2.0);           // double each time
    backoff.setMaxInterval(10_000L);      // cap at 10 seconds

    return new DefaultErrorHandler(recoverer, backoff);
}

Backoff schedule: attempt 1 (immediate) → wait 1s → attempt 2 → wait 2s → attempt 3 → wait 4s → attempt 4 → exhausted → DLT.

This is blocking retry — the consumer thread sits idle waiting out each backoff interval before the next attempt, holding up that partition’s processing the entire time. §6 covers the non-blocking alternative for when that trade-off isn’t acceptable.

3. Retryable vs non-retryable exceptions

Not all exceptions should be retried — some will never succeed no matter how many times they’re attempted.

Retryable (transient — worth retrying)

  • TransientDataAccessException — temporary DB issue
  • OptimisticLockingFailureException — concurrent update conflict
  • ResourceAccessException — network timeout to a downstream HTTP service
  • RecoverableDataAccessException

Non-retryable (fatal — skip straight to DLT)

  • DeserializationException — malformed payload will never parse
  • ValidationException — invalid business data won't become valid on retry
  • NullPointerException — code bug, retrying won't help
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<?, ?> kafkaTemplate) {
    var recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate,
        (r, ex) -> new TopicPartition(r.topic() + ".DLT", r.partition()));

    var backoff = new ExponentialBackOffWithMaxRetries(3);
    backoff.setInitialInterval(1_000L);
    backoff.setMultiplier(2.0);

    var handler = new DefaultErrorHandler(recoverer, backoff);

    // These go straight to DLT, no retries
    handler.addNotRetryableExceptions(
        DeserializationException.class,
        ValidationException.class
    );

    return handler;
}

4. Wire error handler into listener container factory

@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
        ConsumerFactory<String, String> consumerFactory,
        DefaultErrorHandler errorHandler) {

    var factory = new ConcurrentKafkaListenerContainerFactory<String, String>();
    factory.setConsumerFactory(consumerFactory);
    factory.setCommonErrorHandler(errorHandler);   // attach the handler
    factory.setConcurrency(3);
    return factory;
}

setCommonErrorHandler replaces the older setErrorHandler / setBatchErrorHandler from Spring Kafka 2.x — one unified API for both record and batch listeners in Spring Kafka 3.x.

5. Observe the DLT

Always consume the DLT in production — a growing DLT means your consumer has a systematic problem.

@KafkaListener(topics = "orders.DLT", groupId = "orders-dlt-monitor")
public void handleDlt(
        ConsumerRecord<String, String> record,
        @Header("kafka_dlt-exception-fqcn") String exceptionClass,
        @Header("kafka_dlt-exception-message") String errorMessage,
        @Header("kafka_dlt-original-offset") long originalOffset) {

    log.error("DLT message: key={} exception={} message={} originalOffset={}",
        record.key(), exceptionClass, errorMessage, originalOffset);

    // Options:
    // 1. Alert on-call (PagerDuty, Slack)
    alertingService.notify(record, errorMessage);

    // 2. Store for later replay after bug fix
    dltRepository.save(toDltEntry(record));
}

Replay DLT messages after a bug fix

// Read from DLT and re-publish to original topic
@KafkaListener(topics = "orders.DLT", groupId = "orders-dlt-replayer")
public void replayDlt(ConsumerRecord<String, String> record) {
    String originalTopic = new String(
        record.headers().lastHeader("kafka_dlt-original-topic").value()
    );
    kafkaTemplate.send(originalTopic, record.key(), record.value());
    log.info("Replayed key={} to topic={}", record.key(), originalTopic);
}

Replay is not automatically idempotent. Replaying a DLT message re-triggers your listener’s full business logic — if that logic already partially succeeded before failing (e.g. it charged a payment, then failed sending a confirmation event), a naive replay can duplicate the side effect. Design listeners to be idempotent (dedupe by message key/ID) if DLT replay is part of your recovery process.

6. Non-blocking retry with @RetryableTopic

DefaultErrorHandler's backoff (§2) is blocking — the consumer thread stalls on that partition for the entire backoff duration, delaying every other message behind it in the same partition. For longer backoff windows or high-throughput topics, Spring Kafka's @RetryableTopic annotation implements non-blocking retry: failed messages are republished to dedicated retry topics, freeing the original partition to keep processing while the retry delay elapses elsewhere.

@RetryableTopic(
    attempts = "4",
    backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 10000),
    autoCreateTopics = "true",
    include = {TransientDataAccessException.class, ResourceAccessException.class}
)
@KafkaListener(topics = "orders", groupId = "order-service")
public void consume(ConsumerRecord<String, String> record) {
    processOrder(record.value());
}

@DltHandler
public void handleDlt(ConsumerRecord<String, String> record) {
    log.error("Exhausted retries for key={}", record.key());
    alertingService.notify(record);
}

What this creates automatically:

orders → orders-retry-0 (1s delay) → orders-retry-1 (2s delay) → orders-retry-2 (4s delay) → orders.DLT

Each retry attempt happens on its own topic and partition, consumed by a dedicated internal listener that respects the configured delay before reprocessing — the original orders topic's consumer thread is never blocked waiting.

7. Blocking vs non-blocking retry — which to use

Decision rule: if backoff intervals are short (seconds) and throughput is moderate, blocking retry’s simplicity usually wins. Once total backoff time starts meaningfully delaying unrelated messages behind a struggling one — or a downstream outage means minutes-long backoffs — non-blocking retry topics prevent one bad message from stalling an entire partition’s throughput.

8. Monitoring error handling health

# Treat the DLT like any other topic for lag monitoring
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group orders-dlt-monitor

Alerting practice: alert on DLT message rate (messages/minute), not just absolute count — a slow trickle over days can be normal for a large system, but a sudden rate spike means something just broke and needs immediate attention.

9. Testing error handler behavior

@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = {"orders", "orders.DLT"})
class ErrorHandlerIntegrationTest {

    @Autowired KafkaTemplate<String, String> kafkaTemplate;
    @Autowired ConsumerFactory<String, String> consumerFactory;

    @Test
    void nonRetryableExceptionGoesStraightToDlt() {
        kafkaTemplate.send("orders", "bad-key", "malformed-payload");

        var dltConsumer = consumerFactory.createConsumer("dlt-verify", "test");
        dltConsumer.subscribe(List.of("orders.DLT"));

        var records = KafkaTestUtils.getRecords(dltConsumer, Duration.ofSeconds(10));
        assertThat(records.count()).isEqualTo(1);
        assertThat(records.iterator().next().headers().lastHeader("kafka_dlt-exception-fqcn"))
            .isNotNull();
    }

    @Test
    void retryableExceptionSucceedsWithinBackoffWindow() {
        // simulate a transient failure that succeeds on the 2nd attempt
        // assert the message is processed successfully and NEVER reaches the DLT
    }
}

Test both directions explicitly: a non-retryable exception should land on the DLT immediately (verify the exception headers are set correctly for downstream alerting), and a transient failure that later succeeds should never reach the DLT at all — both are easy to get backwards with a misconfigured addNotRetryableExceptions list.

10. Common pitfalls

  • Using blocking retry with long backoff windows on a busy topic — stalls the entire partition behind one struggling message; reach for @RetryableTopic once backoff durations get long relative to your throughput needs
  • Forgetting include/addNotRetryableExceptions and retrying everything — a permanent bug (bad deserialization, null pointer) gets retried N times for no benefit before finally landing on the DLT, adding latency with zero chance of success
  • Assuming DLT replay is safe by default — as noted in §5, replay can duplicate side effects if listener logic isn’t idempotent
  • Not monitoring the DLT at all — the single most common production gap; a DLT with no consumer and no alerting is a silent failure pipeline that nobody notices until a customer complains
  • Ordering assumptions with @RetryableTopic — non-blocking retry topics don't preserve strict per-key ordering the way blocking retry does; don't use it for use cases where retry-time ordering matters as much as eventual delivery

Key Takeaways

  • DefaultErrorHandler: retry N times with backoff, then publish to DLT — but this blocks the partition for the backoff duration
  • DeadLetterPublishingRecoverer routes failures to {topic}.DLT with full error headers
  • Exponential backoff: 1s → 2s → 4s — avoids hammering a struggling downstream service
  • Non-retryable exceptions (deserialization, validation) skip retries, go straight to DLT
  • @RetryableTopic gives non-blocking retry via dedicated retry topics — trades strict ordering for not stalling the partition during backoff
  • Always monitor the DLT — track message rate, not just count, and alert on spikes
  • DLT messages can be replayed after a bug fix, but replay isn’t automatically idempotent — design for it explicitly
  • Test both directions: non-retryable exceptions must reach the DLT quickly; transient ones that later succeed must never reach it

If you loved reading the story, don’t forget to clap 👏. You can reach out to me and follow me on Medium, Twitter, GitHub, Linkedln

Support me through GitHub Sponsors.

Next

➡️ Day 19: Kafka vs RabbitMQ — when to replace your queue

Resources


메타데이터
post_id
db8a68006f2e
slug
60-day-kafka-4-learning-plan-week-3-day-18-of-60-db8a68006f2e
url
https://medium.com/@boottechnologies-ci/60-day-kafka-4-learning-plan-week-3-day-18-of-60-db8a68006f2e
canonical_url
https://medium.com/@boottechnologies-ci/60-day-kafka-4-learning-plan-week-3-day-18-of-60-db8a68006f2e
author_url
https://medium.com/@boottechnologies-ci
status
ok
fetched_at
2026-08-25 21:52:18