1. Introduction

In this tutorial, we’ll explore different error response formats in the Spring framework. We’ll also understand how to raise and handle RFC7807 ProblemDetail with custom attributes, as well as how to raise custom exceptions in Spring WebFlux.

2. Exception Response Formats in Spring Boot 3

Let’s understand the various error response formats supported out-of-the-box.

By default, Spring Framework provides the DefaultErrorAttributes class that implements the ErrorAttributes interface to generate an error response in the event of an unhandled error. In the case of a default error, the system generates a JSON response structure that we can examine more closely:

{
    "timestamp": "2023-04-01T00:00:00.000+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/api/example"
}

While this error response contains a few key attributes, it may not be beneficial in investigating the issue. Fortunately, we can modify this default behavior by creating a custom implementation of the ErrorAttributes interface in our Spring WebFlux application.

Starting with Spring Framework 6 ProblemDetail, representation for an RFC7807 specification is supported. ProblemDetail includes a few standard attributes that define error details, also an option to extend the details for customization. The supported attributes are listed below:

  • type (string) – a URI reference that identifies the problem type
  • title (string) – short summary of the problem type
  • status (number) – the HTTP status code
  • detail (string) – should contain the details of the exception.
  • instance (string) – a URI reference to identify the specific reason for the issue. For instance, it can refer to the attribute which caused the issue.

In addition to the standard attributes mentioned above, ProblemDetail also contains a Map<String, Object> to add custom parameters to provide more detailed information about the issue.

Let’s take a look at a sample error response structure with custom object errors:

{
  "type": "https://example.com/probs/email-invalid",
  "title": "Invalid email address",
  "detail": "The email address 'john.doe' is invalid.",
  "status": 400,
  "timestamp": "2023-04-07T12:34:56.789Z",
  "errors": [
    {
      "code": "123",
      "message": "Error message",
      "reference": "https//error/details#123"
    }
  ]
}

Spring Framework also provides a base implementation called ErrorResponseException. This exception encapsulates a ProblemDetail object, which generates additional information about the error that occurred. We can extend this exception to customize and add attributes.

3. How to Implement ProblemDetail RFC 7807 Exception

Although Spring 6+ / Spring Boot 3+ applications support the ProblemDetail exception by default, we need to enable it in one of the following ways.

3.1. Enable ProblemDetail Exception by Properties File

The ProblemDetail exception can be enabled by adding a property:

spring:
  mvc:
    problemdetails:
      enabled: true

3.2. Enable ProblemDetail Exception by Adding Exception Handler

The ProblemDetail exception can also be enabled by extending ResponseEntityExceptionHandler and adding a custom exception handler (even without any overrides):

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
    //...
}

We’ll use this approach for this article, as we need to add custom exception handlers.

3.3. Implement ProblemDetail Exception

Let’s examine how to raise and handle a ProblemDetail exception with custom attributes by considering a straightforward application that provides a few endpoints for creating and retrieving User information.

Our controller has a GET /v1/users/{userId} endpoint that retrieves the user information based on the provided userId. If it cannot find any record, the code throws a simple custom exception called UserNotFoundException:

@GetMapping("/v1/users/{userId}")
public Mono<ResponseEntity<User>> getUserById(@PathVariable Long userId) {
    return Mono.fromCallable(() -> {
        User user = userMap.get(userId);
        if (user == null) {
            throw new UserNotFoundException("User not found with ID: " + userId);
        }
        return new ResponseEntity<>(user, HttpStatus.OK);
    });
}

Our UserNotFoundException extends RunTimeException:

public class UserNotFoundException extends RuntimeException {

    public UserNotFoundException(String message) {
        super(message);
    }
}

Since we have a GlobalExceptionHandler custom handler that extends ResponseEntityExceptionHandler, ProblemDetail becomes the default exception format. To test this, we can try accessing the application with an unsupported HTTP method, for instance, POST, to view the exception format.

When a MethodNotAllowedException gets thrown, the ResponseEntityExceptionHandler will handle the exception and generate a response in the ProblemDetail format:

curl --location --request POST 'localhost:8080/v1/users/1'

This results in the ProblemDetail object as the response:

{
    "type": "about:blank",
    "title": "Method Not Allowed",
    "status": 405,
    "detail": "Supported methods: [GET]",
    "instance": "/users/1"
}

3.4. Extend ProblemDetail Exception with Custom Attributes in Spring WebFlux

Let’s extend the example by providing an exception handler for UserNotFoundException, which adds a custom object to the ProblemDetail response.

The ProblemDetail Object contains a properties attribute that accepts a String as a key and value as any Object.

We’ll add a custom object called ErrorDetails. This object contains the error code and message, as well as an error reference URL with additional details and instructions for resolving the issue:

@JsonSerialize(using = ErrorDetailsSerializer.class)
public enum ErrorDetails {
    API_USER_NOT_FOUND(123, "User not found", "http://example.com/123");
    @Getter
    private Integer errorCode;
    @Getter
    private String errorMessage;
    @Getter
    private String referenceUrl;

    ErrorDetails(Integer errorCode, String errorMessage, String referenceUrl) {
        this.errorCode = errorCode;
        this.errorMessage = errorMessage;
        this.referenceUrl = referenceUrl;
    }
}

To override the error behavior for UserNotException, we need to provide an error handler in the GlobalExceptionHandler class. This handler should set the API_USER_NOT_FOUND property of the ErrorDetails object, along with any other error details provided by the ProblemDetail object:

@ExceptionHandler(UserNotFoundException.class)
protected ProblemDetail handleNotFound(RuntimeException ex) {
    ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problemDetail.setTitle("User not found");
    problemDetail.setType(URI.create("https://example.com/problems/user-not-found"));
    problemDetail.setProperty("errors", List.of(ErrorDetails.API_USER_NOT_FOUND));
    return problemDetail;
}

We’ll also need an ErrorDetailsSerializer and ProblemDetailSerializer to customize the response format.

The ErrorDetailsSerializer is responsible for formatting our custom error object with error code, error message, and reference details:

public class ErrorDetailsSerializer extends JsonSerializer<ErrorDetails> {
    @Override
    public void serialize(ErrorDetails value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeStartObject();
        gen.writeStringField("code", value.getErrorCode().toString());
        gen.writeStringField("message", value.getErrorMessage());
        gen.writeStringField("reference", value.getReferenceUrl());
        gen.writeEndObject();
    }
}

The ProblemDetailSerializer is responsible for formatting the overall ProblemDetail object along with the custom object(with the help of ErrorDetailsSerializer):

public class ProblemDetailsSerializer extends JsonSerializer<ProblemDetail> {

    @Override
    public void serialize(ProblemDetail value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeStartObject();
        gen.writeObjectField("type", value.getType());
        gen.writeObjectField("title", value.getTitle());
        gen.writeObjectField("status", value.getStatus());
        gen.writeObjectField("detail", value.getDetail());
        gen.writeObjectField("instance", value.getInstance());
        gen.writeObjectField("errors", value.getProperties().get("errors"));
        gen.writeEndObject();
    }
}

Now, when we try to access the endpoint with an invalid userId, we should receive an error message with our custom attributes:

$ curl --location 'localhost:8080/v1/users/1'

This results in the ProblemDetail object along with the custom attribute:

{
  "type": "https://example.com/problems/user-not-found",
  "title": "User not found",
  "status": 404,
  "detail": "User not found with ID: 1",
  "instance": "/users/1",
  "errors": [
    {
      "errorCode": 123,
      "errorMessage": "User not found",
      "referenceUrl": "http://example.com/123"
    }
  ]
}

We can also use ErrorResponseException that implements ErrorResponse to expose HTTP status, response header, and body with the contract of RFC 7807 ProblemDetail.

In these examples, we have handled global exceptions using ResponseEntityExceptionHandler. Alternatively, AbstractErrorWebExceptionHandler can also be used to handle global Webflux exceptions.

4. Why Custom Exceptions

While the ProblemDetail format is helpful and flexible for adding custom attributes, there are situations where we may prefer to throw a custom error object that includes all details of the error. In such cases, using custom exceptions in Spring can provide a clear, more specific, and more consistent approach to handling errors and exceptions in our code.

5. Implement Custom Exceptions in Spring WebFlux

Let’s consider implementing a custom object as a response instead of ProblemDetail:

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CustomErrorResponse {
    private String traceId;
    private OffsetDateTime timestamp;
    private HttpStatus status;
    private List<ErrorDetails> errors;
}

To throw this custom object, we need a custom exception:

public class CustomErrorException extends RuntimeException {
    @Getter
    private CustomErrorResponse errorResponse;

    public CustomErrorException(String message, CustomErrorResponse errorResponse) {
        super(message);
        this.errorResponse = errorResponse;
    }
}

Let’s create another version, v2 of the endpoint, which throws this custom exception. For simplicity, some of the fields, like traceId, are populated with random values:

@GetMapping("/v2/users/{userId}")
public Mono<ResponseEntity<User>> getV2UserById(@PathVariable Long userId) {
    return Mono.fromCallable(() -> {
        User user = userMap.get(userId);
        if (user == null) {
            CustomErrorResponse customErrorResponse = CustomErrorResponse
              .builder()
              .traceId(UUID.randomUUID().toString())
              .timestamp(OffsetDateTime.now().now())
              .status(HttpStatus.NOT_FOUND)
              .errors(List.of(ErrorDetails.API_USER_NOT_FOUND))
              .build();
            throw new CustomErrorException("User not found", customErrorResponse);
        }
        return new ResponseEntity<>(user, HttpStatus.OK);
    });
}

We need to add a handler in GlobalExceptionHandler to format the exception in the output response finally:

@ExceptionHandler({CustomErrorException.class})
protected ResponseEntity<CustomErrorResponse> handleCustomError(RuntimeException ex) {
    CustomErrorException customErrorException = (CustomErrorException) ex;
    return ResponseEntity.status(customErrorException.getErrorResponse().getStatus()).body(customErrorException.getErrorResponse());
}

Now if we try to access the endpoint with an invalid userId, we should get the error with custom attributes:

$ curl --location 'localhost:8080/v2/users/1'

This results in the CustomErrorResponse object as the response:

{
    "traceId": "e3853069-095d-4516-8831-5c7cfa124813",
    "timestamp": "2023-04-28T15:36:41.658289Z",
    "status": "NOT_FOUND",
    "errors": [
        {
            "code": "123",
            "message": "User not found",
            "reference": "http://example.com/123"
        }
    ]
}

6. Conclusion

In this article, we explored how to enable and use the ProblemDetail RFC7807 exception format provided by Spring Framework and learned how to create and handle custom exceptions in Spring WebFlux.

As always, the source code for the examples is available over on GitHub.