What happens
With a @RetryableTopic listener and no @DltHandler, once a record exhausts its retries and reaches the DLT, the framework's built-in DLT logging consumer fails on every record with:
org.springframework.kafka.listener.ListenerExecutionFailedException: invokeHandler Failed
Caused by: java.lang.IllegalStateException: No Acknowledgment available as an argument, the listener container must have a MANUAL AckMode to populate the Acknowledgment.
Because the DLT consume fails, the record is re-published to the same DLT (... won't be retried. Sending to DLT ...), so it loops and the DLT topic keeps growing.
This works fine on spring-kafka 3.3.x and starts failing after upgrading to 4.0.x. I didn't change any acknowledgment configuration — only the version.
Affected versions
- Broken: Spring Boot 4.0.5 / spring-kafka 4.0.4
- Works: Spring Boot 3.5.5 / spring-kafka 3.3.13
Minimal reproducer
A plain Spring Boot app using the default ack mode (no ack-mode set, enable-auto-commit: false) and String key/value (so there is no deserialization/serialization involved — the value is just a String).
@Component
class DemoListener {
@KafkaListener(topics = "demo.topic", groupId = "demo")
@RetryableTopic(attempts = "2", backOff = @BackOff(value = 1000))
public void handle(ConsumerRecord<String, String> record) {
throw new RuntimeException("force retry -> DLT");
}
}
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
enable-auto-commit: false
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
Produce a single record to demo.topic.
On spring-kafka 3.3.13 — the record flows main → retry → DLT, and the DLT is consumed cleanly:
RetryTopicConfigurer : Received message in dlt listener: demo.topic-dlt-0@0
On spring-kafka 4.0.4 — same flow, but the DLT consumer fails and loops:
DeadLetterPublishingRecovererFactory : Record: topic = demo.topic-dlt, partition = 0, offset = 0, main topic = demo.topic threw an error at topic demo.topic-dlt and won't be retried. Sending to DLT with name demo.topic-dlt.
...
Caused by: java.lang.IllegalStateException: No Acknowledgment available as an argument, the listener container must have a MANUAL AckMode to populate the Acknowledgment.
The only thing changed between the two runs is the spring-kafka version.
Expected
The built-in DLT logging consumer should consume and log the record under the default ack mode, the same as on 3.3.x — without the user having to switch the container to a MANUAL ack mode or add a custom @DltHandler.
Possible cause (a guess — I haven't confirmed it in a debugger)
The built-in DLT handler is RetryTopicConfigurer.LoggingDltListenerHandlerMethod.logMessage(Object, @NonNull Acknowledgment).
MessagingMessageListenerAdapter.determineInferredType decides whether to substitute a no-op Acknowledgment when the container doesn't provide one, based on:
this.noOpAck |= methodParameter.getParameterAnnotation(NonNull.class) != null;
In 4.0 that @NonNull is org.jspecify.annotations.NonNull, which is @Target(TYPE_USE). MethodParameter.getParameterAnnotation(...) returns only declaration annotations, not TYPE_USE ones, so it returns null, noOpAck stays false, and no no-op ack is substituted — the null ack then reaches the handler and the check throws. In 3.3.x the same parameter used org.springframework.lang.NonNull (a declaration annotation), which getParameterAnnotation did see. That lines up with the version where it started failing, but I haven't verified it's the real trigger.
What happens
With a
@RetryableTopiclistener and no@DltHandler, once a record exhausts its retries and reaches the DLT, the framework's built-in DLT logging consumer fails on every record with:Because the DLT consume fails, the record is re-published to the same DLT (
... won't be retried. Sending to DLT ...), so it loops and the DLT topic keeps growing.This works fine on spring-kafka 3.3.x and starts failing after upgrading to 4.0.x. I didn't change any acknowledgment configuration — only the version.
Affected versions
Minimal reproducer
A plain Spring Boot app using the default ack mode (no
ack-modeset,enable-auto-commit: false) andStringkey/value (so there is no deserialization/serialization involved — the value is just aString).Produce a single record to
demo.topic.On spring-kafka 3.3.13 — the record flows main → retry → DLT, and the DLT is consumed cleanly:
On spring-kafka 4.0.4 — same flow, but the DLT consumer fails and loops:
The only thing changed between the two runs is the spring-kafka version.
Expected
The built-in DLT logging consumer should consume and log the record under the default ack mode, the same as on 3.3.x — without the user having to switch the container to a MANUAL ack mode or add a custom
@DltHandler.Possible cause (a guess — I haven't confirmed it in a debugger)
The built-in DLT handler is
RetryTopicConfigurer.LoggingDltListenerHandlerMethod.logMessage(Object, @NonNull Acknowledgment).MessagingMessageListenerAdapter.determineInferredTypedecides whether to substitute a no-opAcknowledgmentwhen the container doesn't provide one, based on:In 4.0 that
@NonNullisorg.jspecify.annotations.NonNull, which is@Target(TYPE_USE).MethodParameter.getParameterAnnotation(...)returns only declaration annotations, notTYPE_USEones, so it returnsnull,noOpAckstaysfalse, and no no-op ack is substituted — the null ack then reaches the handler and the check throws. In 3.3.x the same parameter usedorg.springframework.lang.NonNull(a declaration annotation), whichgetParameterAnnotationdid see. That lines up with the version where it started failing, but I haven't verified it's the real trigger.