Getting the response body in error case with Spring WebClient
Asked Answered
E

2

7

I am trying to replace the existing client code with RestTemplate with a WebClient. For that reason, most of the calls need to be blocking, so that the main portion of the application does not need to change. When it comes to error handling this poses a bit of a problem. There are several cases that have to be covered:

  • In a successful case the response contains a JSON object of type A
  • In an error case (HTTP status 4xx or 5xx) the response may contain a JSON object of type B
  • On certain requests with response status 404 I need to return an empty List matching the type of a successful response

In order to produce the correct error (Exception) the error response needs to be considered. So far I am unable to get my hands on the error body.

I am using this RestController method to produce the error response:

@GetMapping("/error/404")
@ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity error404() {
    return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse());
}

With this response object:

public class ErrorResponse {    
    private String message = "Error message";    
    public String getMessage() {
        return message;
    }
}

The WebClient is defined as follows:

WebClient.builder()
    .baseUrl("http://localhost:8081")
    .clientConnector(connector)
    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
    .build();

With the connector being of type CloseableHttpAsyncClient (Apache Http client5).

From my test application I make the call like this:

public String get(int httpStatus) {
    try {
        return webClient.get()
                .uri("/error/" + httpStatus)
                .retrieve()
                .onStatus(HttpStatus::isError, clientResponse -> {
                    clientResponse.bodyToMono(String.class).flatMap(responseBody -> {
                        log.error("Body from within flatMap within onStatus: {}", responseBody);
                        return Mono.just(responseBody);
                    });
                    return Mono.error(new RuntimeException("Resolved!"));
                })
                .bodyToMono(String.class)
                .flatMap(clientResponse -> {
                    log.warn("Body from within flatMap: {}", clientResponse);
                    return Mono.just(clientResponse);
                })
                .block();
    } catch (Exception ex) {
        log.error("Caught Error: ", ex);
        return ex.getMessage();
    }
}

What I get is the RuntimeException from the onStatus return and of course the caught exception in the end. I am missing the processing from the bodyToMono from within the onStatus. My suspicion is that this is not executed due to the blocking nature, as the response body is dealt with the bodyToMono after the onStatus. When commenting out the onStatus I would expect that we log the warning in the flatMap, which does not happen either.

In the end I would like to define the handling of errors as a filter so that the code does not need to be repeated on every call, but I need to get the error response body, so that the exception can be populated with the correct data.

How can I retrieve the error response in a synchronous WebClient call?

This question is similar to Spring Webflux : Webclient : Get body on error, which has no accepted answer and some of the suggested approaches use methods that are no deprecated.

Eddaeddana answered 28/3, 2022 at 6:7 Comment(0)
E
5

Here is one approach to handle error responses:

  • use onStatus to capture error http status
  • deserialize error response clientResponse.bodyToMono(ErrorResponse.class)
  • generate new error signal based on the error response Mono.error(new RuntimeException(error.getMessage())). Example uses RuntimeException but I would suggest to use custom exception to simplify error handling downstream.
webClient.get()
        .uri("/error/" + httpStatus)
        .retrieve()
        .onStatus(HttpStatus::isError, clientResponse ->
                clientResponse.bodyToMono(ErrorResponse.class)
                        .flatMap(error ->
                                Mono.error(new RuntimeException(error.getMessage()))
                        )
        )
        .bodyToMono(Response.class)

You don't really need try-catch. If you block the above code would return Response in case of the non-error response and throws exception with custom message in case of error response.

Update

Here is a full test using WireMock

class WebClientErrorHandlingTest {
    private WireMockServer wireMockServer;

    @BeforeEach
    void init() {
        wireMockServer = new WireMockServer(wireMockConfig().dynamicPort());
        wireMockServer.start();
        WireMock.configureFor(wireMockServer.port());
    }

    @Test
    void test() {
        stubFor(post("/test")
                .willReturn(aResponse()
                        .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                        .withStatus(400)
                        .withBody("{\"message\":\"Request error\",\"errorCode\":\"10000\"}")
                )
        );

        WebClient webClient = WebClient.create("http://localhost:" + wireMockServer.port());

        Mono<Response> request = webClient.post()
                .uri("/test")
                .retrieve()
                .onStatus(HttpStatus::isError, clientResponse ->
                        clientResponse.bodyToMono(ErrorResponse.class)
                                .flatMap(error ->
                                        Mono.error(new RuntimeException(error.getMessage() + ": " + error.getErrorCode()))
                                )
                )
                .bodyToMono(Response.class);


        RuntimeException ex = assertThrows(RuntimeException.class, () -> request.block());
        assertEquals("Request error: 10000", ex.getMessage());
    }

    @Data
    private static class ErrorResponse {
        private String message;
        private int errorCode;
    }

    @Data
    private static class Response {
        private String result;
    }
}
Edible answered 28/3, 2022 at 16:43 Comment(4)
That is already part of the example and that does not work. I assume it is because I have block() at the end.Eddaeddana
please be more specific. what exactly doesn't work?. this is a working example and`block is not an issueEdible
check the updated example. hopefully it helps. As for block, please check another answer #71648005 to understand how reactive signals are transformed after block.Edible
Tanks for the update, now I see where I went wrong: in the lambda function of the onStatus I had the bodyToMono followed by the return statement of the RuntimeException. Therefore I got the runtime exception and the other part was never executed as it is not returned.Eddaeddana
S
2

I know I'm late here, but I had to do quite some digging the past couple of days to get a solution that I wanted. In our situation, we just wanted the response to process normally from the WebClient's perspective no matter the status. We have a uniform format that all API's adhere to when returning a response between services.

From the ResponseSpec interface JavaDoc for onStatus method:

To suppress the treatment of a status code as an error and process it as a normal response, return Mono.empty() from the function. The response will then propagate downstream to be processed.

var response = webClient
           .method()
           .retrieve()
           .onStatus(HttpStatus::isError, clientResponse -> Mono.empty())
           .toEntity(String.class)

Can do whatever I want with the response object from here on.

Symptomatology answered 18/5, 2023 at 20:54 Comment(1)
Thank you for hint. The json response is really routed as is, but unfortunately the http status (e.g. 4xx/5xx) is changed to 200.Patron

© 2022 - 2024 — McMap. All rights reserved.