From f69df9b76756565e5913104c0cc25d4e29780d29 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 17 Jun 2025 12:49:29 +0200 Subject: [PATCH] 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 --- .../aop/retry/AbstractRetryInterceptor.java | 161 +++++++++++++++ .../aop/retry/MethodRetryPredicate.java | 48 +++++ .../aop/retry/MethodRetrySpec.java | 88 ++++++++ .../aop/retry/SimpleRetryInterceptor.java | 45 ++++ .../RetryAnnotationBeanPostProcessor.java | 46 +++++ .../RetryAnnotationInterceptor.java | 82 ++++++++ .../aop/retry/annotation/Retryable.java | 138 +++++++++++++ .../aop/retry/annotation/package-info.java | 7 + .../aop/retry/package-info.java | 7 + .../retry/ReactiveRetryInterceptorTests.java | 194 ++++++++++++++++++ .../aop/retry/RetryInterceptorTests.java | 171 +++++++++++++++ .../util/backoff/ExponentialBackOff.java | 101 ++++++--- 12 files changed, 1058 insertions(+), 30 deletions(-) create mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/AbstractRetryInterceptor.java create mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/MethodRetryPredicate.java create mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/MethodRetrySpec.java create mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/SimpleRetryInterceptor.java create mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationBeanPostProcessor.java create mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationInterceptor.java create mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/annotation/Retryable.java create mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/annotation/package-info.java create mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/package-info.java create mode 100644 spring-aop/src/test/java/org/springframework/aop/retry/ReactiveRetryInterceptorTests.java create mode 100644 spring-aop/src/test/java/org/springframework/aop/retry/RetryInterceptorTests.java diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/AbstractRetryInterceptor.java b/spring-aop/src/main/java/org/springframework/aop/retry/AbstractRetryInterceptor.java new file mode 100644 index 00000000000..fc2e7db46d2 --- /dev/null +++ b/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 include : spec.includes()) { + policyBuilder.includes(include); + } + for (Class 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); + } + } + +} diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/MethodRetryPredicate.java b/spring-aop/src/main/java/org/springframework/aop/retry/MethodRetryPredicate.java new file mode 100644 index 00000000000..13674f91137 --- /dev/null +++ b/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 forMethod(Method method) { + return (t -> shouldRetry(method, t)); + } + +} diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/MethodRetrySpec.java b/spring-aop/src/main/java/org/springframework/aop/retry/MethodRetrySpec.java new file mode 100644 index 00000000000..4a062186013 --- /dev/null +++ b/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> includes, + Collection> 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 exclude : this.excludes) { + if (exclude.isInstance(throwable)) { + return false; + } + } + } + if (!this.includes.isEmpty()) { + boolean included = false; + for (Class include : this.includes) { + if (include.isInstance(throwable)) { + included = true; + break; + } + } + if (!included) { + return false; + } + } + return this.predicate.shouldRetry(method, throwable); + }; + } + +} diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/SimpleRetryInterceptor.java b/spring-aop/src/main/java/org/springframework/aop/retry/SimpleRetryInterceptor.java new file mode 100644 index 00000000000..d790bc94d0a --- /dev/null +++ b/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; + } + +} diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationBeanPostProcessor.java b/spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationBeanPostProcessor.java new file mode 100644 index 00000000000..84c4ca603d6 --- /dev/null +++ b/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()); + } + +} diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationInterceptor.java b/spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationInterceptor.java new file mode 100644 index 00000000000..02a6e1700bd --- /dev/null +++ b/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 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 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); + } + } + +} diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/annotation/Retryable.java b/spring-aop/src/main/java/org/springframework/aop/retry/annotation/Retryable.java new file mode 100644 index 00000000000..46b77c108cd --- /dev/null +++ b/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. + * + *

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[] value() default {}; + + /** + * Applicable exceptions types to attempt a retry for. This attribute + * allows for the convenient specification of assignable exception types. + *

The default is empty, leading to a retry attempt for any exception. + * @see #excludes() + * @see #predicate() + */ + @AliasFor("value") + Class[] includes() default {}; + + /** + * Non-applicable exceptions types to avoid a retry for. This attribute + * allows for the convenient specification of assignable exception types. + *

The default is empty, leading to a retry attempt for any exception. + * @see #includes() + * @see #predicate() + */ + Class[] excludes() default {}; + + /** + * A predicate for filtering applicable exceptions for which + * an invocation can be retried. + *

The default is a retry attempt for any exception. + * @see #includes() + * @see #excludes() + */ + Class predicate() default MethodRetryPredicate.class; + + /** + * The maximum number of retry attempts, in addition to the initial invocation. + *

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. + *

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. + *

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. + *

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()}. + *

The default is unlimited. + * @see #delay() + * @see #jitterDelay() + * @see #delayMultiplier() + */ + long maxDelay() default Integer.MAX_VALUE; + +} diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/annotation/package-info.java b/spring-aop/src/main/java/org/springframework/aop/retry/annotation/package-info.java new file mode 100644 index 00000000000..86ddfd5e8e9 --- /dev/null +++ b/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; diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/package-info.java b/spring-aop/src/main/java/org/springframework/aop/retry/package-info.java new file mode 100644 index 00000000000..1cb4515d59f --- /dev/null +++ b/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; diff --git a/spring-aop/src/test/java/org/springframework/aop/retry/ReactiveRetryInterceptorTests.java b/spring-aop/src/test/java/org/springframework/aop/retry/ReactiveRetryInterceptorTests.java new file mode 100644 index 00000000000..6db4324aa0c --- /dev/null +++ b/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 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 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 retryOperation() { + return Mono.fromCallable(() -> { + counter.incrementAndGet(); + throw new IOException(counter.toString()); + }); + } + + public Mono otherOperation() { + return Mono.fromCallable(() -> { + counter.incrementAndGet(); + throw new AccessDeniedException(counter.toString()); + }); + } + + @Retryable(value = IOException.class, maxAttempts = 1, delay = 10) + public Flux 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()); + } + } + +} diff --git a/spring-aop/src/test/java/org/springframework/aop/retry/RetryInterceptorTests.java b/spring-aop/src/test/java/org/springframework/aop/retry/RetryInterceptorTests.java new file mode 100644 index 00000000000..3f775e802c7 --- /dev/null +++ b/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()); + } + } + +} diff --git a/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java b/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java index 79836518a16..a85809dc607 100644 --- a/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java +++ b/spring-core/src/main/java/org/springframework/util/backoff/ExponentialBackOff.java @@ -21,10 +21,10 @@ import java.util.StringJoiner; import org.springframework.util.Assert; /** - * Implementation of {@link BackOff} that increases the back off period for each - * retry attempt. When the interval has reached the {@linkplain #setMaxInterval - * max interval}, it is no longer increased. Stops retrying once the - * {@linkplain #setMaxElapsedTime max elapsed time} has been reached. + * Implementation of {@link BackOff} that increases the back-off period for each attempt. + * When the interval has reached the {@linkplain #setMaxInterval max interval}, it is no + * longer increased. Stops once the {@linkplain #setMaxElapsedTime max elapsed time} or + * {@linkplain #setMaxAttempts max attempts} has been reached. * *

Example: The default interval is {@value #DEFAULT_INITIAL_INTERVAL} ms; * the default multiplier is {@value #DEFAULT_MULTIPLIER}; and the default max @@ -32,7 +32,7 @@ import org.springframework.util.Assert; * as follows: * *

- * request#     back off
+ * request#     back-off
  *
  *  1              2000
  *  2              3000
@@ -55,32 +55,39 @@ import org.springframework.util.Assert;
  *
  * @author Stephane Nicoll
  * @author Gary Russell
+ * @author Juergen Hoeller
  * @since 4.1
  */
 public class ExponentialBackOff implements BackOff {
 
 	/**
-	 * The default initial interval.
+	 * The default initial interval: 2000 ms.
 	 */
 	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%).
 	 */
 	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;
 
 	/**
-	 * The default maximum elapsed time.
+	 * The default maximum elapsed time: unlimited.
 	 */
 	public static final long DEFAULT_MAX_ELAPSED_TIME = Long.MAX_VALUE;
 
 	/**
-	 * The default maximum attempts.
+	 * The default maximum attempts: unlimited.
 	 * @since 6.1
 	 */
 	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 jitter = DEFAULT_JITTER;
+
 	private double multiplier = DEFAULT_MULTIPLIER;
 
 	private long maxInterval = DEFAULT_MAX_INTERVAL;
@@ -100,6 +109,7 @@ public class ExponentialBackOff implements BackOff {
 	/**
 	 * Create an instance with the default settings.
 	 * @see #DEFAULT_INITIAL_INTERVAL
+	 * @see #DEFAULT_JITTER
 	 * @see #DEFAULT_MULTIPLIER
 	 * @see #DEFAULT_MAX_INTERVAL
 	 * @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.
+	 * 

This applies to the {@linkplain #setInitialInterval initial interval} + * as well as the {@linkplain #setJitter jitter range}. */ public void setMultiplier(double multiplier) { checkMultiplier(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() { return this.multiplier; } /** - * Set the maximum back off time in milliseconds. + * Set the maximum back-off time in milliseconds. */ public void setMaxInterval(long maxInterval) { this.maxInterval = maxInterval; } /** - * Return the maximum back off time in milliseconds. + * Return the maximum back-off time in milliseconds. */ public long getMaxInterval() { return this.maxInterval; @@ -211,15 +249,11 @@ public class ExponentialBackOff implements BackOff { 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 public String toString() { return new StringJoiner(", ", ExponentialBackOff.class.getSimpleName() + "{", "}") .add("initialInterval=" + this.initialInterval) + .add("jitter=" + this.jitter) .add("multiplier=" + this.multiplier) .add("maxInterval=" + this.maxInterval) .add("maxElapsedTime=" + this.maxElapsedTime) @@ -234,7 +268,7 @@ public class ExponentialBackOff implements BackOff { private long currentElapsedTime = 0; - private int attempts; + private int attempts = 0; @Override public long nextBackOff() { @@ -249,23 +283,30 @@ public class ExponentialBackOff implements BackOff { private long computeNextInterval() { long maxInterval = getMaxInterval(); - if (this.currentInterval >= maxInterval) { - return maxInterval; + long nextInterval; + if (this.currentInterval < 0) { + nextInterval = getInitialInterval(); } - else if (this.currentInterval < 0) { - long initialInterval = getInitialInterval(); - this.currentInterval = Math.min(initialInterval, maxInterval); + else if (this.currentInterval >= maxInterval) { + nextInterval = maxInterval; } 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) { - long i = this.currentInterval; - i *= getMultiplier(); - return Math.min(i, maxInterval); + private long applyJitter(long interval) { + long jitter = getJitter(); + if (jitter > 0) { + 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