Logo
Abdelghani Roussi's Blog
Published on

Error handling in Spring web using RFC-9457 specification

Authors
  • Name
    Twitter

Introduction

If you have ever created a RESTful API using Spring web, you probably know that handling error responses is a crucial part of the API design. In most cases, we don't just need to return a simple error message and a status, but we also need to provide more details about the error, such as the error code, the error message, and the error description and more informations if needed by the client.

In this article, we will learn how to handle error responses in a RESTful API the right way using Spring web and the RFC-9457 specification.

you can find the source code of this article in this repository

What is RFC-9457?

RFC-9457 (previously RFC-7807) a.k.a Problem Details for HTTP APIs, is a new standard that defines a common format for error responses in RESTful APIs. It provides a standard way to represent error responses in a consistent and machine-readable format. The RFC-9457 defines a JSON object refered as ProblemDetails that contains the following fields:

  • type: A URI reference that identifies the problem type. The type field is used to identify the error type and provide a link to the documentation that describes the error in more detail, so you can put a link to your API documentation here.
  • title: A short, human-readable summary of the problem type.
  • status: The HTTP status code generated by the origin server for this occurrence of the problem.
  • detail: A human-readable explanation specific to this occurrence of the problem.

The RFC-9457 also defines optional fields such as instance (which is the endpoint uri) and more, but you can also add custom fields to the ProblemDetails object.

RFC-9457 uses the application/problem+json media type to represent error responses. This media type is used to indicate that the response body contains a ProblemDetails object.

Here is an example of an error response that follows the RFC-9457 standard:

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en

{
  "type": "https://example.com/errors/out-of-credit",
  "title": "You do not have enough credit.",
  "status": 403,
  "detail": "Your current balance is 30, but that costs 50.",
  "instance": "/account/12345",
  "traceId": "acbd0001-7b8e-4e81-9d6b-3e34ce7c9a9e", // optional field
  "balance": 30, // additional information
  "cost": 50 // additional information
}

Why should we use RFC-9457?

Using RFC-9457 to model error responses in a RESFTful API has several benefits:

  • Standardization: It provides a standard way to represent and deal with error responses accross different APIs, which makes error handling easier for clients.
  • Documentation: The type field can be used to provide links to the documentation that describes the errors in more details.
  • Usability: The ProblemDetails object can be parsed programmatically by clients to extract the error details and do something with it.
  • Extensibility: The ProblemDetails object is extensible, thus you can add custom informations to provide more context about the error.

Spring web and RFC-9457

Spring support for RFC-9457 (previously knowns as RFC-7807) was implemented in version 6.0.0, so in this article we gonna use Spring boot 3 to demonstrate how to handle error responses using the RFC-9457 specification.

Adding the required dependencies

First, we need to add the those dependencies to your pom.xml file:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
	<groupId>org.projectlombok</groupId>
	<artifactId>lombok</artifactId>
	<optional>true</optional>
</dependency>

Creating the API

Let's create a simple RESTful API that contains 2 endpoints to manage users; one to get a user by id and another to create a new user.

UserController.java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping("{id}")
    public User getUser(@PathVariable Long id){
        return userService.getUserById(id)
                .orElseThrow(() -> new UserNotFoundException(id, "/api/users"));
    }

    @PostMapping
    public User createUser(@Valid @RequestBody User user) {
        return userService.createUser(user);
    }
}
User.java
public record User(
        @NotNull(message = "id must not be null") Long id,
        @NotEmpty(message = "name must not be empty") String name,
        @Email(message = "email should be valid") String email) {
}

Java Record is a new feature in java that were released in version 16 as GA, it allows us to create immutable data classes that provides getters/setters and equals/hashCode methods out of the box)

UserService.java
@Service
public class UserService {
    public Optional<User> getUserById(Long id) {
        if (id == 1) {
            return Optional.of(new User(1L, "Adam Rs", "adam.rs@gmail.com"));
        }
        return Optional.empty();
    }

    public User createUser(User user) {
        return user;
    }
}

If we curl the POST /users endpoint giving it a valid user body, we will get the following response:

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 18 Mar 2024 19:08:45 GMT
Connection: close

{
  "id": 1,
  "name": "Adam Rs",
  "email": "adam.rs@gmail.com"
}

but if we call the same endpoint with an invalid user body, we will get the following response:

HTTP/1.1 400 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 18 Mar 2024 19:10:45 GMT
Connection: close

{
  "timestamp": "2024-03-19T01:10:23.937+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/api/users"
}

As you can see, this is the default error response that Spring returns when an exception is thrown. It contains the timestamp, the status, the error message, and the path. This response is not very helpful for the client because it doesn't provide enough details about the error.

Activating the RFC-9457

There 2 differents ways to activate the support of RFC-9457 in Spring web:

  • The first one is to set the property spring.mvc.problemdetails.enabled to true in the application.properties
  • The second way to extend ResponseEntityExceptionHandler and declare it as an @ControllerAdvice in Spring configuration

By doing so if we curl the POST /users endpoint giving it an invalid user body, we will get the following response:

HTTP/1.1 400 
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Tue, 18 Mar 2024 19:15:45 GMT
Connection: close

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Failed to read request",
  "instance": "/api/users"
}

Notice that the response now returns a Content-Type: application/problem+json and the response body contains a ProblemDetails object that follows the RFC-9457 standard.

Way better, right 🤔 ? Hummm ... a little bit but we can enhance/customize it by providing more information about the error.

To do this we can use 2 approaches:

  • The first one is create a custom ExceptionHandler in a @ControllerAdvice class that extends ResponseEntityExceptionHandler.
  • The second way is to create a custom exception class that extends ErrorResponseException and override the asProblemDetail method to produce a enhanced ProblemDetail.

Customizing the error response

Using a custom ExceptionHandler

GlobalExceptionHandler.java
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        ProblemDetail problemDetail = handleValidationException(ex);
        return ResponseEntity.status(status.value()).body(problemDetail);
    }

    private ProblemDetail handleValidationException(MethodArgumentNotValidException ex) {
        String details = getErrorsDetails(ex);
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(ex.getStatusCode(), details);
        problemDetail.setType(URI.create("http://localhost:8080/errors/bad-request"));
        problemDetail.setTitle("Bad request");
        problemDetail.setInstance(ex.getBody().getInstance());
        problemDetail.setProperty("timestamp", Instant.now()); // adding more data using the Map properties of the ProblemDetail
        return problemDetail;
    }

    private String getErrorsDetails(MethodArgumentNotValidException ex) {
        return Optional.ofNullable(ex.getDetailMessageArguments())
                .map(args -> Arrays.stream(args)
                        .filter(msg -> !ObjectUtils.isEmpty(msg))
                        .reduce("Please make sure to provide a valid request, ", (a, b) -> a + " " + b)
                )
                .orElse("").toString();
    }
}

Notice here that we overidded the handleMethodArgumentNotValid from ResponseEntityExceptionHandler parent class, this is because there is a list of exception handled in ResponseEntityExceptionHandler, and if we try to intercept them in our controller advice using @ExceptionHandler we will get an error on runtime. See more details here Getting Ambiguous @ExceptionHandler method mapped for MethodArgumentNotValidException while startup of spring boot application.

Now if we curl the POST /users endpoint giving it an invalid user body, we will get the following response:

HTTP/1.1 400 
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Tue, 18 Mar 2024 19:20:45 GMT
Connection: close

{
  "type": "http://localhost:8080/errors/bad-request",
  "title": "Bad request",
  "status": 400,
  "detail": "Please make sure to provide a valid request,  email: email should be valid, and name: name must not be empty",
  "instance": "/api/users",
  "timestamp": "2024-03-19T11:18:55.895225Z"
}

Please note that we can also use ErrorResponse instead of ProblemDetail :

return ErrorResponse.builder(ex, HttpStatus.NOT_FOUND, "User with id " + id + " not found")
                .type(URI.create("http://localhost:8080/errors/not-found"))
                .title("User not found")
                .instance(URI.create(request.getContextPath()))
                .property("timestamp", Instant.now()) // additional data
                .build();

Using a custom exception class

If we want to return an RFC-9457 compliant Problem details when a user is not found when calling the GET /users/{id}, we can create a custom exception class that extends ErrorResponseException call the super constructor to produce a custom ProblemDetail object.

and finally here is the custom exception class:

public class UserNotFoundException extends ErrorResponseException {

    public UserNotFoundException(Long userId, String path) {
        super(HttpStatus.NOT_FOUND, problemDetailFrom("User with id " + userId + " not found", path), null);
    }

    private static ProblemDetail problemDetailFrom(String message, String path) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, message);
        problemDetail.setType(URI.create("http://localhost:8080/errors/not-found"));
        problemDetail.setTitle("User not found");
        problemDetail.setInstance(URI.create(path));
        problemDetail.setProperty("timestamp", Instant.now()); // additional data
        return problemDetail;
    }
}

If we curl the GET /users/1 endpoint, we will get the following response:

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 18 Mar 2024 20:04:17 GMT
Connection: close

{
  "id": 1,
  "name": "Adam Rs",
  "email": "adam.rs@gmail.com"
}

but if we call the same endpoint with an invalid user id, we will get the following response:

HTTP/1.1 404 
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Tue, 18 Mar 2024 20:07:45 GMT
Connection: close

{
  "type": "http://localhost:8080/errors/not-found",
  "title": "User not found",
  "status": 404,
  "detail": "User with id 2 not found",
  "instance": "/api/users",
  "timestamp": "2024-03-19T12:04:45.479867Z"
}

That's it for this article, I hope you find it helpful. If you have any questions or suggestions, feel free to leave a comment below, or reach out to me on Twitter.