From c9078bfe1473040e8ded7259d42a98bf7deaa6a0 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 1 Jul 2025 17:27:50 +0200 Subject: [PATCH] Introduce @ConcurrencyLimit annotation based on ConcurrencyThrottleInterceptor Moves @Retryable infrastructure to resilience package in spring-context module. Includes duration parsing and placeholder resolution for @Retryable attributes. Provides convenient @EnableResilientMethods for @Retryable + @ConcurrencyLimit. Closes gh-35133 See gh-34529 --- ...BeanFactoryAwareAdvisingPostProcessor.java | 2 +- .../ConcurrencyThrottleInterceptor.java | 16 +- .../RetryAnnotationBeanPostProcessor.java | 46 ---- .../RetryAnnotationInterceptor.java | 96 ------- .../aop/retry/annotation/Retryable.java | 155 ----------- .../aop/retry/annotation/package-info.java | 7 - .../annotation/ConcurrencyLimit.java | 65 +++++ .../ConcurrencyLimitBeanPostProcessor.java | 118 +++++++++ .../annotation/EnableResilientMethods.java | 69 +++++ .../ResilientMethodsConfiguration.java | 77 ++++++ .../RetryAnnotationBeanPostProcessor.java | 170 ++++++++++++ .../resilience/annotation/Retryable.java | 241 ++++++++++++++++++ .../resilience/annotation/package-info.java | 7 + .../retry/AbstractRetryInterceptor.java | 12 +- .../retry/MethodRetryPredicate.java | 2 +- .../resilience}/retry/MethodRetrySpec.java | 2 +- .../retry/SimpleRetryInterceptor.java | 2 +- .../resilience}/retry/package-info.java | 2 +- .../scheduling/annotation/Scheduled.java | 18 +- .../ScheduledAnnotationBeanPostProcessor.java | 25 +- .../resilience/ConcurrencyLimitTests.java | 188 ++++++++++++++ .../ReactiveRetryInterceptorTests.java | 56 ++-- .../resilience}/RetryInterceptorTests.java | 141 +++++++--- 23 files changed, 1112 insertions(+), 405 deletions(-) delete mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationBeanPostProcessor.java delete mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationInterceptor.java delete mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/annotation/Retryable.java delete mode 100644 spring-aop/src/main/java/org/springframework/aop/retry/annotation/package-info.java create mode 100644 spring-context/src/main/java/org/springframework/resilience/annotation/ConcurrencyLimit.java create mode 100644 spring-context/src/main/java/org/springframework/resilience/annotation/ConcurrencyLimitBeanPostProcessor.java create mode 100644 spring-context/src/main/java/org/springframework/resilience/annotation/EnableResilientMethods.java create mode 100644 spring-context/src/main/java/org/springframework/resilience/annotation/ResilientMethodsConfiguration.java create mode 100644 spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java create mode 100644 spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java create mode 100644 spring-context/src/main/java/org/springframework/resilience/annotation/package-info.java rename {spring-aop/src/main/java/org/springframework/aop => spring-context/src/main/java/org/springframework/resilience}/retry/AbstractRetryInterceptor.java (94%) rename {spring-aop/src/main/java/org/springframework/aop => spring-context/src/main/java/org/springframework/resilience}/retry/MethodRetryPredicate.java (96%) rename {spring-aop/src/main/java/org/springframework/aop => spring-context/src/main/java/org/springframework/resilience}/retry/MethodRetrySpec.java (98%) rename {spring-aop/src/main/java/org/springframework/aop => spring-context/src/main/java/org/springframework/resilience}/retry/SimpleRetryInterceptor.java (96%) rename {spring-aop/src/main/java/org/springframework/aop => spring-context/src/main/java/org/springframework/resilience}/retry/package-info.java (75%) create mode 100644 spring-context/src/test/java/org/springframework/resilience/ConcurrencyLimitTests.java rename {spring-aop/src/test/java/org/springframework/aop/retry => spring-context/src/test/java/org/springframework/resilience}/ReactiveRetryInterceptorTests.java (87%) rename {spring-aop/src/test/java/org/springframework/aop/retry => spring-context/src/test/java/org/springframework/resilience}/RetryInterceptorTests.java (54%) diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java index 358187560b5..c9d07a1fdf8 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java @@ -41,7 +41,7 @@ import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; public abstract class AbstractBeanFactoryAwareAdvisingPostProcessor extends AbstractAdvisingBeanPostProcessor implements BeanFactoryAware { - private @Nullable ConfigurableListableBeanFactory beanFactory; + protected @Nullable ConfigurableListableBeanFactory beanFactory; @Override diff --git a/spring-aop/src/main/java/org/springframework/aop/interceptor/ConcurrencyThrottleInterceptor.java b/spring-aop/src/main/java/org/springframework/aop/interceptor/ConcurrencyThrottleInterceptor.java index 1defad5e53c..e7f267a942b 100644 --- a/spring-aop/src/main/java/org/springframework/aop/interceptor/ConcurrencyThrottleInterceptor.java +++ b/spring-aop/src/main/java/org/springframework/aop/interceptor/ConcurrencyThrottleInterceptor.java @@ -44,10 +44,24 @@ import org.springframework.util.ConcurrencyThrottleSupport; public class ConcurrencyThrottleInterceptor extends ConcurrencyThrottleSupport implements MethodInterceptor, Serializable { + /** + * Create a default {@code ConcurrencyThrottleInterceptor} + * with concurrency limit 1. + */ public ConcurrencyThrottleInterceptor() { - setConcurrencyLimit(1); + this(1); } + /** + * Create a default {@code ConcurrencyThrottleInterceptor} + * with the given concurrency limit. + * @since 7.0 + */ + public ConcurrencyThrottleInterceptor(int concurrencyLimit) { + setConcurrencyLimit(concurrencyLimit); + } + + @Override public @Nullable Object invoke(MethodInvocation methodInvocation) throws Throwable { beforeAccess(); 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 deleted file mode 100644 index 8a377298ee6..00000000000 --- a/spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationBeanPostProcessor.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2002-present 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; - -/** - * A convenient {@link org.springframework.beans.factory.config.BeanPostProcessor - * 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 deleted file mode 100644 index 13332583f6d..00000000000 --- a/spring-aop/src/main/java/org/springframework/aop/retry/annotation/RetryAnnotationInterceptor.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2002-present 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.time.Duration; -import java.util.Arrays; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; - -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; - } - } - - TimeUnit timeUnit = retryable.timeUnit(); - retrySpec = new MethodRetrySpec( - Arrays.asList(retryable.includes()), Arrays.asList(retryable.excludes()), - instantiatePredicate(retryable.predicate()), retryable.maxAttempts(), - toDuration(retryable.delay(), timeUnit), toDuration(retryable.jitter(), timeUnit), - retryable.multiplier(), toDuration(retryable.maxDelay(), timeUnit)); - - MethodRetrySpec existing = this.retrySpecCache.putIfAbsent(cacheKey, retrySpec); - return (existing != null ? existing : retrySpec); - } - - - private static 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); - } - } - - private static Duration toDuration(long value, TimeUnit timeUnit) { - try { - return Duration.of(value, timeUnit.toChronoUnit()); - } - catch (Exception ex) { - throw new IllegalArgumentException( - "Unsupported unit " + timeUnit + " for value \"" + value + "\": " + ex.getMessage()); - } - } - -} 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 deleted file mode 100644 index f0be61bbb13..00000000000 --- a/spring-aop/src/main/java/org/springframework/aop/retry/annotation/Retryable.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2002-present 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 java.util.concurrent.TimeUnit; - -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. - * - *

Inspired by the Spring Retry - * project but redesigned as a minimal core retry feature in the Spring Framework. - * - * @author Juergen Hoeller - * @since 7.0 - * @see RetryAnnotationBeanPostProcessor - * @see RetryAnnotationInterceptor - * @see org.springframework.core.retry.RetryPolicy - * @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 exception 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 exception 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. - */ - long maxAttempts() default 3; - - /** - * The base delay after the initial invocation. If a multiplier is specified, - * this serves as the initial delay to multiply from. - *

The time unit is milliseconds by default but can be overridden via - * {@link #timeUnit}. - *

The default is 1000. - * @see #jitter() - * @see #multiplier() - * @see #maxDelay() - */ - long delay() default 1000; - - /** - * A jitter value for the base retry attempt, randomly subtracted or added to - * the calculated delay, resulting in a value between {@code delay - jitter} - * and {@code delay + jitter} but never below the base {@link #delay()} or - * above {@link #maxDelay()}. If a multiplier is specified, it is applied - * to the jitter value as well. - *

The time unit is milliseconds by default but can be overridden via - * {@link #timeUnit}. - *

The default is 0 (no jitter). - * @see #delay() - * @see #multiplier() - * @see #maxDelay() - */ - long jitter() 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 #jitter()} for each attempt. - *

The default is 1.0, effectively resulting in a fixed delay. - * @see #delay() - * @see #jitter() - * @see #maxDelay() - */ - double multiplier() default 1.0; - - /** - * The maximum delay for any retry attempt, limiting how far {@link #jitter()} - * and {@link #multiplier()} can increase the {@linkplain #delay() delay}. - *

The time unit is milliseconds by default but can be overridden via - * {@link #timeUnit}. - *

The default is unlimited. - * @see #delay() - * @see #jitter() - * @see #multiplier() - */ - long maxDelay() default Long.MAX_VALUE; - - /** - * The {@link TimeUnit} to use for {@link #delay}, {@link #jitter}, - * and {@link #maxDelay}. - *

The default is {@link TimeUnit#MILLISECONDS}. - */ - TimeUnit timeUnit() default TimeUnit.MILLISECONDS; - -} 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 deleted file mode 100644 index 86ddfd5e8e9..00000000000 --- a/spring-aop/src/main/java/org/springframework/aop/retry/annotation/package-info.java +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Annotation-based retry support for common Spring setups. - */ -@NullMarked -package org.springframework.aop.retry.annotation; - -import org.jspecify.annotations.NullMarked; diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/ConcurrencyLimit.java b/spring-context/src/main/java/org/springframework/resilience/annotation/ConcurrencyLimit.java new file mode 100644 index 00000000000..465ba44bb5b --- /dev/null +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/ConcurrencyLimit.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-present 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.resilience.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.aot.hint.annotation.Reflective; + +/** + * A common annotation specifying a concurrency limit for an individual method, + * or for all proxy-invoked methods in a given class hierarchy if annotated at + * the type level. + * + *

In the type-level case, all methods inheriting the concurrency limit + * from the type level share a common concurrency throttle, with any mix + * of such method invocations contributing to the shared concurrency limit. + * Whereas for a locally annotated method, a local throttle with the specified + * limit is going to be applied to invocations of that particular method only. + * + *

This is particularly useful with Virtual Threads where there is generally + * no thread pool limit in place. For asynchronous tasks, this can be constrained + * on {@link org.springframework.core.task.SimpleAsyncTaskExecutor}; for + * synchronous invocations, this annotation provides equivalent behavior through + * {@link org.springframework.aop.interceptor.ConcurrencyThrottleInterceptor}. + * + * @author Juergen Hoeller + * @since 7.0 + * @see EnableResilientMethods + * @see ConcurrencyLimitBeanPostProcessor + * @see org.springframework.aop.interceptor.ConcurrencyThrottleInterceptor + * @see org.springframework.core.task.SimpleAsyncTaskExecutor#setConcurrencyLimit + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Reflective +public @interface ConcurrencyLimit { + + /** + * The applicable concurrency limit: 1 by default, + * effectively locking the target instance for each method invocation. + *

Specify a limit higher than 1 for pool-like throttling, constraining + * the number of concurrent invocations similar to the upper bound of a pool. + */ + int value() default 1; + +} diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/ConcurrencyLimitBeanPostProcessor.java b/spring-context/src/main/java/org/springframework/resilience/annotation/ConcurrencyLimitBeanPostProcessor.java new file mode 100644 index 00000000000..5bdf6f0be0a --- /dev/null +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/ConcurrencyLimitBeanPostProcessor.java @@ -0,0 +1,118 @@ +/* + * Copyright 2002-present 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.resilience.annotation; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.aopalliance.intercept.Joinpoint; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jspecify.annotations.Nullable; + +import org.springframework.aop.Pointcut; +import org.springframework.aop.ProxyMethodInvocation; +import org.springframework.aop.framework.autoproxy.AbstractBeanFactoryAwareAdvisingPostProcessor; +import org.springframework.aop.interceptor.ConcurrencyThrottleInterceptor; +import org.springframework.aop.support.ComposablePointcut; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.Assert; +import org.springframework.util.ConcurrentReferenceHashMap; + +/** + * A convenient {@link org.springframework.beans.factory.config.BeanPostProcessor + * BeanPostProcessor} that applies a concurrency interceptor to all bean methods + * annotated with {@link ConcurrencyLimit} annotations. + * + * @author Juergen Hoeller + * @since 7.0 + */ +@SuppressWarnings("serial") +public class ConcurrencyLimitBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor { + + public ConcurrencyLimitBeanPostProcessor() { + setBeforeExistingAdvisors(true); + + Pointcut cpc = new AnnotationMatchingPointcut(ConcurrencyLimit.class, true); + Pointcut mpc = new AnnotationMatchingPointcut(null, ConcurrencyLimit.class, true); + this.advisor = new DefaultPointcutAdvisor( + new ComposablePointcut(cpc).union(mpc), + new ConcurrencyLimitInterceptor()); + } + + + private static class ConcurrencyLimitInterceptor implements MethodInterceptor { + + private final Map cachePerInstance = + new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK); + + @Override + public @Nullable Object invoke(MethodInvocation invocation) throws Throwable { + Method method = invocation.getMethod(); + Object target = invocation.getThis(); + Class targetClass = (target != null ? target.getClass() : method.getDeclaringClass()); + if (target == null && invocation instanceof ProxyMethodInvocation methodInvocation) { + // Allow validation for AOP proxy without a target + target = methodInvocation.getProxy(); + } + Assert.state(target != null, "Target must not be null"); + + ConcurrencyThrottleCache cache = this.cachePerInstance.computeIfAbsent(target, + k -> new ConcurrencyThrottleCache()); + MethodInterceptor interceptor = cache.methodInterceptors.get(method); + if (interceptor == null) { + synchronized (cache) { + interceptor = cache.methodInterceptors.get(method); + if (interceptor == null) { + boolean perMethod = false; + ConcurrencyLimit limit = AnnotatedElementUtils.getMergedAnnotation(method, ConcurrencyLimit.class); + if (limit != null) { + perMethod = true; + } + else { + interceptor = cache.classInterceptor; + if (interceptor == null) { + limit = AnnotatedElementUtils.getMergedAnnotation(targetClass, ConcurrencyLimit.class); + } + } + if (interceptor == null) { + interceptor = (limit != null ? new ConcurrencyThrottleInterceptor(limit.value()) : + Joinpoint::proceed); + if (!perMethod) { + cache.classInterceptor = interceptor; + } + } + cache.methodInterceptors.put(method, interceptor); + } + } + } + return interceptor.invoke(invocation); + } + } + + + private static class ConcurrencyThrottleCache { + + final Map methodInterceptors = new ConcurrentHashMap<>(); + + @Nullable MethodInterceptor classInterceptor; + } + +} diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/EnableResilientMethods.java b/spring-context/src/main/java/org/springframework/resilience/annotation/EnableResilientMethods.java new file mode 100644 index 00000000000..edea2e0a7fb --- /dev/null +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/EnableResilientMethods.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-present 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.resilience.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.context.annotation.Import; +import org.springframework.core.Ordered; + +/** + * Enables Spring's core resilience features for method invocations: + * {@link Retryable} as well as {@link ConcurrencyLimit}. + * + *

These annotations can also be individually enabled through + * defining a {@link RetryAnnotationBeanPostProcessor} and/or a + * {@link ConcurrencyLimitBeanPostProcessor}. + * + * @author Juergen Hoeller + * @since 7.0 + * @see RetryAnnotationBeanPostProcessor + * @see ConcurrencyLimitBeanPostProcessor + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Import(ResilientMethodsConfiguration.class) +public @interface EnableResilientMethods { + + /** + * Indicate whether subclass-based (CGLIB) proxies are to be created as opposed + * to standard Java interface-based proxies. + *

The default is {@code false}. + *

Note that setting this attribute to {@code true} will affect all + * Spring-managed beans requiring proxying, not just those marked with {@code @Retryable} + * or {@code @ConcurrencyLimit}. For example, other beans marked with Spring's + * {@code @Transactional} annotation will be upgraded to subclass proxying at + * the same time. This approach has no negative impact in practice unless one is + * explicitly expecting one type of proxy vs. another — for example, in tests. + */ + boolean proxyTargetClass() default false; + + /** + * Indicate the order in which the {@link RetryAnnotationBeanPostProcessor} + * and {@link ConcurrencyLimitBeanPostProcessor} should be applied. + *

The default is {@link Ordered#LOWEST_PRECEDENCE} in order to run + * after all other post-processors, so that it can add an advisor to + * existing proxies rather than double-proxy. + */ + int order() default Ordered.LOWEST_PRECEDENCE; + +} diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/ResilientMethodsConfiguration.java b/spring-context/src/main/java/org/springframework/resilience/annotation/ResilientMethodsConfiguration.java new file mode 100644 index 00000000000..338a8fe1fab --- /dev/null +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/ResilientMethodsConfiguration.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-present 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.resilience.annotation; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aop.framework.ProxyProcessorSupport; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportAware; +import org.springframework.context.annotation.Role; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.type.AnnotationMetadata; + +/** + * {@code @Configuration} class that registers the Spring infrastructure beans necessary + * to enable proxy-based method invocations with retry and concurrency limit behavior. + * + * @author Juergen Hoeller + * @since 7.0 + * @see EnableResilientMethods + * @see RetryAnnotationBeanPostProcessor + * @see ConcurrencyLimitBeanPostProcessor + */ +@Configuration(proxyBeanMethods = false) +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class ResilientMethodsConfiguration implements ImportAware { + + private @Nullable AnnotationAttributes enableResilientMethods; + + + @Override + public void setImportMetadata(AnnotationMetadata importMetadata) { + this.enableResilientMethods = AnnotationAttributes.fromMap( + importMetadata.getAnnotationAttributes(EnableResilientMethods.class.getName())); + } + + private void configureProxySupport(ProxyProcessorSupport proxySupport) { + if (this.enableResilientMethods != null) { + proxySupport.setProxyTargetClass(this.enableResilientMethods.getBoolean("proxyTargetClass")); + proxySupport.setOrder(this.enableResilientMethods.getNumber("order")); + } + } + + + @Bean(name = "org.springframework.resilience.annotation.internalRetryAnnotationProcessor") + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public RetryAnnotationBeanPostProcessor retryAdvisor() { + RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor(); + configureProxySupport(bpp); + return bpp; + } + + @Bean(name = "org.springframework.resilience.annotation.internalConcurrencyLimitProcessor") + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public ConcurrencyLimitBeanPostProcessor concurrencyLimitAdvisor() { + ConcurrencyLimitBeanPostProcessor bpp = new ConcurrencyLimitBeanPostProcessor(); + configureProxySupport(bpp); + return bpp; + } + +} diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java b/spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java new file mode 100644 index 00000000000..7c5afc175a5 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java @@ -0,0 +1,170 @@ +/* + * Copyright 2002-present 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.resilience.annotation; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import org.jspecify.annotations.Nullable; + +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.context.EmbeddedValueResolverAware; +import org.springframework.core.MethodClassKey; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.format.annotation.DurationFormat; +import org.springframework.format.datetime.standard.DurationFormatterUtils; +import org.springframework.resilience.retry.AbstractRetryInterceptor; +import org.springframework.resilience.retry.MethodRetryPredicate; +import org.springframework.resilience.retry.MethodRetrySpec; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.util.StringValueResolver; + +/** + * A convenient {@link org.springframework.beans.factory.config.BeanPostProcessor + * BeanPostProcessor} that applies a retry interceptor to all bean methods + * annotated with {@link Retryable} annotations. + * + * @author Juergen Hoeller + * @since 7.0 + */ +@SuppressWarnings("serial") +public class RetryAnnotationBeanPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor + implements EmbeddedValueResolverAware { + + private @Nullable StringValueResolver embeddedValueResolver; + + + 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()); + } + + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.embeddedValueResolver = resolver; + } + + + private 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; + } + } + + TimeUnit timeUnit = retryable.timeUnit(); + retrySpec = new MethodRetrySpec( + Arrays.asList(retryable.includes()), Arrays.asList(retryable.excludes()), + instantiatePredicate(retryable.predicate()), + parseLong(retryable.maxAttempts(), retryable.maxAttemptsString()), + parseDuration(retryable.delay(), retryable.delayString(), timeUnit), + parseDuration(retryable.jitter(), retryable.jitterString(), timeUnit), + parseDouble(retryable.multiplier(), retryable.multiplierString()), + parseDuration(retryable.maxDelay(), retryable.maxDelayString(), timeUnit)); + + 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 (beanFactory != null ? beanFactory.createBean(predicateClass) : + ReflectionUtils.accessibleConstructor(predicateClass).newInstance()); + } + catch (Throwable ex) { + throw new IllegalStateException("Failed to instantiate predicate class [" + predicateClass + "]", ex); + } + } + + private long parseLong(long value, String stringValue) { + if (StringUtils.hasText(stringValue)) { + if (embeddedValueResolver != null) { + stringValue = embeddedValueResolver.resolveStringValue(stringValue); + } + if (StringUtils.hasText(stringValue)) { + return Long.parseLong(stringValue); + } + } + return value; + } + + private double parseDouble(double value, String stringValue) { + if (StringUtils.hasText(stringValue)) { + if (embeddedValueResolver != null) { + stringValue = embeddedValueResolver.resolveStringValue(stringValue); + } + if (StringUtils.hasText(stringValue)) { + return Double.parseDouble(stringValue); + } + } + return value; + } + + private Duration parseDuration(long value, String stringValue, TimeUnit timeUnit) { + if (StringUtils.hasText(stringValue)) { + if (embeddedValueResolver != null) { + stringValue = embeddedValueResolver.resolveStringValue(stringValue); + } + if (StringUtils.hasText(stringValue)) { + return toDuration(stringValue, timeUnit); + } + } + return toDuration(value, timeUnit); + } + + private static Duration toDuration(long value, TimeUnit timeUnit) { + return Duration.of(value, timeUnit.toChronoUnit()); + } + + private static Duration toDuration(String value, TimeUnit timeUnit) { + DurationFormat.Unit unit = DurationFormat.Unit.fromChronoUnit(timeUnit.toChronoUnit()); + return DurationFormatterUtils.detectAndParse(value, unit); + } + } + +} diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java b/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java new file mode 100644 index 00000000000..869d58578b5 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/Retryable.java @@ -0,0 +1,241 @@ +/* + * Copyright 2002-present 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.resilience.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 java.util.concurrent.TimeUnit; + +import org.springframework.aot.hint.annotation.Reflective; +import org.springframework.core.annotation.AliasFor; +import org.springframework.resilience.retry.MethodRetryPredicate; + +/** + * 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. + * + *

Inspired by the Spring Retry + * project but redesigned as a minimal core retry feature in the Spring Framework. + * + * @author Juergen Hoeller + * @since 7.0 + * @see EnableResilientMethods + * @see RetryAnnotationBeanPostProcessor + * @see org.springframework.core.retry.RetryPolicy + * @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 exception 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 exception 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. + *

A specified {@link MethodRetryPredicate} implementation will be instantiated + * per method. It can use dependency injection at the constructor level or through + * autowiring annotations, in case it needs access to other beans or facilities. + * @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. + */ + long maxAttempts() default 3; + + /** + * The maximum number of retry attempts, as a configurable String. + * A non-empty value specified here overrides the {@link #maxAttempts()} attribute. + *

This supports Spring-style "${...}" placeholders as well as SpEL expressions. + * @see #maxAttempts() + */ + String maxAttemptsString() default ""; + + /** + * The base delay after the initial invocation. If a multiplier is specified, + * this serves as the initial delay to multiply from. + *

The time unit is milliseconds by default but can be overridden via + * {@link #timeUnit}. + *

The default is 1000. + * @see #jitter() + * @see #multiplier() + * @see #maxDelay() + */ + long delay() default 1000; + + /** + * The base delay after the initial invocation, as a duration String. + * A non-empty value specified here overrides the {@link #delay()} attribute. + *

The duration String can be in several formats: + *

+ * @return the initial delay as a String value — for example a placeholder, + * or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value + * or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value + * @see #delay() + */ + String delayString() default ""; + + /** + * A jitter value for the base retry attempt, randomly subtracted or added to + * the calculated delay, resulting in a value between {@code delay - jitter} + * and {@code delay + jitter} but never below the base {@link #delay()} or + * above {@link #maxDelay()}. If a multiplier is specified, it is applied + * to the jitter value as well. + *

The time unit is milliseconds by default but can be overridden via + * {@link #timeUnit}. + *

The default is 0 (no jitter). + * @see #delay() + * @see #multiplier() + * @see #maxDelay() + */ + long jitter() default 0; + + /** + * A jitter value for the base retry attempt, as a duration String. + * A non-empty value specified here overrides the {@link #jitter()} attribute. + *

The duration String can be in several formats: + *

+ * @return the initial delay as a String value — for example a placeholder, + * or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value + * or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value + * @see #jitter() + */ + String jitterString() default ""; + + /** + * 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 #jitter()} for each attempt. + *

The default is 1.0, effectively resulting in a fixed delay. + * @see #delay() + * @see #jitter() + * @see #maxDelay() + */ + double multiplier() default 1.0; + + /** + * A multiplier for a delay for the next retry attempt, as a configurable String. + * A non-empty value specified here overrides the {@link #multiplier()} attribute. + *

This supports Spring-style "${...}" placeholders as well as SpEL expressions. + * @see #multiplier() + */ + String multiplierString() default ""; + + /** + * The maximum delay for any retry attempt, limiting how far {@link #jitter()} + * and {@link #multiplier()} can increase the {@linkplain #delay() delay}. + *

The time unit is milliseconds by default but can be overridden via + * {@link #timeUnit}. + *

The default is unlimited. + * @see #delay() + * @see #jitter() + * @see #multiplier() + */ + long maxDelay() default Long.MAX_VALUE; + + /** + * The maximum delay for any retry attempt, as a duration String. + * A non-empty value specified here overrides the {@link #maxDelay()} attribute. + *

The duration String can be in several formats: + *

+ * @return the initial delay as a String value — for example a placeholder, + * or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value + * or a {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE simple format} compliant value + * @see #maxDelay() + */ + String maxDelayString() default ""; + + /** + * The {@link TimeUnit} to use for {@link #delay}, {@link #delayString}, + * {@link #jitter}, {@link #jitterString}, {@link #maxDelay}, and + * {@link #maxDelayString}. + *

The default is {@link TimeUnit#MILLISECONDS}. + *

This attribute is ignored for {@link java.time.Duration} values supplied + * via {@link #delayString}, {@link #jitterString}, or {@link #maxDelayString}. + * @return the {@code TimeUnit} to use + */ + TimeUnit timeUnit() default TimeUnit.MILLISECONDS; + +} diff --git a/spring-context/src/main/java/org/springframework/resilience/annotation/package-info.java b/spring-context/src/main/java/org/springframework/resilience/annotation/package-info.java new file mode 100644 index 00000000000..1e9f19aa565 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/resilience/annotation/package-info.java @@ -0,0 +1,7 @@ +/** + * Annotation-based retry and concurrency limit support. + */ +@NullMarked +package org.springframework.resilience.annotation; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/AbstractRetryInterceptor.java b/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java similarity index 94% rename from spring-aop/src/main/java/org/springframework/aop/retry/AbstractRetryInterceptor.java rename to spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java index 4e0c5e249ff..65942411546 100644 --- a/spring-aop/src/main/java/org/springframework/aop/retry/AbstractRetryInterceptor.java +++ b/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.aop.retry; +package org.springframework.resilience.retry; import java.lang.reflect.Method; @@ -136,10 +136,7 @@ public abstract class AbstractRetryInterceptor implements MethodInterceptor { Publisher publisher = adapter.toPublisher(result); Retry retry = Retry.backoff(spec.maxAttempts(), spec.delay()) - .jitter( - spec.delay().isZero() ? 0.0 : - Math.max(0.0, Math.min(1.0, spec.jitter().toNanos() / (double) spec.delay().toNanos())) - ) + .jitter(calculateJitterFactor(spec)) .multiplier(spec.multiplier()) .maxBackoff(spec.maxDelay()) .filter(spec.combinedPredicate().forMethod(method)); @@ -147,6 +144,11 @@ public abstract class AbstractRetryInterceptor implements MethodInterceptor { Mono.from(publisher).retryWhen(retry)); return adapter.fromPublisher(publisher); } + + private static double calculateJitterFactor(MethodRetrySpec spec) { + return (spec.delay().isZero() ? 0.0 : + Math.max(0.0, Math.min(1.0, spec.jitter().toNanos() / (double) spec.delay().toNanos()))); + } } } diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/MethodRetryPredicate.java b/spring-context/src/main/java/org/springframework/resilience/retry/MethodRetryPredicate.java similarity index 96% rename from spring-aop/src/main/java/org/springframework/aop/retry/MethodRetryPredicate.java rename to spring-context/src/main/java/org/springframework/resilience/retry/MethodRetryPredicate.java index db992392480..f4700963b24 100644 --- a/spring-aop/src/main/java/org/springframework/aop/retry/MethodRetryPredicate.java +++ b/spring-context/src/main/java/org/springframework/resilience/retry/MethodRetryPredicate.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.aop.retry; +package org.springframework.resilience.retry; import java.lang.reflect.Method; import java.util.function.Predicate; diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/MethodRetrySpec.java b/spring-context/src/main/java/org/springframework/resilience/retry/MethodRetrySpec.java similarity index 98% rename from spring-aop/src/main/java/org/springframework/aop/retry/MethodRetrySpec.java rename to spring-context/src/main/java/org/springframework/resilience/retry/MethodRetrySpec.java index 6d9e499d21e..014d7214216 100644 --- a/spring-aop/src/main/java/org/springframework/aop/retry/MethodRetrySpec.java +++ b/spring-context/src/main/java/org/springframework/resilience/retry/MethodRetrySpec.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.aop.retry; +package org.springframework.resilience.retry; import java.time.Duration; import java.util.Collection; diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/SimpleRetryInterceptor.java b/spring-context/src/main/java/org/springframework/resilience/retry/SimpleRetryInterceptor.java similarity index 96% rename from spring-aop/src/main/java/org/springframework/aop/retry/SimpleRetryInterceptor.java rename to spring-context/src/main/java/org/springframework/resilience/retry/SimpleRetryInterceptor.java index 035064c1fdc..4a89a4c34f4 100644 --- a/spring-aop/src/main/java/org/springframework/aop/retry/SimpleRetryInterceptor.java +++ b/spring-context/src/main/java/org/springframework/resilience/retry/SimpleRetryInterceptor.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.aop.retry; +package org.springframework.resilience.retry; import java.lang.reflect.Method; diff --git a/spring-aop/src/main/java/org/springframework/aop/retry/package-info.java b/spring-context/src/main/java/org/springframework/resilience/retry/package-info.java similarity index 75% rename from spring-aop/src/main/java/org/springframework/aop/retry/package-info.java rename to spring-context/src/main/java/org/springframework/resilience/retry/package-info.java index 1cb4515d59f..0fa0a6293e6 100644 --- a/spring-aop/src/main/java/org/springframework/aop/retry/package-info.java +++ b/spring-context/src/main/java/org/springframework/resilience/retry/package-info.java @@ -2,6 +2,6 @@ * A retry interceptor arrangement based on {@code core.retry} and Reactor. */ @NullMarked -package org.springframework.aop.retry; +package org.springframework.resilience.retry; import org.jspecify.annotations.NullMarked; diff --git a/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java b/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java index 5cf0ac6c048..00bc33a3b62 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java +++ b/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java @@ -206,14 +206,14 @@ public @interface Scheduled { * {@link #fixedRate} or {@link #fixedDelay} task. *

The duration String can be in several formats: *

* @return the initial delay as a String value — for example a placeholder, * or a {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 java.time.Duration} compliant value @@ -227,7 +227,7 @@ public @interface Scheduled { * The {@link TimeUnit} to use for {@link #fixedDelay}, {@link #fixedDelayString}, * {@link #fixedRate}, {@link #fixedRateString}, {@link #initialDelay}, and * {@link #initialDelayString}. - *

Defaults to {@link TimeUnit#MILLISECONDS}. + *

The default is {@link TimeUnit#MILLISECONDS}. *

This attribute is ignored for {@linkplain #cron() cron expressions} * and for {@link java.time.Duration} values supplied via {@link #fixedDelayString}, * {@link #fixedRateString}, or {@link #initialDelayString}. diff --git a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java index b8b32fe4753..a6564ec8b85 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java +++ b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java @@ -552,21 +552,6 @@ public class ScheduledAnnotationBeanPostProcessor return null; } - private static Duration toDuration(long value, TimeUnit timeUnit) { - try { - return Duration.of(value, timeUnit.toChronoUnit()); - } - catch (Exception ex) { - throw new IllegalArgumentException( - "Unsupported unit " + timeUnit + " for value \"" + value + "\": " + ex.getMessage()); - } - } - - private static Duration toDuration(String value, TimeUnit timeUnit) { - DurationFormat.Unit unit = DurationFormat.Unit.fromChronoUnit(timeUnit.toChronoUnit()); - return DurationFormatterUtils.detectAndParse(value, unit); // interpreting as long as fallback already - } - /** * Return all currently scheduled tasks, from {@link Scheduled} methods * as well as from programmatic {@link SchedulingConfigurer} interaction. @@ -669,4 +654,14 @@ public class ScheduledAnnotationBeanPostProcessor } } + + private static Duration toDuration(long value, TimeUnit timeUnit) { + return Duration.of(value, timeUnit.toChronoUnit()); + } + + private static Duration toDuration(String value, TimeUnit timeUnit) { + DurationFormat.Unit unit = DurationFormat.Unit.fromChronoUnit(timeUnit.toChronoUnit()); + return DurationFormatterUtils.detectAndParse(value, unit); + } + } diff --git a/spring-context/src/test/java/org/springframework/resilience/ConcurrencyLimitTests.java b/spring-context/src/test/java/org/springframework/resilience/ConcurrencyLimitTests.java new file mode 100644 index 00000000000..3d43c684dce --- /dev/null +++ b/spring-context/src/test/java/org/springframework/resilience/ConcurrencyLimitTests.java @@ -0,0 +1,188 @@ +/* + * Copyright 2002-present 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.resilience; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import org.springframework.aop.framework.AopProxyUtils; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.interceptor.ConcurrencyThrottleInterceptor; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.resilience.annotation.ConcurrencyLimit; +import org.springframework.resilience.annotation.ConcurrencyLimitBeanPostProcessor; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Juergen Hoeller + * @since 7.0 + */ +public class ConcurrencyLimitTests { + + @Test + void withSimpleInterceptor() { + NonAnnotatedBean target = new NonAnnotatedBean(); + ProxyFactory pf = new ProxyFactory(); + pf.setTarget(target); + pf.addAdvice(new ConcurrencyThrottleInterceptor(2)); + NonAnnotatedBean proxy = (NonAnnotatedBean) pf.getProxy(); + + List> futures = new ArrayList<>(10); + for (int i = 0; i < 10; i++) { + futures.add(CompletableFuture.runAsync(proxy::concurrentOperation)); + } + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + assertThat(target.counter).hasValue(0); + } + + @Test + void withPostProcessorForMethod() { + DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); + bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedMethodBean.class)); + ConcurrencyLimitBeanPostProcessor bpp = new ConcurrencyLimitBeanPostProcessor(); + bpp.setBeanFactory(bf); + bf.addBeanPostProcessor(bpp); + AnnotatedMethodBean proxy = bf.getBean(AnnotatedMethodBean.class); + AnnotatedMethodBean target = (AnnotatedMethodBean) AopProxyUtils.getSingletonTarget(proxy); + + List> futures = new ArrayList<>(10); + for (int i = 0; i < 10; i++) { + futures.add(CompletableFuture.runAsync(proxy::concurrentOperation)); + } + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + assertThat(target.current).hasValue(0); + } + + @Test + void withPostProcessorForClass() { + DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); + bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedClassBean.class)); + ConcurrencyLimitBeanPostProcessor bpp = new ConcurrencyLimitBeanPostProcessor(); + bpp.setBeanFactory(bf); + bf.addBeanPostProcessor(bpp); + AnnotatedClassBean proxy = bf.getBean(AnnotatedClassBean.class); + AnnotatedClassBean target = (AnnotatedClassBean) AopProxyUtils.getSingletonTarget(proxy); + + List> futures = new ArrayList<>(30); + for (int i = 0; i < 10; i++) { + futures.add(CompletableFuture.runAsync(proxy::concurrentOperation)); + } + for (int i = 0; i < 10; i++) { + futures.add(CompletableFuture.runAsync(proxy::otherOperation)); + } + for (int i = 0; i < 10; i++) { + futures.add(CompletableFuture.runAsync(proxy::overrideOperation)); + } + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + assertThat(target.current).hasValue(0); + } + + + public static class NonAnnotatedBean { + + AtomicInteger counter = new AtomicInteger(); + + public void concurrentOperation() { + if (counter.incrementAndGet() > 2) { + throw new IllegalStateException(); + } + try { + Thread.sleep(100); + } + catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + counter.decrementAndGet(); + } + } + + + public static class AnnotatedMethodBean { + + AtomicInteger current = new AtomicInteger(); + + @ConcurrencyLimit(2) + public void concurrentOperation() { + if (current.incrementAndGet() > 2) { + throw new IllegalStateException(); + } + try { + Thread.sleep(100); + } + catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + current.decrementAndGet(); + } + } + + + @ConcurrencyLimit(2) + public static class AnnotatedClassBean { + + AtomicInteger current = new AtomicInteger(); + + AtomicInteger currentOverride = new AtomicInteger(); + + public void concurrentOperation() { + if (current.incrementAndGet() > 2) { + throw new IllegalStateException(); + } + try { + Thread.sleep(100); + } + catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + current.decrementAndGet(); + } + + public void otherOperation() { + if (current.incrementAndGet() > 2) { + throw new IllegalStateException(); + } + try { + Thread.sleep(100); + } + catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + current.decrementAndGet(); + } + + @ConcurrencyLimit(1) + public void overrideOperation() { + if (currentOverride.incrementAndGet() > 1) { + throw new IllegalStateException(); + } + try { + Thread.sleep(100); + } + catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + currentOverride.decrementAndGet(); + } + } + +} diff --git a/spring-aop/src/test/java/org/springframework/aop/retry/ReactiveRetryInterceptorTests.java b/spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java similarity index 87% rename from spring-aop/src/test/java/org/springframework/aop/retry/ReactiveRetryInterceptorTests.java rename to spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java index 08aa07d70f3..16680b7d602 100644 --- a/spring-aop/src/test/java/org/springframework/aop/retry/ReactiveRetryInterceptorTests.java +++ b/spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.aop.retry; +package org.springframework.resilience; import java.io.IOException; import java.lang.reflect.Method; @@ -28,11 +28,13 @@ 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 org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor; +import org.springframework.resilience.annotation.Retryable; +import org.springframework.resilience.retry.MethodRetryPredicate; +import org.springframework.resilience.retry.MethodRetrySpec; +import org.springframework.resilience.retry.SimpleRetryInterceptor; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; @@ -58,19 +60,6 @@ public class ReactiveRetryInterceptorTests { 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(); @@ -86,25 +75,6 @@ public class ReactiveRetryInterceptorTests { 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(); @@ -218,6 +188,7 @@ public class ReactiveRetryInterceptorTests { assertThat(target.counter.get()).isEqualTo(4); } + public static class NonAnnotatedBean { AtomicInteger counter = new AtomicInteger(); @@ -284,8 +255,11 @@ public class ReactiveRetryInterceptorTests { } } + // Bean classes for boundary testing + public static class MinimalRetryBean { + AtomicInteger counter = new AtomicInteger(); public Mono retryOperation() { @@ -296,7 +270,9 @@ public class ReactiveRetryInterceptorTests { } } + public static class ZeroDelayJitterBean { + AtomicInteger counter = new AtomicInteger(); public Mono retryOperation() { @@ -307,7 +283,9 @@ public class ReactiveRetryInterceptorTests { } } + public static class JitterGreaterThanDelayBean { + AtomicInteger counter = new AtomicInteger(); public Mono retryOperation() { @@ -318,7 +296,9 @@ public class ReactiveRetryInterceptorTests { } } + public static class FluxMultiValueBean { + AtomicInteger counter = new AtomicInteger(); public Flux retryOperation() { @@ -329,7 +309,9 @@ public class ReactiveRetryInterceptorTests { } } + public static class SuccessfulOperationBean { + AtomicInteger counter = new AtomicInteger(); public Mono retryOperation() { @@ -340,7 +322,9 @@ public class ReactiveRetryInterceptorTests { } } + public static class ImmediateFailureBean { + AtomicInteger counter = new AtomicInteger(); public Mono retryOperation() { diff --git a/spring-aop/src/test/java/org/springframework/aop/retry/RetryInterceptorTests.java b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java similarity index 54% rename from spring-aop/src/test/java/org/springframework/aop/retry/RetryInterceptorTests.java rename to spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java index 0bd79549d4e..525ecf497d0 100644 --- a/spring-aop/src/test/java/org/springframework/aop/retry/RetryInterceptorTests.java +++ b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java @@ -14,22 +14,31 @@ * limitations under the License. */ -package org.springframework.aop.retry; +package org.springframework.resilience; import java.io.IOException; import java.lang.reflect.Method; import java.nio.file.AccessDeniedException; import java.time.Duration; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; 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 org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.resilience.annotation.ConcurrencyLimit; +import org.springframework.resilience.annotation.EnableResilientMethods; +import org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor; +import org.springframework.resilience.annotation.Retryable; +import org.springframework.resilience.retry.MethodRetryPredicate; +import org.springframework.resilience.retry.MethodRetrySpec; +import org.springframework.resilience.retry.SimpleRetryInterceptor; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIOException; @@ -53,18 +62,6 @@ public class RetryInterceptorTests { 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(); @@ -80,12 +77,14 @@ public class RetryInterceptorTests { } @Test - void withAnnotationInterceptorForClass() { - AnnotatedClassBean target = new AnnotatedClassBean(); - ProxyFactory pf = new ProxyFactory(); - pf.setTarget(target); - pf.addAdvice(new RetryAnnotationInterceptor()); - AnnotatedClassBean proxy = (AnnotatedClassBean) pf.getProxy(); + 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); @@ -96,14 +95,21 @@ public class RetryInterceptorTests { } @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); + void withPostProcessorForClassWithStrings() { + Properties props = new Properties(); + props.setProperty("delay", "10"); + props.setProperty("jitter", "5"); + props.setProperty("multiplier", "2.0"); + props.setProperty("maxDelay", "40"); + props.setProperty("limitedAttempts", "1"); + + GenericApplicationContext ctx = new GenericApplicationContext(); + ctx.getEnvironment().getPropertySources().addFirst(new PropertiesPropertySource("props", props)); + ctx.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedClassBeanWithStrings.class)); + ctx.registerBeanDefinition("bpp", new RootBeanDefinition(RetryAnnotationBeanPostProcessor.class)); + ctx.refresh(); + AnnotatedClassBeanWithStrings proxy = ctx.getBean(AnnotatedClassBeanWithStrings.class); + AnnotatedClassBeanWithStrings target = (AnnotatedClassBeanWithStrings) AopProxyUtils.getSingletonTarget(proxy); assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3"); assertThat(target.counter).isEqualTo(3); @@ -113,6 +119,23 @@ public class RetryInterceptorTests { assertThat(target.counter).isEqualTo(6); } + @Test + void withEnableAnnotation() throws Exception { + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + ctx.registerBeanDefinition("bean", new RootBeanDefinition(DoubleAnnotatedBean.class)); + ctx.registerBeanDefinition("config", new RootBeanDefinition(EnablingConfig.class)); + ctx.refresh(); + DoubleAnnotatedBean proxy = ctx.getBean(DoubleAnnotatedBean.class); + DoubleAnnotatedBean target = (DoubleAnnotatedBean) AopProxyUtils.getSingletonTarget(proxy); + + Thread thread = new Thread(() -> assertThatIOException().isThrownBy(proxy::retryOperation)); + thread.start(); + assertThatIOException().isThrownBy(proxy::retryOperation); + thread.join(); + assertThat(target.counter).hasValue(6); + assertThat(target.threadChange).hasValue(2); + } + public static class NonAnnotatedBean { @@ -162,6 +185,32 @@ public class RetryInterceptorTests { } + @Retryable(delayString = "${delay}", jitterString = "${jitter}", + multiplierString = "${multiplier}", maxDelayString = "${maxDelay}", + includes = IOException.class, excludes = AccessDeniedException.class, + predicate = CustomPredicate.class) + public static class AnnotatedClassBeanWithStrings { + + 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, maxAttemptsString = "${limitedAttempts}", delayString = "10ms") + public void overrideOperation() throws IOException { + counter++; + throw new AccessDeniedException(Integer.toString(counter)); + } + } + + private static class CustomPredicate implements MethodRetryPredicate { @Override @@ -170,4 +219,36 @@ public class RetryInterceptorTests { } } + + public static class DoubleAnnotatedBean { + + AtomicInteger current = new AtomicInteger(); + + AtomicInteger counter = new AtomicInteger(); + + AtomicInteger threadChange = new AtomicInteger(); + + volatile String lastThreadName; + + @ConcurrencyLimit(1) + @Retryable(maxAttempts = 2, delay = 10) + public void retryOperation() throws IOException, InterruptedException { + if (current.incrementAndGet() > 1) { + throw new IllegalStateException(); + } + Thread.sleep(100); + current.decrementAndGet(); + if (!Thread.currentThread().getName().equals(lastThreadName)) { + lastThreadName = Thread.currentThread().getName(); + threadChange.incrementAndGet(); + } + throw new IOException(Integer.toString(counter.incrementAndGet())); + } + } + + + @EnableResilientMethods + public static class EnablingConfig { + } + }