A TopTal post on Spring Boot rest API error handling here gives a foundation on error handling with Spring Boot. This article will focus on using error codes for characterizing errors; assigning exception handlers to groups of controllers; and the order of precedence in exception handling using exception handlers.
The practice of wrapping errors in an ApiError object, as described in the aforementioned TopTal blog post helps us to remove traces of implementation details (like exception names and stack traces) from our error responses and ensure that error responses always provide value to the consuming client.
Utilization of Error Codes
In addition to an ApiError object, an integer representing the specific error can be added as a property of the ApiError object. This enables the creation of a faster, more comprehensive, and extensible error handling implementation for the consuming client. If an error code is not used, the consuming client will be forced to process string representations of errors in addition to processing the HTTP status code. This is more CPU intensive than a simple integer comparison when error codes are used.
Error codes also provide a way of standardizing an API. A proper documentation of error codes returned by an API is sufficient for an API client to perform error handling when consuming the API.
By decoupling the HTTP status code from the error codes using error code status resolver implementations, we provide a way to change the HTTP status code of an error in an organized way, without modifying error handling or business logic.
Below is an example of an APIError class that can be used:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Data | |
@Builder | |
public class ApiError { | |
private LocalDateTime timestamp; | |
private String error; | |
private int errorCode; | |
@Singular | |
private List<ErrorCause> causes; | |
private ApiError(LocalDateTime timestamp, String error, int errorCode, List<ErrorCause> causes) { | |
this.timestamp = timestamp == null? LocalDateTime.now(): timestamp ; | |
this.error = error == null? "": error; | |
this.errorCode = errorCode; | |
this.causes = causes == null? Collections.emptyList(): causes; | |
} | |
} |
Error Handling
Now that we have an ApiError class, how do we properly handle errors in API’s? Most errors that occur in API methods are cross-cutting concerns and it is good practice to separate the business logic from the error handling logic. This enables the same error handling logic to be used for multiple API methods without code duplication. The Spring MVC framework caters for this separation of concerns with the concept of Exception Handlers.
An @ExceptionHandler annotation labels a method in a controller or a controller advice as an exception handler. Exception handler methods are called when an exception (root or cause) thrown by the controller matches the exception handled by the handler.
To demonstrate this, we create HelloWorldController to return a SyntaxException when the name property contains a hyphen (apparently, we don’t like hyphenated names here lol).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@RestController | |
public class HelloController { | |
@PostMapping(path = "/sayHello") | |
public HelloResponse sayHello(@RequestBody HelloRequest request){ | |
if(request.getName().contains("-")){ | |
throw new SyntaxException(); | |
} | |
return HelloResponse.builder() | |
.message(String.format("Hello %s!!", request.getName())) | |
.build(); | |
} | |
} |
Controller Exception Handlers
To Handle this exception with a controller exception handler, we declare a method in the controller annotated with @ExceptionHandler as shown:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@RestController | |
public class HelloController { | |
@PostMapping(path = "/sayHello") | |
public HelloResponse sayHello(@RequestBody HelloRequest request){ | |
if(request.getName().contains("-")){ | |
throw new SyntaxException(); | |
} | |
return HelloResponse.builder() | |
.message(String.format("Hello %s!!", request.getName())) | |
.build(); | |
} | |
@ExceptionHandler(SyntaxException.class) | |
public ApiError handleSyntaxException(){ | |
return ApiError.builder() | |
.error("Syntax Error (from controller)") | |
.errorCode(991) | |
.build(); | |
} | |
} |
Once we make a post request with a hyphenated name, the syntax error occurs as shown below:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"timestamp": "2018-09-10T20:43:56.077", | |
"error": "Syntax Error (from controller)", | |
"errorCode": 991, | |
"causes": [] | |
} |
ControllerAdvice Exception Handlers
Exception handler methods can also be added to a ControllerAdvice. ControllerAdvices are component beans that contain methods that perform cross-cutting operations for multiple controllers. This is the most common way of writing ControllerAdvices because it decouples the exception handling logic from the business logic by placing them in separate files. To implement this, we create a ControllerAdvice and implement the desired method just as before.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@RestControllerAdvice | |
public class HelloControllerAdvice { | |
@ExceptionHandler(Exception.class) | |
public ApiError handleGeneralException(){ | |
return ApiError.builder() | |
.error("General Error (from controller-advice)") | |
.errorCode(500) | |
.build(); | |
} | |
@ExceptionHandler(SyntaxException.class) | |
public ApiError handleSyntaxException(){ | |
return ApiError.builder() | |
.error("Syntax Error (from controller-advice)") | |
.errorCode(991) | |
.build(); | |
} | |
} |
In order for this implementation to work, the exception handler in the controller needs to be removed. This is because controller exception handlers have more priority than controller advice exception handlers.
Here, @RestControllerAdvice was used instead of @ControllerAdvice. The latter is a composite annotation of @ControllerAdvice and @ResponseBody
Applying ControllerAdvices to Selected Controllers
ControllerAdvices can be limited to “advice” specific controllers. This group can be defined by base package name, superclass, or annotation. Any of these fields can be set in the properties of the @ControllerAdvice.
Note that since exception handlers are applied at runtime, having selectors such as this reduces performance since it gives the application one extra condition to check in order to decide whether or not to apply the handler.
Matching Exceptions to Handlers
At runtime, the spring MVC framework selects one exception handler to handle an exception thrown by a controller. The selection algorithm follows these rules:
- Exception handlers declared inside the throwing controller are selected in preference to exceptions declared in ControllerAdvices.
- Among methods of an advice bean, a root exception match is preferred to a cause exception match.
- An exception match (whether root or cause) in an advise bean with a higher priority, specified by (@Order annotation or Ordered interface), is selected in preference with those of lower priorities.
- Two exception handler methods with the same target exception cannot be declared on the same ControllerAdvice. This will result in an IllegalStateException due to the ambiguous exception handler method mapping.
- Exception handlers belonging to the same advice are selected according to their specificity. An exact class match is preferred, then a sub-class match and so on.
Customizing HTTP Status Codes
The previous examples return an error response with the HTTP INTERNAL SERVER ERROR (500) by default. To change this, we can either return a ResponseEntity object or use the @ResponseStatus annotation.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@RestControllerAdvice | |
public class HelloControllerAdvice { | |
@ExceptionHandler(Exception.class) | |
public ResponseEntity<ApiError> handleGeneralException(){ | |
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) | |
.body( | |
ApiError.builder() | |
.error("Syntax Error (from controller-advice)") | |
.errorCode(500) | |
.build() | |
); | |
} | |
@ExceptionHandler(SyntaxException.class) | |
@ResponseStatus(HttpStatus.BAD_REQUEST) | |
public ApiError handleSyntaxException(){ | |
return ApiError.builder() | |
.error("Syntax Error (from controller-advice)") | |
.errorCode(991) | |
.build(); | |
} |
Summary
Exception handlers provide a convenient way to handle API errors. API Error Objects make error description more user-friendly and error codes help to efficiently characterize errors.
Codebase
The codebase for this tutorial can be found here.
Keep Koding Konstantly.