Document combining @⁠Retryable with proxy-based features

Add a "Combining @⁠Retryable with Other Proxy-Based Features" section
to the resilience reference documentation, covering interaction with
@⁠Transactional, @⁠Cacheable, and @⁠Async, as well as advice order
customization between @⁠Retryable and @⁠Async via
@⁠EnableResilientMethods(order) and @⁠EnableAsync(order).

Also document the advice chain semantics in @⁠Retryable Javadoc, add
cross-reference TIP blocks in the @⁠Async, @⁠Cacheable, and
@⁠Transactional reference sections, add a @⁠Cacheable combination test
to RetryInterceptorTests, and add RetryableTransactionTests in
spring-tx for the @⁠Transactional combination.

See gh-35584
Closes gh-37005

Signed-off-by: jhan0121 <jhan0121@gmail.com>
This commit is contained in:
flinter
2026-09-18 16:37:25 +02:00
committed by GitHub
parent b49252ed66
commit fc10d200bf
7 changed files with 304 additions and 0 deletions
@@ -115,6 +115,141 @@ whereas the caller of the `@Retryable` method will only ever see the last except
====
[[resilience-annotations-retryable-combining]]
=== Combining `@Retryable` with Other Proxy-Based Features
Spring AOP applies interceptors in a specific order when multiple annotations such as
`@Retryable`, `@Transactional`, `@Cacheable`, and `@Async` are present on the same method.
The resulting advice chain determines how retries interact with each feature, and
understanding that chain is important for using `@Retryable` correctly in combination with
other annotations.
[[resilience-annotations-retryable-combining-transactional]]
==== With `@Transactional`
When `@Transactional` and `@Retryable` are used together, the advice chain is:
----
Retry (OUTER) → Transaction (INNER) → target method
----
Each retry attempt starts a fresh transaction. If the target method throws, the transaction
is rolled back and `@Retryable` decides whether to retry. On success, the transaction
commits. This is usually the desired behavior for transient failures such as database
deadlocks.
[source,java,indent=0,subs="verbatim,quotes"]
----
@Transactional
@Retryable(TransientDataAccessException.class)
public void updateRecord() {
// Each retry runs in its own transaction
}
----
[NOTE]
====
Because the retry interceptor is outside the transaction interceptor, the current
transaction has already been rolled back by the time the retry interceptor receives the
exception. The retry interceptor sees the same, unwrapped exception that the target
method threw.
====
[[resilience-annotations-retryable-combining-cacheable]]
==== With `@Cacheable`
When `@Cacheable` and `@Retryable` are used together, the advice chain is:
----
Retry (OUTER) → Cache (INNER) → target method
----
The cache interceptor runs on every attempt. If the cache is populated between attempts
(for example, by a concurrent request), subsequent retry attempts will return the cached
value without invoking the target method. On success, the cache is populated as normal.
[source,java,indent=0,subs="verbatim,quotes"]
----
@Cacheable("items")
@Retryable
public Item loadItem(String id) {
// Retry wraps the cache lookup; each attempt checks the cache first
}
----
[[resilience-annotations-retryable-combining-async]]
==== With `@Async`
When `@Async` and `@Retryable` are used together, the advice chain is:
----
Async (OUTER) → Retry (INNER) → target method
----
The method is submitted to the async executor once, and all retry attempts run on the
same async thread. The caller receives a `CompletableFuture` or `Future` that completes
when the last retry attempt finishes (either with a result or a final exception).
[source,java,indent=0,subs="verbatim,quotes"]
----
@Async
@Retryable
public CompletableFuture<String> fetchData() {
// Retries happen on the async thread, not the calling thread
}
----
[NOTE]
====
Because `@Async` is outermost, the calling thread is never blocked by retry delays.
All retry attempts, including any configured delay between them, happen on the async
executor thread.
====
[[resilience-annotations-retryable-combining-order]]
==== Adjusting Advice Order
The `@Async` ordering described above reflects the relative `order` of the
`RetryAnnotationBeanPostProcessor` (registered by `@EnableResilientMethods`) and the
`AsyncAnnotationBeanPostProcessor` (registered by `@EnableAsync`). Both are plain
`Ordered` bean post-processors, so you can change their relative ordering by setting the
`order` attribute on `@EnableResilientMethods` and/or `@EnableAsync`.
[source,java,indent=0,subs="verbatim,quotes"]
----
@Configuration
@EnableResilientMethods(order = Ordered.LOWEST_PRECEDENCE) // <1>
@EnableAsync(order = Ordered.LOWEST_PRECEDENCE - 1) // <2>
class AppConfig {
}
----
<1> Raises the retry post-processor's order so that it runs after the async
post-processor.
<2> Lowers the async post-processor's order so that it runs before the retry
post-processor. As a result, retry becomes the outermost advice and async the
innermost, reversing the default order.
[NOTE]
====
With the reversed order shown above, exceptions thrown during asynchronous execution are
not retried: the retry interceptor only sees the `Future` handle, which is returned
immediately, rather than the outcome of the asynchronous invocation. Such an arrangement
only retries synchronous submission failures (for example, a rejected task submission)
and is rarely desirable in practice.
====
[NOTE]
====
This technique does not apply to `@Transactional` or `@Cacheable`. Their advisors are
registered through Spring's shared `InfrastructureAdvisorAutoProxyCreator`, whose own
post-processor `order` is fixed at `Ordered.HIGHEST_PRECEDENCE` and is unaffected by the
`order` attribute on `@EnableTransactionManagement` or `@EnableCaching` (that attribute
only affects ordering relative to other advisors on the same proxy). As a result, retry
advice is always applied outside `@Transactional` and `@Cacheable`, regardless of the
`order` configured on `@EnableResilientMethods`.
====
[[resilience-annotations-concurrencylimit]]
== `@ConcurrencyLimit`
@@ -167,6 +167,11 @@ Reactive Streams cancellation signals. See the
xref:data-access/transaction/programmatic.adoc#tx-prog-operator-cancel[Cancel Signals]
section under "Using the TransactionalOperator" for more details.
TIP: When `@Transactional` is combined with `@Retryable`, the retry advice is applied
outermost, so each retry attempt runs in its own transaction. See
xref:core/resilience.adoc#resilience-annotations-retryable-combining-transactional[Combining `@Retryable` with `@Transactional`]
for details.
[[transaction-declarative-annotations-method-visibility]]
.Method visibility and `@Transactional` in proxy mode
[NOTE]
@@ -44,6 +44,11 @@ The following example uses `@Cacheable` on the `findBook` method with multiple c
public Book findBook(ISBN isbn) {...}
----
TIP: When `@Cacheable` is combined with `@Retryable`, the retry advice is applied
outermost, so each retry attempt checks the cache before invoking the method. See
xref:core/resilience.adoc#resilience-annotations-retryable-combining-cacheable[Combining `@Retryable` with `@Cacheable`]
for details.
[[cache-annotations-cacheable-default-key]]
=== Default Key Generation
@@ -573,6 +573,11 @@ for asynchronous execution in the first place, not externally re-declared to be
However, you can manually set up Spring's `AsyncExecutionInterceptor` with Spring AOP,
in combination with a custom pointcut.
TIP: When `@Async` is combined with `@Retryable`, the async advice is applied outermost, so
all retry attempts run on the async executor thread. See
xref:core/resilience.adoc#resilience-annotations-retryable-combining-async[Combining `@Retryable` with `@Async`]
for details.
[[scheduling-annotation-support-qualification]]
=== Executor Qualification with `@Async`
@@ -42,6 +42,16 @@ import org.springframework.resilience.retry.MethodRetryPredicate;
* <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.
*
* <p>When combined with other proxy-based annotations such as
* {@code @Transactional}, {@code @Cacheable}, or {@code @Async}, the order of the
* resulting advice chain affects retry semantics. By default, {@code @Retryable} advice
* is applied outside {@code @Transactional} and {@code @Cacheable} (each retry attempt
* starts a fresh transaction or checks the cache), but inside {@code @Async} (all retry
* attempts run on the async executor thread). The order relative to {@code @Async} advice
* can be customized via {@link EnableResilientMethods#order()} and
* {@link org.springframework.scheduling.annotation.EnableAsync#order()}, whereas the
* order relative to {@code @Transactional} and {@code @Cacheable} advice is fixed.
*
* @author Juergen Hoeller
* @author Sam Brannen
* @since 7.0
@@ -28,6 +28,7 @@ import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicInteger;
import org.aopalliance.intercept.MethodInterceptor;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -39,6 +40,12 @@ import org.springframework.aop.interceptor.SimpleTraceInterceptor;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.cache.interceptor.SimpleKey;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.core.env.PropertiesPropertySource;
@@ -61,6 +68,7 @@ import static org.assertj.core.api.Assertions.assertThatRuntimeException;
/**
* @author Juergen Hoeller
* @author Sam Brannen
* @author Juhwan Lee
* @since 7.0
*/
class RetryInterceptorTests {
@@ -315,6 +323,25 @@ class RetryInterceptorTests {
assertThat(target.counter).hasValue(3);
}
@Test
void withCacheableAnnotation() {
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.registerBeanDefinition("bean", new RootBeanDefinition(CacheableAnnotatedBean.class));
ctx.registerBeanDefinition("cacheManager", new RootBeanDefinition(ConcurrentMapCacheManager.class));
ctx.registerBeanDefinition("config", new RootBeanDefinition(EnablingConfigWithCaching.class));
ctx.refresh();
CacheableAnnotatedBean proxy = ctx.getBean(CacheableAnnotatedBean.class);
CacheableAnnotatedBean target = (CacheableAnnotatedBean) AopProxyUtils.getSingletonTarget(proxy);
target.cache = ctx.getBean(CacheManager.class).getCache("tests");
// Simulates the cache being concurrently populated between retry attempts.
String result = proxy.retryOperation();
assertThat(result).isEqualTo("concurrently cached value");
// Only invoked once: the 2nd attempt found the cached value instead of
// invoking the target again, proving that retry wraps the cache check.
assertThat(target.counter).hasValue(1);
}
@Test
void withMethodRetryEventListener() throws Exception {
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
@@ -639,6 +666,24 @@ class RetryInterceptorTests {
}
static class CacheableAnnotatedBean {
AtomicInteger counter = new AtomicInteger();
@Nullable Cache cache;
@Cacheable("tests")
@Retryable(maxRetries = 2, delay = 10)
public String retryOperation() {
if (counter.incrementAndGet() == 1) {
this.cache.put(SimpleKey.EMPTY, "concurrently cached value");
throw new IllegalStateException();
}
return "result";
}
}
@EnableResilientMethods
static class EnablingConfig {
}
@@ -649,4 +694,10 @@ class RetryInterceptorTests {
static class EnablingConfigWithAsync {
}
@EnableCaching
@EnableResilientMethods
static class EnablingConfigWithCaching {
}
}
@@ -0,0 +1,93 @@
/*
* 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.transaction.annotation;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.testfixture.CallCountingTransactionManager;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for combining {@link Retryable @Retryable} with
* {@link Transactional @Transactional}.
*
* @author Juhwan Lee
* @since 7.1
*/
class RetryableTransactionTests {
@Test
void withTransactionalAnnotation() {
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(Config.class);
TransactionalAnnotatedBean proxy = ctx.getBean(TransactionalAnnotatedBean.class);
TransactionalAnnotatedBean target = (TransactionalAnnotatedBean) AopProxyUtils.getSingletonTarget(proxy);
CallCountingTransactionManager txManager = ctx.getBean(CallCountingTransactionManager.class);
// 2 = 1 initial invocation + 1 retry attempt
String result = proxy.retryOperation();
assertThat(result).isEqualTo("result");
assertThat(target.counter).hasValue(2);
// 1 rollback for the initial failure, 1 commit for the successful retry
assertThat(txManager.rollbacks).isEqualTo(1);
assertThat(txManager.commits).isEqualTo(1);
ctx.close();
}
static class TransactionalAnnotatedBean {
AtomicInteger counter = new AtomicInteger();
@Transactional
@Retryable(maxRetries = 2, delay = 10)
public String retryOperation() {
if (counter.incrementAndGet() < 2) {
throw new IllegalStateException();
}
return "result";
}
}
@Configuration
@EnableTransactionManagement
@EnableResilientMethods
static class Config {
@Bean
TransactionalAnnotatedBean transactionalAnnotatedBean() {
return new TransactionalAnnotatedBean();
}
@Bean
PlatformTransactionManager transactionManager() {
return new CallCountingTransactionManager();
}
}
}