Resilience with Spring Retry

Most software relies on other services and systems to function. This introduces the inevitable possibility that failures might occur within your system that are not your fault. For such scenarios you would want to attempt whatever is failing a couple times before resolving to some other measures. It might be you’re making a call to a third party API and you want to retry the request multiple times in case of failure or simply writing to a database where locking is implemented, spring retry makes this simple.

What is Spring Retry?

Spring retry is a module within the spring ecosystem that provides and simplifies the ability to re-execute failed operations within your code. It provides a set of abstraction that allow you define when to retry, how many times and how to handle exhausted retries i.e. what to do if all your retry attempts failed.

Adding the dependency

Let’s first spring retry dependency to our pom file. This is the latest version at the time of publishing this article.

    <dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
    <version>2.0.3</version>
    </dependency>

    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    <version>6.0.12</version>
    </dependency>

Retrying Business Logic

There’s two ways to retry your business logic: with a recovery callback or without a recovery callback.

When using recovery callback, we’ll be able to perform additional recovery logic like alerting someone that there has been an error in the system. This may be useful when performing non-critical logic which you can ignore and should not stop other processing from continuing.

Without the recovery callback, we’ll not have this and the exception that was retried will be thrown at the end. This may be useful if it’s critical business logic that you’re performing which must succeed in order to continue processing any other logic.

Let’s see examples for both cases. First, we’ll create a RetryTemplate bean that we’ll use to retry our logic. This is the imperative way of implementing retries. There is also declarative way which involves using annotation provided by the library.

@Bean
public RetryTemplate retryTemplate() {
    return RetryTemplate.builder()
            .maxAttempts(4)
            .fixedBackoff(2000L)
            .retryOn(TimeoutException.class)
            .build();
}

We configured that our retry will perform a maximum of 4 attempts if a TimeOutException is thrown with an interval of 2 seconds. By default Exception.class is retried but you can fine-tune which exceptions you want to retry by providing list of exceptions.

Then we’ll add method that we want to retry. In this example we force an exception to be thrown.

@SneakyThrows
private void doSomethingElse() {
    log.debug("doSomethingElse");
    throw new TimeoutException("timeout");
}

Now we’ll call this method from two places: one where we use recovery callback and another where we don’t.

public void doSomething() {
    log.debug("doSomething");
    retryTemplate().execute(context -> {
        doSomethingElse();
        return null;
    });
}

public void doSomethingSafely() {
    log.debug("doSomethingSafely");
    retryTemplate().execute(context -> {
        doSomethingElse();
        return null;
    }, context -> {
        // custom recovery logic
        log.debug("retry exhausted");
        return context.getLastThrowable();
    });
}

Testing

We’ll add the following tests to see the results.

@SpringBootTest
class MyServiceClassTest {
    @Autowired
    private MyServiceClass myServiceClass;

    @Test
    void testDoSomething() {
        // when doSomething is called TimeOutException is thrown and the retryTemplate is called 4 times
        Assertions.assertThrows(TimeoutException.class, () -> myServiceClass.doSomething());
    }

    @Test
    void testDoSomethingSafely() {
        // when doSomethingSafe is called TimeOutException is thrown and the retryTemplate is called 4 times and then callback logic is executed
        Assertions.assertDoesNotThrow(() -> myServiceClass.doSomethingSafely());
    }
}

And sure from the logs we can see our logic is retried as expected.

2023-09-23T16:02:33.163+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomething
2023-09-23T16:02:33.164+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomethingElse
2023-09-23T16:02:35.172+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomethingElse
2023-09-23T16:02:37.179+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomethingElse
2023-09-23T16:02:39.191+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomethingElse
2023-09-23T16:02:39.204+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomethingSafely
2023-09-23T16:02:39.205+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomethingElse
2023-09-23T16:02:41.218+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomethingElse
2023-09-23T16:02:43.226+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomethingElse
2023-09-23T16:02:45.240+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : doSomethingElse
2023-09-23T16:02:45.240+01:00 DEBUG 30520 --- [           main] spring.retry.demo.MyServiceClass         : retry exhausted

Here’s a link to GitHub repo https://github.com/mamin11/spring-retry for this tutorial.