Skip to content

Commit f69df9b

Browse files
committed
Introduce retry interceptor and annotation-based retry support
Based on RetryTemplate with ExponentialBackOff. Includes optional jitter support in ExponentialBackOff. Supports reactive methods through Reactor's RetryBackoffSpec. Closes gh-34529
1 parent cd5e4c2 commit f69df9b

File tree

12 files changed

+1058
-30
lines changed

12 files changed

+1058
-30
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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.aop.retry;
18+
19+
import java.lang.reflect.Method;
20+
import java.time.Duration;
21+
22+
import org.aopalliance.intercept.MethodInterceptor;
23+
import org.aopalliance.intercept.MethodInvocation;
24+
import org.jspecify.annotations.Nullable;
25+
import org.reactivestreams.Publisher;
26+
import reactor.core.publisher.Flux;
27+
import reactor.core.publisher.Mono;
28+
import reactor.util.retry.Retry;
29+
30+
import org.springframework.core.ReactiveAdapter;
31+
import org.springframework.core.ReactiveAdapterRegistry;
32+
import org.springframework.core.retry.RetryException;
33+
import org.springframework.core.retry.RetryPolicy;
34+
import org.springframework.core.retry.RetryTemplate;
35+
import org.springframework.core.retry.Retryable;
36+
import org.springframework.util.ClassUtils;
37+
import org.springframework.util.backoff.ExponentialBackOff;
38+
39+
/**
40+
* Abstract retry interceptor implementation, adapting a given
41+
* retry specification to either {@link RetryTemplate} or Reactor.
42+
*
43+
* @author Juergen Hoeller
44+
* @since 7.0
45+
* @see #getRetrySpec
46+
* @see RetryTemplate
47+
* @see Mono#retryWhen
48+
* @see Flux#retryWhen
49+
*/
50+
public abstract class AbstractRetryInterceptor implements MethodInterceptor {
51+
52+
/**
53+
* Reactive Streams API present on the classpath?
54+
*/
55+
private static final boolean reactiveStreamsPresent = ClassUtils.isPresent(
56+
"org.reactivestreams.Publisher", AbstractRetryInterceptor.class.getClassLoader());
57+
58+
private final @Nullable ReactiveAdapterRegistry reactiveAdapterRegistry;
59+
60+
61+
public AbstractRetryInterceptor() {
62+
if (reactiveStreamsPresent) {
63+
this.reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance();
64+
}
65+
else {
66+
this.reactiveAdapterRegistry = null;
67+
}
68+
}
69+
70+
71+
@Override
72+
public @Nullable Object invoke(MethodInvocation invocation) throws Throwable {
73+
Method method = invocation.getMethod();
74+
Object target = invocation.getThis();
75+
MethodRetrySpec spec = getRetrySpec(method, (target != null ? target.getClass() : method.getDeclaringClass()));
76+
77+
if (spec == null) {
78+
return invocation.proceed();
79+
}
80+
81+
if (this.reactiveAdapterRegistry != null) {
82+
ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(method.getReturnType());
83+
if (adapter != null) {
84+
Object result = invocation.proceed();
85+
if (result == null) {
86+
return null;
87+
}
88+
return ReactorDelegate.adaptReactiveResult(result, adapter, spec, method);
89+
}
90+
}
91+
92+
RetryTemplate retryTemplate = new RetryTemplate();
93+
94+
RetryPolicy.Builder policyBuilder = RetryPolicy.builder();
95+
for (Class<? extends Throwable> include : spec.includes()) {
96+
policyBuilder.includes(include);
97+
}
98+
for (Class<? extends Throwable> exclude : spec.excludes()) {
99+
policyBuilder.excludes(exclude);
100+
}
101+
policyBuilder.predicate(spec.predicate().forMethod(method));
102+
policyBuilder.maxAttempts(spec.maxAttempts());
103+
retryTemplate.setRetryPolicy(policyBuilder.build());
104+
105+
ExponentialBackOff backOff = new ExponentialBackOff();
106+
backOff.setInitialInterval(spec.delay());
107+
backOff.setJitter(spec.jitterDelay());
108+
backOff.setMultiplier(spec.delayMultiplier());
109+
backOff.setMaxInterval(spec.maxDelay());
110+
backOff.setMaxAttempts(spec.maxAttempts());
111+
retryTemplate.setBackOffPolicy(backOff);
112+
113+
try {
114+
return retryTemplate.execute(new Retryable<>() {
115+
@Override
116+
public @Nullable Object execute() throws Throwable {
117+
return invocation.proceed();
118+
}
119+
@Override
120+
public String getName() {
121+
Object target = invocation.getThis();
122+
return ClassUtils.getQualifiedMethodName(method, (target != null ? target.getClass() : null));
123+
}
124+
});
125+
}
126+
catch (RetryException ex) {
127+
Throwable cause = ex.getCause();
128+
throw (cause != null ? cause : new IllegalStateException(ex.getMessage(), ex));
129+
}
130+
}
131+
132+
/**
133+
* Determine the retry specification for the given method on the given target.
134+
* @param method the currently executing method
135+
* @param targetClass the class of the current target object
136+
* @return the retry specification as a {@link MethodRetrySpec}
137+
*/
138+
protected abstract @Nullable MethodRetrySpec getRetrySpec(Method method, Class<?> targetClass);
139+
140+
141+
/**
142+
* Inner class to avoid a hard dependency on Reactive Streams and Reactor at runtime.
143+
*/
144+
private static class ReactorDelegate {
145+
146+
public static Object adaptReactiveResult(
147+
Object result, ReactiveAdapter adapter, MethodRetrySpec spec, Method method) {
148+
149+
Publisher<?> publisher = adapter.toPublisher(result);
150+
Retry retry = Retry.backoff(spec.maxAttempts(), Duration.ofMillis(spec.delay()))
151+
.jitter((double) spec.jitterDelay() / spec.delay())
152+
.multiplier(spec.delayMultiplier())
153+
.maxBackoff(Duration.ofMillis(spec.maxDelay()))
154+
.filter(spec.combinedPredicate().forMethod(method));
155+
publisher = (adapter.isMultiValue() ? Flux.from(publisher).retryWhen(retry) :
156+
Mono.from(publisher).retryWhen(retry));
157+
return adapter.fromPublisher(publisher);
158+
}
159+
}
160+
161+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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.aop.retry;
18+
19+
import java.lang.reflect.Method;
20+
import java.util.function.Predicate;
21+
22+
/**
23+
* Predicate for retrying a {@link Throwable} from a specific {@link Method}.
24+
*
25+
* @author Juergen Hoeller
26+
* @since 7.0
27+
* @see MethodRetrySpec#predicate()
28+
*/
29+
@FunctionalInterface
30+
public interface MethodRetryPredicate {
31+
32+
/**
33+
* Determine whether the given {@code Method} should be retried after
34+
* throwing the given {@code Throwable}.
35+
* @param method the method to potentially retry
36+
* @param throwable the exception encountered
37+
*/
38+
boolean shouldRetry(Method method, Throwable throwable);
39+
40+
/**
41+
* Build a {@code Predicate} for testing exceptions from a given method.
42+
* @param method the method to build a predicate for
43+
*/
44+
default Predicate<Throwable> forMethod(Method method) {
45+
return (t -> shouldRetry(method, t));
46+
}
47+
48+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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.aop.retry;
18+
19+
import java.util.Collection;
20+
import java.util.Collections;
21+
22+
/**
23+
* A specification for retry attempts on a given method, combining common
24+
* retry characteristics. This roughly matches the annotation attributes
25+
* on {@link org.springframework.aop.retry.annotation.Retryable}.
26+
*
27+
* @author Juergen Hoeller
28+
* @since 7.0
29+
* @param includes applicable exceptions types to attempt a retry for
30+
* @param excludes non-applicable exceptions types to avoid a retry for
31+
* @param predicate a predicate for filtering exceptions from applicable methods
32+
* @param maxAttempts the maximum number of retry attempts
33+
* @param delay the base delay after the initial invocation (in milliseconds)
34+
* @param jitterDelay a jitter delay for the next retry attempt (in milliseconds)
35+
* @param delayMultiplier a multiplier for a delay for the next retry attempt
36+
* @param maxDelay the maximum delay for any retry attempt (in milliseconds)
37+
* @see AbstractRetryInterceptor#getRetrySpec
38+
* @see SimpleRetryInterceptor#SimpleRetryInterceptor(MethodRetrySpec)
39+
* @see org.springframework.aop.retry.annotation.Retryable
40+
*/
41+
public record MethodRetrySpec(
42+
Collection<Class<? extends Throwable>> includes,
43+
Collection<Class<? extends Throwable>> excludes,
44+
MethodRetryPredicate predicate,
45+
int maxAttempts,
46+
long delay,
47+
long jitterDelay,
48+
double delayMultiplier,
49+
long maxDelay) {
50+
51+
public MethodRetrySpec(MethodRetryPredicate predicate, int maxAttempts, long delay) {
52+
this(predicate, maxAttempts, delay, 0,1.0, Integer.MAX_VALUE);
53+
}
54+
55+
public MethodRetrySpec(MethodRetryPredicate predicate, int maxAttempts, long delay,
56+
long jitterDelay, double delayMultiplier, long maxDelay) {
57+
58+
this(Collections.emptyList(), Collections.emptyList(), predicate, maxAttempts, delay,
59+
jitterDelay, delayMultiplier, maxDelay);
60+
}
61+
62+
63+
MethodRetryPredicate combinedPredicate() {
64+
return (method, throwable) -> {
65+
if (!this.excludes.isEmpty()) {
66+
for (Class<? extends Throwable> exclude : this.excludes) {
67+
if (exclude.isInstance(throwable)) {
68+
return false;
69+
}
70+
}
71+
}
72+
if (!this.includes.isEmpty()) {
73+
boolean included = false;
74+
for (Class<? extends Throwable> include : this.includes) {
75+
if (include.isInstance(throwable)) {
76+
included = true;
77+
break;
78+
}
79+
}
80+
if (!included) {
81+
return false;
82+
}
83+
}
84+
return this.predicate.shouldRetry(method, throwable);
85+
};
86+
}
87+
88+
}
Lines changed: 45 additions & 0 deletions
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.aop.retry;
18+
19+
import java.lang.reflect.Method;
20+
21+
/**
22+
* A simple concrete retry interceptor based on a given {@link MethodRetrySpec}.
23+
*
24+
* @author Juergen Hoeller
25+
* @since 7.0
26+
*/
27+
public class SimpleRetryInterceptor extends AbstractRetryInterceptor {
28+
29+
private final MethodRetrySpec retrySpec;
30+
31+
32+
/**
33+
* Create a {@code SimpleRetryInterceptor} for the given {@link MethodRetrySpec}.
34+
* @param retrySpec the specification to use for all method invocations
35+
*/
36+
public SimpleRetryInterceptor(MethodRetrySpec retrySpec) {
37+
this.retrySpec = retrySpec;
38+
}
39+
40+
@Override
41+
protected MethodRetrySpec getRetrySpec(Method method, Class<?> targetClass) {
42+
return this.retrySpec;
43+
}
44+
45+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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.aop.retry.annotation;
18+
19+
import org.springframework.aop.Pointcut;
20+
import org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor;
21+
import org.springframework.aop.support.ComposablePointcut;
22+
import org.springframework.aop.support.DefaultPointcutAdvisor;
23+
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
24+
import org.springframework.beans.factory.config.BeanPostProcessor;
25+
26+
/**
27+
* A convenient {@link BeanPostProcessor} that applies {@link RetryAnnotationInterceptor}
28+
* to all bean methods annotated with {@link Retryable} annotations.
29+
*
30+
* @author Juergen Hoeller
31+
* @since 7.0
32+
*/
33+
@SuppressWarnings("serial")
34+
public class RetryAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor {
35+
36+
public RetryAnnotationBeanPostProcessor() {
37+
setBeforeExistingAdvisors(true);
38+
39+
Pointcut cpc = new AnnotationMatchingPointcut(Retryable.class, true);
40+
Pointcut mpc = new AnnotationMatchingPointcut(null, Retryable.class, true);
41+
this.advisor = new DefaultPointcutAdvisor(
42+
new ComposablePointcut(cpc).union(mpc),
43+
new RetryAnnotationInterceptor());
44+
}
45+
46+
}

0 commit comments

Comments
 (0)