Skip to content

Commit baf3703

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 baf3703

26 files changed

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

0 commit comments

Comments
 (0)