Implementing Exponential Backoff With Spring Retry
Optimize Java retries with Spring Retry. Replace manual loops with @Retryable, add exponential backoff, and manage API, DB, and messaging failures easily.
Join the DZone community and get the full member experience.
Join For FreeHi, engineers! Have you ever been asked to implement a retry algorithm for your Java code? Or maybe you saw something similar in the codebase of your project?
public void someActionWithRetries() {
int maxRetries = 3;
int attempt = 0;
while (true) {
attempt++;
try {
System.out.println("attempt number = " + attempt);
performTask();
System.out.println("Task completed");
break;
} catch (Exception e) {
System.out.println("Failure: " + e.getMessage());
if (attempt >= maxRetries) {
System.out.println("Max retries attempt”);
throw new RuntimeException("Unable to complete task after " + maxRetries + " attempts", e);
}
System.out.println("Retrying");
}
}
}
We can see that the code above executes a while
loop until a task is successfully performed or the maximum retry attempt is reached. In this scenario, an exception is thrown, and the method execution terminates.
But what if I tell you that this code might be wrapped into one line method with one annotation?
This is the moment Spring Retry enters the room.
Let’s first answer this simple question: When do we need retries?
- API integration. Our downstream service might be unavailable for short periods or just throttling, and we want to retry in case of any of these scenarios.
- DB connection. DB Transaction may fail because of, for instance, replicas switching or just because of a short time peak in
db
load. We want to implement retries to back up these scenarios as well. - Messaging processing. We want to make sure that when we are consuming messages our service will not fail processing messages in case of first error. Our goal is to give a second chance before sending a message to a dead letter queue.
Implementing Spring Retry
To add Spring Retry to your application, you need to add two dependencies first: Spring Retry and Spring AOP. As of writing this article, the versions below are the latest.
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.11</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
<version>6.2.2</version>
</dependency>
We also need to enable retries using annotation @EnableRetry
. I’m adding this annotation above @SpringBootApplication
annotation.
@EnableRetry
@SpringBootApplication
public class RetryApplication {
public static void main(String[] args) {
SpringApplication.run(RetryApplication.class, args);
}
}
Remember the code I started with? Let’s create a new service and put this code into it. Also, let’s add an implementation of the performTask
method, which typically throws exceptions.
@Service
public class RetriableService {
public void someActionWithRetries() {
int maxRetries = 3;
int attempt = 0;
while (true) {
attempt++;
try {
System.out.println("attempt number = " + attempt);
performTask();
System.out.println("Task completed");
break;
} catch (Exception e) {
System.out.println("Failure: " + e.getMessage());
if (attempt >= maxRetries) {
System.out.println("Max retries attempt");
throw new RuntimeException("Unable to complete task after " + maxRetries + " attempts", e);
}
System.out.println("Retrying");
}
}
}
private static void performTask() throws RuntimeException {
double random = Math.random();
System.out.println("Random =" + random);
if (random < 0.9) {
throw new RuntimeException("Random Exception");
}
System.out.println("Exception was not thrown");
}
}
And let’s add this service execution to our application entry point.
@EnableRetry
@SpringBootApplication
public class RetryApplication {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(RetryApplication.class, args);
RetriableService bean = ctx.getBean(RetriableService.class);
bean.someActionWithRetries();
}
}
Our main goal is to execute performTask
without exceptions. We implemented a simple retry strategy using a while loop and manually managing the number of retries and behavior in case of errors.
Additionally, we updated our main method, just to make code executable (you may execute it any way you prefer to, it actually does not matter).
When we run our application, we may see a similar log:
attempt number = 1
Random =0.2026321848196292
Failure: Random Exception
Retrying
attempt number = 2
Random =0.28573469016365216
Failure: Random Exception
Retrying
attempt number = 3
Random =0.25888484319397653
Failure: Random Exception
Max retries attempt
Exception in thread "main" java.lang.RuntimeException: Unable to complete task after 3 attempts
As we can see, we tried the times and threw an exception when all three attempts failed. Our application is working, but just to execute a one-line method, we added a lot of lines of code. And what if we want to cover another method with the same retry mechanism? Do we need to copy-paste our code? We see that even though our solution is a working one, it does not seem to be an optimal one.
Is there a way to make it better?
Yes, there is. We are going to add Spring Retry to our logic. We’ve already added all necessary dependencies and enabled Retry by adding annotation. Now, let’s make our method retriable using Spring Retry.
We just need to add the following annotation and provide the number of maximum attempts:
@Retryable(maxAttempts = 3)
In the second step, we need to delete all the useless code, and this is how our service looks now:
@Retryable(maxAttempts = 3)
public void someActionWithRetries() {
performTask();
}
private static void performTask() throws RuntimeException {
double random = Math.random();
System.out.println("Random =" + random);
if (random < 0.9) {
throw new RuntimeException("Random Exception");
}
System.out.println("Exception was not thrown");
}
When we execute our code, we will see the following log line:
Random =0.04263677120823861
Random =0.6175610369948504
Random =0.226853770441114
Exception in thread "main" java.lang.RuntimeException: Random Exception
The code is still trying to perform the task and fails after three unsuccessful attempts. We have already improved our code by adding an aspect to handle retries. But, we also can make our retries more efficient by introducing an exponential backoff strategy.
What Is Exponential Backoff?
Imagine you are calling an external API for which you have some quotas. Sometimes, when you reach your quota, this API throws a throttling exception saying your quota has been exceeded and you need to wait some time to be able to make an API call again.
You know that quotas should be reset quite soon, but I don’t know when exactly it will happen. So, you decide to keep making API calls until a successful response is received, but increase the delay for every next call.
For instance:
1st call - failure
Wait 100ms
2nd call - failure
Wait 200ms
3rd call - failure
Wait 400ms
4th call - success
You can see how delays between calls are exponentially increasing. This is exactly what the exponential backoff strategy is about -> retying with exponentially increasing delay.
And yes, we can simply implement this strategy using Spring Retry. Let’s extend our code:
@Retryable(maxAttempts = 10, backoff = @Backoff(delay = 100, multiplier = 2.0, maxDelay = 1000))
public void someActionWithRetries() {
performTask();
}
We’ve increased maxAttempts
value to 10, and added backoff configuration with the following params:
-
Delay – delay in milliseconds for the first retry
-
Multiplier – multiplier for 2nd and following retry. In our case, the second retry will happen in 200ms after the 1st retry failed. If the second retry fails, the third will be executed in 400ms, etc.
-
maxDelay – the limit in milliseconds for delays. When your delay reaches the
maxDelay
value, it is not increasing anymore.
Let’s add one more log line to be able to track milliseconds of the current timestamp in the performTask
method and execute our code:
private static void performTask() throws RuntimeException {
System.out.println("Current timestamp=" +System.currentTimeMillis()%100000);
........
}
Current timestamp=41935
Random =0.5630325878313412
Current timestamp=42046
Random =0.3049870877017091
Current timestamp=42252
Random =0.6046786246149355
Current timestamp=42658
Random =0.35486866685708773
Current timestamp=43463
Random =0.5374704153455458
Current timestamp=44469
Random =0.922956819951388
Exception was not thrown
We can see that it took six attempts (five retries) to perform the task without an exception. We can also see that the difference between the first and second execution is about 100 ms, as configured.
The difference between the second and third execution is about 200 ms, confirming that the multiplier of 2 is working as expected.
Pay attention to the delay before the last execution. It is not 1,600 ms, as we might have expected (a multiplier of 2 for the fifth execution), but 1,000 ms because we set that as the upper limit.
Conclusion
We successfully implemented an exponential backoff strategy using Spring Retry. It helped us to get rid of Utils
code and make our retry strategy more manageable. We also discussed scenarios when retries are mostly used, and now we are more aware of when to use them.
The functionality I showed in this article is only about 30% of what Spring Retry allows us to do, and we will see more advanced approaches in the next article.
Opinions expressed by DZone contributors are their own.
Comments