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:
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;
*
* @Nullable
* private String title;
*
* private int status;
*
* @Nullable
* private String detail;
*
* @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 @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;
}
};
}
}