Java + Quarkus 2

In the previous installment of the Quarkus from the ground up series, you saw the beginnings of a fully functional, OpenAPI-compliant REST API built using Quarkus. That article covered all of the architectural layers, from managing database schemas with Flyway to building the API itself with RESTEasy Reactive. You saw happy-path use cases, but didn't get into the concepts around error handling. In this article, you'll dive into error handling, build a solid error response model, and see how you can help API consumers reduce toil in their work.

Note: Once you're ready to dive in, you can download the complete source code for this article.

Why error handling matters

Error handling is one of those aspects of a system's architecture that deserves more attention. When you're designing a new feature, most of the discussion centers on what happens when things go right—but when that feature goes into production, most of the attention is focused on what is going wrong. As is true with observability and other architectural concerns, if the development team spent a bit more time upfront in the design of error handling within an application, they could reap considerable rewards in production in terms of application availability and stability.

Out-of-the-box functionality

Before modeling and implementing error handling into the sample application, it would be good to explore what error responses look like without any additional code. Begin by cloning the repository for the application you developed in the last article. Next, you'll need to create a new test that uses Mockito to create a response with an unexpected runtime exception. To use Mockito, you need to add the Quarkus extension:


<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5-mockito</artifactId>
    <scope>test</scope>
</dependency>

The test creates a mocked CustomerService instance configured to throw a RuntimeException. When the response is returned, extract it as a Response object and set a breakpoint to better understand what the returned JSON structure looks like:


package com.redhat.exception;

import com.redhat.customer.CustomerService;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.mockito.InjectMock;
import io.restassured.response.Response;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import static io.restassured.RestAssured.given;

@QuarkusTest
public class ThrowableMapperTest {

    @InjectMock
    CustomerService customerService;

    @Test
    public void throwUnexpectedRuntimeExceptionInCustomerService() {
        Mockito.when(customerService.findAll()).thenThrow(new RuntimeException("Completely Unexpected"));
        Response errorResponse = given()
                .when()
                .get("/customers")
                .then()
                .statusCode(500)
                .extract().response();
        errorResponse.prettyPrint();
    }

}

Here's the response JSON structure:


{
    "details": "Error id 327095e1-b8fa-491e-b3f1-e8944bd8023c-1, java.lang.RuntimeException: Completely Unexpected",
    "stack": "java.lang.RuntimeException: Completely Unexpected\n\tat com.redhat.customer.CustomerService_ClientProxy.findAll(Unknown Source)\n\tat com.redhat.customer.CustomerResource.get(CustomerResource.java:43)\n\tat com.redhat.customer.CustomerResource$quarkusrestinvoker$get_458c9ef52b4c83180ab57cf43ffebc046fc42b84.invoke(Unknown Source)\n\tat org.jboss.resteasy.reactive.server.handlers.InvocationHandler.handle(InvocationHandler.java:29)\n\tat org.jboss.resteasy.reactive.server.handlers.InvocationHandler.handle(InvocationHandler.java:7)\n\tat org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext.run(AbstractResteasyReactiveContext.java:141)\n\tat io.quarkus.vertx.core.runtime.VertxCoreRecorder$13.runWith(VertxCoreRecorder.java:543)\n\tat org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2449)\n\tat org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1478)\n\tat org.jboss.threads.DelegatingRunnable.run(DelegatingRunnable.java:29)\n\tat org.jboss.threads.ThreadLocalResettingRunnable.run(ThreadLocalResettingRunnable.java:29)\n\tat io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)\n\tat java.base/java.lang.Thread.run(Thread.java:833)"
}

This response is pretty gnarly, and the API consumer would have to perform a ton of parsing to get useful information out of it. The stack field also leaks information about our implementation, which is a security problem. While the framework has transformed a RuntimeException into a well-formed HTTP response with the proper HTTP status code, the result is unusable.

Out of the box: ConstraintViolationException

Now that you've seen the results of an unexpected exception, let's take a look at the constraint violation use case. This application uses the Hibernate Validator framework to validate data objects as they are passed between layers. The Customer object has @NotEmpty validations on both the firstName and lastName fields, along with an @Email validator on the email field that will validate the value of the String as a well-formed email address if present. When the method parameter for the Customer object has a @Valid annotation, the validator framework will perform the validations. If any constraint violations are present, the framework will throw the ConstraintViolationException.

To see what happens out of the box, you can temporarily create a new test based on one of the existing tests. This temporary test will send a Customer object with a null firstName field:


    @Test
    public void postFailNoFirstNameResponse() {
        Customer customer = createCustomer();
        customer.setFirstName(null);
        Response errorResponse = given()
                .contentType(ContentType.JSON)
                .body(customer)
                .post()
                .then()
                .statusCode(400)
                .extract().response();
        errorResponse.prettyPrint();
    }

Here's the JSON response:


{
    "title": "Constraint Violation",
    "status": 400,
    "violations": [
        {
            "field": "post.customer.firstName",
            "message": "must not be empty"
        }
    ]
}

While it's a vast improvement over the unexpected runtime exception case, the structure and content are still insufficient. The response structure is too specific to the underlying implementation. You want a more generic design that can handle all error responses.

Update the validation messages

Building a better error response model begins with the error messages. How you implement those messages will depend on your need for internationalization. If your API is scoped for use in a single language, the annotations themselves can use default message interpolation by passing a message parameter to the annotation. This method supports building messages using message expressions:


@NotEmpty(message = "Customer's first name is required.")
private String firstName;

If your application needs to support internationalization, you should use message properties files. Name these files using the pattern ValidationMessages_loc.properties, substituting the appropriate locale abbreviation (en, es, etc.) for loc, so developers can build message bundles for every language. Here's an example of the ValidationMessages.properties file, the default, non-locale-specific message bundle placed in the src/main/resources folder:


System.error=An unexpected error has occurred. Please contact support.
Customer.firstName.required=Customer's first name is required
Customer.lastName.required=Customer's last name is required
Customer.email.invalid=Customer's email address is invalid

With this method, you can use the keys of the message bundle to express the correct validation message as part of the annotation:


@NotEmpty(message = "{Customer.firstName.required}")
private String firstName;

Once you've implemented this response model, your response JSON content will contain much more helpful error messages:


{
    "title": "Constraint Violation",
    "status": 400,
    "violations": [
        {
            "field": "post.customer.firstName",
            "message": "Customer's first name is required"
        }
    ]
}

Now that you have taken care of the error message aspect of the application, you can restructure the error response.

Model the error response

Modeling the error response can be a point of contention in application architecture because no full-featured standards govern the response structure. There have been some attempts at standardization, such as a Request for Comments (RFC) published in 2016 called Problem details for HTTP APIs. This RFC is a good start for standardizing things like the field names of the response structure. However, the design doesn't easily support common use cases like the constraint violation, so the result is a collection of errors for a single response.

When it comes to modeling errors, developers must choose between multiple structures or a single unified structure. Using multiple structures requires the consumer to perform introspection on the error structure to determine where the data resides. In contrast, a single suitable structure requires the consumer to create an implied model where the cardinality of the messages is the focal point. Here's are a couple of examples of how an error structure response might be modeled:


// System Error Response - Single
{
    "errorId": "971e0747-f7ec-4d21-915b-c66257db05c3",
    "message": "An unexpected error has occurred. Please contact support"
}

// System Error Response - Multiple
{
    "errorId": "971e0747-f7ec-4d21-915b-c66257db05c3",
    "errors": [
        {
            "message": "An unexpected error has occurred. Please contact support"
        }
    ]
}

For this article, we will focus on creating a single unified error response structure. This structure should handle both application and system errors, whether they are expected or not:


package com.redhat.exception;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.EqualsAndHashCode;
import lombok.Getter;

import java.util.List;

@Getter
@EqualsAndHashCode
public class ErrorResponse {

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String errorId;
    private List<ErrorMessage> errors;

    public ErrorResponse(String errorId, ErrorMessage errorMessage) {
        this.errorId = errorId;
        this.errors = List.of(errorMessage);
    }

    public ErrorResponse(ErrorMessage errorMessage) {
        this(null, errorMessage);
    }

    public ErrorResponse(List<ErrorMessage> errors) {
        this.errorId = null;
        this.errors = errors;
    }

    public ErrorResponse() {
    }

    @Getter
    @EqualsAndHashCode
    public static class ErrorMessage {

        @JsonInclude(JsonInclude.Include.NON_NULL)
        private String path;
        private String message;

        public ErrorMessage(String path, String message) {
            this.path = path;
            this.message = message;
        }

        public ErrorMessage(String message) {
            this.path = null;
            this.message = message;
        }

        public ErrorMessage() {
        }
    }

}

Some implementation notes:

  • The application uses Lombok to manage the boilerplate code, as in the previous article in this series.
    • The @Getter annotation creates getters for the class variables and the @EqualsAndHashCode annotation autogenerates the equals and hashCode methods using a default methodology in which all class variables are used for both implementations.
    • As mentioned in the last article, you should use libraries such as Lombok with extreme care. These annotations can sometimes create generated implementations with easily hidden performance or runtime execution impacts.
  • The @JsonInclude(JsonInclude.Include.NON_NULL) annotation provides instructions to not serialize the item if it is null.

Catching Throwable: Introduction to the ExceptionMapper

Now that you have the data structure for the error response, you can begin to model how to handle exceptions in the application. In Quarkus, you can use the JAX-RS ExceptionMapper class to perform the necessary transformations between exceptions and your error response.

The exception mapper infrastructure starts by catching Throwable, which is the ultimate superclass of all errors and exceptions, checked and unchecked. By catching Throwable in an ExceptionMapper, you can ensure that all unexpected exceptions can be caught, processed, logged, and transformed to the ErrorResponse. This will eliminate the unwanted behavior in which an unexpected exception might leak stack traces and other internals back to the consumer.


package com.redhat.exception;

import lombok.extern.slf4j.Slf4j;

import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.ResourceBundle;
import java.util.UUID;

@Provider
@Slf4j
public class ThrowableMapper implements ExceptionMapper<Throwable> {

    @Override
    public Response toResponse(Throwable e) {
        String errorId = UUID.randomUUID().toString();
        log.error("errorId[{}]", errorId, e);
        String defaultErrorMessage = ResourceBundle.getBundle("ValidationMessages").getString("System.error");
        ErrorResponse.ErrorMessage errorMessage = new ErrorResponse.ErrorMessage(defaultErrorMessage);
        ErrorResponse errorResponse = new ErrorResponse(errorId, errorMessage);
        return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(errorResponse).build();
    }

}

Some implementation notes:

  • The implementation uses the Lombok @Slf4j annotation to create a private, final, and static reference to a log variable that uses the Simple Logging Facade for Java (SLF4J).
  • ResourceBundle.getBundle("ValidationMessages") allows you to get a specific error message from the configured validation messages. You'll learn more about the details of this when you dive into the constraint violation exception scenario in the next section.
  • In the toResponse method, a unique identifier for the error is created using UUID. This identifier is used when generating the log statements to facilitate debugging in a production environment. If you pass the unique identifier back to the consumer, the consumer can then reference the identifier in a support ticket, which the support team can use to quickly search the logs and identify what the cause of the error might be.
  • Once the error is logged, it is then transformed into the ErrorResponse object and returned to the consumer with an HTTP status code of 500, denoting a generic internal server error.

ConstraintViolationExceptionMapper

Now, consider the constraint violation use case once again. What you would like to see is a response message body produced with an array of error messages. To model this, you can use the existing ErrorResponse and write a new ConstraintViolationExceptionMapper to handle the specific exception:


package com.redhat.exception;

import javax.validation.ConstraintViolationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.List;
import java.util.stream.Collectors;

@Provider
public class ConstraintViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {

    @Override
    public Response toResponse(ConstraintViolationException e) {
        List<ErrorResponse.ErrorMessage> errorMessages = e.getConstraintViolations().stream()
                .map(constraintViolation -> new ErrorResponse.ErrorMessage(constraintViolation.getPropertyPath().toString(), constraintViolation.getMessage()))
                .collect(Collectors.toList());
        return Response.status(Response.Status.BAD_REQUEST).entity(new ErrorResponse(errorMessages)).build();
    }

}

With both of these mechanisms in place, the error response body is exactly what you're looking for:

{
    "errors": [
        {
            "path": "post.customer.lastName",
            "message": "Customer's last name is required"
        },
        {
            "path": "post.customer.email",
            "message": "Customer's email address is invalid"
        },
        {
            "path": "post.customer.firstName",
            "message": "Customer's first name is required"
        }
    ]
}

Why your enterprise should standardize its error model

Using the common ErrorResponse object and creating ExceptionMapper implementations for all of your use cases gives you a unified and easily consumable API error model. This approach works great for an individual team; however, another problem arises when all of the development teams in an enterprise design their error models independently and end up with different naming conventions and structures, especially in a polyglot world. The API consumers, whether they are other microservices or front-end developers building UIs, end up toiling over the myriad of different error responses.

This is a great opportunity for enterprise standardization. Bringing together the API teams in a common guild allows everyone in the company to come together in building and publishing a common standard for error responses. This eliminates toil on the API consumer end and reduces API development work by utilizing existing, documented response structures and even building shared libraries for use across teams.

Last updated: September 20, 2023