Spring ProblemDetail and ErrorResponse

Starting since Spring 6 and Spring Boot 3, the Spring Framework supports the “Problem Details for HTTP APIs” specification, RFC 7807. This Spring tutorial will guide you through this new enhancement in detail.

1. Problem Details Specification [RFC 7807]

This RFC defines simple JSON and XML document formats that can be used to communicate the problem details to the API consumers. This is useful in cases where HTTP status codes are not enough to describe the problem with an HTTP API.

The following is an example of a problem that occurred while transferring from one bank account to another, and we do not have sufficient balance in our account.

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

{
	"status": 403,
	"type": "https://bankname.com/common-problems/low-balance",
	"title": "You not have enough balance",
	"detail": "Your current balance is 30 and you are transterring 50",
	"instance": "/account-transfer-service"
}

Here the key phrases are:

  • status: HTTP status code generated by the server.
  • type: A URL that identifies the problem type and how to mitigate it. The default value is – about:blank.
  • title: A short summary of the problem.
  • detail: Problem explanation specific to this occurrence.
  • instance: URL of the service where this problem occurred. The default value is the current request URL.

2. Support in Spring Framework

The following are the main abstractions in the Spring framework for supporting the problem details specification:

2.1. ProblemDetail

It is the primary object for representing the problem detail model. As discussed in the previous section, it contains standard fields, and non-standard fields can be added as properties.

ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "message");
pd.setType(URI.create("http://my-app-host.com/errors/not-found"));
pd.setTitle("Record Not Found");

To add a non-standard field, use the setProperty() method.

pd.setProperty("property-key", "property-value");

2.2. ErrorResponse

This interface exposes HTTP error response details, including HTTP status, response headers, and a body of type ProblemDetail. It can be used to supply even more information to the client in comparison to only sending the ProblemDetail object.

All Spring MVC exceptions implement ErrorResponse interface. So all MVC exceptions are already compliant with the specification.

2.3. ErrorResponseException

It is a very basic ErrorResponse implementation that we can use as a convenient base class for creating more specific exception classes.

ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Null Pointer Exception");
pd.setType(URI.create("http://my-app-host.com/errors/npe"));
pd.setTitle("Null Pointer Exception");

throw new ErrorResponseException(HttpStatus.INTERNAL_SERVER_ERROR, pd, npe);

2.4. ResponseEntityExceptionHandler

It is a convenient base class for an @ControllerAdvice that handles all Spring MVC exceptions according to RFC specification and any ErrorResponseException, and renders an error response with a body.

@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
	
	@ExceptionHandler(CustomException.class)
  public ProblemDetail handleCustomException(CustomException ex, WebRequest request) {

    ProblemDetail pd = //build object..
    return pd;
  }
}

We can return ProblemDetail or ErrorResponse from any @ExceptionHandler or from any @RequestMapping method to render an RFC 7807 response.

3. Sending ProblemDetail using ResponseEntity

In failure cases, create a new instance of ProblemDetail class, populate relevant information and set it into the ResponseEntity object.

The following API fails when id is greater than 100.

@Value("${hostname}")
private String hostname;

@GetMapping(path = "/	employees/v2/{id}")
public ResponseEntity getEmployeeById_V2(@PathVariable("id") Long id) {
  if (id < 100) {
    return ResponseEntity.ok(new Employee(id, "lokesh", "gupta", "admin@howtodoinjava.com"));
  } else {
    ProblemDetail pd = ProblemDetail
        .forStatusAndDetail(HttpStatus.NOT_FOUND, "Employee id '" + id + "' does no exist");
    pd.setType(URI.create("http://my-app-host.com/errors/not-found"));
    pd.setTitle("Record Not Found");
    pd.setProperty("hostname", hostname);
    return ResponseEntity.status(404).body(pd);
  }
}

Let us test with the id = 101. It will return the response in RFC specification.

{
    "detail": "Employee id '101' does no exist",
    "hostname": "localhost",
    "instance": "/employees/v2/101",
    "status": 404,
    "title": "Record Not Found",
    "type": "http://my-app-host.com/errors/not-found"
}

4. Throwing ErrorResponseException from REST Controller

Another way to send the problem detail information is by throwing the ErrorResponseException instance from the @RequestMapping handler methods.

This is especially useful in cases where we already have an exception that we cannot send to the client (e.g. NullPointerException). In such cases, we populate the essential information in ErrorResponseException and throw it. Spring MVC handlers internally process this exception and parse it into RFC-specified response format.

@GetMapping(path = "/v3/{id}")
public ResponseEntity getEmployeeById_V3(@PathVariable("id") Long id) {

  try {
  	//somthing threw this exception
    throw new NullPointerException("Something was expected but it was null");
  }
  catch (NullPointerException npe) {
    ProblemDetail pd = ProblemDetail
        .forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
            "Null Pointer Exception");
    pd.setType(URI.create("http://my-app-host.com/errors/npe"));
    pd.setTitle("Null Pointer Exception");
    pd.setProperty("hostname", hostname);
    throw new ErrorResponseException(HttpStatus.NOT_FOUND, pd, npe);
  }
}

5. Adding ProblemDetail to Custom Exceptions

Most applications create exception classes that are more close to their business domain/model. A few such exceptions could be RecordNotFoundException, TransactionLimitException etc. They are more readable and concisely represent an error scenario in the code.

Most of the time, these exceptions are subclasses of RuntimeException.

public class RecordNotFoundException extends RuntimeException {

  private final String message;

  public RecordNotFoundException(String message) {
    this.message = message;
  }
}

And we throw these exceptions from several places in the code.

@GetMapping(path = "/v1/{id}")
public ResponseEntity getEmployeeById_V1(@PathVariable("id") Long id) {
  if (id < 100) {
    return ResponseEntity.ok(...);
  } else {
    throw new RecordNotFoundException("Employee id '" + id + "' does no exist");
  }
}

The best way to add the problem detail information in such exceptions is in the @ControllerAdvice class. We must handle the exception in @ExceptionHandler(RecordNotFoundException.class) method and add the required information.

@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

  @Value("${hostname}")
  private String hostname;

  @ExceptionHandler(RecordNotFoundException.class)
  public ProblemDetail handleRecordNotFoundException(RecordNotFoundException ex, WebRequest request) {

    ProblemDetail body = ProblemDetail
        .forStatusAndDetail(HttpStatusCode.valueOf(404),ex.getLocalizedMessage());
    body.setType(URI.create("http://my-app-host.com/errors/not-found"));
    body.setTitle("Record Not Found");
    body.setProperty("hostname", hostname);

    return body;
  }
}

6. Verifying ProblemDetail Response in Unit Tests

We can also test verify the problem detail responses of the above sections using RestTemplate in a unit test.

@Test
public void testAddEmployee_V2_FailsWhen_IncorrectId() {
  try {
    this.restTemplate.getForObject("/employees/v2/101", Employee.class);
  } catch (RestClientResponseException ex) {

    ProblemDetail pd = ex.getResponseBodyAs(ProblemDetail.class);
    assertEquals("Employee id '101' does no exist", pd.getDetail());
    assertEquals(404, pd.getStatus());
    assertEquals("Record Not Found", pd.getTitle());
    assertEquals(URI.create("http://my-app-host.com/errors/not-found"), pd.getType());
    assertEquals("localhost", pd.getProperties().get("hostname"));
  }
}

Note that if we are using Spring Webflux, we can use the WebClient API to validate the returned problem details.

@Test
void testAddEmployeeUsingWebFlux_V2_FailsWhen_IncorrectId() {

  this.webClient.get().uri("/employees/v2/101")
      .retrieve()
      .bodyToMono(String.class)
      .doOnNext(System.out::println)
      .onErrorResume(WebClientResponseException.class, ex -> {

        ProblemDetail pd = ex.getResponseBodyAs(ProblemDetail.class);
        //assertions...
        return Mono.empty();
      })
      .block();
}

7. Conclusion

In this Spring 6 tutorial, we learned about the new feature supporting the problem details specification in the Spring framework. After this feature, we can return the instances of ProblemDetail or ErrorResponse from any @ExceptionHandler or from any @RequestMapping methods and Spring framework add the necessary schema into the API response.

The code discussed in this tutorial is applied to Spring applications as well as Spring boot applications as well.

Happy Learning !!

Sourcecode on Github

Comments

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.

Our Blogs

REST API Tutorial

Dark Mode

Dark Mode