Exception Handling in Spring Boot

Seunghwan Choi·2024년 10월 31일

Java Backend

목록 보기
8/16

Three main methods to handle exceptions in Spring:
1. Controller level handling - @ExceptionHandler
2. Global level handling - @RestControllerAdvice
3. Method level handling - try/catch

  • Instead of wrapping every controller method in try-catch blocks, we can use @RestControllerAdvice annotation which provides a centralized, flexible, and reusable appraoch to handling exceptions across the entire application, which is especially beneficial for large applications or complex APIs.
  • @RestControllerAdvice is a global exception for REST controllers. It applies exception-handling logic across all @RestController classes, ensuring a centralized, consistent response format for all REST endpoints.
    - It is a combination of @ControllerAdvice and @ResponseBody. Just like @ControllerAdvice, it intercepts exceptions thrown by controllers, but also automatically adds @ResponseBody behavior. => This means the responses are JSON-formatted by default, aligning with REST API conventions.

How @RestControllerAdvice works:

1. Defining global exception handlers:

  • With @RestControllerAdvice, we can define methods to handle specific exception types across the application.
  • Each method inside a @RestControllerAdvice - annotated class should use @ExceptionHandler to specify the exceptions it handles.
@RestControllerAdvice
public class GlobalExceptionHandler{
	
    @ExceptionHandler(value = {ResourceNotFoundException.class})
    public ResponseEntity<> handleResourceNotFound(ResourceNotFoundException e){
    	return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
    }
    
    @ExceptionHandler(value = {IllegalArgumentException.class})
    public ResponseEntity<> handleBadRequest(IllegalArgumentException e){
    	return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid input: " + e.getMessage());
    }
}
  • Here, if any controller throws ResourceNotFoundException or IllegalArgumentException, these handlers provide a JSON response with a relevant status code.

2. Centralized Error Handling:

  • By consolidating exception handling in a single class, we ensure that all errors are handled in a standardized way. For instance, we can ensure that every error response has a similar structure, like including a timestamp, status code, and custom error message, apart from the default error JSON response provided.
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleAllExceptions(Exception ex) {
        Map<String, Object> errorDetails = new HashMap<>();
        errorDetails.put("timestamp", LocalDateTime.now());
        errorDetails.put("message", ex.getMessage());
        errorDetails.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
        return new ResponseEntity<>(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

3. Handling Multiple Exception Types in a Single Method:

  • A single method can handle multiple types of exceptions by specifying an array in @ExceptionHandler.
  • This is useful if we want a similar response for multiple exception types.
@ExceptionHandler({ResourceNotFoundException.class, IllegalArgumentException.class})
public ResponseEntity<> handleResourceNotFoundAndBadRequest(Exception e) {
	return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Error: " + e.getMessage());
}

4. Specificity and Ordering

  • Methods in a @RestControllerAdvice class are prioritized by the specificity of the exception type. More specific exception handlers are invoked first.
  • For instance, if there's both a ResourceNotFoundException and a generic Exception handler, Spring will use the ResourceNotFoundException handler for that specific exception, falling back to the generic handler only when needed.

Example

@Slf4j
@RestControllerAdvice
public class RestApiExceptionHandler {

    @ExceptionHandler(value = {Exception.class})
    public ResponseEntity exception(
            Exception e
    ){
        log.error("", e);
        return ResponseEntity.status(200).build();
    }

    @ExceptionHandler(value = {IndexOutOfBoundsException.class})
    public ResponseEntity outOfBound(
            IndexOutOfBoundsException e
    ){
        log.error("IndexOutOfBoundException: " + e.getMessage());
        return ResponseEntity.status(200).build();
    }
}
@Slf4j
@RestController
@RequestMapping("/api")
public class RestApiController {
    @GetMapping(path = "")
    public void hello(){
        var list = List.of("hello");

        var element = list.get(1);

        log.info("element : {}", element);
    }
}
  • Once a request is sent to http://localhost:8080/api, the list.get(1) will throw an IndexOutOfBoundsException which will be then handled by the method defined in @RestControllerAdvice, logging: IndexOutOfBoundException: Index: 1 Size: 1

  • To prove that the RestApiExceptionHandler above handles exception from all classes globally, we can create a new controller, named RestApiBController as below, with a method that throws a number format exception for demonstration purposes.

@RestController
@RequestMapping("/api/b")
public class RestApiBController {

    @GetMapping("/hello")
    public void hello(){
        throw new NumberFormatException("number format exception");
    }
}
  • Once we send a request to http://localhost:8080/api/b/hello, we see:
  • From the above screenshot, we can tell that the exception thrown from another RestApiController is also handled by the RestApiExceptionHandler.
  • And since there is no method specifically defined to handle number format exception, it was handled by the exception() method in RestApiExceptionHandler, which is designed to handle any form of exception.
  • But there might be cases where we would want an exception to be handled elsewhere, like internally within the API controller. In this case, we can simply define the exception handling method within the API controller. The handler defined internally would have a priority. For example:
@Slf4j
@RestController
@RequestMapping("/api/b")
public class RestApiBController {

    @GetMapping("/hello")
    public void hello(){
        throw new NumberFormatException("number format exception");
    }

    @ExceptionHandler(value = { NumberFormatException.class})
    public ResponseEntity numberFormatException(
            NumberFormatException e
    ){
        log.error("RestApiBController: " +  e.getMessage());
        return ResponseEntity.status(HttpStatus.OK).build();
    }
}
  • Once a request sent to http://localhost:8080/api/b/hello, we can see the screenshot below, showing that the exception handler defined locally was invoked instead of the one defined in the RestApiExceptionHandler (global):

  • We can also define a package for which the @RestControllerAdvice will handle the exception for. Once we define this in the annotation, the RestApiExceptionHandler class will only handle exception thrown from the specified package. All other exceptions thrown from other packages will not be handled by the handler class.

@Slf4j
@RestControllerAdvice(basePackages =  "com.example.exception.controller")
public class RestApiExceptionHandler {

    @ExceptionHandler(value = {Exception.class})
    public ResponseEntity exception(
            Exception e
    ){
        log.error("{}", e.getMessage());
        return ResponseEntity.status(200).build();
    }

    @ExceptionHandler(value = {IndexOutOfBoundsException.class})
    public ResponseEntity outOfBound(
            IndexOutOfBoundsException e
    ){
        log.error("IndexOutOfBoundException: " + e.getMessage());
        return ResponseEntity.status(200).build();
    }
}
  • Above, we set the basePackages to com.example.exception.controller. And to prove our point, we create another package called controller2 which contains RestApiController2, as below:
  • RestApiController2:
@Slf4j
@RestController
@RequestMapping("/api2")
public class RestApiController2 {
    @GetMapping(path = "")
    public void hello(){
        var list = List.of("hello");

        var element = list.get(1);

        log.info("element : {}", element);
    }
}
  • We get the below log, which shows that the RestApiExceptionHandler did not handle the exception thrown by RestApiController2, since RestApiExceptionHandler was not programmed to handle exceptions thrown from controller2 package.
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IndexOutOfBoundsException: Index: 1 Size: 1] with root cause
  • Additionally, we can specify the class name which the exception handler class will handler the exceptions are thrown from:
@Slf4j
@RestControllerAdvice(basePackageClasses =  {RestApiController.class, RestApiBController.class })
public class RestApiExceptionHandler {
...
}

Exception Handling Application 1

  • UserResponse.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserResponse {
    private String name;
    private Integer age;
    private String id;
}
  • Api.java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Slf4j
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Api<T> {
    private T data;
    private String resultCode;
    private String resultMessage;

}
  • The Api<T> class acts as a generic response wrapper that provides a consistent, clean, and easily extensible structure for API responses.
  • UserApiController.java
@RestController
@RequestMapping("/api/user")
public class UserApiController {

    private static List<UserResponse> userList = List.of(
            UserResponse.builder()
                    .id("1")
                    .age(10)
                    .name("choi seunghwan")
                    .build()
            ,
            UserResponse.builder()
                    .id("2")
                    .age(11)
                    .name("hong gildong")
                    .build()

    );

    @GetMapping("/id/{userId}")
    public Api<UserResponse> getUser(
            @PathVariable String userId
    ){
        var user = userList.stream()
                .filter(
                        it -> it.getId().equals(userId)
                )
                .findFirst()
                .get();

        Api<UserResponse> response = Api.<UserResponse>builder()
                .resultCode(String.valueOf(HttpStatus.OK.value()))
                .resultMessage(HttpStatus.OK.name())
                .data(user)
                .build();

        return response;
    }
}
  • Explanation of Api.<UserResponse>builder():
    - .<UserResponse> explicitly specifies the generic type parameter UserResponse for the builder being created. The period after Api is used to chain the type specification <UserResponse> with the static builder() method.
    • Why builder() has no period before it?
      • The builder() method is static and belongs to the Api class. After specifying the generic type <UserResponse>, the code calls this static method directly.
  • In the RestApiExceptionHandler.java:
    @ExceptionHandler(value = {NoSuchElementException.class})
    public ResponseEntity<Api> noSuchElement(
            NoSuchElementException e
    ){
        log.error("", e);

        var response = Api.builder()
                .resultCode(String.valueOf(HttpStatus.NOT_FOUND.value()))
                .resultMessage(HttpStatus.NOT_FOUND.getReasonPhrase())
                .build();

        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(response);
    }
  • Output from http://localhost:8080/api/user/id/3 (since we do not have id 3 in our list, it should throw a NoSuchElementException):
  • Output from http://localhost:8080/api/user/id/2:

Layered Exception Handling using @Order

  • We will create a GlobalExceptionHandler and remove the method that handles Exception.class from the RestApiExceptionHandler.

  • Reason: Separation of concerns.
    - **Specific Handling in RestApiExceptionHandler
    - Targeted exceptions like IndexOutOfBoundsException and NoSuchElementException have their own error-handling logic.

    • **Fallback Handling in GlobalExceptionHandler
      • Any unhandled exceptions are caught by the GlobalExceptionHandler
  • Modified RestApiExceptionHandler:

@Slf4j
@RestControllerAdvice
@Order(1) 
public class RestApiExceptionHandler {

	//Removed @ExceptionHandler(value = {Exception e})

    @ExceptionHandler(value = {IndexOutOfBoundsException.class})
    public ResponseEntity outOfBound(
            IndexOutOfBoundsException e
    ){
        log.error("IndexOutOfBoundException: " + e.getMessage());
        return ResponseEntity.status(200).build();
    }

    @ExceptionHandler(value = {NoSuchElementException.class})
    public ResponseEntity<Api> noSuchElement(
            NoSuchElementException e
    ){
        log.error("", e);

        var response = Api.builder()
                .resultCode(String.valueOf(HttpStatus.NOT_FOUND.value()))
                .resultMessage(HttpStatus.NOT_FOUND.getReasonPhrase())
                .build();

        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(response);
    }
}
  • In the above RestApiExceptionHandler, @Order annotation was added. The value that can be specified with the @Order annotation defines the execution priority of the exception handlers. As shown in the screenshot below, the default value is Integer.MAX_VALUE.

    Meaning, if we label our RestApiExceptionHandler with @Order(1) and GlobalExceptionHandler with @Order (or no annotation), the RestApiExceptionHandler will have the execution priority, since the default priority constant in @Order is Integer.MAX_VALUE.
  • Created GlobalExceptionHandler:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(value = {Exception.class})
    public ResponseEntity<Api> exception(
            Exception e
    ){
        log.error("", e);

        var response = Api.builder()
                .resultCode(String.valueOf(HttpStatus.INTERNAL_SERVER_ERROR))
                .resultMessage(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
                .build();

        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(response);

    }

}
  • Using the same UserApiController as above, if we send a GET request with userID = 4 which is non-existent, it should be handled by the exception(Exception e) method in the GlobalExceptionHandler since there is no method specified to handle RuntimeException in the @Order(1) RestApiExceptionHandler class. The output for the request with userId = 4 would be:

    which was handled by the GlobalExceptionHandler, as shown below:

  • But what if there is a same generic exception(Exception e) method in the RestApiExceptionHandler class? Since the RestApiExceptionHandler is @Order(1), it has the execution priority over the GlobalExceptionHandler. Thus, the RuntimeException will be handled by the RestApiExceptionHandler.

0개의 댓글