1. Overview
Spring Retry provides the ability to automatically re-invoke a failed operation. It helps when the errors may be transient (such as a momentary network glitch). Note, however, a retry is different from a circuit breaker.
In this tutorial, we’ll explore the various ways to utilize Spring Retry, including annotations, the RetryTemplate, and callbacks.
Learn how to better control your application retries using backoff and jitter from Resilience4j.
Learn how to use the most useful modules from the Resilience4j library to build resilient systems.
Spring Batch allows us to set retry strategies on tasks so that they are automatically repeated when there is an error. Here we see how to configure it.
2. Setting up Maven Dependencies
Let’s begin by adding the spring-retry dependency into our pom.xml file:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.12</version>
</dependency>
We also need to add the spring-boot-starter-aspectj dependency in our project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
Further, the spring-boot-starter-aspectj dependency is crucial because:
Have a look at Maven Central for the latest versions of the spring-retry and spring-boot-starter-aspectj dependencies.
3. Enabling Spring Retry
To enable Spring Retry in an application, we need to add the @EnableRetry annotation to our @Configuration class:
@Configuration
@EnableRetry
public class AppConfig { ... }
4. Using Spring Retry
4.1. @Retryable Without Recovery
We can use the @Retryable annotation to add retry functionality to methods:
@Service
public interface MyService {
@Retryable a
void retryService();
}
Since we haven’t specified any exceptions here, it attempts a retry for all the exceptions. Per @Retryable‘s default behavior, it may retry up to two times, with a delay of one second between retries. Further, the maximum number of attempts is 3, which includes the initial failed call and two subsequent retries. If the maximum number of attempts is reached and there’s still an exception, it throws the ExhaustedRetryException.
4.2. @Retryable and @Recover
Let’s now add a recovery method using the @Recover annotation:
@Service
public interface MyService {
@Retryable(retryFor = SQLException.class)
void retryServiceWithRecovery(String sql) throws SQLException;
@Recover
void recover(SQLException e, String sql);
}
Here, it attempts a retry when SQLException is thrown. The @Recover annotation defines a separate recovery method when a @Retryable method fails with a specified exception.
Consequently, if the retryServiceWithRecovery method keeps throwing SqlException after three attempts, it calls the recover() method.
The recovery handler should have the first parameter of type Throwable (optional) and the same return type. The remaining arguments are populated from the argument list of the failed method in the same order.
4.3. Customizing @Retryable’s Behavior
To customize a retry’s behavior, we can use the parameters maxAttempts and backoff:
@Service
public interface MyService {
@Retryable(retryFor = SQLException.class, maxAttempts = 2, backoff = @Backoff(delay = 100))
void retryServiceWithCustomization(String sql) throws SQLException;
}
In the above example, it makes up to two attempts with a delay of 100 milliseconds between attempts.
4.4. Using Spring Properties
We can also use Spring properties in the @Retryable annotation.
To demonstrate this, let’s externalize the values of delay and maxAttempts into a properties file.
First, let’s define the properties in a file called retryConfig.properties:
retry.maxAttempts=3
retry.maxDelay=100
Then, let’s instruct our @Configuration class to load this file:
// ...
@PropertySource("classpath:retryConfig.properties")
public class AppConfig { ... }
Finally, let’s inject the values of retry.maxAttempts and retry.maxDelay in our @Retryable definition:
@Service
public interface MyService {
@Retryable(retryFor = SQLException.class, maxAttemptsExpression = "${retry.maxAttempts}",
backoff = @Backoff(delayExpression = "${retry.maxDelay}"))
void retryServiceWithExternalConfiguration(String sql) throws SQLException;
}
Note that we are now using maxAttemptsExpression and delayExpression instead of maxAttempts and delay.
4.5. Printing Retry Count
To log the retry count when using the @Retryable annotation in Spring, we can use RetrySynchronizationManager.getContext().getRetryCount() within a method to print the current retry attempt:
@Override
public void retryService() {
logger.info("Retry Number: "+ RetrySynchronizationManager.getContext().getRetryCount());
logger.info("throw RuntimeException in method retryService()");
throw new RuntimeException();
}
In the logic above, we’re adding an info log about the retry number. If we execute retryService(), we’ll get logs for each retry with its retry count:
Retry Number: 0
throw RuntimeException in method retryService()
Retry Number: 1
throw RuntimeException in method retryService()
Retry Number: 2
throw RuntimeException in method retryService()
As we can see in the logs, the retry count gets printed for each retry.
5. Using RetryTemplate
5.1. RetryOperations
Spring Retry provides the RetryOperations interface, which supplies overloaded execute() methods:
public interface RetryOperations {
<T> T execute(RetryCallback<T, ? extends Throwable> retryCallback) throws Exception;
...
}
The RetryCallback interface, which is a parameter of execute(), allows the insertion of business logic that needs to be retried upon failure:
public interface RetryCallback<T, E extends Throwable> {
T doWithRetry(RetryContext context) throws Throwable;
}
5.2. Introducing the RetryPolicy Builder API
The RetryTemplate is an implementation of RetryOperations.
Spring Retry simplifies the creation of a RetryPolicy and BackOffPolicy by introducing factory methods and a Builder API (starting from Spring Retry 2.0.0 for some policies). This makes the configuration clearer and more fluent.
The RetryPolicy determines when an operation should be retried. A SimpleRetryPolicy is used to retry a fixed number of times. The number of attempts includes the initial try. For SimpleRetryPolicy, we can set the maximum number of attempts with the maxAttempts parameter in the constructor. This includes the ability to configure zero retries (new SimpleRetryPolicy(1)).
The BackOffPolicy can be used to control backoff between retry attempts. A FixedBackOffPolicy pauses for a fixed period of time before continuing.
5.3. RetryTemplate Configuration
Let’s configure a RetryTemplate bean in our @Configuration class using the new Builder API:
@Configuration
public class AppConfig {
private FixedBackOffPolicy fixedBackOffPolicy(long backOffPeriod) {
FixedBackOffPolicy policy = new FixedBackOffPolicy();
policy.setBackOffPeriod(backOffPeriod);
return policy;
}
@Bean
public RetryTemplate retryTemplate() {
return RetryTemplate.builder()
.maxAttempts(3)
.customBackoff(fixedBackOffPolicy(2000L))
.withListener(new DefaultListenerSupport())
.build();
}
@Bean
public RetryTemplate retryTemplateNoRetry() {
return RetryTemplate.builder()
.maxAttempts(1)
.customBackoff(fixedBackOffPolicy(100L))
.build();
}
}
In the example, we use a helper method for the FixedBackOffPolicy. Further, we use the Builder API RetryTemplate.builder(), which supports a maxAttempts() method that we’ve configured for a maximum of 3 attempts. The maxAttempts includes the initial attempt and all retries. For example, maxAttempts value 3 means one initial attempt and two retries.
Furthermore, we demonstrate that the new Builder API accepts maxAttempts(1) in the RetryTemplateBuilder builder. A value of 1 in maxAttempts() means the initial call is the only attempt; no retries occur.
5.4. Invoking the RetryTemplate
To run code with retry handling, we can call the retryTemplate.execute() method:
retryTemplate.execute(new RetryCallback<Void, RuntimeException>() {
@Override
public Void doWithRetry(RetryContext arg0) {
myService.templateRetryService();
...
}
});
Instead of an anonymous class, we can use a lambda expression:
retryTemplate.execute(arg0 -> {
myService.templateRetryService();
return null;
});
6. Using Listeners
Listeners provide additional callbacks upon retries. We can also utilize these for various cross-cutting concerns across different retry attempts.
6.1. Adding Callbacks
The RetryListener interface provides the callbacks:
public class DefaultListenerSupport extends RetryListenerSupport {
@Override
public <T, E extends Throwable> void close(RetryContext context,
RetryCallback<T, E> callback, Throwable throwable) {
logger.info("onClose");
//...
super.close(context, callback, throwable);
}
@Override
public <T, E extends Throwable> void onError(RetryContext context,
RetryCallback<T, E> callback, Throwable throwable) {
logger.info("onError");
//...
super.onError(context, callback, throwable);
}
@Override
public <T, E extends Throwable> boolean open(RetryContext context,
RetryCallback<T, E> callback) {
logger.info("onOpen");
//...
return super.open(context, callback);
}
}
The open and close callbacks come before and after the entire retry, while onError applies to the individual RetryCallback calls.
6.2. Registering the Listener
We registered our listener DefaultListenerSupport to our RetryTemplate bean using the withListener(new DefaultListenerSupport()) method earlier.
7. Running an Integration Test
To finish our example, let’s verify the results:
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(
classes = AppConfig.class,
loader = AnnotationConfigContextLoader.class
)
public class SpringRetryIntegrationTest {
@Autowired
private MyService myService;
@Autowired
private RetryTemplate retryTemplate;
@Test
public void givenRetryService_whenCallWithException_thenRetry() {
assertThrows(RuntimeException.class, () -> myService.retryService());
}
}
As we can see from the example test logs, we have properly configured the RetryTemplate and the RetryListener:
2020-01-09 20:04:10 [main] INFO o.b.s.DefaultListenerSupport - onOpen
2020-01-09 20:04:10 [main] INFO o.baeldung.springretry.MyServiceImpl
- throw RuntimeException in method templateRetryService()
2020-01-09 20:04:10 [main] INFO o.b.s.DefaultListenerSupport - onError
2020-01-09 20:04:12 [main] INFO o.baeldung.springretry.MyServiceImpl
- throw RuntimeException in method templateRetryService()
2020-01-09 20:04:12 [main] INFO o.b.s.DefaultListenerSupport - onError
2020-01-09 20:04:12 [main] INFO o.b.s.DefaultListenerSupport - onClose
8. Throttling Concurrency with @ConcurrencyLimit
While @Retryable helps recover from transient failures, we can use concurrency throttling to protect resources from being overwhelmed by an excessive number of simultaneous requests.
The @ConcurrencyLimit annotation is part of the Core Spring Resilience features in Spring Framework 7.0 and is used to constrain the number of concurrent executions for a specific method or all proxy-invoked methods within a class.
8.1. Enabling and Using @ConcurrencyLimit
To enable this feature, let’s annotate our @Configuration class with @EnableResilientMethods, which activates concurrency throttling logic:
@Configuration
@EnableRetry
@EnableResilientMethods
public class AppConfig { ... }
The @ConcurrencyLimit annotation specifies the maximum number of concurrent invocations allowed for the annotated method.
This is especially useful when integrating with downstream services or resources that have strict rate limits or capacity constraints, or when using Virtual Threads (Project Loom), where the default concurrency can be very high.
Let’s look at this annotation in brief:
- Syntax: We apply it directly to the service method, e.g., @ConcurrencyLimit(5).
- Behavior: If the limit is reached, it blocks subsequent callers until a running execution completes and a slot becomes available.
- Default Value: The default limit is 1, which is effectively a non-distributed lock on the method, ensuring that only one thread can be executing the annotated method or methods in the class hierarchy at any given time.
8.2. Example Usage
Let’s add a method to MyService that limits its concurrent executions to 5:
@ConcurrencyLimit(5)
void concurrentLimitService();
Further, let’s add a new implementation for the concurrency limit to MyServiceImpl:
@Override
@ConcurrencyLimit(5)
public void concurrentLimitService() {
logger.info("Concurrency Limit Active. Current Thread: " + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
logger.info("Concurrency Limit Released. Current Thread: " + Thread.currentThread().getName());
}
In the implementation, we simulate a time-consuming task to observe throttling. Accordingly, if more than five threads attempt to call concurrentLimitService() at the same time, it blocks the sixth thread until one of the first five finishes.
8.3. Running a JUnit Test on the Concurrency Limit
To effectively test the @ConcurrencyLimit(5) feature, we need to create multiple threads that attempt to call the method simultaneously. This verifies that only five threads execute immediately, while the others block until a slot becomes available. We use concurrent programming primitives like ExecutorService and CountDownLatch for precise control over thread execution:
- ExecutorService: Launches multiple worker threads
- CyclicBarrier (startBarrier): Ensures all 10 threads wait until the moment the test starts, guaranteeing simultaneous access
- AtomicInteger (successfulCalls): Tracks, in a thread-safe manner, how many threads have entered the body of the service method (i.e., successfully passed the concurrency gate)
Let’s verify the throttling behavior with a test method that uses concurrent programming constructs:
@Test
public void givenConcurrentLimitService_whenCalledByManyThreads_thenLimitIsEnforced()
throws InterruptedException, BrokenBarrierException, TimeoutException {
int limit = 5;
int totalThreads = 10;
CountDownLatch releaseLatch = new CountDownLatch(1);
final AtomicInteger successfulCalls = new AtomicInteger(0);
doAnswer((Answer) invocation -> {
successfulCalls.incrementAndGet();
releaseLatch.await(5, TimeUnit.SECONDS);
return null;
}).when(myService).concurrentLimitService();
CyclicBarrier startBarrier = new CyclicBarrier(totalThreads + 1);
CountDownLatch finishLatch = new CountDownLatch(totalThreads);
ExecutorService executor = Executors.newFixedThreadPool(totalThreads);
for (int i = 0; i < totalThreads; i++) { executor.submit(() -> {
try {
startBarrier.await();
myService.concurrentLimitService();
} catch (Exception e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
} finally {
finishLatch.countDown();
}
});
}
startBarrier.await(1, TimeUnit.SECONDS);
Thread.sleep(1000);
assertEquals(limit, successfulCalls.get(),
"Only the defined limit of threads should've successfully entered the service method before rejection.");
releaseLatch.countDown();
finishLatch.await(5, TimeUnit.SECONDS);
executor.shutdownNow();
}
This test confirms that the @ConcurrencyLimit annotation is successfully intercepting the calls and throttling concurrency:
- Preparation and Mocking: To start with, the doAnswer mocks the myService.concurrentLimitService() method. This custom behavior ensures that any thread that successfully passes the @ConcurrencyLimit gate, executes successfulCalls.incrementAndGet() and then immediately blocks on the releaseLatch.await(). Further, the successfulCalls counter tracks the threads that the Spring Aspect allows to enter the method.
- Simultaneous Launch: A CyclicBarrier synchronizes the 10 worker threads and the main test thread. Once all threads are ready (lined up at the barrier), the main thread calls startBarrier.await(), releasing all 10 threads simultaneously to call myService.concurrentLimitService().
- Throttling Enforced: Then, as the 10 threads rush to execute the service method, the @ConcurrencyLimit(5) aspect intervenes. Accordingly, the first 5 threads each acquire a concurrency token, proceed to the mocked method’s body, increment successfulCalls (to 5), and immediately block on the releaseLatch. However, the concurrency limiting mechanism blocks the remaining 5 threads when they attempt to acquire a token before they can enter the mocked method’s body.
- Verification: The main test thread pauses briefly using Thread.sleep(1000) to allow the throttling mechanism to take effect. It then performs the critical assertion, where assertEquals(limit, successfulCalls.get()) verifies that the successfulCalls counter is exactly 5. This proves that the Spring Framework’s concurrency throttle successfully blocked the remaining threads from executing the service logic.
- Clean-up: Finally, the main thread calls releaseLatch.countDown(), unblocking the 5 threads that were paused inside the mocked method, allowing the test to complete gracefully by waiting for all threads via the finishLatch.
9. Conclusion
In this article, we saw how to use Spring Retry using annotations, the RetryTemplate, and callback listeners. Furthermore, we learned that we can use @ConcurrencyLimit for concurrency throttling.
The code backing this article is available on GitHub. Once you're
logged in as a Baeldung Pro Member, start learning and coding on the project.