Handling exceptions and errors gracefully is essential for building robust applications. Spring Boot offers different ways of doing it and in this chapter we will learn one of the best way to handle exception/errors.
Overview
Spring Boot has its default error handling logic. You might be thinking if there is default error handling then why should we build a custom one. The reason behind it is that the default error response is insufficient or poor for the API consumer to understand. Each exception or error response must show complete reason for why it is failing and even sending some extra message response if necessary. This is how you build an enterprise applications.
Here’s the default response from Spring Boot:
{
"timestamp": "2022-05-15T23:43:23.010+00:00",
"status": 404,
"error": "Not Found",
"path": "/api/message/send/1"
}
As you can see, the default response contains a few attributes such as timestamp, status, error, and path. “Not Found” makes little sense. Is the error because the URL could not be found or because no object was returned from the database? There are numerous scenarios, and the main priority should be to provide a meaningful response that the client can understand.
Spring along with default response offers different ways to customize our response too. Let’s see how to do that.
@ControllerAdvice or @RestControllerAdvice Annotation
This annotation allows the developer to address exception handling across the application and can also be called as Global exception handler for the application. It is the interceptor for any exceptions being thrown in the application.
The exceptions thrown in the application is caught by @ControllerAdvice annotation. @RestControllerAdvice is the combination of @ControllerAdvice and @ResponseBody. If you use @ControllerAdvice, then on top of that you should use @ResponseBody as well. @ResponseBody tells spring that the returned response should be serialized into JSON. For this post I will be using @RestControllerAdvice annotation.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public final ResponseEntity<String> handleHttpRequestMethodNotSupported(
HttpRequestMethodNotSupportedException ex) {
StringBuilder message = new StringBuilder();
message.append(ex.getMethod());
message.append(" method is not supported for this request. Supported methods are ");
ex.getSupportedHttpMethods().forEach(m -> message.append(m + " "));
return ResponseEntity.status(405)
.body(message.toString());
}
}
@RestConrtollerAdvice annotation is added on top of a class and the class is termed as Exception Handler class. The annotation will tell Spring that this class is where all the exceptions are handled. So, any errors or exception being thrown within the application is intercepted by this class. Any customized error response is returned in the handler methods like handleHttpRequestMethodNotSupported.
@ExceptionHandler
@RestControllerAdvice does not do all the magic for exception handling. It tells where to look if the exception is being thrown to return customized response and hence @ExceptionHandler comes into play. @ExceptionHandler annotations is used on top of handler methods and tells to handle only specific exception. Any method annotated with @ExceptionHandler will automatically intercept the exception and return the customized response.
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public final ResponseEntity<String> handleHttpRequestMethodNotSupported(
HttpRequestMethodNotSupportedException ex) {
StringBuilder message = new StringBuilder();
message.append(ex.getMethod());
message.append(" method is not supported for this request. Supported methods are ");
ex.getSupportedHttpMethods().forEach(m -> message.append(m + " "));
return ResponseEntity.status(405)
.body(message.toString());
}
@ExceptionHandler is used on top of handleHttpRequestMethodNotSupported method. An argument can be passed inside the annotation stating what type of exception will this method handle. The handler method will handle HttpRequestMethodNotSupportedException exception only. For this specific purpose, we will returning String message as a response only with http status.
@ResponseStatus
@ResponseStatus is used to let the exception handler method know what type of HTTP Status should be throw like 400 if bad request, 404 if not found etc. Here’s an example if you want to use.
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class ApiException extends RuntimeException {
private HttpStatus status;
private List<ErrorDetail> errorDetailList = new ArrayList<>();
public ApiException(String message, HttpStatus status, List<ErrorDetail> errorDetailList) {
super(message);
this.status =status;
this.errorDetailList = errorDetailList;
}
}
Spring Boot Exception Complete Example
Setup project for Exception Handle
For this example, I will be using Spring JPA to just visualized the realtime example and will be using lombok as dependencies so that I do not need to write all the getter and setter. H2 serves as a database for this simple example. You do not need to worry much about Spring JPA or Lombok if you do not have much idea about.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.7</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.fullstack.coder</groupId>
<artifactId>ExceptionHandle</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ExceptionHandle</name>
<description>Demo project for Spring Boot Exception Handle</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
The main dependencies that’s required is spring-boot-starter-web.
spring.datasource.url=jdbc:h2:mem:handleException
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto= update
spring.h2.console.enabled=true
The above pom.xml adds configuration for connectivity with H2 database.
Custom Error Message
This is the custom error message that is send as response to the consumer or client instead of default error message. Let’s define it.
@Getter
@Setter
@AllArgsConstructor
public class ApiError {
private HttpStatus httpStatus;
private String message;
private List<ErrorDetail> errorDetailList;
}
@Getter
@Setter
public class ErrorDetail {
private String attributeName;
private String reason;
}
ApiError is a custom error response that contains various attributes. ErrorDetail is a summary of the error based on each attribute. For instance, a Post request with multiple attributes does not meet the requirements. In that case, dumping all errors in a single line is not a good idea. So, we have the ErrorDetail class, which will list the error with all of its attributes, making the response much cleaner.
You can add any attributes that are required by your business.
Custom Exception
We have the error message and that might be good for many cases but if we want to customized more and handle the error elegantly then we need to make some more changes. We need a custom exception which can be thrown in any part of code. Using this custom exception, custom error message would be thrown in the form of ApiError.
@Getter
@Setter
public class ApiException extends RuntimeException {
private HttpStatus status;
private List<ErrorDetail> errorDetailList = new ArrayList<>();
public ApiException(String message, HttpStatus status, List<ErrorDetail> errorDetailList) {
super(message);
this.status =status;
this.errorDetailList = errorDetailList;
}
}
This class extends RuntimeException which is the superclass of exceptions that can be thrown during runtime or normal operations of the API. Runtime exception can be numerous as there can be many enormous cases in API that needs to be handled. They all fall under RuntimeException. Compiler is unable to decode the error behind it and hence called unchecked exception.
This custom exception is thrown inside the application and handled by Global exception handler which is in next section.
GlobalExceptionHandler Class
As discussed earlier, the class is annotated with @RestControllerAdvice to let Spring know that any exception being thrown should be routed to this class and should be handled here. Now, we are no longer depended upon the default exception handling strategy by Spring.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public final ResponseEntity<ApiError> handleApiException(ApiException ex) {
ApiError apiError = new ApiError(ex.getStatus(), ex.getMessage(), ex.getErrorDetailList());
return ResponseEntity.status(ex.getStatus().value())
.body(apiError);
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public final ResponseEntity<ApiError> handleHttpRequestMethodNotSupported(
HttpRequestMethodNotSupportedException ex) {
StringBuilder message = new StringBuilder();
message.append(ex.getMethod());
message.append(" method is not supported for this request. Supported methods are ");
ex.getSupportedHttpMethods().forEach(m -> message.append(m + " "));
ApiError apiError = new ApiError(HttpStatus.METHOD_NOT_ALLOWED, message.toString(), new ArrayList<>());
return ResponseEntity.status(apiError.getHttpStatus().value())
.body(apiError);
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public final ResponseEntity<ApiError> handleHttpMediaTypeNotSupported(
HttpMediaTypeNotSupportedException ex) {
StringBuilder message = new StringBuilder();
message.append(ex.getContentType());
message.append(" media type is not supported for this request. Supported media types are ");
ex.getSupportedMediaTypes().forEach(m -> message.append(m + " "));
ApiError apiError = new ApiError(HttpStatus.UNSUPPORTED_MEDIA_TYPE, message.toString(), new ArrayList<>());
return ResponseEntity.status(apiError.getHttpStatus().value())
.body(apiError);
}
@ExceptionHandler(NoHandlerFoundException.class)
public final ResponseEntity<ApiError> handleNoHandlerFoundException(NoHandlerFoundException ex) {
ApiError apiError = new ApiError(HttpStatus.NOT_FOUND, ex.getMessage(), new ArrayList<>());
return ResponseEntity.status(apiError.getHttpStatus().value())
.body(apiError);
}
@ExceptionHandler(Exception.class)
public final ResponseEntity<ApiError> handleAllException(Exception ex) {
if (ex instanceof HttpMediaTypeNotAcceptableException) {
ApiError apiError = new ApiError(HttpStatus.NOT_ACCEPTABLE, ex.getMessage(), new ArrayList<>());
return ResponseEntity.status(apiError.getHttpStatus().value())
.body(apiError);
}
ApiError apiError = new ApiError(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage(), new ArrayList<>());
return ResponseEntity.status(apiError.getHttpStatus().value())
.body(apiError);
}
}
@ExceptionHandler(ApiException.class) handles ApiException that is thrown within the code. Similarly, there are different types of exception and I did try to handle few of them like HttpRequestMethodNotSupportedException, HttpMediaTypeNotSupportedException, and NoHandlerFoundException.
NoHandlerException is redirected to DefaultHandlerException and to solve this you need to couple of lines in application.properties.
spring.mvc.throw-exception-if-no-handler-found=true
spring.web.resources.add-mappings=false
So, what happens when some other exception is fired; in that case we need a handler methods which will handle rest of the exception being triggered. @ExceptionHandler(Exception.class) does that. There are just many types of exception and you are supposed to handle what fits your API. There is one more way to handle exception inside Exception handler methods by specifying types of exception within it.
In the above example, inside handleAllException method, the exception is checked if it instance of HttpMediaTypeNotAcceptableException and the customized response is returned based on it.
Define Controller
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
UserRepository userRepository;
@PostMapping("/save")
public ResponseEntity<User> saveUser(@RequestBody User user) {
if (user.getName() == null || user.getName().isEmpty()) {
ErrorDetail errorDetail = new ErrorDetail();
errorDetail.setAttributeName("name");
errorDetail.setReason("cannot be null or empty");
throw new ApiException("User data is not acceptable", HttpStatus.BAD_REQUEST, Arrays.asList(errorDetail));
}
User saveUser = userRepository.save(user);
return new ResponseEntity<User>(saveUser, HttpStatus.OK);
}
@GetMapping("/{id}")
public ResponseEntity<User> getById(@PathVariable Integer id) {
Optional userOptional = userRepository.findById(id);
if(userOptional.isEmpty()) {
throw new ApiException("Id not found", HttpStatus.NOT_FOUND, new ArrayList<>());
}
return new ResponseEntity<User>((User) userOptional.get(), HttpStatus.OK);
}
}
Define User Class
@Entity
@Table
public class User implements Serializable {
private static final long serialVersionUID = 7156526077883281623L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
private Integer age;
//removing getter and setter for brevity
}
Define UserRepository
@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
}
Project Structure
Testing using postman
By default, the project runs on 8080 port. Now, let’s test it with postman.
Conclusion
Exception is part of the code. You cannot walk away from it. So, in this post we saw how to handle error gracefully. Creating customized exception called ApiException which can be thrown anywhere inside the code with customized error response. All the exception are handled inside the GlobalExceptionHandler. We also saw that there are different types of exception and most of the exception can be handled through handler methods.