Skip to content

@RetryableTopic: built-in DLT logging handler fails with "No Acknowledgment available" on 4.0 (works on 3.3.x) #4468

@abhimoondra

Description

@abhimoondra

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions