Skip to content

Lab 10: Exception Handling

Description

When a coffee order ID isn't found, we currently throw an Exception. Instead, we'd like to respond with the standard 404 (Not Found) status code.

Goals

  • Discover how to adjust the defaults for JSON error responses
  • Learn about ResponseEntity for more control over response headers
  • See different exception handling methods with ExceptionHandler and RestControllerAdvice

a. Current Behavior

Using a tool like cURL or Postman (not a browser, because we want the JSON content and not HTML), do a GET with an invalid Order ID, e.g.:

curl -v localhost:8080/api/coffee/orders/9999

You'll see something like the following:

{
  "timestamp": "2020-12-08T01:06:20.790+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "trace": "... a whole long stacktrace here ...",
  "message": "No message available",
  "path": "/api/coffee/orders/9999"
}

In a production environment, you generally don't want all that information, but the reason such detail is returned is because we have Spring Boot DevTools as a dependency, which turns on troubleshooting options when running locally. Let's turn off the stacktrace detail. Add the following to the application.properties file:

# Note: When DevTools is active, this is set to ALWAYS
server.error.include-stacktrace=never

Dev Tools Property Defaults

For all of the properties that Dev Tools sets, see the org.springframework.boot.devtools.env.DevToolsPropertyDefaultsPostProcessor class.

Restart the application and do the GET request again and you'll now see something like:

{
  "timestamp": "2020-12-08T01:05:14.523+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "No message available",
  "path": "/api/coffee/orders/9999"
}

In the next step, we'll convert it from a regular exception, which causes a 500 status code, to a 404.

Spring Boot Server Properties

For all of the available server properties that you can set in application.properties, see the Spring Boot documentation here: https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#server-properties. The ones we're concerned with here are the server.error.* properties.


b. Add Failing Test

Before we change the code to return a 404 status, we'll create a failing test.

Add the following test to the CoffeeOrderWebTest class, which should fail with a NoSuchElementException:

@Test
public void getNonExistentCoffeeOrderReturnsNotFoundStatus() throws Exception {
  mockMvc.perform(get("/api/coffee/orders/9999")
                  .accept(MediaType.APPLICATION_JSON))
         .andExpect(status().isNotFound());
}

c. ResponseEntity.ok

In the CoffeeOrderController, the GET-mapped method currently returns a CoffeeOrderResponse object, which gets converted to JSON and returned in the body of the HTTP response. In order to have control over the response headers, such as the status code, we need to use Spring's ResponseEntity. To use it, change the method to return a ResponseEntity<CoffeeOrderResponse> like so:

public ResponseEntity<CoffeeOrderResponse> coffeeOrder...

Then change the return in the method to use ResponseEntity.ok(/*body*/) to return the response:

...
CoffeeOrderResponse response = CoffeeOrderResponse.from(coffeeOrder);
return ResponseEntity.ok(response);

Warning

If you find unit tests that won't compile due to this change in the return value, you can extract the response from the ResponseEntity by calling getBody() on it. E.g.:

CoffeeOrderResponse coffeeOrderResponse = coffeeOrderController.coffeeOrder(10L).getBody();

This hasn't fixed the problem yet, but remains compatible with other tests, so those should remain passing (so make sure to run them!).

d. ResponseEntity.notFound

Now you can write code to check for the coffee order being found. If the Optional is present, then return the response, otherwise return "Not Found" like this (unlike the ok() method, this needs the .build() to create the entity):

return ResponseEntity.notFound().build();

Run all the tests and they should all pass.

e. Exception Handler Method

There are some errors that you might want to handle across multiple methods in the same class, such as an ID being negative (which is always invalid). Here, we'll use @ExceptionHandler which is like a high-level try..catch.

  1. Add a new test to CoffeeOrderWebTest:

    @Test
    public void getNegativeIdReturnsBadRequestStatus() throws Exception {
        mockMvc.perform(get("/api/coffee/orders/-1")
                          .accept(MediaType.APPLICATION_JSON))
               .andExpect(status().isBadRequest());
    }
    

    Run it and watch it fail.

  2. In the CoffeeOrderController add a new method:

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public void handleIllegalArgumentAsBadRequest() {
    }
    

    This method will be executed when any IllegalArgumentException is thrown within the same controller class and will cause an HTTP status of Bad Request (400) to be returned (via the @ResponseStatus annotation). No actual behavior is needed as the annotations will tell Spring what to do.

  3. Add code in the GET-mapped method such that if the order ID is less than zero, it throws an IllegalArgumentException.

  4. Run the test, and it should now pass.

f. Capturing Exceptions Across Controllers

In order to handle exceptions in the same way across multiple controllers, you can use the @RestControllerAdvice annotation.

  1. Create a new class called BadRequestControllerAdvice and annotate it with @RestControllerAdvice.

  2. Move the handleIllegalArgumentAsBadRequest exception handler method from the controller class into this new advice class.

  3. Run the tests and they should continue to pass.


Once you've completed the above steps, let the instructor know. If there's still time left for this lab, you may continue with the optional item below.


g. Custom Error Response [optional]

(If you have time during the lab exercise period, you can try this section.)

Instead of using the default error message, you can make your own.

  1. Create a new class called ErrorResponse and add two private final fields:

    1. int statusCode - this will hold the HTTP status code
    2. String message - this will hold the error message
  2. Generate a constructor for ErrorResponse, along with getters for both fields.

  3. In the BadRequestControllerAdvice class, change the handleIllegalArgumentAsBadRequest to be:

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentAsBadRequest(
                            IllegalArgumentException exception) {
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(new ErrorResponse(
                        HttpStatus.BAD_REQUEST.value(),
                        exception.getMessage()));
    }
    
  4. Try it out using cURL or Postman and pass in a negative value.

    Optional Annotation Information

    The method-level annotation @ResponseStatus and the exception parameter to the @ExceptionHandler annotation are optional as they repeat information that is in the method's argument (the IllegalArgumentException) and the returned status in the ResponseEntity. It's useful to keep them as they provide useful documentation.


HTTP Problem Details RFC Standard

The RFC that describes a proposed standard for a "problem details" response is described here: https://datatracker.ietf.org/doc/html/rfc7807

A Spring-based implementation that does all the work for you can be found here: https://github.com/zalando/problem-spring-web