Spring Framework Native Resilience
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Spring Framework 7 put a small set of resilience features into the core framework: the annotations @Retryable and
@ConcurrencyLimit for method invocations, the switch that turns them on, @EnableResilientMethods, and a programmatic
RetryTemplate with a RetryPolicy and a RetryListener. They replace the retry half of the Spring Retry project,
which is now archived. They are deliberately minimal: there is no circuit breaker, no rate limiter, no per-call time
limiter, no metrics and no Actuator endpoint. This page covers what they do, every attribute and its default, how they
behave next to Resilience4j, how to migrate from Spring Retry, and how to choose between the three options a Spring Boot
4 service has today. It was written against Resilience4j 2.4.0, Spring Boot 4.1.x (4.1.1), Spring Framework 7.0.x
(7.0.9) and Java 21, and every example on it was compiled and run on exactly those versions on 4 October 2026.
What changes on Spring Boot 3.5.x. Nothing on this page works there. Spring Boot 3.5.16 manages Spring Framework
version 6.2.19, and in that version the packages org.springframework.resilience and org.springframework.core.retry do not
exist (verified by loading the classes from the 6.2.19 jars: Retryable, ConcurrencyLimit, EnableResilientMethods,
RetryTemplate and RetryPolicy are all absent). On a Boot 3.5 service the choices for retry are Resilience4j or the
archived Spring Retry, which the Boot 3.5.16 dependency management still pins to 2.0.13; Spring Boot 4.1.1 no longer
manages Spring Retry at all. The remainder of the page is about Boot 4.
The vocabulary (idempotency, retry storm, bulkhead) is introduced in Resilience Fundamentals. The Resilience4j equivalents are covered in Retry and Bulkhead, and the way the two worlds share one method is in Annotations & Aspect Order.
What Spring Framework 7 added
All of it is in spring-core and spring-context, so there is no extra dependency and no AspectJ. The types, their
packages and the version that introduced them:
| Type | Package | Since | What it is |
|---|---|---|---|
|
|
7.0 |
Declarative retry for a method, or for every proxied method of a class. |
|
|
7.0 ( |
Declarative cap on concurrent invocations of a method, or of a class. |
|
|
7.0 |
Registers the post-processors that honour the two annotations above. |
|
|
7.0.3 |
Application event published for every exception met while a |
|
|
7.0 |
Per-method predicate used by |
|
|
7.0.3 |
Thrown by |
|
|
7.0 |
The programmatic API behind the annotation; usable on any block of code. |
|
|
7.0 |
A listener that fans out to several listeners, and a wrapper that turns a |
Source: Spring Framework reference: Resilience
Features and the
org.springframework.resilience
package at v7.0.9. The @since tags were read from the 7.0.9 sources. The Javadoc of @Retryable describes the
annotation as inspired by Spring Retry but redesigned as a minimal core retry feature.
Enabling it: @EnableResilientMethods
Like most annotation-driven features of Spring, the two annotations are metadata that something has to honour. The
switch is @EnableResilientMethods on a @Configuration class. It imports one configuration class that registers two
infrastructure beans, a RetryAnnotationBeanPostProcessor and a ConcurrencyLimitBeanPostProcessor, each of which wraps
matching beans in a Spring AOP proxy. Either processor can also be declared alone if only one feature is wanted.
@SpringBootApplication
@EnableResilientMethods // proxyTargetClass = false, order = LOWEST_PRECEDENCE - 1 by default
public class BookshelfApplication {
public static void main(String[] args) {
SpringApplication.run(BookshelfApplication.class, args);
}
}
Source: @EnableResilientMethods
Javadoc. Run on Spring Boot 4.1.1.
The annotation has two attributes:
| Attribute | Default | Meaning |
|---|---|---|
|
|
|
|
|
The order of the two post-processors among the other bean post-processors, chosen so that they run after every common post-processor except |
Three facts about the setup were verified by running it and are easy to miss:
-
Spring Boot 4.1.1 does not enable it for you. A bean with
@Retryablein a Boot application that has no@EnableResilientMethodsis not proxied and the method ran exactly once. There is no error and no warning. -
The proxy type depends on the context. In a plain Spring context a bean that implements an interface gets a JDK proxy, and
@Retryableon the method of the implementation class was ignored (one invocation); the same annotation on the interface method worked (two invocations), andproxyTargetClass = trueproduced a CGLIB proxy. In a Spring Boot application the auto-configured proxy creator forces class-based proxies, so the annotation on the implementation class works. The behaviour is the one described in the AOP chapter; the Resilience4j annotations have the same interface caveat, covered in Annotations & Aspect Order. -
Only calls through the proxy count. A call from another method of the same class (
this.lookup(…)) is not retried, and afinalmethod cannot be intercepted by a CGLIB proxy. A package-private method of a CGLIB-proxied bean was retried.
@Retryable
@Retryable can be placed on a method, or on a type, where it applies to every proxy-invoked method of the class
hierarchy. With no attributes at all it means: retry any exception, at most three times after the initial failure,
waiting one second between attempts, then rethrow the last original exception.
Every attribute
The defaults below were read from the 7.0.9 sources of org.springframework.resilience.annotation.Retryable and
confirmed by running the method with each one set.
| Attribute | Default | Meaning |
|---|---|---|
|
empty |
The convenient single-attribute form: |
|
empty (any exception) |
Exception types to retry. Each is matched against the thrown exception and its causes: with |
|
empty |
Exception types never to retry, matched the same way. Combined with |
|
|
A |
|
|
The number of retries, so total invocations are 1 + |
|
empty |
A non-empty value overrides |
|
|
The base wait before the first retry, in |
|
empty |
Overrides |
|
|
An absolute duration (not a factor) added to the delay to spread clients apart. See below for the range. |
|
empty |
Overrides |
|
|
Applied to the previous delay, and to the jitter, for every further retry. |
|
empty |
Overrides |
|
|
The ceiling for any single wait, limiting how far |
|
empty |
Overrides |
|
|
Since 7.0.2. The maximum elapsed time for the initial invocation and all retries including the waits, in |
|
empty |
Since 7.0.2. Overrides |
|
|
The unit of |
Source: @Retryable
Javadoc and the RetryAnnotationBeanPostProcessor
source at v7.0.9, which resolves the String variants through the embedded value resolver.
A run of every attribute on a method that always throws IllegalStateException, on Spring Framework 7.0.9:
| Annotation | Observed |
|---|---|
|
4 invocations, gaps of about 1007, 1003 and 1006 ms, the original |
|
4 invocations, gaps of 12, 13 and 11 ms |
|
6 invocations, gaps of 23, 42, 85, 105 and 103 ms (20, 40, 80, then capped at 100) |
|
5 invocations; over 2000 samples of the back-off each wait fell in 100 to 149 ms |
|
over 2000 samples the waits were 200 to 249 ms, then 300 to 499, then 600 to 999, then 1200 to 1999 |
|
1 invocation, rethrown at once |
|
1 invocation |
|
3 invocations, then the original exception: the third sleep (50 ms) would have crossed 120 ms |
|
3 invocations, gaps of 16 and 17 ms |
Source: the output of the runs described above, on Spring Framework 7.0.9. The jitter ranges were sampled from
RetryPolicy.getBackOff().start(): with a jitter the wait lands between the delay and the delay plus the jitter, and the
jitter itself is multiplied along with the delay, so it is not symmetric around the delay in practice even though the
Javadoc describes "between delay minus jitter and delay plus jitter but never below the base delay".
Two traps are specific to the attributes:
-
Changing
timeUnitneeds an explicitmaxDelay.@Retryable(delay = 1, timeUnit = TimeUnit.SECONDS, maxRetries = 1)threwArithmeticException: long overflowbefore the method was invoked once (0 invocations), because the defaultmaxDelayofLong.MAX_VALUEis converted from seconds to milliseconds. AddingmaxDelay = 10made the same annotation work (2 invocations, a gap of 1006 ms). With the default unit there is no problem. -
The default retries every exception. That includes validation and business exceptions that will never succeed. Narrow it with
includes,excludesor a predicate, and keep the call idempotent.
A predicate for HTTP status codes
For the Bookshelf, the third-party ISBN-metadata API answers 503 and 429 now and then and 404 when an ISBN is
unknown. A MethodRetryPredicate expresses exactly that, in one place:
public class TransientFailure implements MethodRetryPredicate {
@Override
public boolean shouldRetry(Method method, Throwable throwable) {
return throwable instanceof ResourceAccessException // I/O failure, timeout
|| throwable instanceof HttpServerErrorException // any 5xx
|| throwable instanceof HttpClientErrorException.TooManyRequests; // 429
}
}
Source: MethodRetryPredicate
Javadoc. Run against WireMock: a 404 was not retried (1 request), a 429 that never cleared was retried (4 requests).
What the caller sees: the original exception
When the attempts run out the annotation rethrows the last original exception from the target method, not a wrapper.
Internally it runs a RetryTemplate, which throws a RetryException, and the interceptor unwraps the cause. That is
the same contract as Resilience4j’s Retry, and it means existing catch blocks keep working. The one asymmetry is the
reactive form, covered next.
Reactive return types
@Retryable also works on a method that returns a reactive type: the interceptor does not call the method again, it
decorates the returned publisher with Reactor’s retry, using Retry.backoff(maxRetries, delay), a jitter factor derived
from jitter divided by delay (clamped to the range 0 to 1), the multiplier and maxDelay as the maximum backoff, and a
timeout operator when a timeout is set. This needs Reactor on the classpath; a CompletableFuture is not treated as
reactive.
@Retryable(maxRetries = 2, delay = 10)
public Mono<String> title(String isbn) {
return Mono.defer(() -> webClient.get().uri("/isbn/{isbn}", isbn).retrieve().bodyToMono(String.class));
}
Source: the AbstractRetryInterceptor
source at v7.0.9 and the reference’s reactive example. Behaviour measured on 7.0.9:
| Case | Observed |
|---|---|
|
Returned the value, 3 subscriptions, 2 |
|
3 invocations, then |
|
|
|
Not retried; the original exception propagated |
|
3 invocations, then a |
A method that throws before it returns the |
Not retried (1 invocation) and no event: the retry only decorates the publisher, so an exception thrown while the method itself assembles it is outside the decorated pipeline |
|
Not retried (1 invocation, no event): |
Source: the output of the runs described above, on Spring Framework 7.0.9 with Reactor 3.8.x. For the Resilience4j approach to the same problem, with the reactive operators, see Reactive, Kotlin & RxJava.
MethodRetryEvent
While a @Retryable method runs, Spring publishes a MethodRetryEvent (package org.springframework.resilience.retry,
since 7.0.3, a subclass of org.springframework.context.event.MethodFailureEvent) on the application context. It is the
only built-in observability the feature has. It exposes getMethod(), getFailure() and isRetryAborted().
-
Every failed invocation publishes an event with
retryAborted = falseand the exception the method threw. -
When the retrying ends without success (exhaustion, timeout or interruption), one more event is published with
retryAborted = true. Its failure is the final exception of the retry infrastructure: aRetryExceptionfor an imperative method, aRetryExhaustedExceptionorTimeoutExceptionfor a reactive one. -
A successful retry publishes no
retryAbortedevent: a method that failed twice and then succeeded produced 2 events, both withretryAborted = false. -
A call that fails with an excluded exception on the first invocation still publishes two events: one with
retryAborted = falsecarrying the exception, and one withretryAborted = truecarrying theRetryException(verified for an imperative and for a reactive method). The number of events is therefore not the number of retries.
A default call that failed all four times produced five events: four with false and one with true carrying a
RetryException. The listener below turns them into a Micrometer counter, which is how the Bookshelf makes up for the
fact that the annotation registers no meters of its own.
@Component
public class RetryAudit {
private final MeterRegistry registry;
RetryAudit(MeterRegistry registry) {
this.registry = registry;
}
@EventListener
void onRetry(MethodRetryEvent event) {
registry.counter("bookshelf.retry.failures",
"method", event.getMethod().getName(),
"aborted", Boolean.toString(event.isRetryAborted())).increment();
}
}
Source: MethodRetryEvent
Javadoc. Run: after a lookup that failed with 503 twice and then succeeded the counter with aborted=false had grown
by 2; after a lookup that failed four times the counter with aborted=true had grown by 1. The event is an ordinary
Spring application event, so it can feed logs, metrics or tracing; the sibling page
Observability covers the Resilience4j meters and Actuator
endpoints that this feature does not have.
@ConcurrencyLimit
@ConcurrencyLimit caps how many threads may be inside a method at the same time. Its Javadoc motivates it with virtual
threads, where there is no thread pool to act as a natural limit; for synchronous calls it applies Spring’s long-standing
ConcurrencyThrottleInterceptor. The comparison with the Resilience4j semaphore bulkhead is in
Bulkhead. For a real-world use, the Spring AI production page caps
concurrent model calls with @ConcurrencyLimit next to a Resilience4j RateLimiter, in
Spring AI: rate limiting and concurrency.
The attributes are these:
| Attribute | Default | Meaning |
|---|---|---|
|
|
The convenient form: |
|
|
|
|
empty |
Overrides |
|
|
Since 7.0.3. |
@Component
class Limited {
@ConcurrencyLimit(2) // BLOCK is the default policy
String blocking() { return slowCall(); }
@ConcurrencyLimit(limit = 2, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
String rejecting() { return slowCall(); }
}
Source: the Javadoc linked above. Run with virtual threads and a 200 ms call:
-
blocking()called six times at once: all six returneddone, the peak concurrency was 2 and the whole batch took 618 ms, about three waves of 200 ms. -
rejecting()called four times at once: two returneddoneand two threwInvocationRejectedExceptionwith the messageConcurrency limit reached: 2 - not allowed to enter …Limited.rejecting. The rejection took 0 ms: a rejected caller does not wait. The exception is anInvocationRejectedExceptionand, through it, ajava.util.concurrent.RejectedExecutionException;getTarget()returns the bean. -
limitString = "${lim}"withlim=3gave a peak of 3;-1gave a peak of 6 for six callers;1gave a peak of 1.
Where the throttle lives:
-
On a method, the throttle belongs to that method of that bean instance. On a class, all proxied methods of the instance share one throttle: with
@ConcurrencyLimit(limit = 2, policy = REJECT)on the class, two concurrent callers ofa()andb()together filled it and the other two were rejected. -
There are no events and no meters, and no Actuator endpoint. The interceptor of 7.0.9 publishes nothing.
-
It limits the synchronous call. On a method that returns a
Monothe throttle is released as soon as the method returns theMono, so it limits nothing about the subscriptions: withlimit = 1andREJECT, four concurrent subscriptions all succeeded and the peak was 4. Use a Resilience4j bulkhead operator or aflatMapconcurrency argument for reactive code.
Turning a rejection into an HTTP answer is one @ExceptionHandler. The Bookshelf rejects the fifth concurrent
reviews read with a 503 and a Retry-After header:
@Service
public class ReviewsService {
private final ReviewsRepository repository;
ReviewsService(ReviewsRepository repository) {
this.repository = repository;
}
/** At most 4 concurrent reads; the fifth caller is rejected at once instead of queueing. */
@ConcurrencyLimit(limit = 4, policy = ThrottlePolicy.REJECT)
public List<String> reviews(String isbn) {
return repository.slowRead(isbn);
}
}
@ControllerAdvice
class ApiErrors {
@ExceptionHandler(InvocationRejectedException.class)
ResponseEntity<Void> tooBusy() {
return ResponseEntity.status(503).header(HttpHeaders.RETRY_AFTER, "1").build();
}
}
Source: InvocationRejectedException
Javadoc. Run: six concurrent GET /books/978/reviews against the 300 ms repository returned four 200 OK and two
503 SERVICE_UNAVAILABLE with Retry-After=1.
Stacking with Resilience4j on one method
The native annotations and the Resilience4j annotations are different mechanisms and can sit on the same method. The
native ones are applied by BeanPostProcessors that call setBeforeExistingAdvisors(true), so they are added in front of
whatever advisors the AspectJ auto-proxy creator already installed on the bean, which is where the Resilience4j aspects
live. The result, observed in three runs on Boot 4.1.1:
-
@Retryable(maxRetries = 2)with@CircuitBreaker: the native retry is the outer layer. The backend was called 3 times and the breaker recorded 3 failed calls. -
@Retryable(maxRetries = 2)with Resilience4j@Retry(maxAttempts = 3): the retries multiply, 3 x 3 = 9 backend calls. -
@Retryablewith@ConcurrencyLimit(limit = 1, policy = REJECT)on one method: the concurrency limit is the outer layer (the later post-processor wraps the earlier one). While one caller slept in the back-off between attempts, a second caller was rejected: the permit is held for the whole retrying, not released between attempts.
(added last, outermost)"] L --> R["@Retryable advisor
(added before existing advisors)"] R --> A["Resilience4j aspects, in the order
Retry, CircuitBreaker, RateLimiter,
TimeLimiter, Bulkhead"] A --> T[("target method")]
The practical consequences are the ones the earlier pages warned about for two retry layers: do not put native
@Retryable and Resilience4j @Retry on one method, and remember that with a native retry outside a breaker, every
attempt is a call the breaker counts. In the Bookshelf the breaker has a window of 4 calls, so one lookup that fails all
four times opens it by itself; a retry that then runs into the open breaker would retry CallNotPermittedException too
(it is "any exception") unless it is excluded, which is why the example below has excludes = CallNotPermittedException.class.
The aspect order of the Resilience4j annotations themselves is in
Annotations & Aspect Order.
Programmatic retry
@Retryable only reaches methods of Spring beans called through the proxy. For any other block of code there is the
RetryTemplate, a lightweight object that executes a Retryable operation under a RetryPolicy. The Framework
reference notes that templates are cheap and can be created on the fly for one invocation.
RetryPolicy
A RetryPolicy decides which exceptions are retried and supplies the BackOff (how long to wait and how many retries).
It is built with the factory methods or the fluent builder:
RetryPolicy defaults = RetryPolicy.withDefaults(); // 1000 ms fixed, 3 retries
RetryPolicy four = RetryPolicy.withMaxRetries(4); // 1000 ms fixed, 4 retries
RetryPolicy policy = RetryPolicy.builder()
.includes(ResourceAccessException.class, HttpServerErrorException.class)
.excludes(HttpClientErrorException.class)
.predicate(t -> !(t instanceof InterruptedException)) // applied after includes and excludes
.maxRetries(2)
.delay(Duration.ofMillis(100))
.jitter(Duration.ofMillis(10))
.multiplier(2.0)
.maxDelay(Duration.ofSeconds(1))
.timeout(Duration.ofSeconds(3))
.build();
Source: RetryPolicy
Javadoc. Defaults printed from the policies on 7.0.9:
-
RetryPolicy.withDefaults().getBackOff()isFixedBackOff[interval=1000, maxAttempts=3], with a timeout ofPT0S. -
RetryPolicy.withMaxRetries(4).getBackOff()isFixedBackOff[interval=1000, maxAttempts=4]. -
RetryPolicy.builder().build().getBackOff()isExponentialBackOff[initialInterval=1000, jitter=0, multiplier=1.0, maxInterval=Long.MAX_VALUE, maxElapsedTime=Long.MAX_VALUE, maxAttempts=3], which behaves as a fixed one-second delay. -
The builder defaults are
DEFAULT_MAX_RETRIES = 3,DEFAULT_DELAY = 1000,DEFAULT_JITTER = 0,DEFAULT_MULTIPLIER = 1.0,DEFAULT_MAX_DELAY = Long.MAX_VALUEandDEFAULT_TIMEOUT = Duration.ZERO. -
Invalid values are rejected when building:
withMaxRetries(-1)threwIllegalArgumentException: Invalid maxRetries (-1): must be positive or zero for no retry, andmaxDelay(Duration.ZERO)threw… must be greater than zero. -
A custom
BackOff(aFixedBackOfforExponentialBackOfffromorg.springframework.util.backoff, or your own) replacesmaxRetries,delay,jitter,multiplierandmaxDelay; the builder’s Javadoc warns not to set both. It can be set withbackOff(…). A policy can also be implemented directly:shouldRetry(Throwable)is the only method without a default.
RetryTemplate: invoke versus execute
The template has two families of methods, and they differ in what the caller sees when retrying fails:
invoke(Supplier) and invoke(Runnable) |
execute(Retryable) |
|
|---|---|---|
Result when attempts run out |
The last original exception, as the annotation does. A runtime exception or |
A checked |
Detail available |
None beyond the exception; use a |
|
Source: RetryTemplate
Javadoc and the source
at v7.0.9. Run: a Retryable that threw attempt 1, attempt 2 and attempt 3 produced a RetryException with
getRetryCount() == 2, getExceptions() listing the three, getLastException() equal to the cause and two suppressed
exceptions.
RetryTemplate template = new RetryTemplate(RetryPolicy.withMaxRetries(2));
// the original exception comes out, like with @Retryable
try {
String cover = template.invoke(() -> http.get().uri("/covers/{isbn}", isbn).retrieve().body(String.class));
}
catch (HttpServerErrorException ex) {
// the last 5xx
}
// all attempts are visible
try {
template.execute(() -> http.get().uri("/covers/{isbn}", isbn).retrieve().body(String.class));
}
catch (RetryException ex) {
List<Throwable> attempts = ex.getExceptions(); // oldest first
Throwable last = ex.getLastException();
}
Source: Spring Framework reference: Resilience
Features. Run on 7.0.9. One surprise is worth knowing: execute also throws a RetryException when the first failure
is not retryable (an excluded exception), with getRetryCount() == 0, so catching RetryException does not mean that a
retry happened.
RetryListener
A single RetryListener can be set on a template with setRetryListener(…); several can be combined with a
CompositeRetryListener. Every callback has an empty default implementation. They fire in this order (from a template
with maxRetries = 2 whose operation fails three times, and from one that fails once and then succeeds):
| Callback | When |
|---|---|
|
After every invocation, including the first one and a first-time success ( |
|
Before each retry, after the back-off sleep. |
|
When a retry (not the initial invocation) throws. |
|
When a retry succeeds. |
|
The policy refuses further attempts, including the case where the first exception was not retryable. |
|
The policy’s timeout was or would be exceeded; the message was |
|
The thread was interrupted during the back-off; the thread’s interrupt flag is restored. |
Source: RetryListener
Javadoc. The sequences above were printed from a listener implementing every callback: for three failures it logged
onRetryableExecution, beforeRetry, onRetryFailure, onRetryableExecution, beforeRetry, onRetryFailure,
onRetryableExecution and onRetryPolicyExhaustion.
A RetryTemplate bean for the Bookshelf
The Bookshelf fetches cover-image URLs from a second third-party endpoint. That call is made from a plain client, not from an annotated method, so it uses a template bean with a listener that counts failures:
@Configuration
class CoverRetryConfig {
@Bean
RetryTemplate coverRetryTemplate(MeterRegistry registry) {
RetryPolicy policy = RetryPolicy.builder()
.includes(ResourceAccessException.class, HttpServerErrorException.class)
.maxRetries(2)
.delay(Duration.ofMillis(100))
.multiplier(2.0)
.maxDelay(Duration.ofSeconds(1))
.timeout(Duration.ofSeconds(3))
.build();
RetryTemplate template = new RetryTemplate(policy);
template.setRetryListener(new RetryListener() {
@Override
public void onRetryableExecution(RetryPolicy p, Retryable<?> retryable, RetryState state) {
if (!state.isSuccessful()) {
registry.counter("bookshelf.cover.attempt.failed").increment();
}
}
@Override
public void onRetryPolicyExhaustion(RetryPolicy p, Retryable<?> retryable, RetryException e) {
registry.counter("bookshelf.cover.exhausted").increment();
}
});
return template;
}
}
@Component
public class CoverClient {
private final RestClient http;
private final RetryTemplate retry;
CoverClient(RestClient.Builder builder, RetryTemplate coverRetryTemplate,
@Value("${bookshelf.isbn.url}") String baseUrl) {
this.http = builder.baseUrl(baseUrl).build();
this.retry = coverRetryTemplate;
}
public String coverUrl(String isbn) {
return retry.invoke(() -> http.get().uri("/covers/{isbn}", isbn).retrieve().body(String.class));
}
}
Source: Spring Framework reference: Resilience
Features. Run against WireMock: 503 then 200 returned the URL after 2 requests with the failed-attempt counter at 1;
a 503 that never cleared threw HttpServerErrorException$ServiceUnavailable after 3 requests, with the exhaustion counter
at 1 and 3 more failed attempts counted.
The Bookshelf with native resilience
The ISBN lookup combines three of the pieces above plus one Resilience4j annotation, on one method of one bean:
@Component
public class IsbnMetadataClient {
private final RestClient http;
IsbnMetadataClient(RestClient.Builder builder, @Value("${bookshelf.isbn.url}") String baseUrl) {
this.http = builder.baseUrl(baseUrl).build();
}
@Retryable(
predicate = TransientFailure.class,
excludes = CallNotPermittedException.class, // never retry into an open circuit
maxRetries = 3, delay = 200, jitter = 50, multiplier = 2.0, maxDelay = 2000,
timeout = 5000)
@CircuitBreaker(name = "isbn")
public BookMetadata lookup(String isbn) {
return http.get().uri("/isbn/{isbn}", isbn).retrieve().body(BookMetadata.class);
}
}
resilience4j:
circuitbreaker:
instances:
isbn:
slidingWindowType: COUNT_BASED
slidingWindowSize: 4
minimumNumberOfCalls: 4
failureRateThreshold: 50
waitDurationInOpenState: 5s
Source: Spring Framework reference and
Resilience4j: Spring Boot 2 & 3 for the YAML keys. Run on Spring
Boot 4.1.1 against WireMock, with @EnableResilientMethods on the application class:
| Stub | Observed |
|---|---|
|
The metadata was returned after 3 requests; 2 failure events with |
|
4 requests, then |
the next lookup, with the breaker open |
|
|
|
|
4 requests, then |
The Resilience4j-only equivalent of the same lookup is @Retry(name = "isbn") and @CircuitBreaker(name = "isbn") with
the retry configured in YAML; it brings events, the retries endpoint and meters, and cannot be mistaken for a native
retry. See Retry for that variant.
Spring Retry is archived
Spring Retry (org.springframework.retry:spring-retry) was for years the Spring answer to retry: @EnableRetry,
@Retryable, @Recover, RetryTemplate, BackOffPolicy, RetryListener, plus a stateful @CircuitBreaker. The project
moved to the spring-attic organisation and is now archived: the repository at
spring-attic/spring-retry is read-only, its README announces that the project
"is no longer maintained as an open-source project" and points to the Spring Framework resilience features as its
replacement, and the last release is 2.0.13 (8 June 2026). The old address github.com/spring-projects/spring-retry
redirects to the archive. Spring Retry 2.0.13 still ran on Spring Framework 7.0.9 in a Boot 4.1.1 test (an @EnableRetry
bean with @Retryable and @Recover worked), so an application does not break the day it upgrades, but there will be
no fixes. Two other facts matter for planning:
-
Spring Boot 4.1.1 no longer manages the version: its dependency BOM has no Spring Retry entry, so a build that kept the dependency must pin
2.0.13itself. Spring Boot 3.5.16 still manages 2.0.13. -
Other Spring projects that used Spring Retry internally (the README names Spring Batch and Spring Integration) have their own migration stories; check their release notes rather than assuming.
Source: Spring Retry (archived) and the
Core Spring Resilience Features announcement. The
archive state, the README text and the 2.0.13 release date were read from the GitHub API; the managed version from the
spring-boot-dependencies POMs of 3.5.16 and 4.1.1 on Maven Central.
Migration table
Every row was checked against the Spring Retry 2.0.13 sources and the Spring Framework 7.0.9 sources, and the rows that describe behaviour were run.
| Spring Retry | Spring Framework 7 | What to watch |
|---|---|---|
|
|
Both default to |
|
|
|
|
|
The Framework matches the exception and its causes. Empty means every exception in both libraries. |
|
|
Same matching. |
|
|
A SpEL expression becomes a class; it gets the |
|
|
Remember to subtract one. |
|
|
Same default delay of one second, and |
|
|
A Spring Retry multiplier of 0 or 1 is a fixed delay; the Framework multiplier of 1.0 is. A multiplier above 1 is exponential in both. |
|
|
Set |
|
|
Spring Retry only honours |
|
nothing |
There is no recovery or fallback in the Framework feature (the package has only |
|
nothing in core |
Use Resilience4j’s |
|
|
The annotation has no listener list; subscribe to |
|
|
No recovery callback and no |
|
|
The Framework has one policy type with a builder; implement |
|
|
|
|
|
One listener per template, combine with |
|
|
|
Artifact |
none: |
Remove the dependency and the |
Source: Retryable
of Spring Retry at v2.0.13 and its sibling Backoff, EnableRetry, Recover and RetryListener sources, against the
Framework Javadoc linked in the sections above. Run on Spring Boot 4.1.1 with Spring Retry 2.0.13 and Spring Framework
version 7.0.9: the first block is the old code, the second the new.
// Spring Retry 2.0.13: three invocations in total, then the @Recover method answers
@Configuration
@EnableRetry
class OldConfig { }
@Service
class OldCovers {
@org.springframework.retry.annotation.Retryable(retryFor = IOException.class, maxAttempts = 3,
backoff = @Backoff(delay = 10))
String fetch(String isbn) throws IOException { return http.fetch(isbn); }
@Recover
String fallback(IOException e, String isbn) { return "no-cover"; }
}
// Spring Framework 7: maxRetries = maxAttempts - 1, the fallback moves to the caller
@Configuration
@EnableResilientMethods
class NewConfig { }
@Service
class NewCovers {
@org.springframework.resilience.annotation.Retryable(includes = IOException.class, maxRetries = 2, delay = 10)
String fetch(String isbn) throws IOException { return http.fetch(isbn); }
}
String cover(String isbn) {
try {
return newCovers.fetch(isbn);
}
catch (IOException e) {
return "no-cover"; // what @Recover did
}
}
Source: the Spring Retry README and sources at
spring-attic/spring-retry, and the Framework reference linked above. Run:
the old bean returned recovered:978:down 978 after 3 invocations 15 ms apart; the new bean threw the original
IOException after 3 invocations. Both @EnableRetry and @EnableResilientMethods can be active in one context during a
migration: each processed its own annotation, so beans can be moved one at a time. A RetryTemplate moves the same way
(Spring Retry: 3 invocations, onError three times, close once, then a RecoveryCallback result; Framework 7: invoke
with a listener).
Feature comparison
The three options a Spring Boot 4 service has for resilience, side by side. Spring Cloud CircuitBreaker (SCCB) is an
abstraction (CircuitBreakerFactory) with two implementations: a Resilience4j one and, new with Spring Framework 7, one
built on the RetryTemplate ("Framework Retry"). SCCB is covered in
Spring Cloud CircuitBreaker.
| Capability | Spring Framework 7 | Resilience4j 2.4.0 (Boot starter) | Spring Cloud CircuitBreaker 5.0.3 |
|---|---|---|---|
Retry |
Yes: |
Yes: |
Not part of the Resilience4j implementation. The Framework Retry implementation runs the supplier under a |
Circuit breaker |
No in the core. (The SCCB Framework Retry implementation adds a simple one.) |
Yes: count-based and time-based sliding windows, slow-call rate, half-open probes, manual transitions. |
Yes, through |
Rate limiter |
No. |
Yes: |
No. |
Time limiter |
No per-call timeout. |
Yes: |
Yes with the Resilience4j implementation, configured with the breaker through the |
Bulkhead |
|
Yes: semaphore and thread-pool bulkheads, bounded waits, events. |
Yes with the Resilience4j implementation when |
Fallback |
No (no |
Yes: |
Yes: the function passed to |
Metrics |
None built in. A |
Yes: Micrometer meters for every module ( |
Through the Resilience4j implementation, when Actuator and |
Actuator endpoints |
None. ( |
Yes: |
None of its own; the endpoints are those of the Resilience4j starter, when it is present. |
Health |
None. |
Circuit breaker and rate limiter health indicators ( |
None of its own. |
Reactive |
|
Operators for Reactor and RxJava, Kotlin coroutine extensions, annotation support for |
|
Configuration |
Annotation attributes, |
YAML or properties for every instance, shared configurations, customizers, runtime changes. |
A |
Extra dependency |
None. |
|
|
Spring Boot 3.5 / Framework 6.2 |
Not available. |
Yes ( |
Yes, in the Spring Cloud release train that matches Boot 3. |
Source: Spring Framework: Resilience Features, Resilience4j: Spring Boot 2 & 3, Spring Cloud CircuitBreaker: Framework Retry and Resilience4J implementation. The Framework 7 column was read from sources and run; the Resilience4j column follows the sibling pages; the SCCB column was checked against the 5.0.3 documentation, the 5.0.3 jars (which hold no retry or rate-limiter class in the Resilience4j implementation) and one run of the Framework Retry implementation.
Spring Cloud CircuitBreaker’s Framework Retry implementation
New with Spring Framework 7, spring-cloud-circuitbreaker-framework-retry provides a CircuitBreakerFactory backed by
a RetryTemplate. It is the nearest thing to the old Spring Retry @CircuitBreaker: the circuit opens after one invocation
whose retries were all exhausted, counting invocations, not attempts.
var factory = new FrameworkRetryCircuitBreakerFactory();
factory.configureDefault(id -> new FrameworkRetryConfigBuilder(id)
.retryPolicy(RetryPolicy.builder().maxRetries(2).delay(Duration.ofMillis(5)).build())
.openTimeout(Duration.ofMillis(300))
.resetTimeout(Duration.ofSeconds(5))
.build());
var breaker = factory.create("isbn");
String title = breaker.run(() -> isbnClient.lookup(isbn).title(), t -> "unknown");
Source: Spring Cloud
CircuitBreaker: Framework Retry. Run with 5.0.3 and a supplier that always failed: the first run made 3 invocations and
returned the fallback; the second run returned the fallback with no invocation (circuit open after one exhausted
invocation); after 350 ms (past the 300 ms openTimeout) one probe invocation succeeded and closed the circuit. Two
differences from the documentation page are worth knowing. The fallback received a RetryException, not the original
exception. And the page lists RetryPolicy factories (withMaxDuration, withBackoff, forExceptions, and and() and
or() combinators) that do not exist in Spring Framework 7.0.9, whose RetryPolicy has only withDefaults,
withMaxRetries and builder: use the builder, as above.
Decision guide
The questions that decide it, in order, for a Spring Boot 4 service. The guide is a starting point: the two families
coexist, and a service can use @ConcurrencyLimit for a lock on a repository and Resilience4j for everything that calls
out.
(Spring Retry only as a stopgap, it is archived)"] B -- yes --> C{"Which protection is needed?"} C -- "retry only, on a bean method or a code block" --> D{"Need retry events, the retries endpoint,
result-based retry or Retry-After?"} D -- no --> D1["Framework 7: @Retryable or RetryTemplate"] D -- yes --> D2["Resilience4j Retry"] C -- "cap concurrent calls on a bean you own" --> E{"Need metrics, a bounded wait,
a fallback or a thread pool?"} E -- no --> E1["Framework 7: @ConcurrencyLimit (prefer REJECT)"] E -- yes --> E2["Resilience4j Bulkhead"] C -- "circuit breaker, rate limiter, time limiter" --> F{"Is a vendor-neutral CircuitBreakerFactory needed
(Spring Cloud Gateway, OpenFeign)?"} F -- yes --> F1["Spring Cloud CircuitBreaker
with the Resilience4j implementation"] F -- no --> F2["Resilience4j directly"] C -- "metrics, Actuator, health" --> D2 C -- "reactive pipeline" --> G["Resilience4j operators
(@Retryable also decorates Mono and Flux)"]
| Situation | Choice for the Bookshelf |
|---|---|
Retry the cover-image fetch, a plain code block, no dashboards needed |
|
Retry the ISBN lookup and protect it with a breaker, with the retry visible on a dashboard |
Resilience4j |
Protect the reviews database from too many concurrent readers, and answer |
|
Limit the calls per second to the quota-bound ISBN API |
Resilience4j |
A Spring Cloud Gateway route or a Feign client needs a breaker |
Spring Cloud CircuitBreaker over Resilience4j. |
The service is still on Spring Boot 3.5 |
Resilience4j with |
Source: this page’s runs and the sibling pages. The guide leans on these verified facts: the native features have no
metrics, health or endpoints; Boot 4.1.1 does not enable them without @EnableResilientMethods; and nothing native exists
before Spring Framework 7.
Pitfalls
| Pitfall | Fix |
|---|---|
Forgetting |
Boot 4.1.1 does not enable the annotations: the bean was not proxied and the method ran once, with no warning. Put the annotation on the application or a configuration class and test that the proxy exists. |
Porting |
|
The default retries every exception |
Business and validation errors are retried four times for nothing. Narrow with |
|
|
Expecting |
It is an absolute duration; the waits sampled between the delay and the delay plus the jitter. |
Self-invocation, |
No retry, a null-pointer on a CGLIB proxy, or one invocation. Call through another bean, avoid |
Expecting a fallback |
There is no |
Expecting the original exception from a reactive method |
A |
Throwing before the |
Neither is retried (1 invocation, no event). Defer the work into the |
|
Callers over the limit wait without a timeout and queue up behind a slow dependency. Prefer |
|
It is released when the method returns the publisher, so it limits nothing about subscriptions (peak 4 with a limit of 1). Limit concurrency in the pipeline. |
|
Every attempt is a breaker call: one lookup that failed four times filled a window of four and opened the breaker. Count that in |
Native |
They multiply: 3 x 3 = 9 backend calls in a run. Use one retry layer. |
|
The limit is outermost, so the permit is held during the back-off sleeps. Keep long delays out of a method that also limits concurrency, or split it. |
Expecting metrics or Actuator output |
The native features register no meters and no endpoint; |
|
|
Following the Spring Cloud CircuitBreaker Framework Retry page literally |
|
Keeping Spring Retry on Boot 4 without a version |
Boot 4.1.1 does not manage it. Pin 2.0.13 and plan the migration; there will be no fixes. |
What changed recently
-
Spring Framework 7.0 introduced
@Retryable,@ConcurrencyLimit,@EnableResilientMethods,RetryTemplate,RetryPolicyandRetryListener, and Spring Retry was archived in favour of them. -
7.0.2 added the
timeoutattribute of@Retryableand thetimeoutofRetryPolicy. -
7.0.3 added the
policyattribute of@ConcurrencyLimitwithInvocationRejectedException, andMethodRetryEvent. -
7.0.9, the version this page ran on, defines
DEFAULT_JITTERandDEFAULT_TIMEOUTonRetryPolicy.Builder. -
Spring Cloud CircuitBreaker 5.0 has a Framework Retry implementation. Its Resilience4j starter still pulls Resilience4j 2.3.0 in 5.0.3; the alignment recipe is in Spring Cloud CircuitBreaker.
-
Spring Retry 2.0.13 (8 June 2026) is the last release; the project is archived.
-
The whole move from Spring Retry and Hystrix to the current stack is collected in Migration & What’s Changed.