Block Image

Cos'è RestTemplate

I microservizi per comunicare tra loro posso scegliere di utilizzare un approccio sincrono (il chiamante aspetta una risposta dal chiamato), oppure utilizzare un approccio asincrono (ad esempio utilizzando code). Questo dipende dalle esigenze che si hanno.
L'approccio sincrono più diffuso è quello tramite chiamate ad API REST.
RestTemplate è la classe di Spring che permette proprio di effettuare chiamate REST.
È disponibile con la libreria di spring-web:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>
RestTemplate è il client REST sincrono di Spring, ma esiste anche un client REST asincrono, WebClient. Se possibile consiglio, di utilizzare quest'ultimo. Per saperne di più, leggi il mio articolo: Spring WebFlux

Block Image

Il problema

Può succedere che il microservizio chiamato sia temporaneamente irraggiungibile (ad esempio perché in quel momento è in sovraccarico).
Magari però se lo si chiamasse qualche secondo dopo, si riceverebbe un 200!
Nell'architettura a microservizi è importante avere un sistema resiliente a questi tipi di errori e il pattern Retry ci viene in soccorso!

Block Image

RetryTemplate è una classe di Spring che permette di effettuare automaticamente delle retry se una determinata chiamata va in errore, secondo delle policy di default o delle policy personalizzate.
Per utilizzarla dobbiamo importare la seguente libreria:

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

Un esempio di utilizzo di RestTemplate con RetryTemplate:

retryTemplate.execute(retryContext -> 
        restTemplate.getForObject(url, String.class));

oppure tramite annotation:

@Retryable
public void get(URI url) {
   restTemplate.getForObject(url, String.class);
}

Quindi per gestire le retry con RetryTemplate, dovremmo utilizzare uno di questi due modi su ogni metodo che usa RestTemplate. E se volessimo centralizzare la gestione delle retry? Ci sono svariati modi per farlo, io vi mostrerò uno dei modi più semplici e veloci.

Un possibile soluzione: sovrascriviamo i metodi di RestTemplate

Potremmo sfruttare l'ereditarietà della OOP, sovrascrivendo i metodi di RestTemplate su cui siamo interessati a effettuare delle retry:

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));
   }
}

La classe CustomRetryTemplateBuilder è una classe builder che ho scritto per creare un oggetto di RetryTemplate con una policy di retry personalizzata, ovvero a seconda degli HTTP status che il chiamante ci restituisce (voglio effettuare le retry solo per determinati status HTTP, in questo caso 429, 502, 504 e 503). La classe è la seguente:

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())
        );
    }
}

Analizziamo la classe:

  • il campo httpStatusRetry indica per quali HTTP status effettuare le retry (se il client non valorizza questo campo, la lista viene valorizzata col valore restituito dal metodo getDefaults())
  • il campo retryMaxAttempts indica quante retry effettuare dopo la prima invocazione fallita (valore di default a true)
  • possiamo effettuare le retry in base agli status HTTP grazie al fatto che RestTemplate lancia delle eccezioni di tipo HttpStatusCodeException che contengono lo status code. Sfruttiamo questa eccezione nel metodo configureStatusCodeBasedRetryPolicy.

A questo punto, possiamo creare il bean di RestTemplate in una classe di configurazione:

@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

Testiamo RestTemplate

Testiamo il RestTemplate esteso utilizzando questa libreria:

<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>mockwebserver</artifactId>
    <version>4.10.0</version>
    <scope>test</scope>
</dependency>

che ci permette di creare un mock di un Web Server e decidere cosa ci deve rispondere alla prima invocazione, alla seconda, etc.

Testiamo il caso in cui il server risponda per tre volte con errore, con status gestiti dalla retry e una quarta volta con un 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();
  }

}

Block Image

Come possiamo notare dall'immagine sopra, il test è verde. Vengono effettuate quattro chiamate totali.

Aggiungiamo anche il caso di test in cui il server, questa volta, ci risponde con errore alle prime quattro invocazioni (tutti con status gestiti dalla retry) e alla quinta invocazione ci risponde con 200. In quel caso, ci aspettiamo che la chiamata al server fallisca poiché i tutti i tentativi della retry vengono esauriti:

@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();
    }
}

Infine, testiamo il caso in cui il server risponda con uno status code non gestito dalla retry. In quel caso ci aspettiamo che la chiamata al server fallisca subito:

@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();
    }
}

Conclusioni

In questo breve articolo abbiamo visto come gestire le retry di chiamate REST con RestTemplate e RetryTemplate in base agli status HTTP. Inoltre, abbiamo centralizzato la configurazione della retry, cosicché le classi possano utilizzare RestTemplate in modo trasparente alle retry. Trovate il codice completo sul mio repo di GitHub al seguente link: GitHub.

Altri articoli su Spring: Spring.
Articoli su Docker: Docker.

Libri consigliati su Spring, Docker e Kubernetes: