Skip to main content

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.
PartFor you ifCovers
1 — Beginneryou have never used the new annotationswhat ships where, switching it on, the smallest retry, the three ways it silently stays off
2 — Intermediateyou are replacing spring-retry or adding retries to a clientcounting invocations, back-off and jitter, what is and is not retried, @ConcurrencyLimit, retries and transactions
3 — Advancedyou run Resilience4j todaywhat 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

Where things live

PiecePackageJar
@Retryable, @ConcurrencyLimit, @EnableResilientMethodsorg.springframework.resilience.annotationspring-context
MethodRetryEvent, the interceptorsorg.springframework.resilience.retryspring-context
RetryTemplate, RetryPolicy, RetryListenerorg.springframework.core.retryspring-core
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.

Part 2 — What @Retryable actually does, counted

maxRetries = 3 is four calls

A method that always fails, on defaults:
    "invocations": 4,
    "started at (ms)": [
        0,
        1001,
        2002,
        3004
    ],
    "gaps (ms)": [
        1001,
        1001,
        1002
    ],
    "elapsed (ms)": 3008,
    "caller": "threw: com.ankurm.resilience.support.TransientException: attempt 4 failed",
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.

Back-off, and jitter that only adds

@Retryable(maxRetries = 5, delay = 100, multiplier = 2, maxDelay = 500)
    "gaps (ms)": [
        101,
        200,
        401,
        501,
        501
    ],
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

ScenarioInvocationsWhy
includes = IllegalStateException, throws IllegalArgumentException1not included
includes = IOException, throws UncheckedIOException(IOException)3causes are matched
returns a Mono that errors3retried by re-subscribing
returns CompletableFuture.failedFuture(...)1the 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:
ScenarioInvocationsElapsed
attempts of 300 ms, delay = 100, timeout = 100031105 ms — one attempt past the budget
one attempt of 1500 ms, timeout = 50011502 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:
SetupSucceededFailedElapsed
no limit10203 ms
@ConcurrencyLimit(2)BLOCK, the default101006 ms
policy = REJECT28 × InvocationRejectedException205 ms
Resilience4j @Bulkhead, max-wait-duration: 028 × BulkheadFullException231 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:
    "caller got": "success",
    "attempts": [
        "attempt 1: transaction active=true, connection holder @6aa73704",
        "attempt 2: transaction active=true, connection holder @1978b580",
        "attempt 3: transaction active=true, connection holder @7d5534c2"
    ],
    "rows in stock_movement": 1
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

NeedSpring Framework 7.0.9Resilience4j 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
Metricsnoneresilience4j.* meters, Actuator endpoints, health
Per-instance configurationplaceholders in the *String attributesresilience4j.<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.

The breaker, measured

resilience4j:
  circuitbreaker:
    instances:
      payments:
        sliding-window-size: 10
        minimum-number-of-calls: 5
        failure-rate-threshold: 50
        wait-duration-in-open-state: 2s
        permitted-number-of-calls-in-half-open-state: 2
        "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.
@CircuitBreaker(name = "combo")
@Retryable(maxRetries = 3, delay = 10)
public String chargeWithBoth() {
    return call();
}
Two calls against a failing downstream:
    "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.
CGLIB proxy of PaymentClient – advisors outermost first 0. Spring RetryAnnotationInterceptor inserted at index 0 1. ExposeInvocationInterceptor 2. Resilience4j Retry order 2147483642 3. Resilience4j CircuitBreaker order 2147483643 4-5. TimeLimiter, Bulkhead order 2147483645, 2147483646 → method body RetryAnnotationBeanPostProcessor calls setBeforeExistingAdvisors(true). When the bean is already a proxy – here because of the Resilience4j aspects, elsewhere because of @Transactional – its advisor goes in front of everything, whatever the order values say. Around a transaction that is what you want. Around a circuit breaker it retries the “circuit open” answer. @ConcurrencyLimit’s post-processor does the same, which is why it ended up outside the retry in Part 2.
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 inWhat
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.13Spring Framework 7Watch
@EnableRetry@EnableResilientMethods
@Retryable(maxAttempts = 3) (the default)@Retryable(maxRetries = 2)attempts vs retries
retryFor / noRetryForincludes / excludescauses are matched
@Backoff(delay, multiplier, maxDelay)delay, multiplier, maxDelay1000 ms default in both
@Backoff(random = true)jitterjitter only adds
@Recovernonecatch in the caller
stateful retrynone

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.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.