What is RestTemplate
Microservices to communicate with each other can choose to use a synchronous approach (the caller waits for a response from the called),
or use an asynchronous approach (e.g., using queues). This depends on the requirements one has.
The most common synchronous approach is through calls to REST APIs.
RestTemplate is the Spring class that allows precisely for synchronous REST calls.
It is available with the spring-web library:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
The problem
It may happen that the called microservice is temporarily unreachable (for example, because it is overloaded at that time).
Perhaps, however, if you called it a few seconds later, you would receive a 200!
In microservice architecture, it is important to have a system resilient to these types of errors, and the Retry pattern
comes to our rescue!
RetryTemplate is a Spring class that allows automatic retries if a given call fails,
according to default policies or custom policies.
To use it we need to import the following library:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
An example of using RestTemplate with RetryTemplate:
retryTemplate.execute(retryContext ->
restTemplate.getForObject(url, String.class));
or by annotation:
@Retryable
public void get(URI url) {
restTemplate.getForObject(url, String.class);
}
So to handle retries with RetryTemplate, we would have to use one of these two ways on each method that uses RestTemplate. What if we wanted to centralize the management of retries? There are a variety of ways to do this, I will show you one of the quickest and simplest ways.
A possible solution: override the methods of RestTemplate
We could take advantage of OOP inheritance by overriding the RestTemplate methods on which we are interested in performing retries:
public class RestTemplateRetryable extends RestTemplate {
private final RetryTemplate retryTemplate;
public RestTemplateRetryable(int retryMaxAttempts) {
this.retryTemplate = new CustomRetryTemplateBuilder()
.withRetryMaxAttempts(retryMaxAttempts)
.withHttpStatus(HttpStatus.TOO_MANY_REQUESTS)
.withHttpStatus(HttpStatus.BAD_GATEWAY)
.withHttpStatus(HttpStatus.GATEWAY_TIMEOUT)
.withHttpStatus(HttpStatus.SERVICE_UNAVAILABLE)
.build();
}
@Override
public <T> T getForObject(@NonNull URI url, @NonNull Class<T> responseType) throws RestClientException {
return retryTemplate.execute(retryContext ->
super.getForObject(url, responseType));
}
@Override
public <T> T getForObject(@NonNull String url, @NonNull Class<T> responseType, @NonNull Object... uriVariables) throws RestClientException {
return retryTemplate.execute(retryContext ->
super.getForObject(url, responseType, uriVariables));
}
@Override
public <T> T getForObject(@NonNull String url, @NonNull Class<T> responseType, @NonNull Map<String, ?> uriVariables) throws RestClientException {
return retryTemplate.execute(retryContext ->
super.getForObject(url, responseType, uriVariables));
}
}
The CustomRetryTemplateBuilder class is a builder class that I wrote to create a RetryTemplate object with a custom retry policy, that is, depending on the HTTP statuses that the caller returns to us (I want to perform retries only for certain HTTP statuses, in this case 429, 502, 504 and 503). The class is as follows:
public class CustomRetryTemplateBuilder {
private static final int DEFAULT_MAX_ATTEMPS = 3;
private final Set<HttpStatusCode> httpStatusRetry;
private int retryMaxAttempts = DEFAULT_MAX_ATTEMPS;
public CustomRetryTemplateBuilder() {
this.httpStatusRetry = new HashSet<>();
}
public CustomRetryTemplateBuilder withHttpStatus(HttpStatus httpStatus) {
this.httpStatusRetry.add(httpStatus);
return this;
}
public CustomRetryTemplateBuilder withRetryMaxAttempts(int retryMaxAttempts) {
this.retryMaxAttempts = retryMaxAttempts;
return this;
}
public RetryTemplate build() {
if (this.httpStatusRetry.isEmpty()) {
this.httpStatusRetry.addAll(getDefaults());
}
return createRetryTemplate();
}
private RetryTemplate createRetryTemplate() {
RetryTemplate retry = new RetryTemplate();
ExceptionClassifierRetryPolicy policy = new ExceptionClassifierRetryPolicy();
policy.setExceptionClassifier(configureStatusCodeBasedRetryPolicy());
retry.setRetryPolicy(policy);
return retry;
}
private Classifier<Throwable, RetryPolicy> configureStatusCodeBasedRetryPolicy() {
//one execution + 3 retries
SimpleRetryPolicy simpleRetryPolicy = new SimpleRetryPolicy(1 + this.retryMaxAttempts);
NeverRetryPolicy neverRetryPolicy = new NeverRetryPolicy();
return throwable -> {
if (throwable instanceof HttpStatusCodeException httpException) {
return getRetryPolicyForStatus(httpException.getStatusCode(), simpleRetryPolicy, neverRetryPolicy);
}
return neverRetryPolicy;
};
}
private RetryPolicy getRetryPolicyForStatus(HttpStatusCode httpStatusCode, SimpleRetryPolicy simpleRetryPolicy, NeverRetryPolicy neverRetryPolicy) {
if (this.httpStatusRetry.contains(httpStatusCode)) {
return simpleRetryPolicy;
}
return neverRetryPolicy;
}
private Set<HttpStatusCode> getDefaults() {
return Set.of(
HttpStatusCode.valueOf(HttpStatus.SERVICE_UNAVAILABLE.value()),
HttpStatusCode.valueOf(HttpStatus.BAD_GATEWAY.value()),
HttpStatusCode.valueOf(HttpStatus.GATEWAY_TIMEOUT.value())
);
}
}
Let's analyze the class:
- the httpStatusRetry field indicates for which HTTP statuses to make retries (if the client does not value this field, the list is valued with the value returned by the getDefaults() method)
- the retryMaxAttempts field indicates how many retries to make after the first failed invocation (default value at true)
- we can perform retries based on HTTP statuses due to the fact that RestTemplate throws exceptions of type HttpStatusCodeException that contain the status code. We exploit this exception in the configureStatusCodeBasedRetryPolicy method.
At this point, we can create the RestTemplate bean in a configuration class:
@Configuration
public class AppConfig {
@Bean
RestTemplate restTemplate(@Value("${rest-template-retry.max-attempts}") int retryMaxAttempts) {
return new RestTemplateRetryable(retryMaxAttempts);
}
}
In application.properties: rest-template-retry.max-attempts=3
Let's test RestTemplate
We test the extended RestTemplate using this library:
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.10.0</version>
<scope>test</scope>
</dependency>
which allows us to create a mock of a Web Server and decide what should respond to us on the first invocation, the second, etc.
We test the case where the server responds three times with error, with statuses handled by the retry, and a fourth time with a 200:
@Test
void testRetryWithTreeFails() throws IOException {
RestTemplate restTemplate = new AppConfig().restTemplate(3);
try(MockWebServer mockWebServer = new MockWebServer()) {
String expectedResponse = "expect that it works";
mockWebServer.enqueue(new MockResponse().setResponseCode(429));
mockWebServer.enqueue(new MockResponse().setResponseCode(502));
mockWebServer.enqueue(new MockResponse().setResponseCode(429));
mockWebServer.enqueue(new MockResponse().setResponseCode(200)
.setBody(expectedResponse));
mockWebServer.start();
HttpUrl url = mockWebServer.url("/test");
String response = restTemplate.getForObject(url.uri(), String.class);
assertThat(response).isEqualTo(expectedResponse);
mockWebServer.shutdown();
}
}
As we can see from the image above, the test is green. Four total calls are made.
We also add the test case in which the server, this time, responds to us with error on the first four invocations (all with status handled by the retry) and on the fifth invocation responds to us with 200. In that case, we expect the call to the server to fail since the all retries of the retry are exhausted:
@Test
void testRetryWithFourFails() throws IOException {
RestTemplate restTemplate = new AppConfig().restTemplate(3);
try(MockWebServer mockWebServer = new MockWebServer()) {
String expectedResponse = "expect that it works";
mockWebServer.enqueue(new MockResponse().setResponseCode(429));
mockWebServer.enqueue(new MockResponse().setResponseCode(502));
mockWebServer.enqueue(new MockResponse().setResponseCode(429));
mockWebServer.enqueue(new MockResponse().setResponseCode(429));
mockWebServer.enqueue(new MockResponse().setResponseCode(200)
.setBody(expectedResponse));
mockWebServer.start();
HttpUrl url = mockWebServer.url("/test");
Assertions.assertThrows(HttpClientErrorException.TooManyRequests.class,
() -> restTemplate.getForObject(url.uri(), String.class));
mockWebServer.shutdown();
}
}
Finally, we test the case where the server responds with a status code that is not handled by the retry. In that case we expect the call to the server to fail immediately:
@Test
void testRetryWithFailureNotManaged() throws IOException {
RestTemplate restTemplate = new AppConfig().restTemplate(3);
try(MockWebServer mockWebServer = new MockWebServer()) {
String expectedResponse = "expect that it works";
mockWebServer.enqueue(new MockResponse().setResponseCode(500));
mockWebServer.enqueue(new MockResponse().setResponseCode(200)
.setBody(expectedResponse));
mockWebServer.start();
HttpUrl url = mockWebServer.url("/test");
Assertions.assertThrows(HttpServerErrorException.InternalServerError.class,
() -> restTemplate.getForObject(url.uri(), String.class));
mockWebServer.shutdown();
}
}
Conclusions
In this short article, we saw how to handle REST call retries with RestTemplate and RetryTemplate based on HTTP statuses. In addition, we have centralized the retry configuration so that classes can use RestTemplate in a way that is transparent to retries. You can find the full code on my GitHub repo at the following link: GitHub.
More articles on Spring: Spring.
Articles about Docker: Docker.
Recommended books on Spring, Docker, and Kubernetes:
- Pro Spring 5 (Spring from zero to hero): https://amzn.to/3KvfWWO
- Pivotal Certified Professional Core Spring 5 Developer Exam: A Study Guide Using Spring Framework 5 (per certificazione Spring): https://amzn.to/3KxbJSC
- Pro Spring Boot 2: An Authoritative Guide to Building Microservices, Web and Enterprise Applications, and Best Practices (Spring Boot del dettaglio): https://amzn.to/3TrIZic
- Docker: Sviluppare e rilasciare software tramite container: https://amzn.to/3AZEGDI
- Kubernetes TakeAway: Implementa i cluster K8s come un professionista: https://amzn.to/3dVxxuP