Skip to content

Commit 31d2055

Browse files
committed
Replace Spring Retry usage to core retry
This commit replaces Spring Retry by the core retry support introduced in Spring Framework 7. This is a breaking change mostly in configuration that is detailed below. The main feature in Spring Kafka is BackOffValuesGenerator that generates the required BackOff values upfront. These are then managed by the listener infrastructure and Spring Retry is no longer involved. Moving this code from BackOffPolicy to BackOff dramatically simplifies that class as Spring Framework's core API naturally provides this information without the need of an extra infrastructure. From a configuration standpoint, Spring Kafka relies quite heavily on Spring Retry's `@Backoff`. As there is no equivalent, the annotation has been moved to Spring Kafka proper with the following improvements: * Harmonized name (`@BackOff` instead of `@Backoff`). * Revisited Javadoc. * Support for expression evaluation and `java.util.Duration` format. The creation of a `BackOff` instance from the annotation is now isolated in `BackOffFactory` and the relevant tests have been added. `RetryTopicConfigurationBuilder` is mostly backward-compatible but `uniformRandomBackoff` has been deprecated as we feel that its name does not convey what it actually does. `RetryingDeserializer` no longer offer a `RecoveryCallback` but an equivalent function that takes `RetryException` as an input. This contains the exceptions thrown as well as the number of retry attempts. The use of BinaryExceptionClassifier has been replaced by the newly introduced `ExceptionMatcher` that is a copy of the original algorithm with a polished API. With the migration done, we believe that further improvements can be made here: `@BackOff` oddly looks like Spring Framework's `@Retryable`. As a matter of a fact, the `maxAttempts` and `includes`/`excludes` from `@RetryableTopic` are touching the same concepts. One option would be to open up `@Retryable` so that it can be used in more case. Another area of improvement is that harmonization of BackOff as a term. It is named "Backoff" in several places, including in `@RetryableTopic`, and it would be nice if the concept had the same syntax everywhere. With Spring Retry being completely removed, this commit also removes the dependency and any further references to it. Signed-off-by: Stéphane Nicoll <[email protected]>
1 parent cc679a4 commit 31d2055

File tree

45 files changed

+1418
-489
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1418
-489
lines changed

build.gradle

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ ext {
7171
scalaVersion = '2.13'
7272
springBootVersion = '3.5.0' // docs module
7373
springDataVersion = '2025.1.0-SNAPSHOT'
74-
springRetryVersion = '2.0.12'
7574
springVersion = '7.0.0-SNAPSHOT'
7675

7776
idPrefix = 'kafka'
@@ -249,9 +248,6 @@ project ('spring-kafka') {
249248
api 'org.springframework:spring-context'
250249
api 'org.springframework:spring-messaging'
251250
api 'org.springframework:spring-tx'
252-
api ("org.springframework.retry:spring-retry:$springRetryVersion") {
253-
exclude group: 'org.springframework'
254-
}
255251
api "org.apache.kafka:kafka-clients:$kafkaVersion"
256252
api 'io.micrometer:micrometer-observation'
257253
optionalApi "org.apache.kafka:kafka-streams:$kafkaVersion"
@@ -322,7 +318,6 @@ project ('spring-kafka-test') {
322318
api "org.apache.logging.log4j:log4j-slf4j-impl:$log4jVersion"
323319
api 'org.springframework:spring-context'
324320
api 'org.springframework:spring-test'
325-
api "org.springframework.retry:spring-retry:$springRetryVersion"
326321

327322
api "org.apache.kafka:kafka-clients:$kafkaVersion:test"
328323
api "org.apache.kafka:kafka-server:$kafkaVersion"

samples/sample-04/src/main/java/com/example/Application.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import org.springframework.kafka.annotation.RetryableTopic;
2727
import org.springframework.kafka.support.KafkaHeaders;
2828
import org.springframework.messaging.handler.annotation.Header;
29-
import org.springframework.retry.annotation.Backoff;
29+
import org.springframework.kafka.annotation.Backoff;
3030

3131
/**
3232
* Sample shows use of topic-based retry.

spring-kafka-docs/src/main/antora/modules/ROOT/pages/kafka/annotation-error-handling.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ The exceptions that are considered fatal, by default, are:
228228
since these exceptions are unlikely to be resolved on a retried delivery.
229229

230230
You can add more exception types to the not-retryable category, or completely replace the map of classified exceptions.
231-
See the Javadocs for `DefaultErrorHandler.addNotRetryableException()` and `DefaultErrorHandler.setClassifications()` for more information, as well as those for the `spring-retry` `BinaryExceptionClassifier`.
231+
See the Javadocs for `DefaultErrorHandler.addNotRetryableException()` and `DefaultErrorHandler.setClassifications()` for more information, as well as `ExceptionMatcher`.
232232

233233
Here is an example that adds `IllegalArgumentException` to the not-retryable exceptions:
234234

@@ -502,7 +502,7 @@ The exceptions that are considered fatal, by default, are:
502502
since these exceptions are unlikely to be resolved on a retried delivery.
503503

504504
You can add more exception types to the not-retryable category, or completely replace the map of classified exceptions.
505-
See the Javadocs for `DefaultAfterRollbackProcessor.setClassifications()` for more information, as well as those for the `spring-retry` `BinaryExceptionClassifier`.
505+
See the Javadocs for `DefaultAfterRollbackProcessor.setClassifications()` for more information, as well as `ExceptionMatcher`.
506506

507507
Here is an example that adds `IllegalArgumentException` to the not-retryable exceptions:
508508

spring-kafka-docs/src/main/antora/modules/ROOT/pages/kafka/serdes.adoc

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,10 @@ ConsumerFactory cf = new DefaultKafkaConsumerFactory(myConsumerConfigs,
400400
new RetryingDeserializer(myUnreliableValueDeserializer, retryTemplate));
401401
----
402402

403-
Starting with version `3.1.2`, a `RecoveryCallback` can be set on the `RetryingDeserializer` optionally.
403+
A recovery callback be set on the `RetryingDeserializer`, to return a fallback object
404+
if all retries are exhausted.
404405

405-
Refer to the https://github.com/spring-projects/spring-retry[spring-retry] project for configuration of the `RetryTemplate` with a retry policy, back off policy, etc.
406+
Refer to the https://github.com/spring-projects/spring-framework[Spring Framework] project for configuration of the `RetryTemplate` with a retry policy, back off, etc.
406407

407408

408409
[[messaging-message-conversion]]
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
* Copyright 2018-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.kafka.annotation;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
import org.springframework.core.annotation.AliasFor;
26+
import org.springframework.core.retry.RetryPolicy;
27+
import org.springframework.format.annotation.DurationFormat;
28+
import org.springframework.util.backoff.ExponentialBackOff;
29+
import org.springframework.util.backoff.FixedBackOff;
30+
31+
/**
32+
* Collects metadata for creating a {@link org.springframework.util.backoff.BackOff BacOff}
33+
* instance as part of a {@link RetryPolicy}. Values can be provided as is or using a
34+
* {@code *String} equivalent that supports more format, as well as expression evaluations.
35+
* <p>
36+
* The available attributes lead to the following:
37+
* <ul>
38+
* <li>With no explicit settings, the default is a {@link FixedBackOff} with a delay of
39+
* {@value #DEFAULT_DELAY} ms</li>
40+
* <li>With only {@link #delay()} set: the backoff is a fixed delay with that value</li>
41+
* <li>In all other cases, an {@link ExponentialBackOff} is created with the values of
42+
* {@link #delay()} (default: {@value RetryPolicy.Builder#DEFAULT_DELAY} ms),
43+
* {@link #maxDelay()} (default: no maximum), {@link #multiplier()}
44+
* (default: {@value RetryPolicy.Builder#DEFAULT_MULTIPLIER}) and {@link #jitter()}
45+
* (default: no jitter).</li>
46+
* </ul>
47+
*
48+
* @author Dave Syer
49+
* @author Gary Russell
50+
* @author Aftab Shaikh
51+
* @author Stephane Nicoll
52+
* @since 4.0
53+
*/
54+
@Target(ElementType.ANNOTATION_TYPE)
55+
@Retention(RetentionPolicy.RUNTIME)
56+
@Documented
57+
public @interface BackOff {
58+
59+
/**
60+
* Default {@link #delay()} in milliseconds.
61+
*/
62+
long DEFAULT_DELAY = 1000;
63+
64+
/**
65+
* Alias for {@link #delay()}.
66+
* <p>Intended to be used when no other attributes are needed, for example:
67+
* {@code @BackOff(2000)}.
68+
*
69+
* @return the based delay in milliseconds (default{@value DEFAULT_DELAY})
70+
*/
71+
@AliasFor("delay")
72+
long value() default DEFAULT_DELAY;
73+
74+
/**
75+
* Specify the base delay after the initial invocation.
76+
* <p>If only a {@code delay} is specified, a {@link FixedBackOff} with that value
77+
* as the interval is configured.
78+
* <p>If a {@linkplain #multiplier() multiplier} is specified, this serves as the
79+
* initial delay to multiply from.
80+
* <p>The default is {@value DEFAULT_DELAY} milliseconds.
81+
*
82+
* @return the based delay in milliseconds (default{@value DEFAULT_DELAY})
83+
*/
84+
@AliasFor("value")
85+
long delay() default DEFAULT_DELAY;
86+
87+
/**
88+
* Specify the base delay after the initial invocation using a String format. If
89+
* this is specified, takes precedence over {@link #delay()}.
90+
* <p>The delay String can be in several formats:
91+
* <ul>
92+
* <li>a plain long &mdash; which is interpreted to represent a duration in
93+
* milliseconds</li>
94+
* <li>any of the known {@link DurationFormat.Style}: the {@link DurationFormat.Style#ISO8601 ISO8601}
95+
* style or the {@link DurationFormat.Style#SIMPLE SIMPLE} style &mdash; using
96+
* milliseconds as fallback if the string doesn't contain an explicit unit</li>
97+
* <li>Regular expressions, such as {@code ${example.property}} to use the
98+
* {@code example.property} from the environment</li>
99+
* </ul>
100+
*
101+
* @return the based delay as a String value &mdash; for example a placeholder
102+
* @see #delay()
103+
*/
104+
String delayString() default "";
105+
106+
/**
107+
* Specify the maximum delay for any retry attempt, limiting how far
108+
* {@linkplain #jitter jitter} and the {@linkplain #multiplier() multiplier} can
109+
* increase the {@linkplain #delay() delay}.
110+
* <p>Ignored if only {@link #delay()} is set, otherwise an {@link ExponentialBackOff}
111+
* with the given max delay or an unlimited delay if not set.
112+
*
113+
* @return the maximum delay
114+
*/
115+
long maxDelay() default 0;
116+
117+
/**
118+
* Specify the maximum delay for any retry attempt using a String format. If this is
119+
* specified, takes precedence over {@link #maxDelay()}..
120+
* <p>The max delay String can be in several formats:
121+
* <ul>
122+
* <li>a plain long &mdash; which is interpreted to represent a duration in
123+
* milliseconds</li>
124+
* <li>any of the known {@link DurationFormat.Style}: the {@link DurationFormat.Style#ISO8601 ISO8601}
125+
* style or the {@link DurationFormat.Style#SIMPLE SIMPLE} style &mdash; using
126+
* milliseconds as fallback if the string doesn't contain an explicit unit</li>
127+
* <li>Regular expressions, such as {@code ${example.property}} to use the
128+
* {@code example.property} from the environment</li>
129+
* </ul>
130+
*
131+
* @return the max delay as a String value &mdash; for example a placeholder
132+
* @see #maxDelay()
133+
*/
134+
String maxDelayString() default "";
135+
136+
/**
137+
* Specify a multiplier for a delay for the next retry attempt, applied to the previous
138+
* delay, starting with the initial {@linkplain #delay() delay} as well as to the
139+
* applicable {@linkplain #jitter() jitter} for each attempt.
140+
* <p>Ignored if only {@link #delay()} is set, otherwise an {@link ExponentialBackOff}
141+
* with the given multiplier or {@code 1.0} if not set.
142+
*
143+
* @return the value to multiply the current interval by for each attempt
144+
*/
145+
double multiplier() default 0;
146+
147+
/**
148+
* Specify a multiplier for a delay for the next retry attempt using a String format.
149+
* If this is specified, takes precedence over {@link #multiplier()}.
150+
* <p>The multiplier String can be in several formats:
151+
* <ul>
152+
* <li>a plain double</li>
153+
* <li>Regular expressions, such as {@code ${example.property}} to use the
154+
* {@code example.property} from the environment</li>
155+
* </ul>
156+
*
157+
* @return the value to multiply the current interval by for each attempt &mdash;
158+
* for example a placeholder
159+
* @see #multiplier()
160+
*/
161+
String multiplierString() default "";
162+
163+
/**
164+
* Specify a jitter value for the base retry attempt, randomly subtracted or added to
165+
* the calculated delay, resulting in a value between {@code delay - jitter} and
166+
* {@code delay + jitter} but never below the {@linkplain #delay() base delay} or
167+
* above the {@linkplain #maxDelay() max delay}.
168+
* <p>If a {@linkplain #multiplier() multiplier} is specified, it is applied to the
169+
* jitter value as well.
170+
* <p>Ignored if only {@link #delay()} is set, otherwise an {@link ExponentialBackOff}
171+
* with the given jitter or no jitter if not set.
172+
*
173+
* @return the jitter value in milliseconds
174+
* @see #delay()
175+
* @see #maxDelay()
176+
* @see #multiplier()
177+
*/
178+
long jitter() default 0;
179+
180+
/**
181+
* Specify a jitter value for the base retry attempt using a String format. If this is
182+
* specified, takes precedence over {@link #jitter()}.
183+
* <p>The jitter String can be in several formats:
184+
* <ul>
185+
* <li>a plain long &mdash; which is interpreted to represent a duration in
186+
* milliseconds</li>
187+
* <li>any of the known {@link DurationFormat.Style}: the {@link DurationFormat.Style#ISO8601 ISO8601}
188+
* style or the {@link DurationFormat.Style#SIMPLE SIMPLE} style &mdash; using
189+
* milliseconds as fallback if the string doesn't contain an explicit unit</li>
190+
* <li>Regular expressions, such as {@code ${example.property}} to use the
191+
* {@code example.property} from the environment</li>
192+
* </ul>
193+
*
194+
* @return the jitter as a String value &mdash; for example a placeholder
195+
* @see #jitter()
196+
*/
197+
String jitterString() default "";
198+
199+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright 2018-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.kafka.annotation;
18+
19+
import java.time.Duration;
20+
import java.util.concurrent.TimeUnit;
21+
import java.util.function.Supplier;
22+
23+
import org.jspecify.annotations.Nullable;
24+
25+
import org.springframework.core.retry.RetryPolicy;
26+
import org.springframework.format.annotation.DurationFormat;
27+
import org.springframework.format.datetime.standard.DurationFormatterUtils;
28+
import org.springframework.util.Assert;
29+
import org.springframework.util.StringUtils;
30+
import org.springframework.util.StringValueResolver;
31+
import org.springframework.util.backoff.FixedBackOff;
32+
33+
/**
34+
* Create a {@link org.springframework.util.backoff.BackOff} from the state of a
35+
* {@link BackOff @BackOff} annotation.
36+
*
37+
* @author Stephane Nicoll
38+
*/
39+
final class BackOffFactory {
40+
41+
private static final long DEFAULT_DELAY = 1000;
42+
43+
private final @Nullable StringValueResolver embeddedValueResolver;
44+
45+
BackOffFactory(@Nullable StringValueResolver embeddedValueResolver) {
46+
this.embeddedValueResolver = embeddedValueResolver;
47+
}
48+
49+
/**
50+
* Create a {@link org.springframework.util.backoff.BackOff} instance based on the
51+
* state of the given {@link BackOff @Backff}. The returned backoff instance has
52+
* unlimited number of attempts as these are controlled externally.
53+
*
54+
* @param annotation the annotation to source the parameters from
55+
* @return a {@link org.springframework.util.backoff.BackOff}
56+
*/
57+
public org.springframework.util.backoff.BackOff createFromAnnotation(BackOff annotation) { // NOSONAR
58+
Duration delay = resolveDuration("delay", () -> annotation.delay() == DEFAULT_DELAY
59+
? annotation.value() : annotation.delay(), annotation::delayString);
60+
Duration maxDelay = resolveDuration("maxDelay", annotation::maxDelay, annotation::maxDelayString);
61+
double multiplier = resolveMultiplier(annotation);
62+
Duration jitter = resolveDuration("jitter", annotation::jitter, annotation::jitterString);
63+
if (maxDelay == Duration.ZERO && multiplier == 0 && jitter == Duration.ZERO) {
64+
Assert.isTrue(!delay.isNegative(),
65+
() -> "Invalid delay (%dms): must be >= 0.".formatted(delay.toMillis()));
66+
return new FixedBackOff(delay.toMillis());
67+
}
68+
RetryPolicy.Builder retryPolicyBuilder = RetryPolicy.builder().maxAttempts(Long.MAX_VALUE);
69+
retryPolicyBuilder.delay(delay);
70+
if (maxDelay != Duration.ZERO) {
71+
retryPolicyBuilder.maxDelay(maxDelay);
72+
}
73+
if (multiplier != 0) {
74+
retryPolicyBuilder.multiplier(multiplier);
75+
}
76+
if (jitter != Duration.ZERO) {
77+
retryPolicyBuilder.jitter(jitter);
78+
}
79+
return retryPolicyBuilder.build().getBackOff();
80+
}
81+
82+
private Duration resolveDuration(String attributeName, Supplier<@Nullable Long> valueRaw,
83+
Supplier<String> valueString) {
84+
String resolvedValue = resolve(valueString.get());
85+
if (StringUtils.hasLength(resolvedValue)) {
86+
try {
87+
return toDuration(resolvedValue, TimeUnit.MILLISECONDS);
88+
}
89+
catch (RuntimeException ex) {
90+
throw new IllegalArgumentException(
91+
"Invalid duration value for '%s': '%s'; %s".formatted(attributeName, resolvedValue, ex));
92+
}
93+
}
94+
Long raw = valueRaw.get();
95+
return (raw != null && raw != 0) ? Duration.ofMillis(raw) : Duration.ZERO;
96+
}
97+
98+
private Double resolveMultiplier(BackOff annotation) {
99+
String resolvedMultiplier = resolve(annotation.multiplierString());
100+
if (StringUtils.hasLength(resolvedMultiplier)) {
101+
try {
102+
return Double.valueOf(resolvedMultiplier);
103+
}
104+
catch (NumberFormatException ex) {
105+
throw new IllegalArgumentException(
106+
"Invalid multiplier: '%s'; %s".formatted(resolvedMultiplier, ex));
107+
}
108+
}
109+
return annotation.multiplier();
110+
}
111+
112+
private @Nullable String resolve(String valueString) {
113+
if (StringUtils.hasLength(valueString) && this.embeddedValueResolver != null) {
114+
return this.embeddedValueResolver.resolveStringValue(valueString);
115+
}
116+
return valueString;
117+
}
118+
119+
private static Duration toDuration(String valueToResolve, TimeUnit timeUnit) {
120+
DurationFormat.Unit unit = DurationFormat.Unit.fromChronoUnit(timeUnit.toChronoUnit());
121+
return DurationFormatterUtils.detectAndParse(valueToResolve, unit);
122+
}
123+
124+
}

0 commit comments

Comments
 (0)