Resilience in Spring 7: @Retryable and @ConcurrencyLimit
Introduction
This tutorial explains the resilience features that are new in Spring Framework 7:@Retryable, which calls a failing method again, with growing pauses between the attempts, and @ConcurrencyLimit, which caps how many threads may run a method at the same time. Both live in Spring Framework itself and replace much of what the separate Spring Retry project used to provide. We will retry a flaky exchange-rate call with exponential backoff and watch the timing of each attempt, see what happens when all attempts fail, retry only the exceptions worth retrying, observe retries through events, use the programmatic RetryTemplate, and limit concurrent calls on virtual threads, first by making callers wait and then by rejecting them. Every output below comes from running the code with Spring Framework 7.0.10 and Spring Boot 4.1.2.Why retry, and why back off
Calls to other systems fail now and then for reasons that pass by themselves: a timeout, a dropped connection, a server restarting, a database failover. Such transient failures often succeed on the second or third attempt. Retrying immediately, though, can make things worse - a struggling server gets hit again at once, by every client at the same moment. Good retries therefore -- wait between attempts, and wait longer each time: exponential backoff, such as 100 ms, 200 ms, 400 ms;
- add jitter, a random part of the delay, so that many clients do not retry in lockstep;
- stop after a few attempts, and report the failure;
- retry only failures that can pass: a timeout, yes; an invalid argument or a "not found", never.
Switching resilience on
The annotations are inspring-context, so no extra dependency is needed. Like caching and @Async, they work through proxies, which @EnableResilientMethods on a configuration class switches on - Spring Boot 4.1 does not do it automatically -
src/main/java/.../resilience/ResilienceApp.java
//ResilienceApp.java
package com.javabrahman.resilience;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.resilience.annotation.EnableResilientMethods;
@SpringBootApplication
@EnableResilientMethods
public class ResilienceApp {
// runs one demonstration, chosen with -Ddemo=...
public static void main(String[] args) throws Exception {
try (ConfigurableApplicationContext context = SpringApplication.run(ResilienceApp.class, args)) {
Demos demos = context.getBean(Demos.class);
switch (System.getProperty("demo")) {
case "retry" -> demos.retry();
case "exhausted" -> demos.exhausted();
case "excluded" -> demos.excluded();
case "template" -> demos.template();
case "block" -> demos.limit(false);
case "reject" -> demos.limit(true);
default -> throw new IllegalArgumentException("unknown demo");
}
}
}
}
src/main/java/.../resilience/Attempts.java
//Attempts.java
package com.javabrahman.resilience;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class Attempts {
private final String name;
private final AtomicInteger count = new AtomicInteger();
private final AtomicLong start = new AtomicLong();
public Attempts(String name) {
this.name = name;
}
// records an attempt and returns its number
public int next() {
long now = System.nanoTime();
start.compareAndSet(0, now);
int n = count.incrementAndGet();
double seconds = Math.round((now - start.get()) / 100_000_000.0) / 10.0;
System.out.println(" " + name + " attempt " + n + " at " + seconds + " s");
return n;
}
}
@Retryable with exponential backoff
The exchange-rate client below stands for a call to a rate server that times out three times before it answers. Its first method is retried up to 4 times, after a delay that starts at 100 ms and doubles each time; onlyIOExceptions are retried -
src/main/java/.../resilience/RateClient.java
//RateClient.java
package com.javabrahman.resilience;
import java.io.IOException;
import java.net.SocketTimeoutException;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
@Service
public class RateClient {
private final Attempts usd = new Attempts("usdRate");
private final Attempts eur = new Attempts("eurRate");
private final Attempts any = new Attempts("rate");
@Retryable(includes = IOException.class, maxRetries = 4, delay = 100, multiplier = 2)
public double usdRate() throws IOException {
if (usd.next() <= 3) {
throw new SocketTimeoutException("rate server timed out");
}
return 88.5;
}
@Retryable(includes = IOException.class, maxRetries = 2, delay = 100)
public double eurRate() throws IOException {
eur.next();
throw new IOException("rate server is down");
}
@Retryable(includes = IOException.class, delay = 100)
public double rate(String currency) throws IOException {
any.next();
if (currency.isBlank()) {
throw new IllegalArgumentException("no currency given"); // not an IOException: never retried
}
throw new IOException("rate server is down");
}
}
src/main/java/.../resilience/Demos.java
//Demos.java
package com.javabrahman.resilience;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.springframework.core.retry.RetryException;
import org.springframework.core.retry.RetryListener;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryState;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.core.retry.Retryable;
import org.springframework.stereotype.Component;
@Component
public class Demos {
private final RateClient rates;
private final ReportExporter exporter;
public Demos(RateClient rates, ReportExporter exporter) {
this.rates = rates;
this.exporter = exporter;
}
void retry() throws IOException {
System.out.println("usdRate() returned " + rates.usdRate());
}
void exhausted() {
try {
rates.eurRate();
} catch (IOException e) {
System.out.println("caller caught " + e);
}
}
void excluded() throws IOException {
try {
rates.rate(" ");
} catch (IllegalArgumentException e) {
System.out.println("caller caught " + e);
}
}
void template() {
RetryPolicy policy = RetryPolicy.builder()
.includes(IOException.class)
.maxRetries(2)
.delay(Duration.ofMillis(100))
.build();
RetryTemplate template = new RetryTemplate(policy);
template.setRetryListener(new RetryListener() {
@Override
public void beforeRetry(RetryPolicy retryPolicy, Retryable<?> retryable, RetryState state) {
System.out.println(" listener: retry " + state.getRetryCount()
+ " after " + state.getLastException().getMessage());
}
});
Attempts attempts = new Attempts("inventory");
try {
template.execute(new Retryable<Integer>() {
@Override
public Integer execute() throws IOException {
attempts.next();
throw new IOException("inventory service unavailable");
}
@Override
public String getName() { // used in messages; a lambda would show its generated class name
return "inventory check";
}
});
} catch (RetryException e) {
System.out.println("RetryException: " + e.getMessage());
System.out.println(" retries: " + e.getRetryCount() + ", exceptions collected: " + e.getExceptions().size()
+ ", cause: " + e.getCause());
}
}
void limit(boolean reject) throws Exception {
long start = System.nanoTime();
List<Future<String>> results = new ArrayList<>();
try (ExecutorService callers = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 6; i++) {
int report = i;
results.add(callers.submit(() -> reject ? exporter.exportOrReject(report) : exporter.export(report)));
}
}
for (Future<String> result : results) {
try {
System.out.println(" " + result.get());
} catch (java.util.concurrent.ExecutionException e) {
System.out.println(" " + e.getCause());
}
}
System.out.println("6 calls took " + Math.round((System.nanoTime() - start) / 100_000_000.0) / 10.0
+ " s, at most " + exporter.maxInParallel() + " ran at the same time");
}
}
usdRate attempt 1 at 0.0 s usdRate attempt 2 at 0.1 s usdRate attempt 3 at 0.3 s usdRate attempt 4 at 0.7 s usdRate() returned 88.5
usdRate() simply took 0.7 s and returned the rate. The attributes, with their defaults -
maxRetries(3): retries after the first attempt, so 4 means up to 5 calls in all.delay(1000 ms),multiplier(1.0, a fixed delay),maxDelay(unlimited): the backoff.timeUnitchanges the unit of the numbers.jitter(0): the most by which each delay may randomly vary. Use it in production; it is left out here so that the times are predictable.timeout(none): an overall time limit for all attempts together.includes/value,excludes, andpredicate: which exceptions are retried. By default, all of them.- Each numeric attribute has a
...Stringvariant that takes a placeholder, such asdelayString = "${rates.retry-delay}".
When every attempt fails
eurRate() always fails. With maxRetries = 2 -
OUTPUT of the above codeeurRate attempt 1 at 0.0 s eurRate attempt 2 at 0.1 s eurRate attempt 3 at 0.2 s caller caught java.io.IOException: rate server is down
IOException, not a wrapper - so the caller handles a failed retry exactly as a failed call. The retries added 0.2 s to the time until the failure was known: generous retry settings make a caller wait a long time for a definite "no".
Retrying only what can succeed
rate() retries IOExceptions only. An IllegalArgumentException means that the request itself is wrong, and will be wrong on every attempt -
OUTPUT of the above coderate attempt 1 at 0.0 s caller caught java.lang.IllegalArgumentException: no currency given
includes, @Retryable would have retried it three times, with a second's pause each time, for nothing. Decide explicitly which exceptions are transient: network and timeout exceptions, a "503 Service Unavailable" or "429 Too Many Requests" from an HTTP client, an optimistic locking conflict, a deadlock in the database.
Seeing retries happen
A retry that hides failures also hides trouble: a service that only answers on the third attempt is a service with a problem. Each failed attempt of an@Retryable method publishes a MethodRetryEvent, an application eventClick to read tutorial: Application Events in Spring with @EventListener that a listener can log or count, and so does the end of the retries, with isRetryAborted() returning true -
src/main/java/.../resilience/RetryLogger.java
//RetryLogger.java
package com.javabrahman.resilience;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.resilience.retry.MethodRetryEvent;
import org.springframework.stereotype.Component;
@Component
@Profile("events") // only in the run below
public class RetryLogger {
@EventListener
public void onRetry(MethodRetryEvent event) {
System.out.println(" [MethodRetryEvent] " + event.getMethod().getName()
+ (event.isRetryAborted() ? " aborted: " : " failed: ") + event.getFailure().getMessage());
}
}
usdRate attempt 1 at 0.0 s [MethodRetryEvent] usdRate failed: rate server timed out usdRate attempt 2 at 0.1 s [MethodRetryEvent] usdRate failed: rate server timed out usdRate attempt 3 at 0.3 s [MethodRetryEvent] usdRate failed: rate server timed out usdRate attempt 4 at 0.7 s usdRate() returned 88.5
eurRate attempt 1 at 0.0 s [MethodRetryEvent] eurRate failed: rate server is down eurRate attempt 2 at 0.1 s [MethodRetryEvent] eurRate failed: rate server is down eurRate attempt 3 at 0.2 s [MethodRetryEvent] eurRate failed: rate server is down [MethodRetryEvent] eurRate aborted: Retry policy for operation 'com.javabrahman.resilience.RateClient.eurRate' exhausted; aborting execution caller caught java.io.IOException: rate server is down
RetryTemplate: retries in code
Where an annotation does not fit - a lambda, a third-party call, a different policy per call -RetryTemplate in spring-core retries a piece of code with a RetryPolicy, built with the same settings as the annotation. A RetryListener receives callbacks before each retry and when the policy is exhausted. The template() method of Demos above uses both -
OUTPUT of the above codeinventory attempt 1 at 0.0 s listener: retry 1 after inventory service unavailable inventory attempt 2 at 0.1 s listener: retry 2 after inventory service unavailable inventory attempt 3 at 0.2 s RetryException: Retry policy for operation 'inventory check' exhausted; aborting execution retries: 2, exceptions collected: 3, cause: java.io.IOException: inventory service unavailable
execute() throws a checked RetryException when the retries are exhausted. It carries the number of retries, every exception that occurred, and the last one as its cause. Its message names the operation by the Retryable's getName(), which is why the example implements the interface instead of passing a lambda.
@ConcurrencyLimit: at most N at a time
Retries protect the caller from a struggling service; a concurrency limit protects the service, or any scarce resource, from too many callers. With virtual threads, as the @Async tutorialClick to read tutorial: Async Processing in Spring with @Async and Virtual Threads showed, there is no pool size to limit work any more: a thousand requests become a thousand threads.@ConcurrencyLimit on a method lets only a given number of threads run it at the same time. By default, the others wait for a free slot; with policy = REJECT, they get an InvocationRejectedException at once -
src/main/java/.../resilience/ReportExporter.java
//ReportExporter.java
package com.javabrahman.resilience;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.resilience.annotation.ConcurrencyLimit;
import org.springframework.resilience.annotation.ConcurrencyLimit.ThrottlePolicy;
import org.springframework.stereotype.Service;
@Service
public class ReportExporter {
private final AtomicInteger running = new AtomicInteger();
private final AtomicInteger max = new AtomicInteger();
@ConcurrencyLimit(2)
public String export(int report) throws InterruptedException {
return work(report);
}
@ConcurrencyLimit(limit = 2, policy = ThrottlePolicy.REJECT)
public String exportOrReject(int report) throws InterruptedException {
return work(report);
}
private String work(int report) throws InterruptedException {
max.accumulateAndGet(running.incrementAndGet(), Math::max);
try {
Thread.sleep(300); // stands for a call to a reporting system that handles 2 requests at a time
return "report " + report + " exported";
} finally {
running.decrementAndGet();
}
}
public int maxInParallel() {
return max.get();
}
}
report 1 exported report 2 exported report 3 exported report 4 exported report 5 exported report 6 exported 6 calls took 1.0 s, at most 2 ran at the same time
org.springframework.resilience.InvocationRejectedException: Concurrency limit reached: 2 - not allowed to enter com.javabrahman.resilience.ReportExporter.exportOrReject report 2 exported org.springframework.resilience.InvocationRejectedException: Concurrency limit reached: 2 - not allowed to enter com.javabrahman.resilience.ReportExporter.exportOrReject report 4 exported org.springframework.resilience.InvocationRejectedException: Concurrency limit reached: 2 - not allowed to enter com.javabrahman.resilience.ReportExporter.exportOrReject org.springframework.resilience.InvocationRejectedException: Concurrency limit reached: 2 - not allowed to enter com.javabrahman.resilience.ReportExporter.exportOrReject 6 calls took 0.3 s, at most 2 ran at the same time
- With BLOCK, all six reports were exported, never more than two at once: three rounds of 0.3 s. The waiting callers are virtual threads, so waiting is cheap.
- With REJECT, two calls ran and the other four failed at once with
InvocationRejectedException(aRejectedExecutionException). Rejecting suits callers that have something better to do than wait - answer "busy, try later", or serve a cached value. - Each annotated method has its own limit:
export()andexportOrReject()count separately. The annotation can also be put on a class, for all of its methods.
Good practice
- Proxy rules: both annotations are applied by a proxy, so the method must be public and called from another bean. A call through
thisis not retried and not limited. - Retry at one level only: when a controller, a service and an HTTP client each retry three times, one failure becomes 64 calls. Retry closest to the failing call.
- Keep the total time bounded: few retries, a
maxDelayortimeout, and jitter in production. - Make retried operations idempotent, and watch the retry events: frequent retries are an early warning.
- Know what is not included: Spring Framework 7 offers retries and concurrency limits, not circuit breakers, rate limiters or fallbacks. For those, Resilience4j - directly or through Spring Cloud Circuit Breaker - remains the tool.
Summary
In this tutorial we used the resilience features of Spring Framework 7, switched on with@EnableResilientMethods. @Retryable retried a timing-out rate call after 100, 200 and 400 ms until it succeeded, threw the method's own exception after the last failed retry, and, limited with includes, did not retry an invalid request at all. MethodRetryEvents made each failed attempt visible, and RetryTemplate retried code with a RetryPolicy and a RetryListener. @ConcurrencyLimit(2) kept six virtual threads to two at a time, either queuing them or rejecting them. This completes the module on AOP, caching, scheduling and events: all of its features - aspects, caching, async execution, retries and limits - turned out to be advice applied by Spring's proxiesClick to read tutorial: How Spring Proxies Work and the Self-Invocation Trap.Spring AOP, Caching, Scheduling and Events Tutorials
Application Events in Spring with @EventListenerClick to read tutorial: Application Events in Spring with @EventListener Spring AOP Explained: Aspects, Advice and PointcutsClick to read tutorial: Spring AOP Explained: Aspects, Advice and Pointcuts How Spring Proxies Work and the Self-Invocation TrapClick to read tutorial: How Spring Proxies Work and the Self-Invocation Trap Caching in Spring Boot with @Cacheable and CaffeineClick to read tutorial: Caching in Spring Boot with @Cacheable and Caffeine Scheduling Tasks in Spring with @ScheduledClick to read tutorial: Scheduling Tasks in Spring with @Scheduled Async Processing in Spring with @Async and Virtual ThreadsClick to read tutorial: Async Processing in Spring with @Async and Virtual Threads
Application Events in Spring with @EventListenerClick to read tutorial: Application Events in Spring with @EventListener Spring AOP Explained: Aspects, Advice and PointcutsClick to read tutorial: Spring AOP Explained: Aspects, Advice and Pointcuts How Spring Proxies Work and the Self-Invocation TrapClick to read tutorial: How Spring Proxies Work and the Self-Invocation Trap Caching in Spring Boot with @Cacheable and CaffeineClick to read tutorial: Caching in Spring Boot with @Cacheable and Caffeine Scheduling Tasks in Spring with @ScheduledClick to read tutorial: Scheduling Tasks in Spring with @Scheduled Async Processing in Spring with @Async and Virtual ThreadsClick to read tutorial: Async Processing in Spring with @Async and Virtual Threads