Skip to content

Commit 97522cf

Browse files
committed
Introduce Builder API and factory methods for RetryPolicy
Prior to this commit, we had three concrete RetryPolicy implementations. - MaxRetryAttemptsPolicy - MaxDurationAttemptsPolicy - PredicateRetryPolicy However, there was no way to combine the behavior of those policies. Furthermore, the PredicateRetryPolicy was practically useless as a standalone policy, since it did not have a way to end an infinite loop for a Retryable that continually throws an exception which matches the predicate. This commit therefore replaces the current built-in RetryPolicy implementations with a fluent Builder API and dedicated factory methods for common use cases. In addition, this commit also introduces built-in support for specifying include/exclude lists. Examples: new MaxRetryAttemptsPolicy(5) --> RetryPolicy.withMaxAttempts(5) new MaxDurationAttemptsPolicy(Duration.ofSeconds(5)) --> RetryPolicy.withMaxDuration(Duration.ofSeconds(5)) new PredicateRetryPolicy(IOException.class::isInstance) --> RetryPolicy.builder() .maxAttempts(3) .predicate(IOException.class::isInstance) .build(); The following example demonstrates all supported features of the builder. RetryPolicy.builder() .maxAttempts(5) .maxDuration(Duration.ofMillis(100)) .includes(IOException.class) .excludes(FileNotFoundException.class) .predicate(t -> t.getMessage().contains("Unexpected failure")) .build(); Closes gh-35058
1 parent 945f3fb commit 97522cf

13 files changed

+713
-537
lines changed
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Copyright 2002-2025 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.core.retry;
18+
19+
import java.time.Duration;
20+
import java.time.LocalDateTime;
21+
import java.util.Set;
22+
import java.util.StringJoiner;
23+
import java.util.function.Predicate;
24+
25+
import org.jspecify.annotations.Nullable;
26+
27+
import org.springframework.util.Assert;
28+
29+
/**
30+
* Default {@link RetryPolicy} created by {@link RetryPolicy.Builder}.
31+
*
32+
* @author Sam Brannen
33+
* @author Mahmoud Ben Hassine
34+
* @since 7.0
35+
*/
36+
class DefaultRetryPolicy implements RetryPolicy {
37+
38+
private final int maxAttempts;
39+
40+
private final @Nullable Duration maxDuration;
41+
42+
private final Set<Class<? extends Throwable>> includes;
43+
44+
private final Set<Class<? extends Throwable>> excludes;
45+
46+
private final @Nullable Predicate<Throwable> predicate;
47+
48+
49+
DefaultRetryPolicy(int maxAttempts, @Nullable Duration maxDuration, Set<Class<? extends Throwable>> includes,
50+
Set<Class<? extends Throwable>> excludes, @Nullable Predicate<Throwable> predicate) {
51+
52+
Assert.isTrue((maxAttempts > 0 || maxDuration != null), "Max attempts or max duration must be specified");
53+
54+
this.maxAttempts = maxAttempts;
55+
this.maxDuration = maxDuration;
56+
this.includes = includes;
57+
this.excludes = excludes;
58+
this.predicate = predicate;
59+
}
60+
61+
62+
@Override
63+
public RetryExecution start() {
64+
return new DefaultRetryPolicyExecution();
65+
}
66+
67+
@Override
68+
public String toString() {
69+
StringJoiner result = new StringJoiner(", ", "DefaultRetryPolicy[", "]");
70+
if (this.maxAttempts > 0) {
71+
result.add("maxAttempts=" + this.maxAttempts);
72+
}
73+
if (this.maxDuration != null) {
74+
result.add("maxDuration=" + this.maxDuration.toMillis() + "ms");
75+
}
76+
if (!this.includes.isEmpty()) {
77+
result.add("includes=" + names(this.includes));
78+
}
79+
if (!this.excludes.isEmpty()) {
80+
result.add("excludes=" + names(this.excludes));
81+
}
82+
if (this.predicate != null) {
83+
result.add("predicate=" + this.predicate.getClass().getSimpleName());
84+
}
85+
return result.toString();
86+
}
87+
88+
89+
private static String names(Set<Class<? extends Throwable>> types) {
90+
StringJoiner result = new StringJoiner(", ", "[", "]");
91+
for (Class<? extends Throwable> type : types) {
92+
String name = type.getCanonicalName();
93+
result.add(name != null? name : type.getName());
94+
}
95+
return result.toString();
96+
}
97+
98+
99+
/**
100+
* {@link RetryExecution} for {@link DefaultRetryPolicy}.
101+
*/
102+
private class DefaultRetryPolicyExecution implements RetryExecution {
103+
104+
private final LocalDateTime retryStartTime = LocalDateTime.now();
105+
106+
private int retryCount;
107+
108+
109+
@Override
110+
public boolean shouldRetry(Throwable throwable) {
111+
if (DefaultRetryPolicy.this.maxAttempts > 0 &&
112+
this.retryCount++ >= DefaultRetryPolicy.this.maxAttempts) {
113+
return false;
114+
}
115+
if (DefaultRetryPolicy.this.maxDuration != null) {
116+
Duration retryDuration = Duration.between(this.retryStartTime, LocalDateTime.now());
117+
if (retryDuration.compareTo(DefaultRetryPolicy.this.maxDuration) > 0) {
118+
return false;
119+
}
120+
}
121+
if (!DefaultRetryPolicy.this.excludes.isEmpty()) {
122+
for (Class<? extends Throwable> excludedType : DefaultRetryPolicy.this.excludes) {
123+
if (excludedType.isInstance(throwable)) {
124+
return false;
125+
}
126+
}
127+
}
128+
if (!DefaultRetryPolicy.this.includes.isEmpty()) {
129+
boolean included = false;
130+
for (Class<? extends Throwable> includedType : DefaultRetryPolicy.this.includes) {
131+
if (includedType.isInstance(throwable)) {
132+
included = true;
133+
break;
134+
}
135+
}
136+
if (!included) {
137+
return false;
138+
}
139+
}
140+
return DefaultRetryPolicy.this.predicate == null || DefaultRetryPolicy.this.predicate.test(throwable);
141+
}
142+
143+
@Override
144+
public String toString() {
145+
StringJoiner result = new StringJoiner(", ", "DefaultRetryPolicyExecution[", "]");
146+
if (DefaultRetryPolicy.this.maxAttempts > 0) {
147+
result.add("maxAttempts=" + DefaultRetryPolicy.this.maxAttempts);
148+
result.add("retryCount=" + this.retryCount);
149+
}
150+
if (DefaultRetryPolicy.this.maxDuration != null) {
151+
result.add("maxDuration=" + DefaultRetryPolicy.this.maxDuration.toMillis() + "ms");
152+
result.add("retryStartTime=" + this.retryStartTime);
153+
}
154+
if (!DefaultRetryPolicy.this.includes.isEmpty()) {
155+
result.add("includes=" + names(DefaultRetryPolicy.this.includes));
156+
}
157+
if (!DefaultRetryPolicy.this.excludes.isEmpty()) {
158+
result.add("excludes=" + names(DefaultRetryPolicy.this.excludes));
159+
}
160+
if (DefaultRetryPolicy.this.predicate != null) {
161+
result.add("predicate=" + DefaultRetryPolicy.this.predicate.getClass().getSimpleName());
162+
}
163+
return result.toString();
164+
}
165+
}
166+
167+
}

spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,28 @@
1616

1717
package org.springframework.core.retry;
1818

19+
import java.time.Duration;
20+
import java.util.Collections;
21+
import java.util.LinkedHashSet;
22+
import java.util.Set;
23+
import java.util.function.Predicate;
24+
25+
import org.jspecify.annotations.Nullable;
26+
27+
import org.springframework.util.Assert;
28+
1929
/**
2030
* Strategy interface to define a retry policy.
2131
*
32+
* <p>Also provides factory methods and a fluent builder API for creating retry
33+
* policies with common configurations. See {@link #withMaxAttempts(int)},
34+
* {@link #withMaxDuration(Duration)}, {@link #builder()}, and the configuration
35+
* options in {@link Builder} for details.
36+
*
37+
* @author Sam Brannen
2238
* @author Mahmoud Ben Hassine
2339
* @since 7.0
40+
* @see RetryExecution
2441
*/
2542
public interface RetryPolicy {
2643

@@ -30,4 +47,133 @@ public interface RetryPolicy {
3047
*/
3148
RetryExecution start();
3249

50+
51+
/**
52+
* Create a {@link RetryPolicy} configured with a maximum number of retry attempts.
53+
* @param maxAttempts the maximum number of retry attempts; must be greater than zero
54+
* @see Builder#maxAttempts(int)
55+
*/
56+
static RetryPolicy withMaxAttempts(int maxAttempts) {
57+
return builder().maxAttempts(maxAttempts).build();
58+
}
59+
60+
/**
61+
* Create a {@link RetryPolicy} configured with a maximum retry {@link Duration}.
62+
* @param maxDuration the maximum retry duration; must be positive
63+
* @see Builder#maxDuration(Duration)
64+
*/
65+
static RetryPolicy withMaxDuration(Duration maxDuration) {
66+
return builder().maxDuration(maxDuration).build();
67+
}
68+
69+
/**
70+
* Create a {@link Builder} to configure a {@link RetryPolicy} with common
71+
* configuration options.
72+
*/
73+
static Builder builder() {
74+
return new Builder();
75+
}
76+
77+
78+
/**
79+
* Fluent API for configuring a {@link RetryPolicy} with common configuration
80+
* options.
81+
*/
82+
final class Builder {
83+
84+
private int maxAttempts;
85+
86+
private @Nullable Duration maxDuration;
87+
88+
private final Set<Class<? extends Throwable>> includes = new LinkedHashSet<>();
89+
90+
private final Set<Class<? extends Throwable>> excludes = new LinkedHashSet<>();
91+
92+
private @Nullable Predicate<Throwable> predicate;
93+
94+
95+
private Builder() {
96+
// internal constructor
97+
}
98+
99+
100+
/**
101+
* Specify the maximum number of retry attempts.
102+
* @param maxAttempts the maximum number of retry attempts; must be
103+
* greater than zero
104+
* @return this {@code Builder} instance for chained method invocations
105+
*/
106+
public Builder maxAttempts(int maxAttempts) {
107+
Assert.isTrue(maxAttempts > 0, "Max attempts must be greater than zero");
108+
this.maxAttempts = maxAttempts;
109+
return this;
110+
}
111+
112+
/**
113+
* Specify the maximum retry {@link Duration}.
114+
* @param maxDuration the maximum retry duration; must be positive
115+
* @return this {@code Builder} instance for chained method invocations
116+
*/
117+
public Builder maxDuration(Duration maxDuration) {
118+
Assert.isTrue(!maxDuration.isNegative() && !maxDuration.isZero(), "Max duration must be positive");
119+
this.maxDuration = maxDuration;
120+
return this;
121+
}
122+
123+
/**
124+
* Specify the types of exceptions for which the {@link RetryPolicy}
125+
* should retry a failed operation.
126+
* <p>This can be combined with {@link #excludes(Class...)} and
127+
* {@link #predicate(Predicate)}.
128+
* @param types the types of exceptions to include in the policy
129+
* @return this {@code Builder} instance for chained method invocations
130+
*/
131+
@SafeVarargs // Making the method final allows us to use @SafeVarargs.
132+
@SuppressWarnings("varargs")
133+
public final Builder includes(Class<? extends Throwable>... types) {
134+
Collections.addAll(this.includes, types);
135+
return this;
136+
}
137+
138+
/**
139+
* Specify the types of exceptions for which the {@link RetryPolicy}
140+
* should not retry a failed operation.
141+
* <p>This can be combined with {@link #includes(Class...)} and
142+
* {@link #predicate(Predicate)}.
143+
* @param types the types of exceptions to exclude from the policy
144+
* @return this {@code Builder} instance for chained method invocations
145+
*/
146+
@SafeVarargs // Making the method final allows us to use @SafeVarargs.
147+
@SuppressWarnings("varargs")
148+
public final Builder excludes(Class<? extends Throwable>... types) {
149+
Collections.addAll(this.excludes, types);
150+
return this;
151+
}
152+
153+
/**
154+
* Specify a custom {@link Predicate} that the {@link RetryPolicy} will
155+
* use to determine whether to retry a failed operation based on a given
156+
* {@link Throwable}.
157+
* <p>If a predicate has already been configured, the supplied predicate
158+
* will be {@linkplain Predicate#and(Predicate) combined} with the
159+
* existing predicate.
160+
* <p>This can be combined with {@link #includes(Class...)} and
161+
* {@link #excludes(Class...)}.
162+
* @param predicate a custom predicate
163+
* @return this {@code Builder} instance for chained method invocations
164+
*/
165+
public Builder predicate(Predicate<Throwable> predicate) {
166+
this.predicate = (this.predicate != null ? this.predicate.and(predicate) : predicate);
167+
return this;
168+
}
169+
170+
/**
171+
* Build the {@link RetryPolicy} configured via this {@code Builder}.
172+
*/
173+
public RetryPolicy build() {
174+
return new DefaultRetryPolicy(this.maxAttempts, this.maxDuration,
175+
this.includes, this.excludes, this.predicate);
176+
}
177+
}
178+
33179
}

spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import org.jspecify.annotations.Nullable;
2525

2626
import org.springframework.core.log.LogAccessor;
27-
import org.springframework.core.retry.support.MaxRetryAttemptsPolicy;
2827
import org.springframework.util.Assert;
2928
import org.springframework.util.backoff.BackOff;
3029
import org.springframework.util.backoff.BackOffExecution;
@@ -59,7 +58,8 @@ public class RetryTemplate implements RetryOperations {
5958

6059
private static final LogAccessor logger = new LogAccessor(RetryTemplate.class);
6160

62-
private RetryPolicy retryPolicy = new MaxRetryAttemptsPolicy();
61+
62+
private RetryPolicy retryPolicy = RetryPolicy.withMaxAttempts(3);
6363

6464
private BackOff backOffPolicy = new FixedBackOff(Duration.ofSeconds(1));
6565

@@ -98,9 +98,11 @@ public RetryTemplate(RetryPolicy retryPolicy, BackOff backOffPolicy) {
9898

9999
/**
100100
* Set the {@link RetryPolicy} to use.
101-
* <p>Defaults to {@code new MaxRetryAttemptsPolicy()}.
101+
* <p>Defaults to {@code RetryPolicy.withMaxAttempts(3)}.
102102
* @param retryPolicy the retry policy to use
103-
* @see MaxRetryAttemptsPolicy
103+
* @see RetryPolicy#withMaxAttempts(int)
104+
* @see RetryPolicy#withMaxDuration(Duration)
105+
* @see RetryPolicy#builder()
104106
*/
105107
public void setRetryPolicy(RetryPolicy retryPolicy) {
106108
Assert.notNull(retryPolicy, "Retry policy must not be null");

0 commit comments

Comments
 (0)