Browse Source

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
pull/35086/head
Juergen Hoeller 6 months ago
parent
commit
f69df9b767
  1. 161
      spring-aop/src/main/java/org/springframework/aop/retry/AbstractRetryInterceptor.java
  2. 48
      spring-aop/src/main/java/org/springframework/aop/retry/MethodRetryPredicate.java
  3. 88
      spring-aop/src/main/java/org/springframework/aop/retry/MethodRetrySpec.java
  4. 45
      spring-aop/src/main/java/org/springframework/aop/retry/SimpleRetryInterceptor.java
  5. 46
      spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationBeanPostProcessor.java
  6. 82
      spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationInterceptor.java
  7. 138
      spring-aop/src/main/java/org/springframework/aop/retry/annotation/Retryable.java
  8. 7
      spring-aop/src/main/java/org/springframework/aop/retry/annotation/package-info.java
  9. 7
      spring-aop/src/main/java/org/springframework/aop/retry/package-info.java
  10. 194
      spring-aop/src/test/java/org/springframework/aop/retry/ReactiveRetryInterceptorTests.java
  11. 171
      spring-aop/src/test/java/org/springframework/aop/retry/RetryInterceptorTests.java
  12. 101
      spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java

161
spring-aop/src/main/java/org/springframework/aop/retry/AbstractRetryInterceptor.java

@ -0,0 +1,161 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aop.retry;
import java.lang.reflect.Method;
import java.time.Duration;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.jspecify.annotations.Nullable;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import org.springframework.core.ReactiveAdapter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.core.retry.RetryException;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.core.retry.Retryable;
import org.springframework.util.ClassUtils;
import org.springframework.util.backoff.ExponentialBackOff;
/**
* Abstract retry interceptor implementation, adapting a given
* retry specification to either {@link RetryTemplate} or Reactor.
*
* @author Juergen Hoeller
* @since 7.0
* @see #getRetrySpec
* @see RetryTemplate
* @see Mono#retryWhen
* @see Flux#retryWhen
*/
public abstract class AbstractRetryInterceptor implements MethodInterceptor {
/**
* Reactive Streams API present on the classpath?
*/
private static final boolean reactiveStreamsPresent = ClassUtils.isPresent(
"org.reactivestreams.Publisher", AbstractRetryInterceptor.class.getClassLoader());
private final @Nullable ReactiveAdapterRegistry reactiveAdapterRegistry;
public AbstractRetryInterceptor() {
if (reactiveStreamsPresent) {
this.reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance();
}
else {
this.reactiveAdapterRegistry = null;
}
}
@Override
public @Nullable Object invoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
Object target = invocation.getThis();
MethodRetrySpec spec = getRetrySpec(method, (target != null ? target.getClass() : method.getDeclaringClass()));
if (spec == null) {
return invocation.proceed();
}
if (this.reactiveAdapterRegistry != null) {
ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(method.getReturnType());
if (adapter != null) {
Object result = invocation.proceed();
if (result == null) {
return null;
}
return ReactorDelegate.adaptReactiveResult(result, adapter, spec, method);
}
}
RetryTemplate retryTemplate = new RetryTemplate();
RetryPolicy.Builder policyBuilder = RetryPolicy.builder();
for (Class<? extends Throwable> include : spec.includes()) {
policyBuilder.includes(include);
}
for (Class<? extends Throwable> exclude : spec.excludes()) {
policyBuilder.excludes(exclude);
}
policyBuilder.predicate(spec.predicate().forMethod(method));
policyBuilder.maxAttempts(spec.maxAttempts());
retryTemplate.setRetryPolicy(policyBuilder.build());
ExponentialBackOff backOff = new ExponentialBackOff();
backOff.setInitialInterval(spec.delay());
backOff.setJitter(spec.jitterDelay());
backOff.setMultiplier(spec.delayMultiplier());
backOff.setMaxInterval(spec.maxDelay());
backOff.setMaxAttempts(spec.maxAttempts());
retryTemplate.setBackOffPolicy(backOff);
try {
return retryTemplate.execute(new Retryable<>() {
@Override
public @Nullable Object execute() throws Throwable {
return invocation.proceed();
}
@Override
public String getName() {
Object target = invocation.getThis();
return ClassUtils.getQualifiedMethodName(method, (target != null ? target.getClass() : null));
}
});
}
catch (RetryException ex) {
Throwable cause = ex.getCause();
throw (cause != null ? cause : new IllegalStateException(ex.getMessage(), ex));
}
}
/**
* Determine the retry specification for the given method on the given target.
* @param method the currently executing method
* @param targetClass the class of the current target object
* @return the retry specification as a {@link MethodRetrySpec}
*/
protected abstract @Nullable MethodRetrySpec getRetrySpec(Method method, Class<?> targetClass);
/**
* Inner class to avoid a hard dependency on Reactive Streams and Reactor at runtime.
*/
private static class ReactorDelegate {
public static Object adaptReactiveResult(
Object result, ReactiveAdapter adapter, MethodRetrySpec spec, Method method) {
Publisher<?> publisher = adapter.toPublisher(result);
Retry retry = Retry.backoff(spec.maxAttempts(), Duration.ofMillis(spec.delay()))
.jitter((double) spec.jitterDelay() / spec.delay())
.multiplier(spec.delayMultiplier())
.maxBackoff(Duration.ofMillis(spec.maxDelay()))
.filter(spec.combinedPredicate().forMethod(method));
publisher = (adapter.isMultiValue() ? Flux.from(publisher).retryWhen(retry) :
Mono.from(publisher).retryWhen(retry));
return adapter.fromPublisher(publisher);
}
}
}

48
spring-aop/src/main/java/org/springframework/aop/retry/MethodRetryPredicate.java

@ -0,0 +1,48 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aop.retry;
import java.lang.reflect.Method;
import java.util.function.Predicate;
/**
* Predicate for retrying a {@link Throwable} from a specific {@link Method}.
*
* @author Juergen Hoeller
* @since 7.0
* @see MethodRetrySpec#predicate()
*/
@FunctionalInterface
public interface MethodRetryPredicate {
/**
* Determine whether the given {@code Method} should be retried after
* throwing the given {@code Throwable}.
* @param method the method to potentially retry
* @param throwable the exception encountered
*/
boolean shouldRetry(Method method, Throwable throwable);
/**
* Build a {@code Predicate} for testing exceptions from a given method.
* @param method the method to build a predicate for
*/
default Predicate<Throwable> forMethod(Method method) {
return (t -> shouldRetry(method, t));
}
}

88
spring-aop/src/main/java/org/springframework/aop/retry/MethodRetrySpec.java

@ -0,0 +1,88 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aop.retry;
import java.util.Collection;
import java.util.Collections;
/**
* A specification for retry attempts on a given method, combining common
* retry characteristics. This roughly matches the annotation attributes
* on {@link org.springframework.aop.retry.annotation.Retryable}.
*
* @author Juergen Hoeller
* @since 7.0
* @param includes applicable exceptions types to attempt a retry for
* @param excludes non-applicable exceptions types to avoid a retry for
* @param predicate a predicate for filtering exceptions from applicable methods
* @param maxAttempts the maximum number of retry attempts
* @param delay the base delay after the initial invocation (in milliseconds)
* @param jitterDelay a jitter delay for the next retry attempt (in milliseconds)
* @param delayMultiplier a multiplier for a delay for the next retry attempt
* @param maxDelay the maximum delay for any retry attempt (in milliseconds)
* @see AbstractRetryInterceptor#getRetrySpec
* @see SimpleRetryInterceptor#SimpleRetryInterceptor(MethodRetrySpec)
* @see org.springframework.aop.retry.annotation.Retryable
*/
public record MethodRetrySpec(
Collection<Class<? extends Throwable>> includes,
Collection<Class<? extends Throwable>> excludes,
MethodRetryPredicate predicate,
int maxAttempts,
long delay,
long jitterDelay,
double delayMultiplier,
long maxDelay) {
public MethodRetrySpec(MethodRetryPredicate predicate, int maxAttempts, long delay) {
this(predicate, maxAttempts, delay, 0,1.0, Integer.MAX_VALUE);
}
public MethodRetrySpec(MethodRetryPredicate predicate, int maxAttempts, long delay,
long jitterDelay, double delayMultiplier, long maxDelay) {
this(Collections.emptyList(), Collections.emptyList(), predicate, maxAttempts, delay,
jitterDelay, delayMultiplier, maxDelay);
}
MethodRetryPredicate combinedPredicate() {
return (method, throwable) -> {
if (!this.excludes.isEmpty()) {
for (Class<? extends Throwable> exclude : this.excludes) {
if (exclude.isInstance(throwable)) {
return false;
}
}
}
if (!this.includes.isEmpty()) {
boolean included = false;
for (Class<? extends Throwable> include : this.includes) {
if (include.isInstance(throwable)) {
included = true;
break;
}
}
if (!included) {
return false;
}
}
return this.predicate.shouldRetry(method, throwable);
};
}
}

45
spring-aop/src/main/java/org/springframework/aop/retry/SimpleRetryInterceptor.java

@ -0,0 +1,45 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aop.retry;
import java.lang.reflect.Method;
/**
* A simple concrete retry interceptor based on a given {@link MethodRetrySpec}.
*
* @author Juergen Hoeller
* @since 7.0
*/
public class SimpleRetryInterceptor extends AbstractRetryInterceptor {
private final MethodRetrySpec retrySpec;
/**
* Create a {@code SimpleRetryInterceptor} for the given {@link MethodRetrySpec}.
* @param retrySpec the specification to use for all method invocations
*/
public SimpleRetryInterceptor(MethodRetrySpec retrySpec) {
this.retrySpec = retrySpec;
}
@Override
protected MethodRetrySpec getRetrySpec(Method method, Class<?> targetClass) {
return this.retrySpec;
}
}

46
spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationBeanPostProcessor.java

@ -0,0 +1,46 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aop.retry.annotation;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor;
import org.springframework.aop.support.ComposablePointcut;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
import org.springframework.beans.factory.config.BeanPostProcessor;
/**
* A convenient {@link BeanPostProcessor} that applies {@link RetryAnnotationInterceptor}
* to all bean methods annotated with {@link Retryable} annotations.
*
* @author Juergen Hoeller
* @since 7.0
*/
@SuppressWarnings("serial")
public class RetryAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor {
public RetryAnnotationBeanPostProcessor() {
setBeforeExistingAdvisors(true);
Pointcut cpc = new AnnotationMatchingPointcut(Retryable.class, true);
Pointcut mpc = new AnnotationMatchingPointcut(null, Retryable.class, true);
this.advisor = new DefaultPointcutAdvisor(
new ComposablePointcut(cpc).union(mpc),
new RetryAnnotationInterceptor());
}
}

82
spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationInterceptor.java

@ -0,0 +1,82 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aop.retry.annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.jspecify.annotations.Nullable;
import org.springframework.aop.retry.AbstractRetryInterceptor;
import org.springframework.aop.retry.MethodRetryPredicate;
import org.springframework.aop.retry.MethodRetrySpec;
import org.springframework.core.MethodClassKey;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.util.ReflectionUtils;
/**
* An annotation-based retry interceptor based on {@link Retryable} annotations.
*
* @author Juergen Hoeller
* @since 7.0
*/
public class RetryAnnotationInterceptor extends AbstractRetryInterceptor {
private final Map<MethodClassKey, MethodRetrySpec> retrySpecCache = new ConcurrentHashMap<>();
@Override
protected @Nullable MethodRetrySpec getRetrySpec(Method method, Class<?> targetClass) {
MethodClassKey cacheKey = new MethodClassKey(method, targetClass);
MethodRetrySpec retrySpec = this.retrySpecCache.get(cacheKey);
if (retrySpec != null) {
return retrySpec;
}
Retryable retryable = AnnotatedElementUtils.getMergedAnnotation(method, Retryable.class);
if (retryable == null) {
retryable = AnnotatedElementUtils.getMergedAnnotation(targetClass, Retryable.class);
if (retryable == null) {
return null;
}
}
retrySpec = new MethodRetrySpec(
Arrays.asList(retryable.includes()), Arrays.asList(retryable.excludes()),
instantiatePredicate(retryable.predicate()), retryable.maxAttempts(),
retryable.delay(), retryable.jitterDelay(),
retryable.delayMultiplier(), retryable.maxDelay());
MethodRetrySpec existing = this.retrySpecCache.putIfAbsent(cacheKey, retrySpec);
return (existing != null ? existing : retrySpec);
}
private MethodRetryPredicate instantiatePredicate(Class<? extends MethodRetryPredicate> predicateClass) {
if (predicateClass == MethodRetryPredicate.class) {
return (method, throwable) -> true;
}
try {
return ReflectionUtils.accessibleConstructor(predicateClass).newInstance();
}
catch (Throwable ex) {
throw new IllegalStateException("Failed to instantiate predicate class [" + predicateClass + "]", ex);
}
}
}

138
spring-aop/src/main/java/org/springframework/aop/retry/annotation/Retryable.java

@ -0,0 +1,138 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aop.retry.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.aop.retry.MethodRetryPredicate;
import org.springframework.aot.hint.annotation.Reflective;
import org.springframework.core.annotation.AliasFor;
/**
* A common annotation specifying retry characteristics for an individual method,
* or for all proxy-invoked methods in a given class hierarchy if annotated at
* the type level.
*
* <p>Aligned with {@link org.springframework.core.retry.RetryTemplate}
* as well as Reactor's retry support, either re-invoking an imperative
* target method or decorating a reactive result accordingly.
*
* @author Juergen Hoeller
* @since 7.0
* @see RetryAnnotationBeanPostProcessor
* @see RetryAnnotationInterceptor
* @see org.springframework.core.retry.RetryTemplate
* @see reactor.core.publisher.Mono#retryWhen
* @see reactor.core.publisher.Flux#retryWhen
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Reflective
public @interface Retryable {
/**
* Convenient default attribute for {@link #includes()},
* typically used with a single exception type to retry for.
*/
@AliasFor("includes")
Class<? extends Throwable>[] value() default {};
/**
* Applicable exceptions types to attempt a retry for. This attribute
* allows for the convenient specification of assignable exception types.
* <p>The default is empty, leading to a retry attempt for any exception.
* @see #excludes()
* @see #predicate()
*/
@AliasFor("value")
Class<? extends Throwable>[] includes() default {};
/**
* Non-applicable exceptions types to avoid a retry for. This attribute
* allows for the convenient specification of assignable exception types.
* <p>The default is empty, leading to a retry attempt for any exception.
* @see #includes()
* @see #predicate()
*/
Class<? extends Throwable>[] excludes() default {};
/**
* A predicate for filtering applicable exceptions for which
* an invocation can be retried.
* <p>The default is a retry attempt for any exception.
* @see #includes()
* @see #excludes()
*/
Class<? extends MethodRetryPredicate> predicate() default MethodRetryPredicate.class;
/**
* The maximum number of retry attempts, in addition to the initial invocation.
* <p>The default is 3.
*/
int maxAttempts() default 3;
/**
* The base delay after the initial invocation in milliseconds.
* If a multiplier is specified, this serves as the initial delay to multiply from.
* <p>The default is 1000.
* @see #jitterDelay()
* @see #delayMultiplier()
* @see #maxDelay()
*/
long delay() default 1000;
/**
* A jitter delay for the base retry attempt (in milliseconds), randomly
* subtracted or added to the calculated delay, resulting in a value
* between {@code delay - jitterDelay} and {@code delay + jitterDelay}
* but never below the base {@link #delay()} or above {@link #maxDelay()}.
* If a multiplier is specified, it applies to the jitter delay as well.
* <p>The default is 0 (no jitter).
* @see #delay()
* @see #delayMultiplier()
* @see #maxDelay()
*/
long jitterDelay() default 0;
/**
* A multiplier for a delay for the next retry attempt, applied
* to the previous delay (starting with {@link #delay()}) as well
* as to the applicable {@link #jitterDelay()} for each attempt.
* <p>The default is 1.0, effectively leading to a fixed delay.
* @see #delay()
* @see #jitterDelay()
* @see #maxDelay()
*/
double delayMultiplier() default 1.0;
/**
* The maximum delay for any retry attempt (in milliseconds), limiting
* how far {@link #jitterDelay()} and {@link #delayMultiplier()} can
* increase {@link #delay()}.
* <p>The default is unlimited.
* @see #delay()
* @see #jitterDelay()
* @see #delayMultiplier()
*/
long maxDelay() default Integer.MAX_VALUE;
}

7
spring-aop/src/main/java/org/springframework/aop/retry/annotation/package-info.java

@ -0,0 +1,7 @@
/**
* Annotation-based retry support for common Spring setups.
*/
@NullMarked
package org.springframework.aop.retry.annotation;
import org.jspecify.annotations.NullMarked;

7
spring-aop/src/main/java/org/springframework/aop/retry/package-info.java

@ -0,0 +1,7 @@
/**
* A retry interceptor arrangement based on {@code core.retry} and Reactor.
*/
@NullMarked
package org.springframework.aop.retry;
import org.jspecify.annotations.NullMarked;

194
spring-aop/src/test/java/org/springframework/aop/retry/ReactiveRetryInterceptorTests.java

@ -0,0 +1,194 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aop.retry;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.AccessDeniedException;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.retry.annotation.RetryAnnotationBeanPostProcessor;
import org.springframework.aop.retry.annotation.RetryAnnotationInterceptor;
import org.springframework.aop.retry.annotation.Retryable;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.assertj.core.api.Assertions.assertThatRuntimeException;
/**
* @author Juergen Hoeller
* @since 7.0
*/
public class ReactiveRetryInterceptorTests {
@Test
void withSimpleInterceptor() {
NonAnnotatedBean target = new NonAnnotatedBean();
ProxyFactory pf = new ProxyFactory();
pf.setTarget(target);
pf.addAdvice(new SimpleRetryInterceptor(new MethodRetrySpec((m, t) -> true, 5, 10)));
NonAnnotatedBean proxy = (NonAnnotatedBean) pf.getProxy();
assertThatIllegalStateException().isThrownBy(() -> proxy.retryOperation().block())
.withCauseInstanceOf(IOException.class).havingCause().withMessage("6");
assertThat(target.counter.get()).isEqualTo(6);
}
@Test
void withAnnotationInterceptorForMethod() {
AnnotatedMethodBean target = new AnnotatedMethodBean();
ProxyFactory pf = new ProxyFactory();
pf.setTarget(target);
pf.addAdvice(new RetryAnnotationInterceptor());
AnnotatedMethodBean proxy = (AnnotatedMethodBean) pf.getProxy();
assertThatIllegalStateException().isThrownBy(() -> proxy.retryOperation().block())
.withCauseInstanceOf(IOException.class).havingCause().withMessage("6");
assertThat(target.counter.get()).isEqualTo(6);
}
@Test
void withPostProcessorForMethod() {
DefaultListableBeanFactory bf = new DefaultListableBeanFactory();
bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedMethodBean.class));
RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor();
bpp.setBeanFactory(bf);
bf.addBeanPostProcessor(bpp);
AnnotatedMethodBean proxy = bf.getBean(AnnotatedMethodBean.class);
AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy);
assertThatIllegalStateException().isThrownBy(() -> proxy.retryOperation().block())
.withCauseInstanceOf(IOException.class).havingCause().withMessage("6");
assertThat(target.counter.get()).isEqualTo(6);
}
@Test
void withAnnotationInterceptorForClass() {
AnnotatedClassBean target = new AnnotatedClassBean();
ProxyFactory pf = new ProxyFactory();
pf.setTarget(target);
pf.addAdvice(new RetryAnnotationInterceptor());
AnnotatedClassBean proxy = (AnnotatedClassBean) pf.getProxy();
assertThatRuntimeException().isThrownBy(() -> proxy.retryOperation().block())
.withCauseInstanceOf(IOException.class).havingCause().withMessage("3");
assertThat(target.counter.get()).isEqualTo(3);
assertThatRuntimeException().isThrownBy(() -> proxy.otherOperation().block())
.withCauseInstanceOf(IOException.class);
assertThat(target.counter.get()).isEqualTo(4);
assertThatIllegalStateException().isThrownBy(() -> proxy.overrideOperation().blockFirst())
.withCauseInstanceOf(IOException.class);
assertThat(target.counter.get()).isEqualTo(6);
}
@Test
void withPostProcessorForClass() {
DefaultListableBeanFactory bf = new DefaultListableBeanFactory();
bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedClassBean.class));
RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor();
bpp.setBeanFactory(bf);
bf.addBeanPostProcessor(bpp);
AnnotatedClassBean proxy = bf.getBean(AnnotatedClassBean.class);
AnnotatedClassBean target = (AnnotatedClassBean) AopProxyUtils.getSingletonTarget(proxy);
assertThatRuntimeException().isThrownBy(() -> proxy.retryOperation().block())
.withCauseInstanceOf(IOException.class).havingCause().withMessage("3");
assertThat(target.counter.get()).isEqualTo(3);
assertThatRuntimeException().isThrownBy(() -> proxy.otherOperation().block())
.withCauseInstanceOf(IOException.class);
assertThat(target.counter.get()).isEqualTo(4);
assertThatIllegalStateException().isThrownBy(() -> proxy.overrideOperation().blockFirst())
.withCauseInstanceOf(IOException.class);
assertThat(target.counter.get()).isEqualTo(6);
}
public static class NonAnnotatedBean {
AtomicInteger counter = new AtomicInteger();
public Mono<Object> retryOperation() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
throw new IOException(counter.toString());
});
}
}
public static class AnnotatedMethodBean {
AtomicInteger counter = new AtomicInteger();
@Retryable(maxAttempts = 5, delay = 10)
public Mono<Object> retryOperation() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
throw new IOException(counter.toString());
});
}
}
@Retryable(delay = 10, jitterDelay = 5, delayMultiplier = 2.0, maxDelay = 40,
includes = IOException.class, excludes = AccessDeniedException.class,
predicate = CustomPredicate.class)
public static class AnnotatedClassBean {
AtomicInteger counter = new AtomicInteger();
public Mono<Object> retryOperation() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
throw new IOException(counter.toString());
});
}
public Mono<Object> otherOperation() {
return Mono.fromCallable(() -> {
counter.incrementAndGet();
throw new AccessDeniedException(counter.toString());
});
}
@Retryable(value = IOException.class, maxAttempts = 1, delay = 10)
public Flux<Object> overrideOperation() {
return Flux.from(Mono.fromCallable(() -> {
counter.incrementAndGet();
throw new AccessDeniedException(counter.toString());
}));
}
}
private static class CustomPredicate implements MethodRetryPredicate {
@Override
public boolean shouldRetry(Method method, Throwable throwable) {
return !"3".equals(throwable.getMessage());
}
}
}

171
spring-aop/src/test/java/org/springframework/aop/retry/RetryInterceptorTests.java

@ -0,0 +1,171 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.aop.retry;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.AccessDeniedException;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.retry.annotation.RetryAnnotationBeanPostProcessor;
import org.springframework.aop.retry.annotation.RetryAnnotationInterceptor;
import org.springframework.aop.retry.annotation.Retryable;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIOException;
/**
* @author Juergen Hoeller
* @since 7.0
*/
public class RetryInterceptorTests {
@Test
void withSimpleInterceptor() {
NonAnnotatedBean target = new NonAnnotatedBean();
ProxyFactory pf = new ProxyFactory();
pf.setTarget(target);
pf.addAdvice(new SimpleRetryInterceptor(new MethodRetrySpec((m, t) -> true, 5, 10)));
NonAnnotatedBean proxy = (NonAnnotatedBean) pf.getProxy();
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6");
assertThat(target.counter).isEqualTo(6);
}
@Test
void withAnnotationInterceptorForMethod() {
AnnotatedMethodBean target = new AnnotatedMethodBean();
ProxyFactory pf = new ProxyFactory();
pf.setTarget(target);
pf.addAdvice(new RetryAnnotationInterceptor());
AnnotatedMethodBean proxy = (AnnotatedMethodBean) pf.getProxy();
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6");
assertThat(target.counter).isEqualTo(6);
}
@Test
void withPostProcessorForMethod() {
DefaultListableBeanFactory bf = new DefaultListableBeanFactory();
bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedMethodBean.class));
RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor();
bpp.setBeanFactory(bf);
bf.addBeanPostProcessor(bpp);
AnnotatedMethodBean proxy = bf.getBean(AnnotatedMethodBean.class);
AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy);
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6");
assertThat(target.counter).isEqualTo(6);
}
@Test
void withAnnotationInterceptorForClass() {
AnnotatedClassBean target = new AnnotatedClassBean();
ProxyFactory pf = new ProxyFactory();
pf.setTarget(target);
pf.addAdvice(new RetryAnnotationInterceptor());
AnnotatedClassBean proxy = (AnnotatedClassBean) pf.getProxy();
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3");
assertThat(target.counter).isEqualTo(3);
assertThatIOException().isThrownBy(proxy::otherOperation);
assertThat(target.counter).isEqualTo(4);
assertThatIOException().isThrownBy(proxy::overrideOperation);
assertThat(target.counter).isEqualTo(6);
}
@Test
void withPostProcessorForClass() {
DefaultListableBeanFactory bf = new DefaultListableBeanFactory();
bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedClassBean.class));
RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor();
bpp.setBeanFactory(bf);
bf.addBeanPostProcessor(bpp);
AnnotatedClassBean proxy = bf.getBean(AnnotatedClassBean.class);
AnnotatedClassBean target = (AnnotatedClassBean) AopProxyUtils.getSingletonTarget(proxy);
assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3");
assertThat(target.counter).isEqualTo(3);
assertThatIOException().isThrownBy(proxy::otherOperation);
assertThat(target.counter).isEqualTo(4);
assertThatIOException().isThrownBy(proxy::overrideOperation);
assertThat(target.counter).isEqualTo(6);
}
public static class NonAnnotatedBean {
int counter = 0;
public void retryOperation() throws IOException {
counter++;
throw new IOException(Integer.toString(counter));
}
}
public static class AnnotatedMethodBean {
int counter = 0;
@Retryable(maxAttempts = 5, delay = 10)
public void retryOperation() throws IOException {
counter++;
throw new IOException(Integer.toString(counter));
}
}
@Retryable(delay = 10, jitterDelay = 5, delayMultiplier = 2.0, maxDelay = 40,
includes = IOException.class, excludes = AccessDeniedException.class,
predicate = CustomPredicate.class)
public static class AnnotatedClassBean {
int counter = 0;
public void retryOperation() throws IOException {
counter++;
throw new IOException(Integer.toString(counter));
}
public void otherOperation() throws IOException {
counter++;
throw new AccessDeniedException(Integer.toString(counter));
}
@Retryable(value = IOException.class, maxAttempts = 1, delay = 10)
public void overrideOperation() throws IOException {
counter++;
throw new AccessDeniedException(Integer.toString(counter));
}
}
private static class CustomPredicate implements MethodRetryPredicate {
@Override
public boolean shouldRetry(Method method, Throwable throwable) {
return !"3".equals(throwable.getMessage());
}
}
}

101
spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java

@ -21,10 +21,10 @@ import java.util.StringJoiner;
import org.springframework.util.Assert; import org.springframework.util.Assert;
/** /**
* Implementation of {@link BackOff} that increases the back off period for each * Implementation of {@link BackOff} that increases the back-off period for each attempt.
* retry attempt. When the interval has reached the {@linkplain #setMaxInterval * When the interval has reached the {@linkplain #setMaxInterval max interval}, it is no
* max interval}, it is no longer increased. Stops retrying once the * longer increased. Stops once the {@linkplain #setMaxElapsedTime max elapsed time} or
* {@linkplain #setMaxElapsedTime max elapsed time} has been reached. * {@linkplain #setMaxAttempts max attempts} has been reached.
* *
* <p>Example: The default interval is {@value #DEFAULT_INITIAL_INTERVAL} ms; * <p>Example: The default interval is {@value #DEFAULT_INITIAL_INTERVAL} ms;
* the default multiplier is {@value #DEFAULT_MULTIPLIER}; and the default max * the default multiplier is {@value #DEFAULT_MULTIPLIER}; and the default max
@ -32,7 +32,7 @@ import org.springframework.util.Assert;
* as follows: * as follows:
* *
* <pre> * <pre>
* request# back off * request# back-off
* *
* 1 2000 * 1 2000
* 2 3000 * 2 3000
@ -55,32 +55,39 @@ import org.springframework.util.Assert;
* *
* @author Stephane Nicoll * @author Stephane Nicoll
* @author Gary Russell * @author Gary Russell
* @author Juergen Hoeller
* @since 4.1 * @since 4.1
*/ */
public class ExponentialBackOff implements BackOff { public class ExponentialBackOff implements BackOff {
/** /**
* The default initial interval. * The default initial interval: 2000 ms.
*/ */
public static final long DEFAULT_INITIAL_INTERVAL = 2000L; public static final long DEFAULT_INITIAL_INTERVAL = 2000L;
/**
* The default jitter range for each interval: 0 ms.
* @since 7.0
*/
public static final long DEFAULT_JITTER = 0;
/** /**
* The default multiplier (increases the interval by 50%). * The default multiplier (increases the interval by 50%).
*/ */
public static final double DEFAULT_MULTIPLIER = 1.5; public static final double DEFAULT_MULTIPLIER = 1.5;
/** /**
* The default maximum back off time. * The default maximum back-off time: 30000 ms.
*/ */
public static final long DEFAULT_MAX_INTERVAL = 30000L; public static final long DEFAULT_MAX_INTERVAL = 30000L;
/** /**
* The default maximum elapsed time. * The default maximum elapsed time: unlimited.
*/ */
public static final long DEFAULT_MAX_ELAPSED_TIME = Long.MAX_VALUE; public static final long DEFAULT_MAX_ELAPSED_TIME = Long.MAX_VALUE;
/** /**
* The default maximum attempts. * The default maximum attempts: unlimited.
* @since 6.1 * @since 6.1
*/ */
public static final int DEFAULT_MAX_ATTEMPTS = Integer.MAX_VALUE; public static final int DEFAULT_MAX_ATTEMPTS = Integer.MAX_VALUE;
@ -88,6 +95,8 @@ public class ExponentialBackOff implements BackOff {
private long initialInterval = DEFAULT_INITIAL_INTERVAL; private long initialInterval = DEFAULT_INITIAL_INTERVAL;
private long jitter = DEFAULT_JITTER;
private double multiplier = DEFAULT_MULTIPLIER; private double multiplier = DEFAULT_MULTIPLIER;
private long maxInterval = DEFAULT_MAX_INTERVAL; private long maxInterval = DEFAULT_MAX_INTERVAL;
@ -100,6 +109,7 @@ public class ExponentialBackOff implements BackOff {
/** /**
* Create an instance with the default settings. * Create an instance with the default settings.
* @see #DEFAULT_INITIAL_INTERVAL * @see #DEFAULT_INITIAL_INTERVAL
* @see #DEFAULT_JITTER
* @see #DEFAULT_MULTIPLIER * @see #DEFAULT_MULTIPLIER
* @see #DEFAULT_MAX_INTERVAL * @see #DEFAULT_MAX_INTERVAL
* @see #DEFAULT_MAX_ELAPSED_TIME * @see #DEFAULT_MAX_ELAPSED_TIME
@ -135,29 +145,57 @@ public class ExponentialBackOff implements BackOff {
} }
/** /**
* Set the value to multiply the current interval by for each retry attempt. * Set the jitter range (ms) to apply for each interval, leading to random
* milliseconds within the range to be subtracted or added, resulting in a
* value between {@code interval - jitter} and {@code interval + jitter}
* but never below {@code initialInterval} or above {@code maxInterval}.
* If a multiplier is specified, it applies to the jitter range as well.
* @since 7.0
*/
public void setJitter(long jitter) {
Assert.isTrue(jitter >= 0, () -> "Invalid jitter '" + jitter + "': Must be >=0.");
this.jitter = jitter;
}
/**
* Return the jitter range to apply for each interval.
* @since 7.0
*/
public long getJitter() {
return this.jitter;
}
/**
* Set the value to multiply the current interval by for each attempt.
* <p>This applies to the {@linkplain #setInitialInterval initial interval}
* as well as the {@linkplain #setJitter jitter range}.
*/ */
public void setMultiplier(double multiplier) { public void setMultiplier(double multiplier) {
checkMultiplier(multiplier); checkMultiplier(multiplier);
this.multiplier = multiplier; this.multiplier = multiplier;
} }
private void checkMultiplier(double multiplier) {
Assert.isTrue(multiplier >= 1, () -> "Invalid multiplier '" + multiplier + "': " +
"Should be greater than or equal to 1. A multiplier of 1 is equivalent to a fixed interval.");
}
/** /**
* Return the value to multiply the current interval by for each retry attempt. * Return the value to multiply the current interval by for each attempt.
*/ */
public double getMultiplier() { public double getMultiplier() {
return this.multiplier; return this.multiplier;
} }
/** /**
* Set the maximum back off time in milliseconds. * Set the maximum back-off time in milliseconds.
*/ */
public void setMaxInterval(long maxInterval) { public void setMaxInterval(long maxInterval) {
this.maxInterval = maxInterval; this.maxInterval = maxInterval;
} }
/** /**
* Return the maximum back off time in milliseconds. * Return the maximum back-off time in milliseconds.
*/ */
public long getMaxInterval() { public long getMaxInterval() {
return this.maxInterval; return this.maxInterval;
@ -211,15 +249,11 @@ public class ExponentialBackOff implements BackOff {
return new ExponentialBackOffExecution(); return new ExponentialBackOffExecution();
} }
private void checkMultiplier(double multiplier) {
Assert.isTrue(multiplier >= 1, () -> "Invalid multiplier '" + multiplier + "'. Should be greater than " +
"or equal to 1. A multiplier of 1 is equivalent to a fixed interval.");
}
@Override @Override
public String toString() { public String toString() {
return new StringJoiner(", ", ExponentialBackOff.class.getSimpleName() + "{", "}") return new StringJoiner(", ", ExponentialBackOff.class.getSimpleName() + "{", "}")
.add("initialInterval=" + this.initialInterval) .add("initialInterval=" + this.initialInterval)
.add("jitter=" + this.jitter)
.add("multiplier=" + this.multiplier) .add("multiplier=" + this.multiplier)
.add("maxInterval=" + this.maxInterval) .add("maxInterval=" + this.maxInterval)
.add("maxElapsedTime=" + this.maxElapsedTime) .add("maxElapsedTime=" + this.maxElapsedTime)
@ -234,7 +268,7 @@ public class ExponentialBackOff implements BackOff {
private long currentElapsedTime = 0; private long currentElapsedTime = 0;
private int attempts; private int attempts = 0;
@Override @Override
public long nextBackOff() { public long nextBackOff() {
@ -249,23 +283,30 @@ public class ExponentialBackOff implements BackOff {
private long computeNextInterval() { private long computeNextInterval() {
long maxInterval = getMaxInterval(); long maxInterval = getMaxInterval();
if (this.currentInterval >= maxInterval) { long nextInterval;
return maxInterval; if (this.currentInterval < 0) {
nextInterval = getInitialInterval();
} }
else if (this.currentInterval < 0) { else if (this.currentInterval >= maxInterval) {
long initialInterval = getInitialInterval(); nextInterval = maxInterval;
this.currentInterval = Math.min(initialInterval, maxInterval);
} }
else { else {
this.currentInterval = multiplyInterval(maxInterval); nextInterval = Math.min((long) (this.currentInterval * getMultiplier()), maxInterval);
} }
return this.currentInterval; this.currentInterval = nextInterval;
return Math.min(applyJitter(nextInterval), maxInterval);
} }
private long multiplyInterval(long maxInterval) { private long applyJitter(long interval) {
long i = this.currentInterval; long jitter = getJitter();
i *= getMultiplier(); if (jitter > 0) {
return Math.min(i, maxInterval); long initialInterval = getInitialInterval();
long applicableJitter = jitter * (interval / initialInterval);
long min = Math.max(interval - applicableJitter, initialInterval);
long max = Math.min(interval + applicableJitter, getMaxInterval());
return min + (long) (Math.random() * (max - min));
}
return interval;
} }
@Override @Override

Loading…
Cancel
Save