javaspringresthttpclient

Handling 404 Errors Gracefully with Spring's RestClient


I am currently working with the new RestClient in Spring Boot 3.2/Spring Framework 5.1, and I have encountered a challenge with handling 404 errors. My goal is to gracefully handle these errors without causing subsequent steps in my code to fail, particularly when converting the response body.

Example:

class CmsClient {
    pubic Policy getPolicy() {
        return restClient.get()
            .uri("some/url")
            .accept(MediaType.APPLICATION_JSON)
            .retrieve()
            .onStatus(ignore404())
            .body(Policy.class);
    }


    public static ResponseErrorHandler ignore404() {
        return new ResponseErrorHandler() {

            @Override
            public boolean hasError(final ClientHttpResponse response) throws IOException {
                return response.getStatusCode() == HttpStatus.NOT_FOUND;
            }

            @Override
            public void handleError(final ClientHttpResponse response) {
                //Do nothing
            }
        };
    }
}

This is the exception I get:

org.springframework.web.client.RestClientException: Error while extracting response for type [package.Policy] and content type [application/json]
    at org.springframework.web.client.DefaultRestClient.readWithMessageConverters(DefaultRestClient.java:216)
    at org.springframework.web.client.DefaultRestClient$DefaultResponseSpec.readBody(DefaultRestClient.java:641)
    at org.springframework.web.client.DefaultRestClient$DefaultResponseSpec.body(DefaultRestClient.java:589)
    at package.CmsClient.getPolicy(CmsClient.java:27)
    at package.CmsClientTest.getPolicy_404_ThrowException_Test(CmsClientTest.java:61)
    at java.base/java.lang.reflect.Method.invoke(Method.java:580)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
Caused by: org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: No content to map due to end-of-input
    at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:406)
    at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.read(AbstractJackson2HttpMessageConverter.java:354)
    at org.springframework.web.client.DefaultRestClient.readWithMessageConverters(DefaultRestClient.java:200)
    ... 7 more
Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input
 at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 0]
    at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59)
    at com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1752)
    at com.fasterxml.jackson.databind.ObjectReader._initForReading(ObjectReader.java:360)
    at com.fasterxml.jackson.databind.ObjectReader._bindAndClose(ObjectReader.java:2095)
    at com.fasterxml.jackson.databind.ObjectReader.readValue(ObjectReader.java:1481)
    at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:395)
    ... 9 more

I anticipate that the method .body(Policy.class); should return null instead of triggering an exception due to a MismatchedInputException. This behavior aligned with what was previously observed in WebClient, and I was expecting a similar approach in this context.

Current Solution: As a temporary measure, one can specify String.class in the body method call. However, this approach compromises the fluidity and ease of the code, necessitating additional handling with ObjectMapper and similar tools.


Solution

  • I solved it by registering a custom message converter:

    public RestClient create(String baseUrl, Duration timeout) {
            return RestClient.builder()
                .baseUrl("...")
                //addFirst is important
                .messageConverters(converterList -> converterList.addFirst(new JsonMessageConverter()))
                .build();
        }
    

    This is my custom message converter:

    import org.jetbrains.annotations.NotNull;
    import org.springframework.http.HttpHeaders;
    import org.springframework.http.HttpInputMessage;
    import org.springframework.http.converter.HttpMessageNotReadableException;
    import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
    
    import java.io.ByteArrayInputStream;
    import java.io.IOException;
    import java.io.InputStream;
    import java.lang.reflect.Type;
    
    class JsonMessageConverter extends MappingJackson2HttpMessageConverter {
    
        @Override
        public Object read(@NotNull Type type, Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
            var bytes = inputMessage.getBody().readAllBytes();
            if (bytes.length == 0) {
                return null;
            }
    
            return super.read(type, contextClass, new HttpInputMessageWrapper(new ByteArrayInputStream(bytes), inputMessage.getHeaders()));
        }
    
        private static class HttpInputMessageWrapper implements HttpInputMessage {
            private final InputStream body;
            private final HttpHeaders headers;
    
            private HttpInputMessageWrapper(InputStream body, HttpHeaders headers) {
                this.body = body;
                this.headers = headers;
            }
    
            @Override
            public @NotNull InputStream getBody() {
                return this.body;
            }
    
            @Override
            public @NotNull HttpHeaders getHeaders() {
                return this.headers;
            }
        }
    }
    

    And then, I add the error handler I posted in the question to handle 404 gracefully:

        Optional<Policy> fetchDataRemotly() {
            var policy = restClient.get()
                .uri("...")
                .accept(MediaType.APPLICATION_JSON)
                .retrieve()
                .onStatus(RestClientFactory.ignore404())
                .body(Policy.class);
    
            /*
             * Variable 'policy' will be null if we get 404.
             */
            return Optional.ofNullable(policy);
        }