Error handling library for Spring Boot

Posted at — Jul 20, 2020
Riekpil logo
Learn how to test real-world applications with the Testing Spring Boot Applications Masterclass. Comprehensive online course with 8 modules and 130+ video lessons to master well-known Java testing libraries: JUnit 5, Mockito, Testcontainers, WireMock, Awaitility, Selenium, LocalStack, Selenide, and Spring's Outstanding Test Support.

An important part of any decent REST API is good error handling. Good error handling has 2 major goals:

  • It will help the developers of your API to understand exactly what is wrong when they learn to work with your API.

  • It will allow to have the mobile app or Single Page Application or whatever that is using your app to give precise error message to the end-user of the application.

We will first take a look at what Spring Boot offers out-of-the-box, and next look at how Error Handling Spring Boot Starter improves this.

I created some example to show how the default Spring Boot error handling works. A user of the REST API can create an information request specifying name, email and phone number. There is also an endpoint to link an information request to a support agent.

Default Spring Boot error handling

Not found exception

Let’s start with the controller method for retrieving a single information request:

@RestController
@RequestMapping("/api/info-requests")
public class InfoRequestRestController {

    private final InfoRequestService service;
    private final SupportAgentService supportAgentService;

    public InfoRequestRestController(InfoRequestService service,
                                     SupportAgentService supportAgentService) {
        this.service = service;
        this.supportAgentService = supportAgentService;
    }

    @GetMapping("{id}") (1)
    public InfoRequest getInfoRequest(@PathVariable("id") Long id) {
        return service.getInfoRequest(id)
                      .orElseThrow(() -> new InfoRequestNotFoundException(id));
    }
}
1 Endpoint for getting a single information request

To see how the error handling works, we’ll create a @WebMvcTest:

@WebMvcTest
class InfoRequestRestControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private ObjectMapper objectMapper;
    @MockBean
    private InfoRequestService requestService;
    @MockBean
    private SupportAgentService supportAgentService;

    @Test
    void testGetInfoRequestWhenNotFoundException() throws Exception {
        long id = 1;
        when(requestService.getInfoRequest(id))
                .thenThrow(new InfoRequestNotFoundException(id)); (1)

        mockMvc.perform(get("/api/info-requests/{id}", id))
               .andExpect(status().isNotFound()) (2)
               .andDo(print()); (3)
    }
}
1 Setup Mockito to throw the InfoRequestNotFoundException
2 Expect to get a 404 NOT FOUND
3 Print the response

In the @WebMvcTest, we setup Mockito to throw the InfoRequestNotFoundException to simulate an unknown information request. The exception is annotated with @ResponseStatus so that Spring Boot will return a 404 NOT FOUND:

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

@ResponseStatus(HttpStatus.NOT_FOUND) (1)
public class InfoRequestNotFoundException extends RuntimeException {
    public InfoRequestNotFoundException(Long id) {
        super("There is no known info request with id " + id);
    }
}
1 Indicate what response status Spring Boot should use when this exception is thrown in a controller method.

Running the test succeeds and the response is printed:

MockHttpServletResponse:
           Status = 404
    Error message = null
          Headers = []
     Content type = null
             Body =
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

An empty body?

If you have some experience with Spring Boot, you will know that this is not what you get in an actual Spring Boot application. The problem is that the default error handling is coded based on servlet container’s error mappings. Because we are using MockMvc, this is not working.

A better way to see what an actual application does in this case is using @SpringBootTest. This is the same test using that:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) (1)
class InfoRequestRestControllerIntegrationTest {

    @LocalServerPort (2)
    int port;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private InfoRequestService requestService;
    @MockBean
    private SupportAgentService supportAgentService;

    @BeforeEach
    void setUp() {
        RestAssured.port = port; (3)
    }

    @Test
    void testGetInfoRequestWhenNotFoundException() throws Exception {
        long id = 1;
        when(requestService.getInfoRequest(id))
                .thenThrow(new InfoRequestNotFoundException(id));

        given().get("/api/info-requests/{id}", id)
               .prettyPeek()
               .then()
               .statusCode(HttpStatus.NOT_FOUND.value());
    }
}
1 Start the full application with the embedded servlet container on a random port
2 Capture the random port
3 Setup RestAssured with the random port

The response in this case:

HTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 19 Jul 2020 09:49:17 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
    "timestamp": "2020-07-19T09:49:17.824+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "",
    "path": "/api/info-requests/1"
}

Which is exactly what is returned when running the actual application. So, if we want to validate our error responses, we need to use @SpringBootTest, which is sub-optimal compared to using @WebMvcTest.

POST body validation

Another example of the default Spring Boot error handling is validation of a HTTP POST body.

The example controller method:

    @PostMapping
    public ResponseEntity<?> addInfoRequest(@Valid @RequestBody CreateInfoRequestRequestBody requestBody) {
        InfoRequest infoRequest = service.createInfoRequest(requestBody);

        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest().path("/{id}")
                .buildAndExpand(infoRequest.getId()).toUri();

        return ResponseEntity.created(location).build();
    }

With the request body class:

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;

public class CreateInfoRequestRequestBody {
    @NotBlank
    private String name;
    @NotBlank
    private String phoneNumber;
    @Email
    @NotBlank
    private String email;

    // getters and setters
}

You need to specify @NotBlank and @Email if the email is mandatory. @Email alone is not enough.

A @WebMvcTest to validate that we get a 400 BAD REQUEST when the content body is not valid:

    @Test
    void testCreateInfoRequestWithInvalidRequestBody() throws Exception {
        mockMvc.perform(post("/api/info-requests")
                                .characterEncoding(StandardCharsets.UTF_8.name())
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(new CreateInfoRequestRequestBody())))
               .andExpect(status().isBadRequest())
               .andDo(print());
    }

Running this test will output:

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = []
     Content type = null
             Body =
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

So again, an empty body due to the reason outlined above.

The same as an @SpringBootTest integration test:

    @Test
    void testCreateInfoRequestWithInvalidRequestBody() throws Exception {
        given().contentType(ContentType.JSON)
               .body(objectMapper.writeValueAsString(new CreateInfoRequestRequestBody()))
               .post("/api/info-requests")
               .prettyPeek()
               .then()
               .statusCode(HttpStatus.BAD_REQUEST.value());
    }

This results in:

HTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 19 Jul 2020 09:57:34 GMT
Connection: close

{
    "timestamp": "2020-07-19T09:57:34.865+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "",
    "path": "/api/info-requests"
}

The error response is non-empty, but it does not indicate the exact validation problem at all.

As a final example, image a PUT endpoint that would link an info request to a support agent:

    @PutMapping("{requestId}/link-to-agent/{agentId}")
    public void linkSupportAgentToInfoRequest(@PathVariable("requestId") Long requestId,
                                              @PathVariable("agentId") Long agentId) {
        InfoRequest infoRequest = service.getInfoRequest(requestId)
                                         .orElseThrow(() -> new InfoRequestNotFoundException(requestId));
        SupportAgent supportAgent = supportAgentService.getSupportAgent(agentId)
                                                       .orElseThrow(() -> new SupportAgentNotFoundException(agentId));

        // ...

    }

We have now 2 cases that a 404 NOT FOUND can be returned. Either the information request is not found, or the support agent is not found (or both). With the error response we get by default, there is no way we can know which is the exact problem.

In summary, these are the drawbacks we get with the default Spring Boot error handling:

  • It does not work in an @WebMvcTest. While we can make it work in a @SpringBootTest, it would be a lot better to have it consistent when using @WebMvcTest so we can make our unit tests run faster.

  • There is no error message in the response that indicates the exact problem. In many cases, the response status alone is not enough to know exactly what is the problem.

  • It does not show the exact validation problems.

Error Handling Spring Boot Starter

To address the above drawbacks, I create the Error Handling Spring Boot Starter library.

To get started, add the following dependency to your Spring Boot project:

<dependency>
    <groupId>io.github.wimdeblauwe</groupId>
    <artifactId>error-handling-spring-boot-starter</artifactId>
    <version>0.3.0</version> (1)
</dependency>
1 0.3.0 was the most recent version at the time of writing. See Maven Central for the most current version.

If we run the first @WebMvcTest, we now get the following JSON response:

{
  "code": "com.wimdeblauwe.examples.errorhandling.inforequest.InfoRequestNotFoundException",
  "message": "There is no known info request with id 1"
}

For the validation error, we get this response:

{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed for object='createInfoRequestRequestBody'. Error count: 3",
  "fieldErrors": [
    {
      "code": "REQUIRED_NOT_BLANK",
      "property": "phoneNumber",
      "message": "must not be blank",
      "rejectedValue": null
    },
    {
      "code": "REQUIRED_NOT_BLANK",
      "property": "email",
      "message": "must not be blank",
      "rejectedValue": null
    },
    {
      "code": "REQUIRED_NOT_BLANK",
      "property": "name",
      "message": "must not be blank",
      "rejectedValue": null
    }
  ]
}

Note how the extra fieldErrors property is added that lists all the validation problems. Each of those shows an error code, the name of the property for which the validation failed, a human readable error message and finally the value that was used in the call (rejectedValue).

As a 3rd example, the linking of support agent with an info request, we get:

{
  "code": "com.wimdeblauwe.examples.errorhandling.inforequest.InfoRequestNotFoundException",
  "message": "There is no known info request with id 1"
}

We can see that the problem during the linking was the information request. With just the 404 NOT FOUND response code, we do not have this info.

Running the equivalent @SpringBootTest tests shows the exact same response.

So, by just including the Error Handling Spring Boot Starter in our project, we get the following improvements over the default Spring Boot error handling:

  • Consistent error responses for @WebMvcTest and @SpringBootTest tests.

  • Detailed validation errors per property

  • code that can be used by machines to react to the error

  • message that shows a human readable message with detailed info

Customization of the error code

By default, the code is the full qualified name of the exception that is thrown. The library uses this as a default since there is little else to go by, but in most cases you will probably want to customize this. There are 2 ways to do that.

The first way is annotating the exception itself, very similar to how @ResponseStatus works:

@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseErrorCode("INFO_REQUEST_NOT_FOUND") (1)
public class InfoRequestNotFoundException extends RuntimeException {
    public InfoRequestNotFoundException(Long id) {
        super("There is no known info request with id " + id);
    }
}
1 Use @ResponseErrorCode to set the value of code that should be used.

After this change, the error response will be:

{
  "code": "INFO_REQUEST_NOT_FOUND",
  "message": "There is no known info request with id 1"
}

The other way is to specify this via a property (e.g. in application.properties). Start with error.handling.code, followed by the full qualified name of the exception:

error.handling.codes.com.wimdeblauwe.examples.errorhandling.inforequest.InfoRequestNotFoundException=UNKNOWN_INFO_REQUEST
{
  "code": "UNKNOWN_INFO_REQUEST",
  "message": "There is no known info request with id 1"
}

Adding additional fields

In some cases, you might want to add additional fields to the error response from the values that are passed to the exception. Suppose we want to add a property infoRequestId with the id that could not be found.

To do that, we need to modify the exception class to use @ErrorResponseProperty:

import io.github.wimdeblauwe.errorhandlingspringbootstarter.ResponseErrorProperty;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class InfoRequestNotFoundException extends RuntimeException {
    private final Long infoRequestId;

    public InfoRequestNotFoundException(Long id) {
        super("There is no known info request with id " + id);
        this.infoRequestId = id;
    }

    @ResponseErrorProperty (1)
    public Long getInfoRequestId() {
        return infoRequestId;
    }
}
1 The @ResponseErrorProperty annotation indicates that the result of the method call should be serialized into the error response

With this in place, the error response becomes:

{
  "code": "UNKNOWN_INFO_REQUEST",
  "message": "There is no known info request with id 1",
  "infoRequestId": 1
}

We can also optionally specify the name of the property that should be used for serialization:

    @ResponseErrorProperty("id")
    public Long getInfoRequestId() {
        return infoRequestId;
    }

Resulting into:

{
  "code": "UNKNOWN_INFO_REQUEST",
  "message": "There is no known info request with id 1",
  "id": 1
}

In this example, we have put the @ResponseErrorProperty annotation on a method, but it can also be put on a field to the same effect.

Exception logging

As a final note on what the Error Handling Spring Boot Starter library brings, there is the error.handling.exception-logging property. This propery controls if a logging statement is printed for each exception that is handled by the library. There are the following options:

  • no_logging: Nothing will be printed

  • message_only: The exception message will be printed (This is the default)

  • with_stacktrace: The full stack trace will be printed

Conclusion

This blog post shows how the default error handling on Spring Boot works and how the Error Handling Spring Boot Starter improves on that.

See Github for the full example sources.

If you want to be notified in the future about new articles, as well as other interesting things I'm working on, join my mailing list!
I send emails quite infrequently, and will never share your email address with anyone else.