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.
Retrying also means the operation may run more than once, so it should be idempotent: reading a rate is; charging a credit card needs an idempotency key. @Retryable with maxRetries 4, delay 100 ms and multiplier 2 - the first attempt fails at 0 s, the retries come after waits of 100, 200 and 400 ms, and the fourth attempt succeeds at about 0.7 s

Switching resilience on

The annotations are in spring-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");
      }
    }
  }
}
A small helper prints each attempt with the time since the first one, rounded to 0.1 s so that the outputs are stable -
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; only IOExceptions 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");
  }
}
 OUTPUT of the above code
   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
The first attempt failed at 0.0 s. The retries followed after 100, 200 and 400 ms - at about 0.1, 0.3 and 0.7 s - and the fourth attempt succeeded. The caller saw none of the failures: 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. timeUnit changes 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, and predicate: which exceptions are retried. By default, all of them.
  • Each numeric attribute has a ...String variant that takes a placeholder, such as delayString = "${rates.retry-delay}".

When every attempt fails

eurRate() always fails. With maxRetries = 2 -  OUTPUT of the above code
   eurRate 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
After three attempts in all, the retry interceptor gave up and threw the last exception to the caller - the method's own 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 code
   rate attempt 1 at 0.0 s
caller caught java.lang.IllegalArgumentException: no currency given
One attempt, and the exception reached the caller at once. Without 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());
  }
}
The first two runs again, with the listener's profile active. In the second, the last event reports that the retry policy was exhausted -  OUTPUT of the retry run, with the event listener
   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
 OUTPUT of the exhausted run, with the event listener
   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 code
   inventory 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
Unlike the annotation, 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();
  }
}
Six virtual threads call the method at the same moment -  OUTPUT with the default policy, BLOCK
   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
 OUTPUT with policy = REJECT
   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 (a RejectedExecutionException). 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() and exportOrReject() count separately. The annotation can also be put on a class, for all of its methods.
@ConcurrencyLimit(2) - six callers arrive, two run the method; with BLOCK the other four wait for a free slot, with REJECT they fail at once with InvocationRejectedException

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 this is 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 maxDelay or timeout, 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.