This documentation is for version 4.5.0.

1. Goal

The goal of the project is to make it easy to have proper error responses for REST APIs built with Spring Boot. Just like Spring Boot itself, it takes an opinionated approach to how the response body should look like in the case of an error.

2. Getting started

2.1. Add the library to your project

The library is available on Maven Central, so it is easy to add the dependency to your project.

Maven
<dependency>
    <groupId>io.github.wimdeblauwe</groupId>
    <artifactId>error-handling-spring-boot-starter</artifactId>
    <version>LATEST_VERSION_HERE</version>
</dependency>
Gradle
compile 'io.github.wimdeblauwe:error-handling-spring-boot-starter:LATEST_VERSION_HERE'
This library is indented to be used with a Spring Boot project. It will not work outside of a Spring Boot project.

2.2. Usage

2.2.1. Default Exception handling

By adding the library on the classpath, it will become active. It registers an @ControllerAdvice bean in the context that will act if an exception is thrown from a @RestController method.

Suppose there is a custom Exception like this:

package com.company.application.user;

public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(UserId userId) {
        super("Could not find user with id " + userId);
    }
}

When this UserNotFoundException is thrown from a controller method, the library will return a JSON response like this:

{
  "code": "USER_NOT_FOUND",
  "message": "Could not find user with id 123"
}

The HTTP status code will be 500 Internal Server Error.

Next to this quite simple default behaviour, there is special handling for some of the common Spring exceptions and there is an extensive amount of customization options that should make this library a good fit for all of your error handling for REST controllers.

This works for @RestController classes that are servlet based and for those that use Spring WebFlux (reactive).

2.2.2. Validation exception handling

For some exceptions like MethodArgumentNotValidException, ConstraintViolationException,…​ there is special handling and extra information is returned in the response.

For example, suppose you have this class:

public class ExampleRequestBody {
    @Size(min = 10)
    private String name;
    @NotBlank
    private String favoriteMovie;

    // getters and setters
}

Which is used as a request body in a controller like this:

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.validation.Valid;

@RestController
@RequestMapping("/example")
public class MyExampleController {

    @PostMapping
    public MyResponse doSomething(@Valid @RequestBody ExampleRequestBody requestBody ) {
        // ...
    }
}

When sending a JSON request body like this:

{
    "name": "",
    "favoriteMovie": null
}

The validation fails, and the following JSON response will be returned:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='exampleRequestBody'. Error count: 2",
  "fieldErrors": [
    {
      "code": "INVALID_SIZE",
      "property": "name",
      "message": "size must be between 10 and 2147483647",
      "rejectedValue": "",
      "path": "name"
    },
    {
      "code": "REQUIRED_NOT_BLANK",
      "property": "favoriteMovie",
      "message": "must not be blank",
      "rejectedValue": null,
      "path": "favoriteMovie"
    }
  ]
}
If there are validation errors on the class level, they will be added in the response as globalErrors.

2.2.3. With @SpringBootTest

There is nothing special to configure for unit tests that use @SpringBootTest. The library is automatically active when it is on the classpath.

2.2.4. With @WebMvcTest

There is nothing special to configure for unit tests that use @WebMvcTest. The library is automatically active when it is on the classpath.

2.2.5. With @WebFluxTest

There is nothing special to configure for unit tests that use @WebFluxTest. The library is automatically active when it is on the classpath.

3. Configuration

3.1. HTTP response status

3.1.1. Set HTTP response status via @ResponseStatus

The library uses 500 Internal Server Error as HTTP response code by default, just like Spring Boot does.

To set a specific status code, you can use @ResponseStatus, which is also standard Spring Boot behaviour. The library will honor what is set there.

Example:

package com.company.application.user;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND) (1)
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(UserId userId) {
        super("Could not find user with id " + userId);
    }
}
1 Specify the HTTP response status via a class level annotation.

3.1.2. Set HTTP response status via properties

Use the error.handling.http-statuses property to set the HTTP response status without adding an annotation to the sources of the Exception class itself. This is mostly useful if you don’t control the sources of the Exception.

For example:

error.handling.http-statuses.java.lang.IllegalArgumentException=bad_request

By setting this, any IllegalArgumentException that happens will have a 400 Bad Request response code. The values are the enum values from org.springframework.http.HttpStatus.

3.1.3. Use ResponseStatusException

When the exception class is org.springframework.web.server.ResponseStatusException (or one of the sub-classes), then the response status is taken from the status set on the Exception itself.

As an example, when a MethodNotAllowedException is thrown, this is the resulting JSON:

{
  "code": "METHOD_NOT_ALLOWED",
  "message": "405 METHOD_NOT_ALLOWED \"Request method 'OPTIONS' not supported\""
}

and the response code will be 405 Method Not Allowed.

3.1.4. Add HTTP response status in JSON response

It is possible to add the HTTP response status in the JSON response payload itself as well. This is not the case by default. To enable this, set the following property:

error.handling.http-status-in-json-response=true

The resulting JSON response will now be:

{
  "status": 404,
  "code": "USER_NOT_FOUND",
  "message": "Could not find user with id 123"
}

3.2. Error codes

3.2.1. Error code style

By default, the full qualified name of the Exception class is converted to ALL_CAPS to be used as the code in the response.

If you like to use the plain full qualified name style for the codes, then specify this property:

error.handling.default-error-code-strategy=FULL_QUALIFIED_NAME

For a class called UserNotFoundException, this default style (ALL_CAPS) will result in the following JSON:

{
  "code": "USER_NOT_FOUND",
  "message": "Could not find user with id 123"
}

Using FULL_QUALIFIED_NAME, the result will be:

{
  "code": "com.company.application.user.UserNotFoundException",
  "message": "Could not find user with id 123"
}

3.2.2. General override of error codes

If the default Error code style is not enough for what you need, you can set a code via the properties by using the full qualified name under the error.handling.codes key:

error.handling.codes.java.lang.IllegalArgumentException=ILLEGAL_ARGUMENT

Result:

{
  "code": "ILLEGAL_ARGUMENT",
  "message": "argument was not as expected"
}

This is mostly useful for Exception types that are not under your own control (E.g. they are coming from a library that you use). If you do have control, it is probably easier to use Per class override of error code.

3.2.3. Per class override of error code

By adding the @ResponseErrorCode annotation as a class level annotation, it is possible to define the code that the response will be using.

Example:

package com.company.application.user;

import io.github.wimdeblauwe.errorhandlingspringbootstarter.ResponseErrorCode;

@ResponseErrorCode("COULD_NOT_FIND_USER")
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(UserId userId) {
        super("Could not find user with id " + userId);
    }
}

This results in this response:

{
  "code": "COULD_NOT_FIND_USER",
  "message": "Could not find user with id 123"
}

3.2.4. General override of validation error codes

The library has codes defined for all jakarta.validation.constraints annotations. It is possible to override those via the application.properties.

The default code for @Size is INVALID_SIZE, but if you want to change this to SIZE_REQUIREMENT_NOT_MET, then define the following property:

error.handling.codes.Size=SIZE_REQUIREMENT_NOT_MET

If there is now a validation error for @Size, then the response body will be:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='exampleRequestBody'. Error count: 1",
  "fieldErrors": [
    {
      "code": "SIZE_REQUIREMENT_NOT_MET", (1)
      "property": "name",
      "message": "size must be between 10 and 2147483647",
      "rejectedValue": "",
      "path": "name"
    }
  ]
}
1 Custom code used for the field error

3.2.5. Field specific override of validation error codes

It is possible to configure a specific error code that only will be used for a combination of a field with a validation annotation.

Suppose you add a regex to validate password rules:

public class CreateUserRequestBody {
    @Pattern(".*{8}")
    private String password;

    // getters and setters
}

By default, this error is in the response:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='createUserRequestBody'. Error count: 1",
  "fieldErrors": [
    {
      "code": "REGEX_PATTERN_VALIDATION_FAILED",
      "property": "password",
      "message": "must match \".*{8}\"",
      "rejectedValue": "",
      "path": "password"
    }
  ]
}

If we would use error.handling.codes.Pattern for the override, then all @Pattern annotations in the whole application would use a different code. If we want to only override this for fields that are named password, we can use:

error.handling.codes.password.Pattern=PASSWORD_COMPLEXITY_REQUIREMENTS_NOT_MET

This results in:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='createUserRequestBody'. Error count: 1",
  "fieldErrors": [
    {
      "code": "PASSWORD_COMPLEXITY_REQUIREMENTS_NOT_MET",
      "property": "password",
      "message": "must match \".*{8}\"",
      "rejectedValue": "",
      "path": "password"
    }
  ]
}

3.3. Error messages

3.3.1. Default behaviour

The library will output the message property of the Exception into the message JSON field by default.

For example:

package com.company.application.user;

public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(UserId userId) {
        super("Could not find user with id " + userId);
    }
}

The response JSON:

{
  "code": "USER_NOT_FOUND",
  "message": "Could not find user with id 123" (1)
}
1 The output uses the message of the Exception by default.

3.3.2. General override of error messages

By using error.handling.messages property, it is possible to globally set an error message for a certain exception or a certain validation annotation.

Exception

Suppose you have this defined:

error.handling.messages.com.company.application.user.UserNotFoundException=The user was not found

The response JSON:

{
  "code": "USER_NOT_FOUND",
  "message": "The user was not found" (1)
}
1 The output uses the configured override.

This can also be used for exception types that are not part of your own application.

For example:

error.handling.messages.jakarta.validation.ConstraintViolationException=There was a validation failure.

Will output the following JSON:

{
  "code": "VALIDATION_FAILED",
  "message": "There was a validation failure.",
  "fieldErrors": [
    {
      "code": "INVALID_SIZE",
      "property": "name",
      "message": "size must be between 10 and 2147483647",
      "rejectedValue": "",
      "path": "name"
    }
  ]
}
Validation annotation

Suppose you have this defined:

error.handling.messages.NotBlank=The property should not be blank

Then the message in the output is this:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='exampleRequestBody'. Error count: 1",
  "fieldErrors": [
    {
      "code": "REQUIRED_NOT_BLANK",
      "property": "name",
      "message": "The property should not be blank",(1)
      "rejectedValue": "",
      "path": "name"
    }
  ]
}
1 Custom message used for the field error

So you start with error.handling.messages and suffix with the name of the validation annotation used (@NotBlank in the above example).

3.3.3. Field specific override of error messages

It is possible to configure a specific error message that only will be used for a combination of a field with a validation annotation.

Suppose you add a regex to validate password rules:

public class CreateUserRequestBody {
    @Pattern(".*{8}")
    private String password;

    // getters and setters
}

By default, this error is in the response:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='createUserRequestBody'. Error count: 1",
  "fieldErrors": [
    {
      "code": "REGEX_PATTERN_VALIDATION_FAILED",
      "property": "password",
      "message": "must match \".*{8}\"",
      "rejectedValue": "",
      "path": "password"
    }
  ]
}

If we would use error.handling.messages.Pattern for the override, then all @Pattern annotations in the whole application would use a different message. If we want to only override this for fields that are named password, we can use:

error.handling.messages.password.Pattern=The password complexity rules are not met. A password must be 8 characters minimum.

This results in:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='createUserRequestBody'. Error count: 1",
  "fieldErrors": [
    {
      "code": "REGEX_PATTERN_VALIDATION_FAILED",
      "property": "password",
      "message": "The password complexity rules are not met. A password must be 8 characters minimum.", (1)
      "rejectedValue": "",
      "path": "password"
    }
  ]
}
1 Custom error message used in the response

By default, the library will only match HTTP response status, Error codes or Error messages settings in the properties when there is an exact match with the full qualified name of the Exception.

If you want to define settings for a group of Exceptions that share a common superclass, then this is possible by enabling the error.handling.search-super-class-hierarchy setting:

error.handling.search-super-class-hierarchy=true

With this in place, we can for instance set the properties for any RuntimeException sub-class like this:

error.handling.http-statuses.java.lang.RuntimeException=bad_request
error.handling.codes.java.lang.RuntimeException=RUNTIME_EXCEPTION
error.handling.messages.java.lang.RuntimeException=A runtime exception has happened

Assume this exception is thrown:

public class MyException extends RuntimeException {}

Then the response will be:

{
  "code": "RUNTIME_EXCEPTION",
  "message": "A runtime exception has happened"
}

3.4.1. Reset back to default messaging at a point in exception class hierarchy

Consider that case where you have an exception like:

public class ApplicationException extends RuntimeException {}

but you already have the following configuration:

error.handling.messages.java.lang.RuntimeException=A runtime exception has happened
error.handling.search-super-class-hierarchy=true

You may reset back to default error messaging from a certain point in the class hierarchy (say my.ApplicationException) by providing an empty value for that exception.

error.handling.messages.my.ApplicationException=

Now, ApplicationException and its subclasses will fall back to default messaging behaviour, rather than always showing "A runtime exception has happened".

3.5. Exception handlers

3.5.1. Validation

There are 4 cases that this library will create specific JSON responses when validation errors occur:

  1. When using @Valid in the parameter of a controller method for a request body. For example:

    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import jakarta.validation.Valid;
    
    @RestController
    @RequestMapping("/example")
    public class MyExampleController {
    
        @PostMapping
        public MyResponse doSomething(@Valid @RequestBody ExampleRequestBody requestBody ) {
            // ...
        }
    }
    
  2. When annotating an @RequestParam parameter with extra validation annotations. For example:

    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import jakarta.validation.Valid;
    
    @RestController
    @RequestMapping("/example")
    @Validated
    public class MyExampleController {
    
        @GetMapping
        public MyResponse doSomething(@NotBlank @RequestParam("param") String param ) {
            // ...
        }
    }
    

    You need to add org.springframework.validation.annotation.Validated annotation on the controller class otherwise, the validation annotations on the request parameter will not work.

  3. When using @Valid in the parameter of a controller method for a class that is mapped to request parameters. For example:

    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import jakarta.validation.Valid;
    
    @RestController
    @RequestMapping("/example")
    public class MyExampleController {
    
        @GetMapping
        public MyResponse doSomething(@Valid ExampleRequestParameters requestParameters ) {
            // ...
        }
    }
    

    Where TestRequestBody would have standard or custom validation annotations.

  4. When using validation annotations on Spring components that are themselves annotated with @Validated. For example:

    import org.springframework.stereotype.Service;
    import org.springframework.validation.annotation.Validated;
    import jakarta.validation.Valid;
    import jakarta.validation.constraints.NotNull;
    
    @Service
    @Validated
    public static class TestService {
        void doSomething(@Valid TestRequestBody requestBody,
                         @NotNull String extraArg) {
    
        }
    }
    

In all cases, the response JSON will be similar to this:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='exampleRequestBody'. Error count: 4",
  "fieldErrors": [
    {
      "code": "INVALID_SIZE",
      "property": "name",
      "message": "size must be between 10 and 2147483647",
      "rejectedValue": "",
      "path": "name"
    },
    {
      "code": "REQUIRED_NOT_BLANK",
      "property": "favoriteMovie",
      "message": "must not be blank",
      "rejectedValue": null,
      "path": "favoriteMovie"
    }
  ],
  "globalErrors": [
    {
      "code": "ValidCustomer",
      "message": "Invalid customer"
    },
    {
      "code": "ValidCustomer",
      "message": "UserAlreadyExists"
    }
  ],
  "parameterErrors": [
    {
      "code": "REQUIRED_NOT_NULL",
      "message": "must not be null",
      "parameter": "extraArg",
      "rejectedValue": null
    }
  ]
}

Breakdown:

  • The code is always VALIDATION_FAILED (unless there was an override defined)

  • The message indicates what object failed the validation and also indicates the amount of validation errors.

  • The fieldErrors array contains all field-level validation problems. It shows the name of the property that failed the validation and the value that was received in the request.

  • The parameterErrors array contains all the parameter-level validation problems. It shows the name of the parameter that failed the validation and the value that was received in the request.

  • The globalErrors array contains the class-level validation problems.

    The code and message used for globalErrors is based on the annotation that was used for validation:

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = CustomerValidator.class)
    public @interface ValidCustomer {
        String message() default "Invalid customer";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    }
    

    As well as the template that is used in the validator itself:

    public class CustomerValidator implements ConstraintValidator<ValidCustomer, CreateCustomerFormData> {
        @Override
        public boolean isValid(CreateCustomerFormData formData, ConstraintValidatorContext context) {
    
            if(...) {
                context.buildConstraintViolationWithTemplate("UserAlreadyExists").addConstraintViolation();
            }
        }
    }
    

If you want to change the message for the global errors, the default Spring mechanism for that keeps working.

So use {} to indicate that Spring should search the messages.properties file:

context.buildConstraintViolationWithTemplate("{UserAlreadyExists}").addConstraintViolation();

Now add the translation to the messages.properties:

UserAlreadyExists=The user already exists

This results in:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='exampleRequestBody'. Error count: 2",
  "globalErrors": [
    {
      "code": "INVALID_CUSTOMER",
      "message": "Invalid customer"
    },
    {
      "code": "INVALID_CUSTOMER",
      "message": "The user already exists"
    }
  ]
}

3.5.2. Unreadable HTTP message

If a controller receives a message that it cannot read, because the JSON is invalid for example, then a HttpMessageNotReadableException is thrown. When this happens, the library will return something like the following response:

{
  "code": "MESSAGE_NOT_READABLE",
  "message": "JSON parse error: Unexpected character ('i' (code 105)): was expecting double-quote to start field name; nested exception is com.fasterxml.jackson.core.JsonParseException: Unexpected character ('i' (code 105)): was expecting double-quote to start field name\n at [Source: (PushbackInputStream); line: 1, column: 3]"
}

3.5.3. Type conversion exceptions

Type conversion exceptions like MethodArgumentTypeMismatchException and TypeMismatchException will have some extra info about the class that was expected and the value that was rejected:

{
  "code": "ARGUMENT_TYPE_MISMATCH",
  "message": "Failed to convert value of type 'java.lang.String' to required type 'com.example.user.UserId'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.PathVariable com.example.user.UserId] for value 'fake_UUID'; nested exception is java.lang.IllegalArgumentException: Invalid UUID string: fake_UUID",
  "expectedType": "com.example.user.UserId",
  "property": "userId",
  "rejectedValue": "fake_UUID"
}

3.5.4. Optimistic locking exceptions

When an org.springframework.orm.ObjectOptimisticLockingFailureException is thrown, the resulting response will be something like:

{
  "code": "OPTIMISTIC_LOCKING_ERROR",
  "message": "Object of class [com.example.user.User] with identifier [87518c6b-1ba7-4757-a5d9-46e84c539f43]: optimistic locking failed",
  "identifier": "87518c6b-1ba7-4757-a5d9-46e84c539f43",
  "persistentClassName": "com.example.user.User"
}

3.5.5. Spring Security exceptions

If Spring Security is on the classpath, then those exceptions will be handled. They will just have a code and a message.

For example:

{
  "code": "ACCESS_DENIED",
  "message": "Access is denied"
}

The full list of Exception types that are handled:

  • AccessDeniedException

  • AccountExpiredException

  • AuthenticationCredentialsNotFoundException

  • AuthenticationServiceException

  • BadCredentialsException

  • UsernameNotFoundException

  • InsufficientAuthenticationException

  • LockedException

  • DisabledException

3.6. Adding extra properties in the response

It is possible to add extra properties in the JSON response by using the @ErrorResponseProperty annotation in your custom Exception class.

3.6.1. Via method annotation

This example annotates the getUserId() method with @ResponseErrorProperty so that the return value of the method is added to the JSON response:

@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseErrorCode("USER_NOT_FOUND")
public class UserNotFoundException extends RuntimeException {

    private final UserId userId;

    public UserNotFoundException(UserId userId) {
        super(String.format("Could not find user with id %s", userId));
        this.userId = userId;
    }

    @ResponseErrorProperty (1)
    public String getUserId() {
        return userId.getValue();
    }
}
1 Add the result of this method as an extra property in the response

The resulting response:

{
  "code": "USER_NOT_FOUND",
  "message": "Could not find user with id UserId{id=8c7fb13c-0924-47d4-821a-36f73558c898}",
  "userId": "8c7fb13c-0924-47d4-821a-36f73558c898"
}

3.6.2. Via field annotation

This example annotates the userId field with @ResponseErrorProperty so that the value of the field is added to the JSON response:

@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseErrorCode("USER_NOT_FOUND")
public class UserNotFoundException extends RuntimeException {

    @ResponseErrorProperty (1)
    private final UserId userId;

    public UserNotFoundException(UserId userId) {
        super(String.format("Could not find user with id %s", userId));
        this.userId = userId;
    }
}
1 Add the result of this method as an extra property in the response

The resulting response:

{
  "code": "USER_NOT_FOUND",
  "message": "Could not find user with id UserId{id=8c7fb13c-0924-47d4-821a-36f73558c898}",
  "userId": "8c7fb13c-0924-47d4-821a-36f73558c898"
}
The annotated field can be public or private.

3.6.3. Overriding the property name

It is also possible to override the property name that will be used in the response by using the value argument of the annotation.

@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseErrorCode("USER_NOT_FOUND")
public class UserNotFoundException extends RuntimeException {

    ...

    @ResponseErrorProperty("id")
    public String getUserId() {
        return userId.asString();
    }
}

The resulting response:

{
  "code": "USER_NOT_FOUND",
  "message": "Could not find user with id UserId{id=8c7fb13c-0924-47d4-821a-36f73558c898}",
  "id": "8c7fb13c-0924-47d4-821a-36f73558c898"
}

3.6.4. Null handling

If a property or method that is annotated with @ResponseErrorProperty returns null, then the JSON output will not contain the property by default. If this is desirable, then use the includeIfNull property on the annotation to change this behaviour:

@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseErrorCode("USER_NOT_FOUND")
public class UserNotFoundException extends RuntimeException {

    private final UserId userId;

    public UserNotFoundException(UserId userId) {
        super(String.format("Could not find user with id %s", userId));
        this.userId = userId;
    }

    @ResponseErrorProperty(includeIfNull=true) (1)
    public String getUserId() {
        return userId.asString();
    }
}
1 Set the includeIfNull setting to true

The resulting response assuming the passed in userId is null:

{
  "code": "USER_NOT_FOUND",
  "message": "Could not find user with id UserId{id=8c7fb13c-0924-47d4-821a-36f73558c898}",
  "userId": null
}

3.6.5. Global customization of the response JSON

The previous methods can only be used if you have access to the source code of the exception class. If you want to add some property globally for all exceptions, also those from libraries you import, then you can implement an ApiErrorResponseCustomizer.

For example, suppose you want to add a timestamp to all error responses. You can do so by declaring the following bean:

    @Bean
    public ApiErrorResponseCustomizer timestampErrorResponseCustomizer() {
        return new ApiErrorResponseCustomizer() {
            @Override
            public void customize(ApiErrorResponse response) {
                response.addErrorProperty("instant", Instant.now());
            }
        };
    }

An example resulting response could look like this:

{
  "code": "USER_NOT_FOUND",
  "message": "Could not find user with id UserId{id=8c7fb13c-0924-47d4-821a-36f73558c898}",
  "instant": "2023-06-14T06:20:12.719357Z"
}

You can declare multiple such beans in your application.

3.7. Custom JSON response field names

If the code, message, fieldErrors and/or globalErrors field names are not to your liking, then you can customize those through the following properties:

error.handling.json-field-names.code=errorCode
error.handling.json-field-names.message=description
error.handling.json-field-names.field-errors=fieldFailures
error.handling.json-field-names.global-errors=classFailures

With these settings, a response will look similar to this:

{
  "errorCode": "VALIDATION_FAILED",
  "description": "Validation failed for object='exampleRequestBody'. Error count: 4",
  "fieldFailures": [
    {
      "code": "INVALID_SIZE",
      "property": "name",
      "message": "size must be between 10 and 2147483647",
      "rejectedValue": "",
      "path": "name"
    }
  ],
  "classFailures": [
    {
      "code": "ValidCustomer",
      "message": "UserAlreadyExists"
    }
  ]
}

3.8. Logging

The library will log a single line to the configured logging output for each Exception that is handled. This behaviour can be changed to log either nothing at all (NO_LOGGING), or to log full stack traces (WITH_STACKTRACE) via the error.handling.exception-logging property.

If you want to keep logging minimal, but still have a full stacktrace for some exceptions, then you can use the error.handling.full-stacktrace-classes property like this:

error.handling.exception-logging=MESSAGE_ONLY
error.handling.full-stacktrace-classes[0]=java.lang.NullPointerException
error.handling.full-stacktrace-classes[1]=org.springframework.http.converter.HttpMessageNotReadableException

With this configuration, all exceptions will have a single log line in the logging output, but NullPointerException and HttpMessageNotReadableException will have full stack traces printed.

Only the exact matches of the listed classes are used, not the subclasses of the specified classes.

Another way to have additional logging is to specify a list of HTTP return codes that you want full stack traces for:

error.handling.full-stacktrace-http-statuses[0]=5xx
error.handling.full-stacktrace-http-statuses[1]=403

This configuration will print a full stack trace for all errors in the 5xx range and for the exact 403 error code.

The logging that is done on the response codes is additionally to the logging done on the exception type. You might want to only enable one of the two to avoid double stack traces.

It is also possible to define on what log level the exception message (and the stacktrace if enabled) should be printed. By default, everything is printed on ERROR, but using the error.handling.log-levels property, this can be changed.

error.handling.log-levels.400=DEBUG
error.handling.log-levels.401=INFO
error.handling.log-levels.5xx=ERROR

With this configuration, 400 Bad Request will be printed on DEBUG level. 401 Unauthorized will be printed on INFO. Finally, all status code in the 5xx range will be printed on ERROR.

3.9. Spring Security

3.9.1. AuthenticationEntryPoint

By default, the library will not provide a response when there is an unauthorized exception. It is impossible for this library to provide auto-configuration for this.

There is however a class io.github.wimdeblauwe.errorhandlingspringbootstarter.UnauthorizedEntryPoint that you can configure in your own security configuration to get the expected behaviour.

First, define the class as a Spring bean. Second, set the bean as the entrypoint.

Example configuration:

import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.UnauthorizedEntryPoint;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper;

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;

public class WebSecurityConfiguration {
    @Bean
    public UnauthorizedEntryPoint unauthorizedEntryPoint(HttpStatusMapper httpStatusMapper,
                                                         ErrorCodeMapper errorCodeMapper,
                                                         ErrorMessageMapper errorMessageMapper,
                                                         ObjectMapper objectMapper) { (1)
        return new UnauthorizedEntryPoint(httpStatusMapper, errorCodeMapper, errorMessageMapper, objectMapper);
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   UnauthorizedEntryPoint unauthorizedEntryPoint) throws Exception {
        http.httpBasic(AbstractHttpConfigurer::disable);

        http.authorizeHttpRequests(customizer -> customizer.anyRequest().authenticated());

        http.exceptionHandling(customizer -> customizer.authenticationEntryPoint(unauthorizedEntryPoint));(2)

        return http.build();
    }
}
1 Define the UnauthorizedEntryPoint as a bean.
2 Use the bean in the security configuration.

3.9.2. AccessDeniedHandler

Similar to the AuthenticationEntryPoint, there is also an AccessDeniedHandler implementation available at io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponseAccessDeniedHandler.

Example configuration:

import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.UnauthorizedEntryPoint;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper;

import org.springframework.context.annotation.Bean;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;

public class WebSecurityConfiguration {

    @Bean
    public AccessDeniedHandler accessDeniedHandler(HttpStatusMapper httpStatusMapper,
                                                   ErrorCodeMapper errorCodeMapper,
                                                   ErrorMessageMapper errorMessageMapper,
                                                   ObjectMapper objectMapper) { (1)
            return new ApiErrorResponseAccessDeniedHandler(objectMapper, httpStatusMapper, errorCodeMapper, errorMessageMapper);
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   AccessDeniedHandler accessDeniedHandler) throws Exception {
        http.httpBasic(AbstractHttpConfigurer::disable);

        http.authorizeHttpRequests(customizer -> customizer.anyRequest().authenticated());

        http.exceptionHandling(customizer -> customizer.accessDeniedHandler(accessDeniedHandler));(2)

        return http.build();
    }
}
1 Define the AccessDeniedHandler as a bean.
2 Use the bean in the security configuration.

You can perfectly combine the AccessDeniedHandler with the UnauthorizedEntryPoint:

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   UnauthorizedEntryPoint unauthorizedEntryPoint,
                                                   AccessDeniedHandler accessDeniedHandler) throws Exception {
        http.httpBasic().disable();

        http.authorizeHttpRequests().anyRequest().authenticated();

        http.exceptionHandling()
            .authenticationEntryPoint(unauthorizedEntryPoint)
            .accessDeniedHandler(accessDeniedHandler);

        return http.build();
    }

3.10. Handle non-rest controller exceptions

The library is setup in such a way that only exceptions coming from @RestController classes are handled. This avoids that the library would interfere with @Controller method exceptions, if you have a mixed setup with for instance an admin backend using @Controller for the web interface and an actual API for a mobile app using @RestController.

The consequence of this is that exceptions that never hit an @RestController are not processed by the library. Example of such exceptions:

  • org.springframework.web.HttpRequestMethodNotSupportedException

  • org.springframework.web.HttpMediaTypeNotSupportedException

If you would like to have support for those exceptions (and your project is only using @RestController classes), then you can define this class:

import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ErrorHandlingControllerAdvice;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.web.bind.annotation.ControllerAdvice;

import java.util.List;

@ControllerAdvice
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class FallbackExceptionHandler extends ErrorHandlingControllerAdvice {

    public FallbackExceptionHandler(List<ApiExceptionHandler> handlers,
                                    FallbackApiExceptionHandler fallbackHandler,
                                    LoggingService loggingService) {
        super(handlers, fallbackHandler, loggingService);
    }
}

3.11. Handle filter exceptions

By default, the library will not handle exceptions from custom filters. Those are implementations of jakarta.servlet.Filter, usually subclasses of org.springframework.web.filter.OncePerRequestFilter in a Spring Boot application.

By setting the property error.handling.handle-filter-chain-exceptions to true, the library will handle those exceptions and return error responses just like is done for exceptions coming from controller methods.

4. Custom exception handler

If the extensive customization options are not enough, you can write your own ApiExceptionHandler implementation.

The contract that you need to implement is defined in the io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiExceptionHandler interface:

package io.github.wimdeblauwe.errorhandlingspringbootstarter;

public interface ApiExceptionHandler {
    /**
     * Determine if this {@link ApiExceptionHandler} can handle the given {@link Throwable}.
     * It is guaranteed that this method is called first, and the {@link #handle(Throwable)} method
     * will only be called if this method returns <code>true</code>.
     *
     * @param exception the Throwable that needs to be handled
     * @return true if this handler can handle the Throwable, false otherwise.
     */
    boolean canHandle(Throwable exception);

    /**
     * Handle the given {@link Throwable} and return an {@link ApiErrorResponse} instance
     * that will be serialized to JSON and returned from the controller method that has
     * thrown the Throwable.
     *
     * @param exception the Throwable that needs to be handled
     * @return the non-null ApiErrorResponse
     */
    ApiErrorResponse handle(Throwable exception);
}
There is also the io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.AbstractApiExceptionHandler implementation that you can use as a base class.

As an example, imagine you want to add the first-level cause of an Exception.

The implementation could look something like this:

package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler;

import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiExceptionHandler;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

@Component (1)
public class CustomExceptionApiExceptionHandler implements ApiExceptionHandler { (2)
    @Override
    public boolean canHandle(Throwable exception) {
        return exception instanceof CustomException; (3)
    }

    @Override
    public ApiErrorResponse handle(Throwable exception) {
        CustomException customException = (CustomException) exception;

        ApiErrorResponse response = new ApiErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, (4)
                                                         "MY_CUSTOM_EXCEPTION",
                                                         exception.getMessage());
        Throwable cause = customException.getCause();
        Map<String, Object> nestedCause = new HashMap<>();
        nestedCause.put("code", "CAUSE");
        nestedCause.put("message", cause.getMessage());
        response.addErrorProperty("cause", nestedCause); (5)

        return response;
    }
}
1 Ensure your custom handler is added to the Spring context. Easiest is just add the @Component annotation.
2 Implement the ApiExceptionHandler interface.
3 Check the exception type.
4 Create the ApiErrorResponse instance.
5 Use addErrorProperty to add custom fields to the response.

This will result in this JSON response:

{
  "code": "MY_CUSTOM_EXCEPTION",
  "message": "parent exception message",
  "cause": {
    "code": "CAUSE",
    "message": "child IOException message"
  }
}

5. Properties

Property Description Default

error.handling.enabled

Allows to enable or disable the error handling

true

error.handling.exception-logging

Allows to set how the exception should be logged. One of: NO_LOGGING, MESSAGE_ONLY, WITH_STACKTRACE.

MESSAGE_ONLY

error.handling.full-stacktrace-classes

Allows to specify a list of full qualified names of Exception classes that will always print a stack trace in the logging, regardless of the error.handling.exception-logging setting. See Logging for more info.

error.handling.full-stacktrace-http-statuses

Allows to specify a list of HTTP error codes (or ranges) that will always print a stack trace in the logging, regardless of the error.handling.exception-logging setting. See Logging for more info.

A fixed code like 500 can be used, or a range like 50x or 5xx is also possible.

error.handling.log-levels

Allows to specify a map of HTTP error codes (or ranges) to log levels. This allows to specify for each HTTP error code on what log level the message (and stack trace) should be printed. See Logging for more info.

A fixed code like 500 can be used, or a range like 50x or 5xx is also possible.

error.handling.default-error-code-strategy

Determines how an Exception is converted into a code in case there is no @ResponseErrorCode present on the class. One of FULL_QUALIFIED_NAME, ALL_CAPS.

ALL_CAPS

error.handling.http-status-in-json-response

Boolean that allows to add a status field with the HTTP response code in the response JSON body.

false

error.handling.http-statuses

Allows to set the HttpStatus response code to use for the full qualified name of an Exception

HttpStatus.INTERNAL_SERVER_ERROR is used for custom exceptions that have no specific response status set here.

error.handling.codes

Allows to set the code that should be used for the full qualified name of an Exception or the name of a validation annotation.

error.handling.messages

Allows to set the message that should be used for the full qualified name of an Exception or the name of a validation annotation.

error.handling.search-super-class-hierarchy

By default, only the exact full qualified name of an Exception is searched for when setting error.handling.http-statuses, error.handling.codes or error.handling.messages. When this is set to true, you can use any superclass from your Exception type as well.

false

error.handling.json-field-names.code

The field name that is used to serialize the code to JSON.

code

error.handling.json-field-names.message

The field name that is used to serialize the message to JSON.

message

error.handling.json-field-names.fieldErrors

The field name that is used to serialize the fieldErrors to JSON.

fieldErrors

error.handling.json-field-names.globalErrors

The field name that is used to serialize the globalErrors to JSON.

globalErrors

error.handling.json-field-names.parameterErrors

The field name that is used to serialize the parameterErrors to JSON.

parameterErrors

error.handling.add-path-to-error

This property allows to remove the path property in the error response when set to false.

true

error.handling.handle-filter-chain-exceptions

Set this to true to have the library intercept any exception thrown from custom filters and also have the same error responses as exceptions thrown from controller methods.

false.

6. Support

If you have problems with the library, or you are missing a feature, feel free to create an issue on GitHub at github.com/wimdeblauwe/error-handling-spring-boot-starter.