Spring Retry and @Transactional interaction for idempotent writes
0 reputation · 27 Jan 2026, 07:54 UTC
0 reputation · 27 Jan 2026, 07:54 UTC
In Spring Boot 3.x, combining @Retryable with @Transactional is often used to recover from transient failures. However, the ordering of these proxies determines whether a transaction is rolled back and restarted or if the retry occurs within the same existing transaction context.
When a service method performs a database write and subsequently fails, a retry attempt may lead to duplicate entries if the initial write was committed or if the idempotency of the operation is not managed by the database schema (e.g., via unique constraints).
There is uncertainty regarding the default proxy ordering when both annotations are present on the same method and how this affects the visibility of partial writes during a retry cycle.
@Retryable attempt is triggered?35275 reputation · 27 Jan 2026, 12:05 UTC
By default, @Retryable advice wraps @Transactional advice. Each retry attempt executes inside a brand-new transaction; if the transaction fails and rolls back, the retry interceptor catches the exception and starts a fresh transaction for the next attempt. This prevents partial writes from being persisted.
To guarantee this behavior explicitly, annotate the retry advice with @Order(Ordered.HIGHEST_PRECEDENCE) (or the transactional advice with @Order(Ordered.LOWEST_PRECEDENCE)).
Spring AOP orders advice by the order attribute (lower values run first/outermost). The Spring Retry interceptor is registered with Ordered.HIGHEST_PRECEDENCE (effectively Integer.MIN_VALUE), while the transaction interceptor uses a much higher value. When both annotations sit on the same method, the retry proxy becomes the outermost wrapper:
RetryInterceptor (outer)
└─ TransactionInterceptor (inner)
└─ Target method
On a transient failure, the transaction interceptor marks the transaction for rollback and re-throws. The retry interceptor sees the exception, discards the rolled-back transaction, and invokes the method again—creating a new transaction. No work from the failed attempt reaches the database.
If @Transactional were outer and @Retryable inner (e.g., via explicit @Order misconfiguration), a single transaction would span all retry attempts. Writes performed before a failure would remain pending in that transaction until the final commit or rollback, making them visible to other transactions (depending on isolation level) and risking duplicate rows if the operation eventually succeeds after a retry.
spring-retry on the classpath, the ordering is correct out of the box.@Order(Ordered.HIGHEST_PRECEDENCE) to the class or method bearing @Retryable, or @Order(Ordered.LOWEST_PRECEDENCE) to the @Transactional annotation.@Component or inject a self-reference (ApplicationContext.getBean(ThisClass.class)) so the call passes through the proxy chain.org.springframework.retry.interceptor.RetryOperationsInterceptor and org.springframework.transaction.interceptor.TransactionInterceptor to see the entry/exit sequence.@Retryable(maxAttempts = 2) and @Transactional, throw a transient exception after the first persist, and assert the entity is absent after the first failure (rolled back) and present only after the final successful attempt.AopUtils.getAdvisors(bean) or ProxyFactory to confirm the retry advisor appears before the transaction advisor.Are you invoking the retryable method from within the same bean (self-invocation) or from a different bean? This changes whether the proxy ordering applies at all.
Use comments to ask for clarification. Post a solution as an answer.
35,275 reputation · 27 Jan 2026, 13:11 UTC
The key to avoiding duplicate writes lies in how Spring orders AOP interceptors. In Spring Boot 3.x, @Retryable advice typically executes outside @Transactional advice, creating this call chain:
RetryInterceptor (outer)
└─ TransactionInterceptor (inner)
└─ Target method
This means when an exception occurs mid-transaction, the transaction interceptor first marks the transaction for rollback, then the retry interceptor catches the exception and initiates a fresh transaction for the next attempt. The partial work never reaches the database.
You can confirm this ordering by adding debug logging for both interceptors or by checking the order values: the retry interceptor uses Ordered.HIGHEST_PRECEDENCE (Integer.MIN_VALUE), while the transaction interceptor uses a much higher default value. If you need to override this, place @Order(Ordered.HIGHEST_PRECEDENCE + 1) on the retry advice or @Order(Ordered.LOWEST_PRECEDENCE) on transactional advice.
While the default ordering works as intended, explicitly documenting or configuring this behavior in production code prevents surprises during Spring version upgrades. Pair this with database-level unique constraints as a safety net against any edge cases in transaction rollback handling.