Spring Framework 7’s Built-in Resilience: @Retryable, @ConcurrencyLimit, and What’s Left for Resilience4j
Spring Framework 7 ships @Retryable and @ConcurrencyLimit in the core. Every behaviour counted invocation by invocation: a maxRetries that means one more call than you think, a timeout that does not time anything out, jitter that only adds, a CompletableFuture that is never retried, and what is still Resilience4j’s job.
For a decade, retrying a method in Spring meant adding a library: spring-retry, or Resilience4j. Spring Framework 7 put retry and concurrency limiting into the core — @Retryable, @ConcurrencyLimit, RetryTemplate — and spring-retry’s README now says the project has been superseded and archived. So the question every Spring Boot 4 team gets to answer is: what is left for Resilience4j, and what changes if you switch?
The announcement posts cover the happy path. This article counts. Every behaviour below was measured by recording each real invocation of the guarded method, and several of them are not what the annotation’s name suggests: a maxRetries that means one more call than you think, a timeout that does not time anything out, jitter that only ever adds, a CompletableFuture that is never retried, and a retry that wraps your circuit breaker from the outside whether you want it to or not.
Part
For you if
Covers
1 — Beginner
you have never used the new annotations
what ships where, switching it on, the smallest retry, the three ways it silently stays off
2 — Intermediate
you are replacing spring-retry or adding retries to a client
counting invocations, back-off and jitter, what is and is not retried, @ConcurrencyLimit, retries and transactions
3 — Advanced
you run Resilience4j today
what only Resilience4j does, both libraries on one method, the 7.0.x API changes, observability
Versions this was verified against. Spring Boot 4.1.1 (GA, published to Maven Central on 20 August 2026), Spring Framework 7.0.9, Resilience4j 2.4.0 with its resilience4j-spring-boot4 module (not managed by Boot; pin it), Eclipse Temurin JDK 25.0.4.1 LTS. The API history below compares the 7.0.0, 7.0.1, 7.0.2, 7.0.3 and 7.0.9 jars by reflection, and the spring-retry comparison uses 2.0.13, its last release.
Companion code: spring-boot-demo, directory resilience/. One demo endpoint per scenario, sixteen contract tests, and every transcript quoted below under docs/output/, regenerated by scripts/run-all.sh.
Part 1 — What Spring Framework 7 ships, and switching it on
No new dependency: if you have Spring Boot 4, you have it. Both libraries in this article define an annotation called Retryable, so the import line matters.
The smallest retry
@Configuration
@EnableResilientMethods
public class ResilienceConfig {
}
@Service
public class FlakyGateway {
@Retryable
public String defaults() {
return callTheFlakyThing();
}
}
The defaults, read from the annotation by reflection rather than from the documentation: any exception is retryable, maxRetries = 3, delay = 1000 ms, multiplier = 1.0, no jitter, no maxDelay, no timeout. A method that fails twice and then succeeds:
"invocations": 3,
"started at (ms)": [
27,
1036,
2037
],
Three calls, a second apart. That is all there is to the happy path.
The three ways it silently stays off
Spring Boot does not enable it. No Spring Boot 4.1.1 auto-configuration registers the post-processors behind these annotations; without @EnableResilientMethods the annotation is metadata. The same method, same failures, with the configuration class switched off:
"invocations": 1,
No warning at startup, no log line at call time.
Self-invocation bypasses the proxy, exactly as it does for @Transactional and @Async — one invocation, the first exception straight to the caller. The AOP article has the mechanics.
A final method cannot be overridden by the CGLIB subclass that implements the proxy, so it is called directly and never retried.
Put a test on the retry, not on the method. All three failures look identical from outside: one call, one exception, no noise. A contract test that makes the method fail once and asserts it was invoked twice catches all three — the companion tests do exactly that, including one that runs with the configuration switched off and asserts a single call.
maxRetries counts retries after the first call. Resilience4j’s max-attempts: 3 is three calls in total — the companion project measures three — and so was spring-retry’s maxAttempts = 3. The same number, one call apart. A mechanical migration from either library adds one call to every failing operation: a third more traffic, aimed at a downstream that is already failing.
The caller receives the last original exception, not a wrapper. That is the annotation path; RetryTemplate.execute(...) throws a RetryException carrying all of them, while RetryTemplate.invoke(...) unwraps like the annotation does.
Exactly as advertised: 800 was capped to 500. Jitter is less obvious. With delay = 200, jitter = 100 you would expect gaps between 100 and 300 ms. Six measured gaps:
"gaps (ms)": [
254,
211,
296,
294,
273,
293
],
None under 200. ExponentialBackOff, read with javap -c, computes the jitter range as [max(interval - j, initialInterval), min(interval + j, maxInterval)]. For the first delay — and for every delay when multiplier is 1 — the lower bound is clamped to delay itself, so jitter can only lengthen the wait. That still de-synchronises a thundering herd, which is the point; just do not size a latency budget on the assumption that jitter averages out to delay.
What is retried, and what is not
Scenario
Invocations
Why
includes = IllegalStateException, throws IllegalArgumentException
1
not included
includes = IOException, throws UncheckedIOException(IOException)
3
causes are matched
returns a Mono that errors
3
retried by re-subscribing
returns CompletableFuture.failedFuture(...)
1
the method returned normally
The cause matching is welcome — an UncheckedIOException wrapping an IOException is the usual shape of an I/O failure in a lambda. The last row is the one to remember. Reactive return types are retried by re-subscription, but a method that returns a CompletableFuture has, as far as the interceptor can see, succeeded: it returned an object. An async client method that returns a future is not protected by @Retryable at all, and nothing tells you.
timeout is a budget, not a timeout
@Retryable(timeout = ...) reads like a call timeout. It is not. Two measurements:
Scenario
Invocations
Elapsed
attempts of 300 ms, delay = 100, timeout = 1000
3
1105 ms — one attempt past the budget
one attempt of 1500 ms, timeout = 500
1
1502 ms — not interrupted
The budget is checked between attempts: once it is spent, no new attempt starts. A hanging call is never cut short. Put the real timeout on the client — the HTTP client’s read timeout, the JDBC query timeout — and read timeout as “stop retrying after about this long”.
@ConcurrencyLimit
Ten virtual threads calling a 200 ms method limited to two:
Setup
Succeeded
Failed
Elapsed
no limit
10
—
203 ms
@ConcurrencyLimit(2) — BLOCK, the default
10
—
1006 ms
policy = REJECT
2
8 × InvocationRejectedException
205 ms
Resilience4j @Bulkhead, max-wait-duration: 0
2
8 × BulkheadFullException
231 ms
BLOCK queues without a timeout — cheap in memory with virtual threads, and still an unbounded latency in front of a slow dependency. InvocationRejectedException extends RejectedExecutionException, so existing handlers for executor rejection catch it.
Put both annotations on one method and something subtle happens. @ConcurrencyLimit(1) with @Retryable(maxRetries = 1, delay = 300), every caller’s first attempt failing: two callers take 815 ms — about 2 × (50 + 300 + 50). The permit is held through the 300 ms back-off, because the limit ended up as the outer interceptor. Section 3 explains why the order is what it is; if you want the permit released between attempts, put the annotations on different beans.
Retries and transactions
@Retryable and @Transactional on the same method, which inserts a row and fails twice. Each attempt records the transaction it ran in:
Three transactions, one row: the retry is outside the transaction, so each failed attempt rolled back its own insert. That is the right order, and you get it without configuring anything. Now call the same method from a method that is itself @Transactional:
"caller got": "org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only",
"attempts": [
"attempt 1: transaction active=true, connection holder @6135f416",
"attempt 2: transaction active=true, connection holder @6135f416",
"attempt 3: transaction active=true, connection holder @6135f416"
],
"rows in stock_movement": 0
Every attempt joined the caller’s transaction. The first failure passed through the inner transaction interceptor, which marked the shared transaction rollback-only; the third attempt succeeded, the retry returned normally, and the outer commit threw. Three attempts, zero rows, an exception the caller never expected. A retry belongs at the outermost transactional boundary, or around a REQUIRES_NEW method. The @Transactional article covers rollback-only in detail.
Part 3 — What is left for Resilience4j
The inventory
Need
Spring Framework 7.0.9
Resilience4j 2.4.0
Retry with back-off and jitter
@Retryable, RetryTemplate
@Retry
Limit concurrent calls
@ConcurrencyLimit (BLOCK/REJECT)
@Bulkhead — semaphore or thread pool, with a wait timeout
Circuit breaker
—
@CircuitBreaker
Rate limiter
—
@RateLimiter
Time limit on a call
— (timeout is a retry budget)
@TimeLimiter, async return types only
Declarative fallback
— (a request for @Recover was declined)
fallbackMethod
Metrics
none
resilience4j.* meters, Actuator endpoints, health
Per-instance configuration
placeholders in the *String attributes
resilience4j.<module>.instances.<name>.*
Read it this way: Spring now covers retry and concurrency well enough that a service needing only those can drop a dependency. The moment you need to stop calling something that is down — a circuit breaker — or to know from a dashboard that you are retrying, Resilience4j is still the tool.
"5: TransientException: payment gateway 503 (call 5) [state after: OPEN]",
"6: CallNotPermittedException: CircuitBreaker 'payments' is OPEN and does not permit further calls [state after: OPEN]",
"7: CallNotPermittedException: CircuitBreaker 'payments' is OPEN and does not permit further calls [state after: OPEN]",
"8: CallNotPermittedException: CircuitBreaker 'payments' is OPEN and does not permit further calls [state after: OPEN]",
"-- 2.1 s later, downstream recovered --",
"9: returned charged [state after: HALF_OPEN]",
"10: returned charged [state after: CLOSED]",
Five failures reach the downstream, then calls fail in microseconds without touching it, and 2.1 s later two successful trial calls close it again.
Resilience4j’s own defaults will not open the breaker in a quiet service.CircuitBreakerConfig.ofDefaults() has slidingWindowSize = 100 and minimumNumberOfCalls = 100, with 60 s in the open state. Until a hundred calls have been recorded, the failure rate is not even evaluated. A service handling a few requests a minute can fail for half an hour with the breaker closed. Set minimum-number-of-calls for your traffic, not for the library’s.
Both libraries on one method
A tempting migration step: keep the Resilience4j circuit breaker and switch retries to Spring’s annotation.
"call 1": {
"caller got": "TransientException: payment gateway 503 (call 4)",
"method body ran": 4,
"elapsed (ms)": 35,
"breaker: buffered / failed / not permitted": "4 / 4 / 0",
"breaker state": "CLOSED"
},
"call 2": {
"caller got": "CallNotPermittedException: CircuitBreaker 'combo' is OPEN and does not permit further calls",
"method body ran": 1,
"elapsed (ms)": 33,
"breaker: buffered / failed / not permitted": "5 / 5 / 3",
"breaker state": "OPEN"
},
The breaker counted every retry attempt as a separate call, and once it opened, the retry spent its remaining three attempts on CallNotPermittedException. With the default one-second delay that is three seconds of a caller waiting to be told the circuit is open.
The order is not an accident of this project. Both of Spring’s post-processors call setBeforeExistingAdvisors(true) in their constructors (javap -c on spring-context 7.0.9), so on a bean that some other auto-proxy has already wrapped, Spring’s interceptor goes to index 0. The order attribute of @EnableResilientMethods is the post-processor’s own order, not the advice’s position, and changing it does not move the retry. If you must combine them, add excludes = CallNotPermittedException.class to the @Retryable — or keep Resilience4j’s @Retry, whose aspect order puts it outside the breaker deliberately.
The API moved during 7.0.x
Reflection over five spring-context jars:
Added in
What
7.0.0 (13 Nov 2025)
@Retryable without timeout, @ConcurrencyLimit without policy, RetryTemplate, RetryPolicy
7.0.2 (11 Dec 2025)
@Retryable.timeout, RetryPolicy.Builder.timeout(..), RetryListener.onRetryPolicyTimeout and onRetryableExecution
7.0.3 (15 Jan 2026)
@ConcurrencyLimit.policy with ThrottlePolicy.BLOCK / REJECT
At the time of writing, the 7.0.9 reference page on resilience mentions neither timeout nor policy. Articles written at the 7.0 GA describe a smaller API than the one you have — and code written against 7.0.3+ does not compile on a Boot version that manages an older Framework.
Observability: events are not a retry counter
Spring’s retry registers no metrics. /actuator/metrics on the companion app lists a dozen resilience4j.* meters and, for Spring, only the app.retry.failures counter the project creates itself from MethodRetryEvent. And the events need care. For an exception that is not even retryable:
"MethodRetryEvents": [
"onlyIllegalState failed with IllegalArgumentException -> will retry",
"onlyIllegalState failed with RetryException -> retry aborted"
]
An event fires for every failure with isRetryAborted() == false — including the last one, and including a failure that will never be retried — and then once more with a RetryException when the policy gives up. A counter of “will retry” events counts failures, not retries.
Migrating from spring-retry
spring-retry 2.0.13
Spring Framework 7
Watch
@EnableRetry
@EnableResilientMethods
@Retryable(maxAttempts = 3) (the default)
@Retryable(maxRetries = 2)
attempts vs retries
retryFor / noRetryFor
includes / excludes
causes are matched
@Backoff(delay, multiplier, maxDelay)
delay, multiplier, maxDelay
1000 ms default in both
@Backoff(random = true)
jitter
jitter only adds
@Recover
none
catch in the caller
stateful retry
none
The long tail
Every @Retryable default, read by reflection, and the 7.0.x API history per release: chapter 1
Resilience4j’s @TimeLimiter on a 2 s future with a 500 ms limit, and why nothing time-limits a blocking call in either library: chapter 6
RetryTemplate.execute versus invoke, and the exception each throws: chapter 3
The full migration table from spring-retry: chapter 7
Should you drop Resilience4j? If all you use is @Retry and a bulkhead, yes — Spring’s annotations do both, with one fewer dependency and one fewer aspect in every stack trace. Recount your maxAttempts as maxRetries on the way, and add a MethodRetryEvent listener if anything alerts on retries. If you use a circuit breaker, a rate limiter, a fallback method or the Resilience4j dashboards, keep it — and do not mix Spring’s @Retryable with its @CircuitBreaker on the same method.
And if you are still on spring-retry, move: it is archived.
No Comments yet!