1. Overview

Error handling is one of the main concerns when developing systems. On a code level, error handling handles exceptions thrown by the code we write. On a service level, by errors, we mean all the non-successful responses we return.

It’s a good practice in large systems, to handle similar errors in a consistent way. For example, in a service with two controllers, we want the authentication error responses to be similar, so that we can debug issues easier. Taking a step back, we probably want the same error response from all services of a system, for simplicity. We can implement this approach by using global exception handlers.

In this tutorial, we’ll focus on the error handling in Micronaut. Similar to most of the Java frameworks, Micronaut provides a mechanism to handle errors commonly. We’ll discuss this mechanism and we’ll demonstrate it in examples.

2. Error Handling in Micronaut

In coding, the only thing we can take for granted is that errors will happen. No matter how good code we write and well-defined tests and test coverage we have, we can’t avoid errors. So, how we’re handling them in our system should be one of our main concerns. Error handling in Micronaut comes easier, by using some of the framework features like status handlers and exception handlers.

If we’re familiar with error handling in Spring, then it’s easy to onboard on Micronaut ways. Micronaut provides handlers to tackle exceptions thrown, but also handlers that deal with specific response statuses. In error status handling, we can set a local scope or a global one. Exception handling is on a global scope only.

One thing worth mentioning is that, if we take advantage of Micronaut environments capabilities, we can set different global error handlers for different active environments. If we have, for example, an error handler that publishes an event message, we can make use of active environments and skip the message publishing functionality on the local environment.

3. Error Handling in Micronaut Using the @Error Annotation

In Micronaut, we can define error handlers using the @Error annotation. This annotation is defined on a method level and it should be inside @Controller annotated classes. It has some functionalities similar to other controller methods, like that it can use request binding annotation on parameters to access request headers, the request body, etc.

By using the @Error annotation for error handling in Micronaut, we can either handle exceptions or response status codes. This is something different from other popular Java frameworks, which only provide handlers per exception.

One feature of the error handlers is that we can set a scope for them. We can have one handler that handles 404 responses for the whole service, by setting the scope to global. If we don’t set a scope, then the handler only handles the specified errors thrown in the same controller.

3.1. Using the @Error Annotation to Handle Response Error Codes

The @Error annotation provides a way for us to handle errors per error response status. This way, we can define a common way to handle all HttpStatus.NOT_FOUND responses, for example. The error statuses we can handle should be one defined in the io.micronaut.http.HttpStatus enum:

@Controller("/notfound")
public class NotFoundController {
    @Error(status = HttpStatus.NOT_FOUND, global = true)
    public HttpResponse<JsonError> notFound(HttpRequest<?> request) {
        JsonError error = new JsonError("Page Not Found")
          .link(Link.SELF, Link.of(request.getUri()));

        return HttpResponse.<JsonError> notFound().body(error);
    }
}

public class CustomException extends RuntimeException {
    public CustomException(String message) {
        super(message);
    }
}

In this controller, we define a method annotated with @Error that handles the HttpStatus.NOT_FOUND responses. The scope is set to global, so all 404 errors should go through this method. After handling, all such errors should return a status code of 404, with a modified body that contains the error message “Page Not Found” and a link.

Notice that even though we use the @Controller annotation, this controller doesn’t specify any HttpMethod, so it doesn’t work as a conventional controller exactly, but it has some implementation similarities, as we mentioned earlier.

Now let’s assume we have an endpoint that gives a NOT_FOUND error response:

@Get("/not-found-error")
public HttpResponse<String> endpoint1() {
    return HttpResponse.notFound();
}

The “/not-found-error” endpoint should always return 404. If we hit this endpoint, the NOT_FOUND error handler should be triggered:

@Test
public void whenRequestThatThrows404_thenResponseIsHandled(
    RequestSpecification spec
) {
    spec.given()
      .basePath(ERRONEOUS_ENDPOINTS_PATH)
      .when()
      .get("/not-found-error")
      .then()
      .statusCode(404)
      .body(Matchers.containsString("\"message\":\"Page Not Found\",\"_links\":"));
}

This Micronaut test makes a GET request to the “/not-found-error” endpoint and gets back the expected 404 status code. However, by asserting the response body, we can verify that the response came through the handler since the error message is the one we added to the handler.

One thing to clear up is that, if we change the base path and path to point to NotFoundController, because there is no GET defined in this controller, only the error, then the server is the one that throws a 404 and the handler still handles it.

3.2. Using @Error Annotation to Handle Exceptions

In a web service, if an exception is not caught and handled anywhere, then the controller returns an internal server error by default. Error handling in Micronaut offers the @Error annotation for such cases.

Let’s create an endpoint that throws an exception and a handler that handles those specific exceptions:

@Error(exception = UnsupportedOperationException.class)
public HttpResponse<JsonError> unsupportedOperationExceptions(HttpRequest<?> request) {
    log.info("Unsupported Operation Exception handled");
    JsonError error = new JsonError("Unsupported Operation")
      .link(Link.SELF, Link.of(request.getUri()));

    return HttpResponse.<JsonError> notFound().body(error);
}

@Get("/unsupported-operation")
public HttpResponse<String> endpoint5() {
    throw new UnsupportedOperationException();
}

The “/unsupported-operation” endpoint only throws an UnsupportedOperationException exception. The unsupportedOperationExceptions method uses the @Error annotation to handle these exceptions. It returns a 404 error code since this resource is not supported and a response body with the message “Unsupported Operation”. Note that the scope in this example is local since we don’t set it to global.

If we hit this endpoint, we should see the handler handling it and giving back the response as defined in the unsupportedOperationExceptions method:

@Test
public void whenRequestThatThrowsLocalUnsupportedOperationException_thenResponseIsHandled(
    RequestSpecification spec
) {
    spec.given()
      .basePath(ERRONEOUS_ENDPOINTS_PATH)
      .when()
      .get("/unsupported-operation")
      .then()
      .statusCode(404)
      .body(containsString("\"message\":\"Unsupported Operation\""));
}

@Test
public void whenRequestThatThrowsExceptionInOtherController_thenResponseIsNotHandled(
    RequestSpecification spec
) {
    spec.given()
      .basePath(PROBES_ENDPOINTS_PATH)
      .when()
      .get("/readiness")
      .then()
      .statusCode(500)
      .body(containsString("\"message\":\"Internal Server Error\""));
}

In the first example, we request the “/unsupported-operation” endpoint, which throws the UnsupportedOperationException exception. Since the local handler is in the same controller, then we get the response we expect from the handler, with the modified response error message “Unsupported Operation”.

In the second example, we request the “/readiness” endpoint, from a different controller, which also throws an UnsupportedOperationException exception. Because this endpoint is defined on a different controller, the local handler is not going to handle the exception, so the response we get is the default with error code 500.

4. Error Handling in Micronaut Using the ExceptionHandler Interface

Micronaut also offers the option to implement an ExceptionHandler interface, to handle specific exceptions in a global scope. This approach requires one class per exception, which means that by default they have to be on a global scope.

Micronaut provides some default exception handlers, for example:

  • jakarta.validation.ConstraintViolationException
  • com.fasterxml.jackson.core.JsonProcessingException
  • UnsupportedMediaException
  • and more

These handlers can of course be overridden on our service, if needed.

One thing to consider is the exception hierarchy. When we create a handler for a specific exception A, an exception B that extends A will also fall under the same handler, unless we implement one more handler for this specific exception B. More detail on that is in the following sections.

4.1. Handling an Exception

As described earlier, we can use the ExceptionHandler interface to handle a specific type of exception globally:

@Slf4j
@Produces
@Singleton
@Requires(classes = { CustomException.class, ExceptionHandler.class })
public class CustomExceptionHandler implements ExceptionHandler<CustomException, HttpResponse<String>> {
    @Override
    public HttpResponse<String> handle(HttpRequest request, CustomException exception) {
        log.info("handling CustomException: [{}]", exception.getMessage());

        return HttpResponse.ok("Custom Exception was handled");
    }
}

In this class, we implement the interface, which uses generics to define which exception we’ll be handling. In this case, it is the CustomException we defined earlier. The class needs to be annotated with @Requires and include the exception class, but also the interface. The handle method takes as parameters the request that triggered the exception and also the exception object. Then, we simply add our custom message in the response body, giving back a 200 response status code.

Now let’s assume we have an endpoint that throws a CustomException:

@Get("/custom-error")
public HttpResponse<String> endpoint3(@Nullable @Header("skip-error") String isErrorSkipped) {
    if (isErrorSkipped == null) {
        throw new CustomException("something else went wrong");
    }
    return HttpResponse.ok("Endpoint 3");
}

The “/custom-error” endpoint accepts an isErrorSkipped header, to enable/disable the exception thrown. If we don’t include the header, then the exception is thrown:

@Test
public void whenRequestThatThrowsCustomException_thenResponseIsHandled(
    RequestSpecification spec
) {
    spec.given()
      .basePath(ERRONEOUS_ENDPOINTS_PATH)
      .when()
      .get("/custom-error")
      .then()
      .statusCode(200)
      .body(is("Custom Exception was handled"));
}

In this test, we request the “/custom-error” endpoint, without including the header. So, a CustomException exception is thrown. Then, we verify that the handler has handled this exception, by asserting on the response code and response body that we expect from the handler.

4.2. Handling Exceptions Based on Hierarchy

In the case of exceptions that are not explicitly handled, if they extend an exception that has a handler, they are implicitly handled by the same handler. Let’s assume we have a CustomeChildException that extends our CustomException:

public class CustomChildException extends CustomException {
    public CustomChildException(String message) {
        super(message);
    }
}

And there’s an endpoint that throws this exception:

@Get("/custom-child-error")
public HttpResponse<String> endpoint4(@Nullable @Header("skip-error") String isErrorSkipped) {
    log.info("endpoint4");
    if (isErrorSkipped == null) {
        throw new CustomChildException("something else went wrong");
    }

    return HttpResponse.ok("Endpoint 4");
}

The “*/custom-child-error*” endpoint accepts an isErrorSkipped header, to enable/disable the exception thrown. If we don’t include the header, then the exception is thrown:

@Test
public void whenRequestThatThrowsCustomChildException_thenResponseIsHandled(
    RequestSpecification spec
) {
    spec.given()
      .basePath(ERRONEOUS_ENDPOINTS_PATH)
      .when()
      .get("/custom-child-error")
      .then()
      .statusCode(200)
      .body(is("Custom Exception was handled"));
}

This test hits the “/custom-child-error” endpoint and triggers the CustomChildException exception. From the response, we can verify that the handler has handled this child exception too, by asserting on the response code and response body that we expect from the handler.

5. Conclusion

In this article, we went through the error handling in Micronaut. There are different ways to handle errors, by handling exceptions or by handling error response status codes. We also saw how we can apply our handlers on different scopes, local and global. Last, we demonstrated all options discussed, with some code examples, and used Micronaut tests to verify the outcomes.

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