Error handling in Spring web using RFC-9457 specification
Tags: java, springboot, rest, programming
Published: 2024-03-20 22:33:24

## 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](https://github.com/roussi/spring-web-rfc-9457)


## What is RFC-9457?

[RFC-9457](https://datatracker.ietf.org/doc/html/rfc9457) (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:

```json
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:

```xml
<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.

<span style={{ backgroundColor : '#febc22', color : 'black'}}> UserController.java </span>
```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);
    }
}
```

<span style={{ backgroundColor : '#febc22', color : 'black'}}> User.java </span>
```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) {
}
```

> <span style={{ backgroundColor : '#bde0fe', color : 'black'}}> Java Record </span> 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)

<span style={{ backgroundColor : '#febc22', color : 'black'}}> UserService.java </span>
```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:

```json
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:

```json
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:

```json
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_ 
```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](https://stackoverflow.com/questions/51991992/getting-ambiguous-exceptionhandler-method-mapped-for-methodargumentnotvalidexce).

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

```json
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 :

```java
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:
```java
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:

```json
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:

```json
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](https://twitter.com/abdelranicom).