Hiep Doan

Proper API error handling for Spring boot App

June 09, 2019

  • spring-boot
  • api

Proper API error handling for Spring boot App

Error handling is a very important part of a reliable and user-friend API. If you think of your API as a product, when something goes wrong with the product, it should clearly indicate the error to users and the reason why. Also, it should be able to guide users to avoid that error from happening again.

For example, if you have an endpoint to create a user: [POST] /v1/users, which expects a valid email and a min-6 letters password and when user sends a too short password, server should returns a 400 Bad request with the body including a translated error message like this: “Password must be at least 6 letters”.

This is nice, because then your mobile or webapp can check for the status code and if it’s not 2xx then it knows right away that something wrong has happen and instead of proceeding to the next screen on mobile app, it displays the error message to user.

This post will present the strategy that I often use for API error handling and how to implement it with Spring boot app.

High-level strategy

  • Status code should be used extensively to indicate the error category. For example, 400 Bad request should indicate an error from client side such as invalid input, failed validation… while 401 Unauthorized, as the name suggests is for error with user’s authorization (invalid email or password when logging in or access token is expired). On the other hand, 5xx error should be used when something wrong happens from server’s side.
  • Error message should be localized to user’s language so client can use it to display to users.
  • For form field validation, error message should be linked with the field name, so client can parse and display the error message right under the form’s field. This is very intuitive and easy for users.
{
  "email": "Please use a valid email",
  "firstName": "First name cannot be empty"
}
  • If internal server error happens, the backend should catch it and log the error so we can debug later. More importantly, it should not return internal error to client but instead, just return a generic message like: “Something wrong happens. Please try again later.”. Of course in this case, the status code should be 500 Internal Server Error.

Implementation with Spring boot:

The above strategy is very easy to be implemented with Spring Boot’s Controller Advice. Specifically, you can have a centralized place to handle exception thrown from inside controller’s classes (i.e endpoints). This class will be in charged of translating the error message to user’s language as well.

In addition, we do need a generic exception handler for all uncaught exception, which should be internal server error. This handler will log exception’s stack trace (and additionally, can send alarm to tech team) and just return a generic error message.

An example of API error can be as followed:

// ApiError.java

@Data
public class ApiError {

  private HttpStatus status;
  private String message;
  private List<String> errors;

  public ApiError(HttpStatus status) {
    this.status = status;
  }

  public ApiError(HttpStatus status, String message) {
    this(status);
    this.message = message;
  }

  public ApiError(HttpStatus status, String message, List<String> errors) {
    this(status, message);
    this.errors = errors;
  }

  public ApiError(HttpStatus status, String message, String error) {
    this(status, message);
    errors = Arrays.asList(error);
  }
}

Then we have the controller advice to handle all kinds of exception:

// RestEntityExceptionHandler.java
@ControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RestResponseEntityExceptionHandler extends BaseExceptionHandler {
  
  public RestResponseEntityExceptionHandler() {
    super();
  }
  
  @Autowired
  public RestResponseEntityExceptionHandler(MessageSource messageSource) {
    this.messageSource = messageSource;
  }
  
  @Override
  protected ResponseEntity<Object> handleBindException(BindException ex, HttpHeaders headers,
                                                       HttpStatus status, WebRequest request) {
    
    List<String> errors = new ArrayList<>();
    
    for (ObjectError error : ex.getAllErrors()) {
      String errorMessage = error.getDefaultMessage() != null
          ? getTranslatedMessage(error.getDefaultMessage(), null)
          : getTranslatedMessage(error);
      String fullErrorMsg = error.getObjectName() + ": " + errorMessage;
      
      //log the warning message for validation
      logger.warn(fullErrorMsg);
      errors.add(fullErrorMsg);
    }
    ApiError apiError = new ApiError(HttpStatus.BAD_REQUEST,
        getTranslatedMessage("error.validation", null));
    apiError.setErrors(errors);
    
    return buildResponseEntity(apiError);
  }
  
  @Override
  protected ResponseEntity<Object> handleMethodArgumentNotValid(
      MethodArgumentNotValidException ex,
      HttpHeaders headers,
      HttpStatus status,
      WebRequest request) {
    
    List<String> errors = new ArrayList<>();
    for (FieldError error : ex.getBindingResult().getFieldErrors()) {
      String code = error.getCode();
      
      Object[] args = error.getArguments();
      String errorMessage = code != null ? getTranslatedMessage(code, args) : error.getDefaultMessage();
      errors.add(error.getField() + ": " + errorMessage);
    }
    for (ObjectError error : ex.getBindingResult().getGlobalErrors()) {
      String errorMessage = error.getDefaultMessage() != null
          ? getTranslatedMessage(error.getDefaultMessage(), null)
          : getTranslatedMessage(error);
      errors.add(errorMessage);
    }
    
    ApiError apiError = new ApiError(HttpStatus.BAD_REQUEST, getTranslatedMessage("error.validation", null));
    apiError.setErrors(errors);
    
    return buildResponseEntity(apiError);
  }
  
  @ExceptionHandler({AccessDeniedException.class})
  protected ResponseEntity<Object> handleAccessDeniedException(final AccessDeniedException ex) {
    
    String errorMessage = "error.access.denied";
    
    if (!errorMessage.equals(ex.getMessage()) && !ex.getMessage().equals(getTranslatedMessage(errorMessage,
        null))) {
      errorMessage = ex.getMessage();
    }
    
    ApiError apiError =
        new ApiError(HttpStatus.FORBIDDEN, getTranslatedMessage(errorMessage, null));
    
    return buildResponseEntity(apiError);
  }
  
  @ExceptionHandler({ForbiddenAccessException.class})
  protected ResponseEntity<Object> handleForbiddenAccessException(final ForbiddenAccessException ex) {
    
    String errorMessage = "error.access.denied";
    
    if (!errorMessage.equals(ex.getMessage()) && !ex.getMessage().equals(getTranslatedMessage(errorMessage,
        null))) {
      errorMessage = ex.getMessage();
    }
    
    ApiError apiError =
        new ApiError(HttpStatus.FORBIDDEN, getTranslatedMessage(errorMessage, null));
    
    return buildResponseEntity(apiError);
  }
  
  @ExceptionHandler({MultipartException.class})
  protected ResponseEntity<Object> handleMultipartException(final MultipartException ex) {
    
    ApiError apiError =
        new ApiError(HttpStatus.BAD_REQUEST, getTranslatedMessage("error.invalid.uploading.file", null));
    
    return buildResponseEntity(apiError);
  }
  
  @ExceptionHandler({DuplicationException.class})
  protected ResponseEntity<Object> handleDuplication(final DuplicationException ex) {
    
    ApiError apiError = new ApiError(HttpStatus.CONFLICT, getTranslatedMessage(ex, ex.getArgs()));
    
    return buildResponseEntity(apiError);
  }
  
  @ExceptionHandler({ReferenceConflictException.class})
  protected ResponseEntity<Object> handleReferenceConflict(final ReferenceConflictException ex) {
    
    ApiError apiError = new ApiError(HttpStatus.CONFLICT, getTranslatedMessage(ex, ex.getArgs()));
    
    return buildResponseEntity(apiError);
  }
  
  @ExceptionHandler({BadRequestException.class})
  public ResponseEntity<Object> handleBadRequest(final BadRequestException ex) {
    ApiError apiError = new ApiError(HttpStatus.BAD_REQUEST, getTranslatedMessage(ex, ex.getArgs()));
    return new ResponseEntity<>(apiError, apiError.getStatus());
  }
  
  @ExceptionHandler({AuthenticationException.class})
  public ResponseEntity<Object> handleAuthenticationException(final AuthenticationException ex) {
    
    ApiError apiError = new ApiError(HttpStatus.UNAUTHORIZED, getTranslatedMessage(ex, null));
    return new ResponseEntity<>(apiError, apiError.getStatus());
  }
  
  @ExceptionHandler({UnprocessableEntityException.class})
  public ResponseEntity<Object> handleUnprocessableEntity(final UnprocessableEntityException ex) {
    
    ApiError apiError = new ApiError(HttpStatus.UNPROCESSABLE_ENTITY, getTranslatedMessage(ex, ex.getArgs()));
    return buildResponseEntity(apiError);
  }
}

And we can have a base class for exception handler with all common tools for translation and error response builder:

// BaseExceptionHandler.java
public abstract class BaseExceptionHandler extends ResponseEntityExceptionHandler {
  
  protected MessageSource messageSource;
  
  public BaseExceptionHandler() {
    super();
  }
  
  /**
   * build the response body in case of an API error
   */
  protected ResponseEntity<Object> buildResponseEntity(ApiError apiError) {
    return new ResponseEntity<>(apiError, apiError.getStatus());
  }
  
  protected String getTranslatedMessage(Exception ex, Object[] args) {
    return  getTranslatedMessage(ex.getMessage(), args);
  }
  
  protected String getTranslatedMessage(ObjectError objectError) {
    Locale locale = LocaleContextHolder.getLocale();
    return  messageSource.getMessage(objectError, locale);
  }
  
  protected String getTranslatedMessage(@Nullable String message, Object[] args) {
    if (message == null) {
      return null;
    }
    Locale locale = LocaleContextHolder.getLocale();
    String[] strArgs = null;
    if (args != null) {
      strArgs = new String[args.length];
      for (int i = 0; i < args.length; i++) {
        strArgs[i] = args[i].toString();
      }
    }
    
    try {
      return messageSource.getMessage(message, strArgs, locale);
    } catch (NoSuchMessageException e) {
      return message;
    }
  }
}

Here we use Spring’s message source to do the translation and as we do not need translation for all messages or not all messages are available in all languages, we handle the NoSuchMessageException manually and just return the message itself. Alternatively, you can fallback to the default language of your app.

Also, it’s worth noting that locale here is retrieved from LocaleContextHolder, which is constructed from the header Accept-Language of the request.

The last piece of the implementation is to handle uncaught exception (internal server error, for example) so we can be sure no weird internal error is returned to clients.

//GenericExceptionHandler.java

@ControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GenericExceptionHandler extends BaseExceptionHandler {
  
  public GenericExceptionHandler() {
    super();
  }
  
  @Autowired
  public GenericExceptionHandler(MessageSource messageSource) {
    this.messageSource = messageSource;
  }
  
  @ExceptionHandler({Exception.class})
  public ResponseEntity<Object> handleGenericException(final Exception ex) {
    // uncaught error happens, just log it here and return an user-friendly response
    logger.error(String.format("Uncaught error happens. Stack trace is: %s", ex + getFullStackTraceLog(ex)));
    
    ApiError apiError = new ApiError(HttpStatus.INTERNAL_SERVER_ERROR,
        getTranslatedMessage("error.generic.internal.server.error", null));
    return buildResponseEntity(apiError);
  }
  
  private String getFullStackTraceLog(Exception ex) {
    return Arrays.asList(ex.getStackTrace())
            .stream()
            .map(Objects::toString)
            .collect(Collectors.joining("\n"));
  }
}

It’s very important to annotate this class with lowest precedence, otherwise all exception will be handled here first. We actually only want to use this class, if the specific exception is not handled elsewhere in other controller advice.

To conclude, in this article, I have shown how we can properly implement API error handling for Spring boot app.

How is your experience with error handling for Spring Boot’s backend app? Feel free to leave your comments in my Medium post.


Hiep Doan

Written by Hiep Doan, a software engineer living in the beautiful Switzerland. I am passionate about building things which matters and sharing my experience along the journey. I also selectively post my articles in Medium. Check it out!