3.9 KiB
5. Non-blocking retries with @RetryableTopic
Blocking retries stall the partition. @RetryableTopic republishes the failed record to a
separate topic and lets the main partition carry on.
@RetryableTopic(attempts = "4", backOff = @BackOff(delay = 500, multiplier = 2.0),
sameIntervalTopicReuseStrategy = SameIntervalTopicReuseStrategy.SINGLE_TOPIC,
exclude = Failures.PermanentFailure.class)
@KafkaListener(topics = "invoices", groupId = "invoices")
public void onInvoice(ConsumerRecord<String, Payment> record, ...) { ... }
Two API changes in Spring Kafka 4.x will stop older examples compiling:
- the attribute is
backOff, notbackoff - the annotation is
org.springframework.kafka.annotation.BackOff, notorg.springframework.retry.annotation.Backoff. Spring Kafka 4 dropped the spring-retry dependency and brought its own.
The failure is package org.springframework.retry.annotation does not exist, which reads like a
missing dependency and is not.
Also new in 4.1: sameIntervalTopicReuseStrategy defaults to SINGLE_TOPIC in
RetryTopicConfigurationBuilder, aligning it with the annotation's default.
What it actually does
From docs/output/retry-topics.txt — a failing record and a good one
published back to back on the same partition:
=== @RetryableTopic delivery trace ===
+0 ms invoices transient-1
+531 ms invoices-retry-500 transient-1
+550 ms invoices ok-1
+1554 ms invoices-retry-1000 transient-1
+3560 ms invoices-retry-2000 transient-1
DLT: [transient-1 on invoices-dlt]
Read the third line. ok-1 was processed at +550 ms, while transient-1 was still two retries
from giving up. With a blocking handler it would have waited for the whole schedule.
Retry topics are named by the delay, not the attempt number. invoices-retry-500,
invoices-retry-1000, invoices-retry-2000 — that is
TopicSuffixingStrategy.SUFFIX_WITH_DELAY_VALUE, the default. So provisioning topics ahead of
time means knowing your whole back-off schedule in advance, and changing the multiplier changes
the topic names, orphaning whatever is still sitting in the old ones. Deploy that change the
way you would a rename.
The cost
Per-key ordering is gone for any record that fails. That is not a side effect; it is the
mechanism. If invoice-7 fails and invoice-7's next event succeeds, they are processed out of
order, and no configuration prevents it.
So the decision is not "blocking or non-blocking", it is:
blocking (DefaultErrorHandler) |
non-blocking (@RetryableTopic) |
|
|---|---|---|
| ordering under failure | preserved | lost for the failing key |
| partition throughput under failure | stalled | unaffected |
| topics to provision | 1 + DLT | 1 + one per distinct delay + DLT |
| long back-offs | limited by max.poll.interval.ms |
unlimited |
If your consumer is idempotent and order-insensitive — most notification, indexing and cache-warm consumers are — retry topics are strictly better. If it applies state transitions per key, blocking retries with a short schedule and a fast DLT are usually the safer answer.
Use exclude (or include) rather than retrying everything: a PermanentFailure here skips the
retry topics entirely and goes straight to invoices-dlt.
@DltHandler
@DltHandler
public void onDlt(ConsumerRecord<String, Payment> record,
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic) { ... }
Without one, the framework still creates and populates the DLT — it just logs and moves on, and
nothing in your application has looked at the record. A @DltHandler that increments a counter
and writes a structured log line is the minimum worth having, because a DLT nobody watches is a
queue that grows until someone notices the disk.