Spring Webflux Retries

If you use Spring Webflux you probably want your requests to be more resilient. In this case we can just use the retries that come packaged with the Webflux library.
There are various cases that we can take into account:

  • too many requests to the server
  • an internal server error
  • unexpected format
  • server timeout

We would make a test case for those using MockWebServer.

We shall add the WebFlux and the MockWebServer to a project:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
            <version>2.7.15</version>
        </dependency>

        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>mockwebserver</artifactId>
            <version>4.11.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
            <version>3.5.9</version>
        </dependency>

Let’s check the scenario of too many requests on the server. In this scenario our request fails because the server will not fulfil it. The server is still functional however and on another request, chances are we shall receive a proper response.

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.SocketPolicy;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;

class WebFluxRetry {

    @Test
    void testTooManyRequests() throws IOException {
        MockWebServer server = new MockWebServer();
        MockResponse tooManyRequests = new MockResponse()
                .setBody("Too Many Requests")
                .setResponseCode(429);
        MockResponse successfulRequests = new MockResponse()
                .setBody("successful");

        server.enqueue(tooManyRequests);
        server.enqueue(tooManyRequests);
        server.enqueue(successfulRequests);
        server.start();

        WebClient webClient = WebClient.builder()
                .baseUrl("http://" + server.getHostName() + ":" + server.getPort())
                .build();

        Mono<String> result = webClient.get()
                .retrieve()
                .bodyToMono(String.class)
                .retry(2);

        StepVerifier.create(result)
                .expectNextMatches(s -> s.equals("successful"))
                .verifyComplete();

        server.shutdown();
    }
}

We used mock server in order to enqueue requests. Essentially the requests we placed on the mock server will be enqueued and consumed every time we do a request. The first two responses would be failed 429 responses from the server.

Let’s check the case of 5xx responses. A 5xx can be caused by various reasons. Usually if we face a 5xx probably there is a problem in the server codebase. However in some cases 5xx might come as a result of an unstable service that regularly restarts, also a server might be deployed in an availability zone that faces network issues, it can even be a failed rollout which is not fully in effect. In this case a retry makes sense. By retrying, the request will be routed to the next server behind the load balancer.
What we shall try a request that has a bad status:

    @Test
    void test5xxResponse() throws IOException {
        MockWebServer server = new MockWebServer();
        MockResponse tooManyRequests = new MockResponse()
                .setBody("Server Error")
                .setResponseCode(500);
        MockResponse successfulRequests = new MockResponse()
                .setBody("successful");

        server.enqueue(tooManyRequests);
        server.enqueue(tooManyRequests);
        server.enqueue(successfulRequests);
        server.start();

        WebClient webClient = WebClient.builder()
                .baseUrl("http://" + server.getHostName() + ":" + server.getPort())
                .build();

        Mono<String> result = webClient.get()
                .retrieve()
                .bodyToMono(String.class)
                .retry(2);

        StepVerifier.create(result)
                .expectNextMatches(s -> s.equals("successful"))
                .verifyComplete();

        server.shutdown();
    }

Also a response with a wrong format is possible to happen if an application goes haywire:

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    private static class UsernameResponse {
        private String username;
    }

    @Test
    void badFormat() throws IOException {
        MockWebServer server = new MockWebServer();
        MockResponse tooManyRequests = new MockResponse()
                .setBody("Plain text");
        MockResponse successfulRequests = new MockResponse()
                .setBody("{\"username\":\"test\"}")
                .setHeader("Content-Type","application/json");

        server.enqueue(tooManyRequests);
        server.enqueue(tooManyRequests);
        server.enqueue(successfulRequests);
        server.start();

        WebClient webClient = WebClient.builder()
                .baseUrl("http://" + server.getHostName() + ":" + server.getPort())
                .build();

        Mono<UsernameResponse> result = webClient.get()
                .retrieve()
                .bodyToMono(UsernameResponse.class)
                .retry(2);

        StepVerifier.create(result)
                .expectNextMatches(s -> s.getUsername().equals("test"))
                .verifyComplete();

        server.shutdown();
    }

If we break it down we created two responses with plain text format. Those responses would be rejected since they cannot be mapped to the UsernameResponse object. Thanks to the retries we managed to get a successful response.

Our last request would tackle the case of a timeout:

    @Test
    void badTimeout() throws IOException {
        MockWebServer server = new MockWebServer();
        MockResponse dealayedResponse= new MockResponse()
                .setBody("Plain text")
                .setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY)
                .setBodyDelay(10000, TimeUnit.MILLISECONDS);
        MockResponse successfulRequests = new MockResponse()
                .setBody("successful");

        server.enqueue(dealayedResponse);
        server.enqueue(successfulRequests);
        server.start();

        WebClient webClient = WebClient.builder()
                .baseUrl("http://" + server.getHostName() + ":" + server.getPort())
                .build();

        Mono<String> result = webClient.get()
                .retrieve()
                .bodyToMono(String.class)
                .timeout(Duration.ofMillis(5_000))
                .retry(1);

        StepVerifier.create(result)
                .expectNextMatches(s -> s.equals("successful"))
                .verifyComplete();

        server.shutdown();
    }

That’s it, thanks to retries our codebase was able to recover from failures and become more resilient. Also we used MockWebServer which can be very handy for simulating these scenarios.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.