Generic Top

Get started with Spring 5 and Spring Boot 2, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Overview

We expect HTTP API calls between microservices to encounter occasional errors.

In Spring Boot with OpenFeign, the default error handler propagates downstream errors, such as Not Found, as Internal Server Error. This is seldom the best way to convey the error. However, both Spring and OpenFeign allow us to provide our own error handling.

In this article, we'll see how default exception propagation works. We'll also learn how to supply our own errors.

2. Default Exception Propagation Strategy

The Feign client makes interactions between microservices straightforward and highly configurable, using annotations and configuration properties. However, API calls might fail due to any random technical reason, bad user requests, or coding errors.

Fortunately, Feign and Spring have a sensible default implementation for error handling.

2.1. Default Exception Propagation in Feign

Feign uses the ErrorDecoder.Default class for its error handling. With this, whenever Feign receives any non-2xx status code, it passes that to the ErrorDecoder's decode method. The decode method either returns a RetryableException if the HTTP response had a Retry-After header or it returns a FeignException otherwise. When retrying, if the request fails after the default number of retries, then the FeignException will be returned.

The decode method stores the HTTP method key and response in the FeignException.

2.2. Default Exception Propagation in Spring Rest Controller

Whenever the RestController receives any unhandled exception, it returns a 500 Internal Server Error response to the client.

Also, Spring provides a well-structured error response with details such as timestamp, HTTP status code, error, and the path:

{
    "timestamp": "2022-07-08T08:07:51.120+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/myapp1/product/Test123"
}

Let's take a deep dive into this with an example.

3. Example Application

Let's imagine we need to build a simple microservice that returns product information from another external service.

First, let's model the Product class with a few properties:

public class Product {
    private String id;
    private String productName;
    private double price;
}

Then, let's implement the ProductController with the Get Product endpoint:

@RestController("product_controller")
@RequestMapping(value ="myapp1")
public class ProductController {

    private ProductClient productClient;

    @Autowired
    public ProductController(ProductClient productClient) {
        this.productClient = productClient;
    }

    @GetMapping("/product/{id}")
    public Product getProduct(@PathVariable String id) {
        return productClient.getProduct(id);
    }
}

Next, let's see how to register the Feign Logger as a Bean:

public class FeignConfig {

    @Bean
    Logger.Level feignLoggerLevel() {
        return Logger.Level.FULL;
    }
}

Finally, let's implement the ProductClient to interface with the external API:

@FeignClient(name = "product-client", url="http://localhost:8081/product/", configuration = FeignConfig.class)
public interface ProductClient {
    @RequestMapping(value = "{id}", method = RequestMethod.GET")
    Product getProduct(@PathVariable(value = "id") String id);
}

Let's now explore default error propagation using the above example.

4. Default Exception Propagation

4.1. Using WireMock Server

To experiment, we'll need to use a mocking framework to simulate the service we're calling.

First, let's include the WireMockServer Maven dependency:

<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock-jre8</artifactId>
    <version>2.33.2</version>
    <scope>test</scope>
</dependency>

Then, let's configure and start the WireMockServer:

WireMockServer wireMockServer = new WireMockServer(8081);
configureFor("localhost", 8081);
wireMockServer.start();

The WireMockServer is started at the same host and port that the Feign client is configured to use.

4.2. Default Exception Propagation in Feign Client

Feign's default error handler, ErrorDecoder.Default, always throws a FeignException.

Let's mock the getProduct method with the WireMock.stubFor to make it appear to be unavailable:

String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));

assertThrows(FeignException.class, () -> productClient.getProduct(productId));

In the above test case, the ProductClient throws the FeignException when it encounters the 503 error from the downstream service.

Next, let's try the same experiment but with a 404 Not Found response:

String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.NOT_FOUND.value())));

assertThrows(FeignException.class, () -> productClient.getProduct(productId));

Again, we're getting a general FeignException. In this situation, perhaps the user requested something that was wrong and our Spring application needs to know that it's a bad user request so that it can handle things differently.

We should note that FeignException does have a status property containing the HTTP status code, but a try/catch strategy routes exceptions based on their type, rather than their properties.

4.3. Default Exception Propagation in Spring Rest Controller

Let's now see how the FeignException propagates back to the requester.

When the ProductController gets the FeignException from the ProductClientit passes that to its default error handling implementation provided by the framework.

Let's assert when the product service is unavailable:

String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));

mockMvc.perform(get("/myapp1/product/" + productId))
  .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()));

Here, we can see that we get the Spring INTERNAL_SERVER_ERROR. This default behavior is not always the best, as different service errors may require different outcomes.

5. Propagating Custom Exceptions in Feign With the ErrorDecoder

Instead of always returning the default FeignException, we should return some application-specific exceptions based on the HTTP status code.

Let's override the decode method in a custom ErrorDecoder implementation:

public class CustomErrorDecoder implements ErrorDecoder {

    @Override
    public Exception decode(String methodKey, Response response) {
        switch (response.status()){
            case 400:
                return new BadRequestException();
            case 404:
                return new ProductNotFoundException("Product not found");
            case 503:
                return new ProductServiceNotAvailableException("Product Api is unavailable");
            default:
                return new Exception("Exception while getting product details");
        }
    }
}

In our custom decode method, we're returning different exceptions with a few application-specific ones to provide more context for the actual problem. We can also include more details in the application-specific exception messages.

We should note that the decode method returns the FeignException rather than throwing it.

Now, let's configure the CustomErrorDecoder in the FeignConfig as a Spring Bean:

@Bean
public ErrorDecoder errorDecoder() {
   return new CustomErrorDecoder();
}

Alternatively, the CustomErrorDecoder can be configured directly in the ProductClient:

@FeignClient(name = "product-client-2", url = "http://localhost:8081/product/", 
   configuration = { FeignConfig.class, CustomErrorDecoder.class })

Then, let's check whether the CustomErrorDecoder returns ProductServiceNotAvailableException:

String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));

assertThrows(ProductServiceNotAvailableException.class, 
  () -> productClient.getProduct(productId));

Again, let's write a test case to assert the ProductNotFoundException when the product is not present:

String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.NOT_FOUND.value())));

assertThrows(ProductNotFoundException.class, 
  () -> productClient.getProduct(productId));

While we're now providing a variety of exceptions from the Feign client, Spring will still produce a generic internal server error when it catches them all. Since this is not what we desire, let's see how we can improve on that.

6. Propagating Custom Exceptions in Spring Rest Controller

As we've seen, the default Spring Boot error handler provides a generic error response. API Consumers might need detailed information with relevant error responses. Ideally, the error response should be able to explain the problem and help in debugging.

We could override the default exception handler in the RestController in many ways.

We'll look into one such approach to handle errors with the RestControllerAdvice annotation.

6.1. Using @RestControllerAdvice

The @RestControllerAdvice annotation allows us to consolidate multiple exceptions into a single, global error handling component. 

Let's imagine a scenario where the ProductController needs to return a different custom error response based on the downstream error.

First, let's create the ErrorResponse class to customize the error response:

public class ErrorResponse {

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss")
    private Date timestamp;

    @JsonProperty(value = "code")
    private int code;

    @JsonProperty(value = "status")
    private String status;
    
    @JsonProperty(value = "message")
    private String message;
    
    @JsonProperty(value = "details")
    private String details;
}

Now, let's subclass the ResponseEntityExceptionHandler and include the @ExceptionHandler annotation with the error handlers:

@RestControllerAdvice
public class ProductExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({ProductServiceNotAvailableException.class})
    public ResponseEntity<ErrorResponse> handleProductServiceNotAvailableException(ProductServiceNotAvailableException exception, WebRequest request) {
        return new ResponseEntity<>(new ErrorResponse(
          HttpStatus.INTERNAL_SERVER_ERROR,
          exception.getMessage(),
          request.getDescription(false)),
          HttpStatus.INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler({ProductNotFoundException.class})
    public ResponseEntity<ErrorResponse> handleProductNotFoundException(ProductNotFoundException exception, WebRequest request) {
        return new ResponseEntity<>(new ErrorResponse(
          HttpStatus.NOT_FOUND,
          exception.getMessage(),
          request.getDescription(false)),
          HttpStatus.NOT_FOUND);
    }
}

In the above code, the ProductServiceNotAvailableException returns as an INTERNAL_SERVER_ERROR response to the client. In contrast, a user-specific error like ProductNotFoundException is handled differently and returns as a NOT_FOUND response.

6.2. Testing the Spring Rest Controller

Let's test the ProductController when product service is unavailable:

String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));

MvcResult result = mockMvc.perform(get("/myapp2/product/" + productId))
  .andExpect(status().isInternalServerError()).andReturn();

ErrorResponse errorResponse = objectMapper.readValue(result.getResponse().getContentAsString(), ErrorResponse.class);
assertEquals(500, errorResponse.getCode());
assertEquals("Product Api is unavailable", errorResponse.getMessage());

Again, let's test the same ProductController but with a product not found error:

String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.NOT_FOUND.value())));

MvcResult result = mockMvc.perform(get("/myapp2/product/" + productId))
  .andExpect(status().isNotFound()).andReturn();

ErrorResponse errorResponse = objectMapper.readValue(result.getResponse().getContentAsString(), ErrorResponse.class);
assertEquals(404, errorResponse.getCode());
assertEquals("Product not found", errorResponse.getMessage());

The above tests show how the ProductController returns different error responses based on the downstream error.

If we hadn't implemented our CustomErrorDecoder, then the RestControllerAdvice is required to handle the default FeignException as a fallback to have a generic error response.

7. Conclusion

In this article, we've explored how the default error handling is implemented in Feign and Spring.

Also, we've seen how we can customize that in Feign client with CustomErrorDecoder and in the Rest Controller with RestControllerAdvice.

As always, all these code examples can be found over on GitHub.

Generic bottom

Get started with Spring 5 and Spring Boot 2, through the Learn Spring course:

>> CHECK OUT THE COURSE
Generic footer banner
Comments are closed on this article!