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.

A timeline of one call to a method annotated with @Retryable and every default. The method is invoked four times, at about 0, 1000, 2000 and 3000 milliseconds, and throws IllegalStateException every time. Between invocations the thread sleeps about one second. Below the timeline, Spring publishes five MethodRetryEvent objects: four with retryAborted false, one for each failed invocation, and a fifth with retryAborted true that carries the RetryException which ended the retrying. The caller finally receives the original IllegalStateException, not the RetryException

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

@Retryable

org.springframework.resilience.annotation

7.0

Declarative retry for a method, or for every proxied method of a class.

@ConcurrencyLimit

org.springframework.resilience.annotation

7.0 (policy since 7.0.3)

Declarative cap on concurrent invocations of a method, or of a class.

@EnableResilientMethods

org.springframework.resilience.annotation

7.0

Registers the post-processors that honour the two annotations above.

MethodRetryEvent

org.springframework.resilience.retry

7.0.3

Application event published for every exception met while a @Retryable method runs.

MethodRetryPredicate

org.springframework.resilience.retry

7.0

Per-method predicate used by @Retryable(predicate = …​).

InvocationRejectedException

org.springframework.resilience

7.0.3

Thrown by @ConcurrencyLimit with ThrottlePolicy.REJECT; extends java.util.concurrent.RejectedExecutionException.

RetryTemplate, RetryPolicy, RetryListener, RetryException, RetryState, Retryable (interface)

org.springframework.core.retry

7.0

The programmatic API behind the annotation; usable on any block of code.

CompositeRetryListener, RetryTask

org.springframework.core.retry.support

7.0

A listener that fans out to several listeners, and a wrapper that turns a Runnable or Callable into a retried one.

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.

Turning the annotations on
@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

proxyTargetClass

false

true asks for subclass (CGLIB) proxies instead of interface-based JDK proxies. It only affects the two post-processors of this feature. The Javadoc recommends a global proxy default plus @Proxyable on individual classes instead.

order

Ordered.LOWEST_PRECEDENCE - 1

The order of the two post-processors among the other bean post-processors, chosen so that they run after every common post-processor except @EnableAsync. It is not an order among advice on one method; see the section on stacking below.

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 @Retryable in a Boot application that has no @EnableResilientMethods is 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 @Retryable on the method of the implementation class was ignored (one invocation); the same annotation on the interface method worked (two invocations), and proxyTargetClass = true produced 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 a final method 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

value (alias of includes)

empty

The convenient single-attribute form: @Retryable(IOException.class) is @Retryable(includes = IOException.class).

includes

empty (any exception)

Exception types to retry. Each is matched against the thrown exception and its causes: with includes = IOException.class, a RuntimeException that wraps an IOException was retried.

excludes

empty

Exception types never to retry, matched the same way. Combined with includes, the exclusion narrows the inclusion.

predicate

MethodRetryPredicate.class (accept everything)

A MethodRetryPredicate with shouldRetry(Method, Throwable), instantiated per method through the bean factory, so it can use constructor injection. The Framework reference states that a predicate is always applied after includes and excludes.

maxRetries

3

The number of retries, so total invocations are 1 + maxRetries: the default calls the method up to 4 times. 0 disables retrying. Verified: maxRetries = 2 ran the method 3 times, 0 ran it once.

maxRetriesString

empty

A non-empty value overrides maxRetries; accepts ${…​} placeholders and SpEL.

delay

1000

The base wait before the first retry, in timeUnit (milliseconds by default). With a multiplier it is the first value to multiply from. Must be zero or more.

delayString

empty

Overrides delay. A plain integer (in timeUnit), an ISO-8601 duration such as PT1S, or the simple style such as 500ms, with placeholders and SpEL.

jitter

0

An absolute duration (not a factor) added to the delay to spread clients apart. See below for the range.

jitterString

empty

Overrides jitter, with the same formats as delayString.

multiplier

1.0 (a fixed delay)

Applied to the previous delay, and to the jitter, for every further retry. 2.0 doubles the wait each time.

multiplierString

empty

Overrides multiplier; placeholders and SpEL.

maxDelay

Long.MAX_VALUE (unlimited)

The ceiling for any single wait, limiting how far jitter and multiplier can grow it.

maxDelayString

empty

Overrides maxDelay, with the same formats as delayString.

timeout

0 (none)

Since 7.0.2. The maximum elapsed time for the initial invocation and all retries including the waits, in timeUnit. A retry whose pending sleep would cross the limit is not started.

timeoutString

empty

Since 7.0.2. Overrides timeout, with the same formats as delayString.

timeUnit

TimeUnit.MILLISECONDS

The unit of delay, jitter, maxDelay, timeout and their String variants (ignored for explicit Duration strings).

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:

Table 1. Measured behaviour of the attributes
Annotation Observed

@Retryable

4 invocations, gaps of about 1007, 1003 and 1006 ms, the original IllegalStateException thrown

@Retryable(delay = 10)

4 invocations, gaps of 12, 13 and 11 ms

@Retryable(maxRetries = 5, delay = 20, multiplier = 2.0, maxDelay = 100)

6 invocations, gaps of 23, 42, 85, 105 and 103 ms (20, 40, 80, then capped at 100)

@Retryable(maxRetries = 4, delay = 100, jitter = 50)

5 invocations; over 2000 samples of the back-off each wait fell in 100 to 149 ms

@Retryable(maxRetries = 4, delay = 200, jitter = 50, multiplier = 2.0)

over 2000 samples the waits were 200 to 249 ms, then 300 to 499, then 600 to 999, then 1200 to 1999

@Retryable(delay = 10, includes = IOException.class) and IllegalStateException thrown

1 invocation, rethrown at once

@Retryable(delay = 10, excludes = IllegalArgumentException.class) and IllegalArgumentException thrown

1 invocation

@Retryable(delay = 10, timeout = 120) with delay = 50

3 invocations, then the original exception: the third sleep (50 ms) would have crossed 120 ms

@Retryable(maxRetriesString = "${r.max}", delayString = "${r.delay}") with 2 and 15ms

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 timeUnit needs an explicit maxDelay. @Retryable(delay = 1, timeUnit = TimeUnit.SECONDS, maxRetries = 1) threw ArithmeticException: long overflow before the method was invoked once (0 invocations), because the default maxDelay of Long.MAX_VALUE is converted from seconds to milliseconds. Adding maxDelay = 10 made 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, excludes or 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:

A predicate that retries transient failures only
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.

A reactive method with the native retry
@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

Mono that fails twice, then succeeds, maxRetries = 2

Returned the value, 3 subscriptions, 2 MethodRetryEvent objects

Mono that always fails, maxRetries = 2

3 invocations, then reactor.core.Exceptions$RetryExhaustedException: Retries exhausted: 2/2 whose cause is the original IllegalStateException. The imperative form rethrows the original exception itself, so code that catches one specific type must unwrap it for reactive methods.

Flux that emits one element then fails, maxRetries = 2

[1, 1, 1, 2, 3]: elements emitted before a failure are not retracted, so a retried Flux can repeat them

Mono with excludes = IllegalArgumentException.class failing with it

Not retried; the original exception propagated

@Retryable(timeout = 120, delay = 50, maxRetries = 20)

3 invocations, then a TimeoutException ("Did not observe any item or terminal signal within 120ms in 'retryWhen'") instead of the original exception

A method that throws before it returns the Mono

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

CompletableFuture that completes exceptionally

Not retried (1 invocation, no event): Future return types are excluded from the reactive adaptation and the exception is not thrown by the method

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 = false and 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: a RetryException for an imperative method, a RetryExhaustedException or TimeoutException for a reactive one.

  • A successful retry publishes no retryAborted event: a method that failed twice and then succeeded produced 2 events, both with retryAborted = false.

  • A call that fails with an excluded exception on the first invocation still publishes two events: one with retryAborted = false carrying the exception, and one with retryAborted = true carrying the RetryException (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.

Bridging MethodRetryEvent to Micrometer
@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

value (alias of limit)

Integer.MIN_VALUE, which is invalid

The convenient form: @ConcurrencyLimit(5).

limit

Integer.MIN_VALUE, which is invalid

1 locks the instance for each invocation, a larger value is pool-like, -1 is unbounded. A limit below -1 (which includes the default) fails at the first call with IllegalStateException: …​ must be configured with a valid limit; verified.

limitString

empty

Overrides limit and value; accepts ${…​} placeholders and SpEL.

policy

ThrottlePolicy.BLOCK

Since 7.0.3. BLOCK makes callers over the limit wait, with no timeout; REJECT throws InvocationRejectedException immediately.

BLOCK and REJECT
@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 returned done, 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 returned done and two threw InvocationRejectedException with the message Concurrency limit reached: 2 - not allowed to enter …​Limited.rejecting. The rejection took 0 ms: a rejected caller does not wait. The exception is an InvocationRejectedException and, through it, a java.util.concurrent.RejectedExecutionException; getTarget() returns the bean.

  • limitString = "${lim}" with lim=3 gave a peak of 3; -1 gave a peak of 6 for six callers; 1 gave 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 of a() and b() 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 Mono the throttle is released as soon as the method returns the Mono, so it limits nothing about the subscriptions: with limit = 1 and REJECT, four concurrent subscriptions all succeeded and the peak was 4. Use a Resilience4j bulkhead operator or a flatMap concurrency 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:

Mapping the rejection to an HTTP response
@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.

  • @Retryable with @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.

flowchart LR C([caller]) --> L["@ConcurrencyLimit advisor
(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")]
Figure 1. The advisor chain around a bean that uses both families

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:

Three ways to get a policy
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() is FixedBackOff[interval=1000, maxAttempts=3], with a timeout of PT0S.

  • RetryPolicy.withMaxRetries(4).getBackOff() is FixedBackOff[interval=1000, maxAttempts=4].

  • RetryPolicy.builder().build().getBackOff() is ExponentialBackOff[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_VALUE and DEFAULT_TIMEOUT = Duration.ZERO.

  • Invalid values are rejected when building: withMaxRetries(-1) threw IllegalArgumentException: Invalid maxRetries (-1): must be positive or zero for no retry, and maxDelay(Duration.ZERO) threw …​ must be greater than zero.

  • A custom BackOff (a FixedBackOff or ExponentialBackOff from org.springframework.util.backoff, or your own) replaces maxRetries, delay, jitter, multiplier and maxDelay; the builder’s Javadoc warns not to set both. It can be set with backOff(…​). 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 Error is rethrown as is; a checked exception is wrapped in UndeclaredThrowableException (verified with an IOException).

A checked RetryException, with all the exceptions.

Detail available

None beyond the exception; use a RetryListener for the rest

getExceptions(), getLastException(), getRetryCount() and suppressed exceptions

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.

invoke and execute
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):

sequenceDiagram participant T as RetryTemplate participant R as Retryable participant L as RetryListener T->>R: execute (initial invocation) R-->>T: throws T->>L: onRetryableExecution (retryCount 0, failed) loop while the policy accepts the exception T->>T: sleep for the BackOff T->>L: beforeRetry (retryCount n) T->>R: execute alt retry throws R-->>T: throws T->>L: onRetryFailure T->>L: onRetryableExecution (failed) else retry succeeds R-->>T: result T->>L: onRetrySuccess T->>L: onRetryableExecution (successful) end end T->>L: onRetryPolicyExhaustion, Timeout or Interruption
Callback When

onRetryableExecution(policy, retryable, state)

After every invocation, including the first one and a first-time success (state.isSuccessful() tells them apart). The best single hook for counting failures.

beforeRetry(policy, retryable, state)

Before each retry, after the back-off sleep.

onRetryFailure(policy, retryable, throwable)

When a retry (not the initial invocation) throws.

onRetrySuccess(policy, retryable, result)

When a retry succeeds.

onRetryPolicyExhaustion(policy, retryable, retryException)

The policy refuses further attempts, including the case where the first exception was not retryable.

onRetryPolicyTimeout(…​)

The policy’s timeout was or would be exceeded; the message was …​ would exceed timeout (120ms) due to pending sleep time (50ms); preemptively aborting execution.

onRetryPolicyInterruption(…​)

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:

A RetryTemplate bean with a listener
@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:

A native retry outside a Resilience4j breaker
@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);
    }
}
The breaker for the example
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

503, 503, then 200

The metadata was returned after 3 requests; 2 failure events with aborted=false; the breaker recorded 2 failed calls and 1 success

503 forever

4 requests, then HttpServerErrorException$ServiceUnavailable (the original); 1 event with aborted=true; the breaker was OPEN with 4 failed calls

the next lookup, with the breaker open

CallNotPermittedException immediately and 0 requests: the exclusion kept the retry from retrying it

404

HttpClientErrorException$NotFound after 1 request: the predicate rejected it

429 forever

4 requests, then HttpClientErrorException$TooManyRequests

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.13 itself. 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

@EnableRetry

@EnableResilientMethods

Both default to proxyTargetClass = false and order = LOWEST_PRECEDENCE - 1. @EnableRetry is meta-annotated with @EnableAspectJAutoProxy; the Framework variant uses its own post-processors and needs no AspectJ. It also switches on @ConcurrencyLimit.

@Retryable(maxAttempts = 3) (the default)

@Retryable(maxRetries = 2)

maxAttempts counts the first call, maxRetries does not. The Spring Retry default of 3 means 3 invocations, the Framework default of 3 means 4. Verified: Spring Retry ran the method 3 times, the Framework default ran it 4 times.

retryFor (and the deprecated include, value)

includes (alias value)

The Framework matches the exception and its causes. Empty means every exception in both libraries.

noRetryFor (and the deprecated exclude)

excludes

Same matching.

exceptionExpression

predicate (a MethodRetryPredicate)

A SpEL expression becomes a class; it gets the Method and the Throwable and can use constructor injection.

maxAttemptsExpression

maxRetriesString

Remember to subtract one.

@Backoff(delay = 1000)

delay = 1000

Same default delay of one second, and delayString accepts a placeholder or SpEL (Spring Retry: delayExpression).

@Backoff(multiplier = 0) (the default: fixed)

multiplier = 1.0 (the default: fixed)

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.

@Backoff(maxDelay = 0) (no cap)

maxDelay = Long.MAX_VALUE (no cap)

Set maxDelay explicitly if you change timeUnit; see the overflow trap above.

@Backoff(random = true)

jitter

Spring Retry only honours random when a multiplier is set (exponential random). The Framework jitter is an absolute duration added on top of the delay, not a random pick between the delay and the maximum.

@Recover

nothing

There is no recovery or fallback in the Framework feature (the package has only @Retryable, @ConcurrencyLimit and @EnableResilientMethods). Catch the exception at the call site, or use a Resilience4j fallbackMethod. Note a Spring Retry trap that disappears: in a class that has @Recover methods, none of which matches the thrown exception, Spring Retry threw ExhaustedRetryException: Cannot locate recovery method; the Framework always rethrows the original exception.

@Retryable(stateful = true) and @CircuitBreaker of Spring Retry

nothing in core

Use Resilience4j’s CircuitBreaker, or the Framework Retry implementation of Spring Cloud CircuitBreaker, which is modelled on Spring Retry’s circuit breaker; see the comparison below.

@Retryable(listeners = …​), label, interceptor

MethodRetryEvent listeners for the annotation; no label

The annotation has no listener list; subscribe to MethodRetryEvent application events instead. The name of the retried operation is the qualified method name.

RetryTemplate (org.springframework.retry.support) with execute(callback, recoveryCallback)

org.springframework.core.retry.RetryTemplate with invoke(…​) or execute(Retryable)

No recovery callback and no RetryContext: the outcome is the original exception from invoke, or a RetryException with all the exceptions from execute. RetryState replaces the context. The old RetryTemplate.builder() becomes RetryPolicy.builder() plus new RetryTemplate(policy).

RetryPolicy (SimpleRetryPolicy, TimeoutRetryPolicy, CompositeRetryPolicy, …​)

RetryPolicy built with maxRetries, timeout, includes, excludes and predicate

The Framework has one policy type with a builder; implement RetryPolicy for anything else. There is no composite policy.

BackOffPolicy (FixedBackOffPolicy, ExponentialBackOffPolicy, ExponentialRandomBackOffPolicy, UniformRandomBackOffPolicy)

org.springframework.util.backoff.BackOff (FixedBackOff, ExponentialBackOff) through RetryPolicy.builder().backOff(…​), or the builder’s delay, jitter, multiplier, maxDelay

BackOff is a long-standing spring-core interface, not new. Spring Retry’s ExponentialBackOffPolicy defaults to a 100 ms initial interval, a multiplier of 2 and a 30 s cap; the Framework @Retryable default is a fixed one second.

RetryListener (open, onSuccess, onError, close) registered with registerListener

RetryListener (onRetryableExecution, beforeRetry, onRetrySuccess, onRetryFailure, onRetryPolicyExhaustion, onRetryPolicyTimeout, onRetryPolicyInterruption) with setRetryListener

One listener per template, combine with CompositeRetryListener. There is no open callback that can veto an execution. The nearest equivalents to onError are onRetryFailure for a retry and onRetryableExecution (checking state.isSuccessful()) for every attempt.

RetryCallback and RecoveryCallback

Retryable<R> (and Supplier or Runnable for invoke)

Retryable.execute() may throw Throwable.

Artifact org.springframework.retry:spring-retry

none: spring-core and spring-context 7

Remove the dependency and the @EnableRetry configuration; change the imports to org.springframework.resilience.annotation and org.springframework.core.retry. On Boot 4 there is no managed version to fall back on.

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.

The same retry in Spring Retry and in Spring Framework 7
// 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: @Retryable, RetryTemplate. Fixed or exponential delay, absolute jitter, timeout, include, exclude and predicate filtering. No result-based retry, no wait derived from the response.

Yes: Retry, @Retry, RetryConfig. Backoff functions, retryOnResult, a wait computed from the exception or result (Retry-After).

Not part of the Resilience4j implementation. The Framework Retry implementation runs the supplier under a RetryPolicy and opens a circuit after one exhausted invocation.

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 CircuitBreakerFactory: Resilience4j implementation (full breaker) or Framework Retry implementation (open after one exhausted retrying, half-open probe after openTimeout).

Rate limiter

No. @ConcurrencyLimit limits concurrency, not calls per period.

Yes: RateLimiter, @RateLimiter.

No.

Time limiter

No per-call timeout. @Retryable(timeout) and the policy timeout bound the whole retrying, waits included.

Yes: TimeLimiter, for CompletionStage and reactive types.

Yes with the Resilience4j implementation, configured with the breaker through the TimeLimiterConfig.

Bulkhead

@ConcurrencyLimit: a semaphore-like cap, BLOCK without a timeout or REJECT. No thread-pool variant.

Yes: semaphore and thread-pool bulkheads, bounded waits, events.

Yes with the Resilience4j implementation when resilience4j-bulkhead is on the classpath (thread-pool by default).

Fallback

No (no @Recover).

Yes: fallbackMethod on the annotations, Decorators.withFallback.

Yes: the function passed to CircuitBreaker.run(supplier, fallback). With the Framework Retry implementation the fallback received a RetryException, not the original exception.

Metrics

None built in. A MethodRetryEvent can be bridged to Micrometer by hand; @ConcurrencyLimit publishes nothing.

Yes: Micrometer meters for every module (resilience4j-micrometer).

Through the Resilience4j implementation, when Actuator and resilience4j-micrometer are on the classpath (it can be switched off with spring.cloud.circuitbreaker.resilience4j.micrometer.enabled). Not documented for the Framework Retry implementation.

Actuator endpoints

None. (/actuator/retries exists only with Resilience4j and was empty in a run that used only the native annotations.)

Yes: circuitbreakers, circuitbreakerevents, retries, ratelimiters, bulkheads, timelimiters and the event endpoints.

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 (registerHealthIndicator).

None of its own.

Reactive

@Retryable on Mono and Flux through Reactor. @ConcurrencyLimit does not limit subscriptions.

Operators for Reactor and RxJava, Kotlin coroutine extensions, annotation support for Mono and Flux with resilience4j-reactor.

ReactiveCircuitBreakerFactory for the Resilience4j implementation; the Framework Retry implementation is documented as not supporting reactive applications.

Configuration

Annotation attributes, RetryPolicy builder; String variants for placeholders. No YAML binding of its own.

YAML or properties for every instance, shared configurations, customizers, runtime changes.

A Customizer bean per implementation; the Resilience4j implementation also reads resilience4j.* properties.

Extra dependency

None.

resilience4j-spring-boot4 and spring-boot-starter-aspectj.

spring-cloud-starter-circuitbreaker-resilience4j or -framework-retry, plus the Spring Cloud BOM; see the sibling page for the Boot 4 alignment of Resilience4j versions.

Spring Boot 3.5 / Framework 6.2

Not available.

Yes (resilience4j-spring-boot3).

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.

The Framework Retry circuit breaker
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.

flowchart TD A([A call needs protection]) --> B{"Spring Boot 4 on Spring Framework 7?"} B -- no --> B1["Resilience4j for everything
(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

RetryTemplate with a listener, as above. No extra dependency.

Retry the ISBN lookup and protect it with a breaker, with the retry visible on a dashboard

Resilience4j @Retry and @CircuitBreaker. If the native @Retryable is kept, bridge MethodRetryEvent to a meter and exclude CallNotPermittedException.

Protect the reviews database from too many concurrent readers, and answer 503 when full

@ConcurrencyLimit(limit = n, policy = REJECT) with an @ExceptionHandler. Move to a Resilience4j bulkhead when a saturation gauge is needed.

Limit the calls per second to the quota-bound ISBN API

Resilience4j RateLimiter; the Framework has nothing comparable.

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 resilience4j-spring-boot3. Plan the move off Spring Retry for the Boot 4 upgrade.

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 @EnableResilientMethods

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 maxAttempts = 3 as maxRetries = 3

maxRetries excludes the first call. The Spring Retry value of 3 is maxRetries = 2.

The default retries every exception

Business and validation errors are retried four times for nothing. Narrow with includes, excludes or a MethodRetryPredicate, and retry only idempotent calls.

timeUnit other than milliseconds without maxDelay

ArithmeticException: long overflow before the method is called once. Set maxDelay explicitly.

Expecting jitter to be symmetric or a factor

It is an absolute duration; the waits sampled between the delay and the delay plus the jitter.

Self-invocation, final methods, an annotation on the implementation of an interface in a plain Spring context

No retry, a null-pointer on a CGLIB proxy, or one invocation. Call through another bean, avoid final, annotate the interface method or set proxyTargetClass = true. Spring Boot forces class proxies.

Expecting a fallback

There is no @Recover. Catch the original exception at the call site, or use a Resilience4j fallbackMethod.

Expecting the original exception from a reactive method

A Mono that exhausts its retries fails with RetryExhaustedException whose cause is the original; a timeout fails with TimeoutException. Unwrap it.

Throwing before the Mono exists, or returning a CompletableFuture

Neither is retried (1 invocation, no event). Defer the work into the Mono, or use Resilience4j’s CompletionStage decoration.

@ConcurrencyLimit with the default BLOCK

Callers over the limit wait without a timeout and queue up behind a slow dependency. Prefer REJECT and map InvocationRejectedException to a 503.

@ConcurrencyLimit on a method returning Mono or Flux

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.

@Retryable outside a Resilience4j breaker

Every attempt is a breaker call: one lookup that failed four times filled a window of four and opened the breaker. Count that in minimumNumberOfCalls, and exclude CallNotPermittedException from the retry.

Native @Retryable plus Resilience4j @Retry on one method

They multiply: 3 x 3 = 9 backend calls in a run. Use one retry layer.

@Retryable plus @ConcurrencyLimit on one method

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; /actuator/retries was empty. Listen to MethodRetryEvent or use a RetryListener.

RetryTemplate.execute throws a checked RetryException, also for a non-retryable first failure

getRetryCount() == 0 tells apart a failure that was never retried. invoke wraps checked exceptions in UndeclaredThrowableException.

Following the Spring Cloud CircuitBreaker Framework Retry page literally

RetryPolicy.withMaxDuration, withBackoff, forExceptions and and() do not exist in 7.0.9; use RetryPolicy.builder(). The fallback receives a RetryException.

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, RetryPolicy and RetryListener, and Spring Retry was archived in favour of them.

  • 7.0.2 added the timeout attribute of @Retryable and the timeout of RetryPolicy.

  • 7.0.3 added the policy attribute of @ConcurrencyLimit with InvocationRejectedException, and MethodRetryEvent.

  • 7.0.9, the version this page ran on, defines DEFAULT_JITTER and DEFAULT_TIMEOUT on RetryPolicy.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.