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
This commit is contained in:
Juergen Hoeller
2025-07-01 17:27:50 +02:00
parent 3ce7613195
commit c9078bfe14
23 changed files with 1114 additions and 407 deletions
@@ -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.
*
* <p>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.
*
* <p>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.
* <p>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;
}
@@ -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<Object, ConcurrencyThrottleCache> 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<Method, MethodInterceptor> methodInterceptors = new ConcurrentHashMap<>();
@Nullable MethodInterceptor classInterceptor;
}
}
@@ -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}.
*
* <p>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.
* <p>The default is {@code false}.
* <p>Note that setting this attribute to {@code true} will affect <em>all</em>
* 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 &mdash; for example, in tests.
*/
boolean proxyTargetClass() default false;
/**
* Indicate the order in which the {@link RetryAnnotationBeanPostProcessor}
* and {@link ConcurrencyLimitBeanPostProcessor} should be applied.
* <p>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;
}
@@ -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.<Integer>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;
}
}
@@ -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<MethodClassKey, MethodRetrySpec> retrySpecCache = new ConcurrentHashMap<>();
@Override
protected @Nullable MethodRetrySpec getRetrySpec(Method method, Class<?> targetClass) {
MethodClassKey cacheKey = new MethodClassKey(method, targetClass);
MethodRetrySpec retrySpec = this.retrySpecCache.get(cacheKey);
if (retrySpec != null) {
return retrySpec;
}
Retryable retryable = AnnotatedElementUtils.getMergedAnnotation(method, Retryable.class);
if (retryable == null) {
retryable = AnnotatedElementUtils.getMergedAnnotation(targetClass, Retryable.class);
if (retryable == null) {
return null;
}
}
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<? extends MethodRetryPredicate> 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);
}
}
}
@@ -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.
*
* <p>Aligned with {@link org.springframework.core.retry.RetryTemplate}
* as well as Reactor's retry support, either re-invoking an imperative
* target method or decorating a reactive result accordingly.
*
* <p>Inspired by the <a href="https://github.com/spring-projects/spring-retry">Spring Retry</a>
* 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<? extends Throwable>[] value() default {};
/**
* Applicable exception types to attempt a retry for. This attribute
* allows for the convenient specification of assignable exception types.
* <p>The default is empty, leading to a retry attempt for any exception.
* @see #excludes()
* @see #predicate()
*/
@AliasFor("value")
Class<? extends Throwable>[] includes() default {};
/**
* Non-applicable exception types to avoid a retry for. This attribute
* allows for the convenient specification of assignable exception types.
* <p>The default is empty, leading to a retry attempt for any exception.
* @see #includes()
* @see #predicate()
*/
Class<? extends Throwable>[] excludes() default {};
/**
* A predicate for filtering applicable exceptions for which
* an invocation can be retried.
* <p>The default is a retry attempt for any exception.
* <p>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<? extends MethodRetryPredicate> predicate() default MethodRetryPredicate.class;
/**
* The maximum number of retry attempts, in addition to the initial invocation.
* <p>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.
* <p>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.
* <p>The time unit is milliseconds by default but can be overridden via
* {@link #timeUnit}.
* <p>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.
* <p>The duration String can be in several formats:
* <ul>
* <li>a plain integer &mdash; which is interpreted to represent a duration in
* milliseconds by default unless overridden via {@link #timeUnit()} (prefer
* using {@link #delay()} in that case)</li>
* <li>any of the known {@link org.springframework.format.annotation.DurationFormat.Style
* DurationFormat.Style}: the {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 ISO8601}
* style or the {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE SIMPLE} style
* &mdash; using the {@link #timeUnit()} as fallback if the string doesn't contain an explicit unit</li>
* <li>one of the above, with Spring-style "${...}" placeholders as well as SpEL expressions</li>
* </ul>
* @return the initial delay as a String value &mdash; 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.
* <p>The time unit is milliseconds by default but can be overridden via
* {@link #timeUnit}.
* <p>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.
* <p>The duration String can be in several formats:
* <ul>
* <li>a plain integer &mdash; which is interpreted to represent a duration in
* milliseconds by default unless overridden via {@link #timeUnit()} (prefer
* using {@link #jitter()} in that case)</li>
* <li>any of the known {@link org.springframework.format.annotation.DurationFormat.Style
* DurationFormat.Style}: the {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 ISO8601}
* style or the {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE SIMPLE} style
* &mdash; using the {@link #timeUnit()} as fallback if the string doesn't contain an explicit unit</li>
* <li>one of the above, with Spring-style "${...}" placeholders as well as SpEL expressions</li>
* </ul>
* @return the initial delay as a String value &mdash; 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.
* <p>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.
* <p>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}.
* <p>The time unit is milliseconds by default but can be overridden via
* {@link #timeUnit}.
* <p>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.
* <p>The duration String can be in several formats:
* <ul>
* <li>a plain integer &mdash; which is interpreted to represent a duration in
* milliseconds by default unless overridden via {@link #timeUnit()} (prefer
* using {@link #maxDelay()} in that case)</li>
* <li>any of the known {@link org.springframework.format.annotation.DurationFormat.Style
* DurationFormat.Style}: the {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 ISO8601}
* style or the {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE SIMPLE} style
* &mdash; using the {@link #timeUnit()} as fallback if the string doesn't contain an explicit unit</li>
* <li>one of the above, with Spring-style "${...}" placeholders as well as SpEL expressions</li>
* </ul>
* @return the initial delay as a String value &mdash; 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}.
* <p>The default is {@link TimeUnit#MILLISECONDS}.
* <p>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;
}
@@ -0,0 +1,7 @@
/**
* Annotation-based retry and concurrency limit support.
*/
@NullMarked
package org.springframework.resilience.annotation;
import org.jspecify.annotations.NullMarked;
@@ -0,0 +1,154 @@
/*
* 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.retry;
import java.lang.reflect.Method;
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;
/**
* 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);
}
}
RetryPolicy retryPolicy = RetryPolicy.builder()
.includes(spec.includes())
.excludes(spec.excludes())
.predicate(spec.predicate().forMethod(method))
.maxAttempts(spec.maxAttempts())
.delay(spec.delay())
.jitter(spec.jitter())
.multiplier(spec.multiplier())
.maxDelay(spec.maxDelay())
.build();
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
try {
return retryTemplate.execute(new Retryable<>() {
@Override
public @Nullable Object execute() throws Throwable {
return invocation.proceed();
}
@Override
public String getName() {
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(), spec.delay())
.jitter(calculateJitterFactor(spec))
.multiplier(spec.multiplier())
.maxBackoff(spec.maxDelay())
.filter(spec.combinedPredicate().forMethod(method));
publisher = (adapter.isMultiValue() ? Flux.from(publisher).retryWhen(retry) :
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())));
}
}
}
@@ -0,0 +1,48 @@
/*
* 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.retry;
import java.lang.reflect.Method;
import java.util.function.Predicate;
/**
* Predicate for retrying a {@link Throwable} from a specific {@link Method}.
*
* @author Juergen Hoeller
* @since 7.0
* @see MethodRetrySpec#predicate()
*/
@FunctionalInterface
public interface MethodRetryPredicate {
/**
* Determine whether the given {@code Method} should be retried after
* throwing the given {@code Throwable}.
* @param method the method to potentially retry
* @param throwable the exception encountered
*/
boolean shouldRetry(Method method, Throwable throwable);
/**
* Build a {@code Predicate} for testing exceptions from a given method.
* @param method the method to build a predicate for
*/
default Predicate<Throwable> forMethod(Method method) {
return (t -> shouldRetry(method, t));
}
}
@@ -0,0 +1,89 @@
/*
* 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.retry;
import java.time.Duration;
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 exception types to attempt a retry for
* @param excludes non-applicable exception 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
* @param jitter a jitter value for the next retry attempt
* @param multiplier a multiplier for a delay for the next retry attempt
* @param maxDelay the maximum delay for any retry attempt
* @see AbstractRetryInterceptor#getRetrySpec
* @see SimpleRetryInterceptor#SimpleRetryInterceptor(MethodRetrySpec)
* @see org.springframework.aop.retry.annotation.Retryable
*/
public record MethodRetrySpec(
Collection<Class<? extends Throwable>> includes,
Collection<Class<? extends Throwable>> excludes,
MethodRetryPredicate predicate,
long maxAttempts,
Duration delay,
Duration jitter,
double multiplier,
Duration maxDelay) {
public MethodRetrySpec(MethodRetryPredicate predicate, long maxAttempts, Duration delay) {
this(predicate, maxAttempts, delay, Duration.ofMillis(0), 1.0, Duration.ofMillis(Long.MAX_VALUE));
}
public MethodRetrySpec(MethodRetryPredicate predicate, long maxAttempts, Duration delay,
Duration jitter, double multiplier, Duration maxDelay) {
this(Collections.emptyList(), Collections.emptyList(), predicate, maxAttempts, delay,
jitter, multiplier, maxDelay);
}
MethodRetryPredicate combinedPredicate() {
return (method, throwable) -> {
if (!this.excludes.isEmpty()) {
for (Class<? extends Throwable> exclude : this.excludes) {
if (exclude.isInstance(throwable)) {
return false;
}
}
}
if (!this.includes.isEmpty()) {
boolean included = false;
for (Class<? extends Throwable> include : this.includes) {
if (include.isInstance(throwable)) {
included = true;
break;
}
}
if (!included) {
return false;
}
}
return this.predicate.shouldRetry(method, throwable);
};
}
}
@@ -0,0 +1,45 @@
/*
* 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.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;
}
}
@@ -0,0 +1,7 @@
/**
* A retry interceptor arrangement based on {@code core.retry} and Reactor.
*/
@NullMarked
package org.springframework.resilience.retry;
import org.jspecify.annotations.NullMarked;
@@ -206,14 +206,14 @@ public @interface Scheduled {
* {@link #fixedRate} or {@link #fixedDelay} task.
* <p>The duration String can be in several formats:
* <ul>
* <li>a plain integer &mdash; which is interpreted to represent a duration in
* milliseconds by default unless overridden via {@link #timeUnit()} (prefer
* using {@link #fixedDelay()} in that case)</li>
* <li>any of the known {@link org.springframework.format.annotation.DurationFormat.Style
* DurationFormat.Style}: the {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 ISO8601}
* style or the {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE SIMPLE} style
* &mdash; using the {@link #timeUnit()} as fallback if the string doesn't contain an explicit unit</li>
* <li>one of the above, with Spring-style "${...}" placeholders as well as SpEL expressions</li>
* <li>a plain integer &mdash; which is interpreted to represent a duration in
* milliseconds by default unless overridden via {@link #timeUnit()} (prefer
* using {@link #fixedDelay()} in that case)</li>
* <li>any of the known {@link org.springframework.format.annotation.DurationFormat.Style
* DurationFormat.Style}: the {@link org.springframework.format.annotation.DurationFormat.Style#ISO8601 ISO8601}
* style or the {@link org.springframework.format.annotation.DurationFormat.Style#SIMPLE SIMPLE} style
* &mdash; using the {@link #timeUnit()} as fallback if the string doesn't contain an explicit unit</li>
* <li>one of the above, with Spring-style "${...}" placeholders as well as SpEL expressions</li>
* </ul>
* @return the initial delay as a String value &mdash; 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}.
* <p>Defaults to {@link TimeUnit#MILLISECONDS}.
* <p>The default is {@link TimeUnit#MILLISECONDS}.
* <p>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}.
@@ -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);
}
}