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
andRestControllerAdvice
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
.
-
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.
-
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. -
Add code in the GET-mapped method such that if the order ID is less than zero, it throws an
IllegalArgumentException
. -
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.
-
Create a new class called
BadRequestControllerAdvice
and annotate it with@RestControllerAdvice
. -
Move the
handleIllegalArgumentAsBadRequest
exception handler method from the controller class into this new advice class. -
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.
-
Create a new class called
ErrorResponse
and add two private final fields:int statusCode
- this will hold the HTTP status codeString message
- this will hold the error message
-
Generate a constructor for
ErrorResponse
, along with getters for both fields. -
In the
BadRequestControllerAdvice
class, change thehandleIllegalArgumentAsBadRequest
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())); }
-
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 (theIllegalArgumentException
) and the returned status in theResponseEntity
. It's useful to keep them as they provide useful documentation.
Spring Boot Error Handling
For general error handling, see https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-error-handling
For exception handlers, see https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/web.html#mvc-ann-exceptionhandler
For controller advice handling, see https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/web.html#mvc-ann-controller-advice
For a writeup on exception handling, check out this page: https://reflectoring.io/spring-boot-exception-handling/
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