Skip to content

Commit 7e93c1e

Browse files
committed
Introduce a minimal retry functionality as a core framework feature
This commit introduces a minimal core retry feature. It is inspired by Spring Retry, but redesigned and trimmed to the bare minimum to cover most cases.
1 parent 907c1db commit 7e93c1e

26 files changed

+1104
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 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+
package org.springframework.core.retry;
17+
18+
import java.time.Duration;
19+
20+
/**
21+
* Strategy interface to define how to calculate the backoff policy.
22+
*
23+
* @author Mahmoud Ben Hassine
24+
* @since 7.0
25+
*/
26+
public interface BackOffPolicy {
27+
28+
/**
29+
* Signal how long to backoff before the next retry attempt.
30+
*
31+
* @return the duration to wait for before the next retry attempt
32+
*/
33+
Duration backOff();
34+
35+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 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+
package org.springframework.core.retry;
17+
18+
import org.springframework.core.retry.support.listener.CompositeRetryListener;
19+
20+
/**
21+
* An extension point that allows to inject code during key retry phases.
22+
*
23+
* <p>Typically registered in a {@link RetryTemplate}, and can be composed using
24+
* a {@link CompositeRetryListener}.
25+
*
26+
* @author Mahmoud Ben Hassine
27+
* @since 7.0
28+
* @see CompositeRetryListener
29+
*/
30+
public interface RetryListener {
31+
32+
/**
33+
* Called before every retry attempt.
34+
*/
35+
default void beforeRetry() {
36+
}
37+
38+
/**
39+
* Called after a successful retry attempt.
40+
* @param result the result of the callback
41+
* @param <T> the type of the result
42+
*/
43+
default <T> void onSuccess(T result) {
44+
}
45+
46+
/**
47+
* Called every time a retry attempt fails.
48+
* @param exception the exception thrown by the callback
49+
*/
50+
default void onFailure(Exception exception) {
51+
}
52+
53+
/**
54+
* Called once the retry policy is exhausted.
55+
* @param exception the last exception thrown by the callback
56+
*/
57+
default void onMaxAttempts(Exception exception) {
58+
}
59+
60+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 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+
package org.springframework.core.retry;
17+
18+
import java.util.concurrent.Callable;
19+
20+
import org.jspecify.annotations.Nullable;
21+
22+
/**
23+
* Main entry point to the core retry functionality. Defines a set of retryable operations.
24+
*
25+
* <p>Implemented by {@link RetryTemplate}. Not often used directly, but a useful
26+
* option to enhance testability, as it can easily be mocked or stubbed.
27+
*
28+
* @author Mahmoud Ben Hassine
29+
* @since 7.0
30+
*/
31+
public interface RetryOperations {
32+
33+
/**
34+
* Retry the given callback (according to the retry policy configured at the implementation level)
35+
* until it succeeds or eventually throw an exception if the retry policy is exhausted.
36+
* @param retryCallback the callback to call initially and retry if needed
37+
* @return the callback's result
38+
* @param <R> the type of the callback's result
39+
* @throws Exception if the retry policy is exhausted
40+
*/
41+
<R> @Nullable R execute(Callable<R> retryCallback) throws Exception;
42+
43+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 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+
package org.springframework.core.retry;
17+
18+
/**
19+
* Strategy interface to define how to calculate the maximin number of retry attempts.
20+
*
21+
* @author Mahmoud Ben Hassine
22+
* @since 7.0
23+
*/
24+
public interface RetryPolicy {
25+
26+
/**
27+
* Return the maximum number of retry attempts
28+
*
29+
* @return the maximum number of retry attempts
30+
*/
31+
int getMaxAttempts();
32+
33+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright 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+
package org.springframework.core.retry;
17+
18+
import java.time.Duration;
19+
import java.util.concurrent.Callable;
20+
21+
import org.apache.commons.logging.Log;
22+
import org.apache.commons.logging.LogFactory;
23+
import org.jspecify.annotations.Nullable;
24+
25+
import org.springframework.core.retry.support.MaxAttemptsRetryPolicy;
26+
import org.springframework.core.retry.support.backoff.FixedBackOffPolicy;
27+
import org.springframework.core.retry.support.listener.CompositeRetryListener;
28+
import org.springframework.util.Assert;
29+
30+
/**
31+
* A basic implementation of {@link RetryOperations} that uses a
32+
* {@link RetryPolicy} and a {@link BackOffPolicy} to retry a
33+
* {@link Callable} piece of code. By default, the callback will be called
34+
* 3 times (<code>MaxAttemptsRetryPolicy(3)<code/>) with a fixed backoff
35+
* of 1 second (<code>FixedBackOffPolicy(Duration.ofSeconds(1))</code>).
36+
*
37+
* <p>It is also possible to register a {@link RetryListener} to intercept and inject code
38+
* during key retry phases (before a retry attempt, after a retry attempt, etc.).
39+
*
40+
* <p>All retry operations performed by this class are logged at debug level,
41+
* using "org.springframework.core.retry.RetryTemplate" as log category.
42+
*
43+
* @author Mahmoud Ben Hassine
44+
* @since 7.0
45+
* @see RetryOperations
46+
* @see RetryPolicy
47+
* @see BackOffPolicy
48+
* @see RetryListener
49+
*/
50+
public class RetryTemplate implements RetryOperations {
51+
52+
protected final Log logger = LogFactory.getLog(getClass());
53+
54+
private RetryPolicy retryPolicy = new MaxAttemptsRetryPolicy(3);
55+
56+
private BackOffPolicy backOffPolicy = new FixedBackOffPolicy(Duration.ofSeconds(1));
57+
58+
private RetryListener retryListener = new RetryListener() {};
59+
60+
/**
61+
* Set the {@link RetryPolicy} to use. Defaults to <code>MaxAttemptsRetryPolicy(3)</code>.
62+
* @param retryPolicy the retry policy to use. Must not be <code>null</code>.
63+
*/
64+
public void setRetryPolicy(RetryPolicy retryPolicy) {
65+
Assert.notNull(retryPolicy, "Retry policy must not be null");
66+
this.retryPolicy = retryPolicy;
67+
}
68+
69+
/**
70+
* Set the {@link BackOffPolicy} to use. Defaults to <code>FixedBackOffPolicy(Duration.ofSeconds(1))</code>.
71+
* @param backOffPolicy the backoff policy to use. Must not be <code>null</code>.
72+
*/
73+
public void setBackOffPolicy(BackOffPolicy backOffPolicy) {
74+
Assert.notNull(backOffPolicy, "BackOff policy must not be null");
75+
this.backOffPolicy = backOffPolicy;
76+
}
77+
78+
/**
79+
* Set a {@link RetryListener} to use. Defaults to a <code>NoOp</code> implementation.
80+
* If multiple listeners are needed, use a {@link CompositeRetryListener}.
81+
* @param retryListener the retry listener to use. Must not be <code>null</code>.
82+
*/
83+
public void setRetryListener(RetryListener retryListener) {
84+
Assert.notNull(retryListener, "Retry listener must not be null");
85+
this.retryListener = retryListener;
86+
}
87+
88+
/**
89+
* Call the retry callback according to the configured retry and backoff policies.
90+
* If the callback succeeds, its result is returned. Otherwise, the last exception will
91+
* be propagated to the caller.
92+
* @param retryCallback the callback to call initially and retry if needed
93+
* @return the result of the callback if any
94+
* @param <R> the type of the result
95+
* @throws Exception thrown if the retry policy is exhausted
96+
*/
97+
@Override
98+
public <R> @Nullable R execute(Callable<R> retryCallback) throws Exception {
99+
Assert.notNull(retryCallback, "Retry Callback must not be null");
100+
int attempts = 0;
101+
int maxAttempts = retryPolicy.getMaxAttempts();
102+
while(attempts++ <= maxAttempts) {
103+
if (logger.isDebugEnabled()) {
104+
logger.debug("Retry attempt #" + attempts);
105+
}
106+
try {
107+
retryListener.beforeRetry();
108+
R result = retryCallback.call();
109+
retryListener.onSuccess(result);
110+
if (logger.isDebugEnabled()) {
111+
logger.debug("Retry attempt #" + attempts + " succeeded");
112+
}
113+
return result;
114+
}
115+
catch (Exception e) {
116+
retryListener.onFailure(e);
117+
Duration duration = backOffPolicy.backOff();
118+
Thread.sleep(duration.toMillis());
119+
if (logger.isDebugEnabled()) {
120+
logger.debug("Attempt #" + attempts + " failed, backing off for " + duration.toMillis() + "ms");
121+
}
122+
if (attempts >= maxAttempts) {
123+
if (logger.isDebugEnabled()) {
124+
logger.debug("Maximum retry attempts " + attempts + " exhausted, aborting execution");
125+
}
126+
retryListener.onMaxAttempts(e);
127+
throw e;
128+
}
129+
}
130+
}
131+
return null;
132+
}
133+
134+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Main package for the core retry functionality.
3+
*/
4+
@NullMarked
5+
package org.springframework.core.retry;
6+
7+
import org.jspecify.annotations.NullMarked;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 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+
package org.springframework.core.retry.support;
17+
18+
import org.springframework.core.retry.RetryPolicy;
19+
20+
/**
21+
* A {@link RetryPolicy} that signals to the caller to always retry the callback.
22+
*
23+
* @author Mahmoud Ben Hassine
24+
* @since 7.0
25+
*/
26+
public class AlwaysRetryPolicy implements RetryPolicy {
27+
28+
@Override
29+
public int getMaxAttempts() {
30+
return Integer.MAX_VALUE;
31+
}
32+
33+
}

0 commit comments

Comments
 (0)