Error handling (Spring Boot >= 3)

Introduction

Let's see a proposal of a common centralized way of handling REST operations exceptions for returning a pre-defined JSON document for them.


Note: For Spring Boot < 3, see:

https://sites.google.com/site/pawneecity/sprint-boot/error-handling-rest-spring-boot/error-handling-sb3


Reference

Problem Details for HTTP APIs (RFC 7807)

https://www.rfc-editor.org/rfc/rfc7807.html


ErrorProblemDetail for the JSON response

Spring Boot defines standard ProblemDetail class that can be extended.

ErrorProblemDetail.java

package edu.cou.myapp;


import org.slf4j.Logger;

import org.springframework.http.HttpStatus;

import org.springframework.http.ProblemDetail;

import org.springframework.web.bind.MethodArgumentNotValidException;


import edu.cou.myapp.exception.AppException;

import edu.cou.myapp.helper.AppHelper;

import lombok.extern.slf4j.Slf4j;


/**

 * Problem Details for HTTP APIs (RFC 7807).

 * 

 * 

 * <pre>

 * private URI type = BLANK_TYPE;

 * 

 * &#64;Nullable

 * private String title;

 * 

 * private int status;

 * 

 * &#64;Nullable

 * private String detail;

 * 

 * &#64;Nullable

 * private URI instance;

 * 

 * @Nullable

 * private Map<String, Object> properties;

 * </pre>

 */

@Slf4j

public class ErrorProblemDetail extends ProblemDetail {


  // System code

  private static final String PROPERTY_SYSTE_CODE = "systemCode";


  // Stack trace

  private static final String PROPERTY_TRACE = "trace";



  /**

   * Constructor.

   * 

   * @param message

   * @param cause

   */

  public ErrorProblemDetail(final String message, final Throwable cause) {

    super();

    // type, eg: "https://example.com/probs/out-of-credit"

    // title, eg: "You do not have enough credit."


    // status

    super.setStatus(this.getHttpStatus(cause));


    // detail, eg: "Your current balance is 30, but that costs 50."

    super.setDetail(message);

    // instance, eg: "/account/12345/msgs/abc"

    // properties (systemCode)

    super.setProperty(PROPERTY_SYSTE_CODE, App.SYSTEM.name());

    // properties (trace - exception stack trace (technical info))

    super.setProperty(PROPERTY_TRACE, AppHelper.stack(true, cause));

  }


  /**

   * Constructor.

   * 

   * @param cause -

   */

  public ErrorProblemDetail(final Throwable cause) {

    this(cause.getLocalizedMessage(), cause);


  }


  /**

   * Determine the status code to be informed by this ProblemDetail.

   * 

   * @param cause A throwable

   * @return An HttpStatus

   */

  private HttpStatus getHttpStatus(Throwable cause) {

    HttpStatus ret;

    if (cause instanceof AppException) {

      ret = ((AppException) cause).getHttpStatus();

    } else if (cause instanceof MethodArgumentNotValidException) {

      ret = HttpStatus.BAD_REQUEST;

    } else {

      String msg = String.format(

          "ErrorProblemDetail doesn't know which http status code assign to exception %s", cause

              .getClass().getCanonicalName());

      log.warn(msg);

      ret = HttpStatus.I_AM_A_TEAPOT;

    }


    return ret;

  }


}


Custom error handler

This handler will intercept exceptions thrown by REST operations and it will return a JSON document based on the previous Error DTO.

ErrorHandler.java

package edu.cou.myapp;


import java.util.List;


import org.springframework.http.HttpHeaders;

import org.springframework.http.HttpStatus;

import org.springframework.http.HttpStatusCode;

import org.springframework.http.ResponseEntity;

import org.springframework.security.access.AccessDeniedException;

import org.springframework.security.core.AuthenticationException;

import org.springframework.web.bind.MethodArgumentNotValidException;

import org.springframework.web.bind.annotation.ExceptionHandler;

import org.springframework.web.bind.annotation.RestControllerAdvice;

import org.springframework.web.context.request.WebRequest;

import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;


import edu.cou.myapp.exception.AppException;

import edu.cou.myapp.exception.ValAppException;

import jakarta.validation.Valid;

import lombok.val;

import lombok.extern.slf4j.Slf4j;


/**

 * Global REST exception handling mechanism (custom error handler).

 * 

 */

@Slf4j

@RestControllerAdvice

public class ErrorHandler extends ResponseEntityExceptionHandler {


  /**

   * handle invalid authentication (401).<br>

   * <br>

   * Remark: It works fine for exceptions thrown by web MVC controllers but it does not work for

   * exceptions thrown by spring security custom filters because they run before the controller

   * methods are invoked.

   * 

   * @param ex

   * @param request

   * @return -

   */

  @ExceptionHandler({ AuthenticationException.class })

  public ResponseEntity<Object> handleAuthenticationException(Exception ex, WebRequest request) {

    // DOC. No multi-language needed because frontend masks it

    var body = new ErrorProblemDetail(

        "L'autenticació és necessària i ha fallat o encara no s'ha proporcionat", ex, log);


    return handleExceptionInternal(ex, body, new HttpHeaders(), HttpStatus.UNAUTHORIZED, request);

  }


  /**

   * handle method level security @PreAuthorize, @PostAuthorize, and @Secure Access Denied (403)

   * 

   * @param ex

   * @param request

   * @return -

   */

  @ExceptionHandler({ AccessDeniedException.class })

  public ResponseEntity<Object> handleAccessDeniedException(Exception ex, WebRequest request) {

    var requestShortDesc = request.getDescription(true); // URI, etc.


    // DOC. No multi-language needed because frontend masks it

    var errMsg = String.format("No teniu permissos per a executar aquesta operación. Informació de la petició:%n%s, requestShortDesc );

    var body = new ErrorProblemDetail("No teniu permissos suficients per a executar aquesta operació", ex, log);


    return handleExceptionInternal(ex, body, new HttpHeaders(), HttpStatus.FORBIDDEN, request);

  }


  /**

   * handle AppException

   * 

   * @param ex

   * @param request

   * @return -

   */

  @ExceptionHandler(value = { AppException.class })

  public ResponseEntity<Object> handleAppException(@Valid AppException ex, WebRequest request) {

    var body = new ErrorProblemDetail(ex.getLocalizedMessage(), ex, log);


    return handleExceptionInternal(ex, body, new HttpHeaders(), ex.getHttpStatus(), request);

  }


  /**

   * handle MethodArgumentNotValidException.

   * 

   * 

   * <pre>

   * for &#64;Valid

   * </pre>

   */

  @Override

  public ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,

      HttpHeaders headers, HttpStatusCode status, WebRequest request) {

    // Get all errors

    List<String> errors = ex.getBindingResult().getFieldErrors().stream().map(x -> x // NOSONAR

        .getDefaultMessage()).toList();

    // Format message the same way ValAppException does

    val ve = new ValAppException(errors);

    //

    var body = new ErrorProblemDetail(ve.getMessage(), ex, log);


    return handleExceptionInternal(ex, body, headers, status, request);

  }


  /**

   * handle default exception

   * 

   * @param ex

   * @param request

   * @return -

   */

  @ExceptionHandler({ Exception.class })

  public ResponseEntity<Object> handleDefaultException(Exception ex, WebRequest request) {

    log.warn("~~~~~~~~~~~~~~~~~~~~~~ APP HANDLING DEFAULT EXCEPTION ~~~~~~~~~~~~~~~~~~~~~~\n", ex);

    var body = new ErrorProblemDetail(ex.getLocalizedMessage(), ex, log);


    return handleExceptionInternal(ex, body, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR,

        request);

  }


}


ErrorConfig: default error attributes overriding

This optional configuration file never applies when the custom error handler intercepts all the possible exceptions.

Note: It uses class ErrorAttributeOptions introduced in Spring Boot 2.3

ErrorConfig.java

package edu.cou.app;


import java.util.Map;


import org.springframework.boot.web.error.ErrorAttributeOptions;

import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;

import org.springframework.boot.web.servlet.error.ErrorAttributes;

import org.springframework.context.annotation.Bean;

import org.springframework.stereotype.Component;

import org.springframework.web.context.request.WebRequest;


/**

 * 

 * It adds the "systemCode" to the DefaultErrorAttributes.<br>

 * systemCode indicates the system that generated the error (this system application).<br>

 * <br>

 * REMARK: Doesn't execute if a custom ErrorErrorHandler intercepts the exception (that's fine).<br>

 * <br>

 * It can be tested with:<br>

 * curl "http://localhost:8080/r/notexist"

 */

@Component

public class ErrorConfig {


  /**

   * Custom bean that implements ErrorAttributes to take control of the content.<br>

   * Note: Implemented subclassing DefaultErrorAttributes.

   * 

   * @return The 'systemCode' attribute added to the default ones

   */

  @Bean

  public ErrorAttributes errorAttributes() {

    return new DefaultErrorAttributes() {

      @Override

      public Map<String, Object> getErrorAttributes(WebRequest webRequest,

          ErrorAttributeOptions options) {


        val errorAttributes = new LinkedHashMap<String, Object>();

        errorAttributes.put("systemCode", "MYAPP");

        errorAttributes.putAll(super.getErrorAttributes(webRequest, options));

        return errorAttributes;

      }

    };

  }


}