Skip to content

Commit 9d2866c

Browse files
committed
Address code review
- Add specific retry exception - Add RetryCallback interface with a logical name for diagnostics purpose - Improve RetryPolicy with ability to specify which exceptions to retry
1 parent 2424453 commit 9d2866c

File tree

6 files changed

+222
-30
lines changed

6 files changed

+222
-30
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
/**
20+
* Callback interface for a retryable piece of code. Used in conjunction with {@link RetryOperations}.
21+
*
22+
* @author Mahmoud Ben Hassine
23+
* @since 7.0
24+
* @param <R> the type of the result
25+
* @see RetryOperations
26+
*/
27+
public interface RetryCallback<R> {
28+
29+
/**
30+
* Method to execute and retry if needed.
31+
* @return the result of the callback
32+
* @throws Exception if an error occurs during the execution of the callback
33+
*/
34+
R run() throws Exception;
35+
36+
/**
37+
* A unique logical name for this callback to distinguish retries around
38+
* business operations.
39+
* @return the name of the callback
40+
*/
41+
String getName();
42+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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.io.Serial;
20+
21+
/**
22+
* Exception class for exhausted retries.
23+
*
24+
* @author Mahmoud Ben Hassine
25+
* @since 7.0
26+
* @see RetryOperations
27+
*/
28+
public class RetryException extends Exception {
29+
30+
@Serial
31+
private static final long serialVersionUID = 5439915454935047936L;
32+
33+
/**
34+
* Create a new exception with a message.
35+
* @param message the exception's message
36+
*/
37+
public RetryException(String message) {
38+
super(message);
39+
}
40+
41+
/**
42+
* Create a new exception with a message and a cause.
43+
* @param message the exception's message
44+
* @param cause the exception's cause
45+
*/
46+
public RetryException(String message, Throwable cause) {
47+
super(message, cause);
48+
}
49+
50+
}

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616

1717
package org.springframework.core.retry;
1818

19-
import java.util.concurrent.Callable;
20-
2119
import org.jspecify.annotations.Nullable;
2220

2321
/**
@@ -28,6 +26,7 @@
2826
*
2927
* @author Mahmoud Ben Hassine
3028
* @since 7.0
29+
* @see RetryTemplate
3130
*/
3231
public interface RetryOperations {
3332

@@ -37,9 +36,9 @@ public interface RetryOperations {
3736
* @param retryCallback the callback to call initially and retry if needed
3837
* @param <R> the type of the callback's result
3938
* @return the callback's result
40-
* @throws Exception if the retry policy is exhausted
39+
* @throws RetryException if the retry policy is exhausted
4140
*/
42-
<R> @Nullable R execute(Callable<R> retryCallback) throws Exception;
41+
<R> @Nullable R execute(RetryCallback<R> retryCallback) throws RetryException;
4342

4443
}
4544

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616

1717
package org.springframework.core.retry;
1818

19+
import java.util.function.Predicate;
20+
1921
/**
20-
* Strategy interface to define how to calculate the maximum number of retry attempts.
22+
* Strategy interface to define how to calculate the maximum number of retry attempts
23+
* and which exceptions to retry.
2124
*
2225
* @author Mahmoud Ben Hassine
2326
* @since 7.0
@@ -30,4 +33,13 @@ public interface RetryPolicy {
3033
*/
3134
int getMaxAttempts();
3235

36+
/**
37+
* Return a predicate that specifies which exceptions to retry. Defaults to a
38+
* predicate that retries all exceptions.
39+
* @return a predicate that specifies which exceptions to retry
40+
*/
41+
default Predicate<Exception> retryOn() {
42+
return exception -> true;
43+
}
44+
3345
}

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

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,32 @@ public class RetryTemplate implements RetryOperations {
5959
private RetryListener retryListener = new RetryListener() {
6060
};
6161

62+
/**
63+
* Create a new retry template with default settings.
64+
*/
65+
public RetryTemplate() {
66+
}
67+
68+
/**
69+
* Create a new retry template with a custom {@link RetryPolicy}.
70+
* @param retryPolicy the retry policy to use
71+
*/
72+
public RetryTemplate(RetryPolicy retryPolicy) {
73+
Assert.notNull(retryPolicy, "Retry policy must not be null");
74+
this.retryPolicy = retryPolicy;
75+
}
76+
77+
/**
78+
* Create a new retry template with a custom {@link RetryPolicy} and {@link BackOffPolicy}.
79+
* @param retryPolicy the retry policy to use
80+
* @param backOffPolicy the backoff policy to use
81+
*/
82+
public RetryTemplate(RetryPolicy retryPolicy, BackOffPolicy backOffPolicy) {
83+
this(retryPolicy);
84+
Assert.notNull(backOffPolicy, "BackOff policy must not be null");
85+
this.backOffPolicy = backOffPolicy;
86+
}
87+
6288
/**
6389
* Set the {@link RetryPolicy} to use. Defaults to <code>MaxAttemptsRetryPolicy(3)</code>.
6490
* @param retryPolicy the retry policy to use. Must not be <code>null</code>.
@@ -92,41 +118,50 @@ public void setRetryListener(RetryListener retryListener) {
92118
* If the callback succeeds, its result is returned. Otherwise, the last exception will
93119
* be propagated to the caller.
94120
* @param retryCallback the callback to call initially and retry if needed
95-
* @param <R> the type of the result
121+
* @param <R> the type of the result
96122
* @return the result of the callback if any
97-
* @throws Exception thrown if the retry policy is exhausted
123+
* @throws RetryException thrown if the retry policy is exhausted
98124
*/
99125
@Override
100-
public <R> @Nullable R execute(Callable<R> retryCallback) throws Exception {
126+
public <R> @Nullable R execute(RetryCallback<R> retryCallback) throws RetryException {
101127
Assert.notNull(retryCallback, "Retry Callback must not be null");
102128
int attempts = 0;
103129
int maxAttempts = this.retryPolicy.getMaxAttempts();
104130
while (attempts++ <= maxAttempts) {
131+
String callbackName = retryCallback.getName();
105132
if (logger.isDebugEnabled()) {
106-
logger.debug("Retry attempt #" + attempts);
133+
logger.debug("Retry callback '" + callbackName + "' attempt #" + attempts);
107134
}
108135
try {
109136
this.retryListener.beforeRetry();
110-
R result = retryCallback.call();
137+
R result = retryCallback.run();
111138
this.retryListener.onSuccess(result);
112139
if (logger.isDebugEnabled()) {
113-
logger.debug("Retry attempt #" + attempts + " succeeded");
140+
logger.debug("Retry callback '" + callbackName + "' attempt #" + attempts + " succeeded");
114141
}
115142
return result;
116143
}
117144
catch (Exception exception) {
145+
if (!this.retryPolicy.retryOn().test(exception)) {
146+
if (logger.isDebugEnabled()) {
147+
logger.debug("Retry callback '" + callbackName + "' aborted on " + exception.getMessage(), exception);
148+
}
149+
break;
150+
}
118151
this.retryListener.onFailure(exception);
119152
Duration duration = this.backOffPolicy.backOff();
120-
Thread.sleep(duration.toMillis());
121153
if (logger.isDebugEnabled()) {
122-
logger.debug("Attempt #" + attempts + " failed, backing off for " + duration.toMillis() + "ms");
154+
logger.debug("Retry callback '" + callbackName + "' attempt #" + attempts + " failed, backing off for " + duration.toMillis() + "ms");
155+
}
156+
try {
157+
Thread.sleep(duration.toMillis());
158+
}
159+
catch (InterruptedException interruptedException) {
160+
throw new RetryException("Unable to backoff for retry callback '" + callbackName + "'", interruptedException);
123161
}
124162
if (attempts >= maxAttempts) {
125-
if (logger.isDebugEnabled()) {
126-
logger.debug("Maximum retry attempts " + attempts + " exhausted, aborting execution");
127-
}
128163
this.retryListener.onMaxAttempts(exception);
129-
throw exception;
164+
throw new RetryException("Retry callback '" + callbackName + "' exceeded maximum retry attempts " + attempts + ", aborting execution", exception);
130165
}
131166
}
132167
}

spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
package org.springframework.core.retry;
1818

1919
import java.time.Duration;
20-
import java.util.concurrent.Callable;
20+
import java.util.function.Predicate;
2121

2222
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
2323
import org.junit.jupiter.api.Test;
@@ -37,44 +37,98 @@ class RetryTemplateTests {
3737

3838
@Test
3939
void testRetryWithSuccess() throws Exception {
40-
// given some unreliable code
41-
Callable<String> callable = new Callable<>() {
40+
// given
41+
RetryCallback<String> retryCallback = new RetryCallback<>() {
4242
int failure;
4343
@Override
44-
public String call() throws Exception {
44+
public String run() throws Exception {
4545
if (failure++ < 2) {
46-
throw new Exception("Error while invoking service");
46+
throw new Exception("Error while invoking greeting service");
4747
}
4848
return "hello world";
4949
}
50+
51+
@Override
52+
public String getName() {
53+
return "greeting service";
54+
}
5055
};
51-
// and a configured retry template
5256
RetryTemplate retryTemplate = new RetryTemplate();
5357
retryTemplate.setRetryPolicy(new MaxAttemptsRetryPolicy(3));
5458
retryTemplate.setBackOffPolicy(new FixedBackOffPolicy(Duration.ofMillis(100)));
5559

5660
// when
57-
String result = retryTemplate.execute(callable);
61+
String result = retryTemplate.execute(retryCallback);
5862

5963
// then
6064
assertThat(result).isEqualTo("hello world");
6165
}
6266

6367
@Test
6468
void testRetryWithFailure() {
65-
// given some unreliable code
66-
Callable<String> callable = () -> {
67-
throw new Exception("Error while invoking service");
69+
// given
70+
RetryCallback<String> retryCallback = new RetryCallback<>() {
71+
@Override
72+
public String run() throws Exception {
73+
throw new Exception("Error while invoking greeting service");
74+
}
75+
76+
@Override
77+
public String getName() {
78+
return "greeting service";
79+
}
6880
};
69-
// and a configured retry template
7081
RetryTemplate retryTemplate = new RetryTemplate();
7182
retryTemplate.setRetryPolicy(new MaxAttemptsRetryPolicy(3));
7283
retryTemplate.setBackOffPolicy(new FixedBackOffPolicy(Duration.ofMillis(100)));
7384

7485
// when
75-
ThrowingCallable throwingCallable = () -> retryTemplate.execute(callable);
86+
ThrowingCallable throwingCallable = () -> retryTemplate.execute(retryCallback);
87+
88+
// then
89+
assertThatThrownBy(throwingCallable)
90+
.isInstanceOf(RetryException.class)
91+
.hasMessage("Retry callback 'greeting service' exceeded maximum retry attempts 3, aborting execution")
92+
.cause().isInstanceOf(Exception.class).hasMessage("Error while invoking greeting service");
93+
}
94+
95+
@Test
96+
void testRetrySpecificException() {
97+
// given
98+
class TechnicalException extends Exception {
99+
public TechnicalException(String message) {
100+
super(message);
101+
}
102+
}
103+
RetryCallback<String> retryCallback = new RetryCallback<>() {
104+
@Override
105+
public String run() throws TechnicalException {
106+
throw new TechnicalException("Error while invoking greeting service");
107+
}
108+
109+
@Override
110+
public String getName() {
111+
return "greeting service";
112+
}
113+
};
114+
MaxAttemptsRetryPolicy retryPolicy = new MaxAttemptsRetryPolicy(3) {
115+
@Override
116+
public Predicate<Exception> retryOn() {
117+
return exception -> exception instanceof TechnicalException;
118+
}
119+
};
120+
RetryTemplate retryTemplate = new RetryTemplate();
121+
retryTemplate.setRetryPolicy(retryPolicy);
122+
retryTemplate.setBackOffPolicy(new FixedBackOffPolicy(Duration.ofMillis(100)));
123+
124+
// when
125+
ThrowingCallable throwingCallable = () -> retryTemplate.execute(retryCallback);
76126

77127
// then
78-
assertThatThrownBy(throwingCallable).hasMessage("Error while invoking service");
128+
assertThatThrownBy(throwingCallable)
129+
.isInstanceOf(RetryException.class)
130+
.cause().isInstanceOf(TechnicalException.class)
131+
.hasMessage("Error while invoking greeting service");
79132
}
133+
80134
}

0 commit comments

Comments
 (0)