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. Thetype
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 aProblemDetails
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
totrue
in theapplication.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 theasProblemDetail
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.
Top comments (0)