Error handling in Spring Webflux

The topic of error handling in web applications is very important. From a client perspective it is essential to know on how was the request proceeded and in case of any error is crucial to provide to the client a valid reason, especially if the error was due to the client’s actions. There are different situations, when notifying callers about concrete reasons is important – think about server-side validations, business logic errors that come due to bad requests or simple not found situations.

The mechanism of error handling in Webflux is different, from what we know from Spring MVC. Core building blocks of reactive apps – Mono and Flux brings a special way to deal with error situations, and while old exception-based error handling still may work for some cases, it violates Spring Webflux nature. In this post I will do an overview of how to process errors in Webflux when it comes to business errors and absent data. I will not cover technical errors in this article, as they are handled by Spring framework.

Do you want to increase your Java collections skills?

This topic is really huge, and a single post is not enough to cover all. That is why I wrote this Practical Guide. Everything you need in the one book. Do you want to become Java ninja?

When do we need to handle errors in Webflux

Before we will move to the subject of error handling, let define what we want to achieve. Assume, that we build application using a conventional architecture with a vertical separation by layers and a horizontal separation by domains. That means, that our app consists of three main layers: repositories (one that handle data access), services (one that do custom business logic) and handlers (to work with HTTP requests/responses; understand them as controllers in Spring MVC). Take a look on the graph below and you can note that potentially errors may occur on any layer:

Figure 1. Typical Webflux app architecture

Although, I need to clarify here, that from a technical perspective errors are not same. On the repository level (and let abstract here also clients, that deal with external APIs and all data-access components) usually occur what is called technical errors. For instance, something can be wrong with a database, so the repository component will throw an error. This error is a subclass of RuntimeException and if we use Spring is handled by framework, so we don’t need to do something here. It will result to 500 error code.

An another case is what we called business errors. This is a violation of custom business rules of your app. While it may be considered as not as a good idea to have such errors, they are unavoidable, because, as it was mentioned before, we have to provide a meaningful response to clients in case of such violations. If you will return to the graph, you will note, that such errors usually occur on the service level, therefore we have to deal with them.

Now, let see how to handle errors and provide error responses in Spring Webflux APIs.

Start from business logic

In my previous post, I demonstrated how to build two-factor authentication for Spring Webflux REST API. That example follows the aforesaid architecture and is organized from repositories, services and handlers. As it was already mentioned, business errors take place inside a service. Prior to reactive Webflux, we often used exception-based error handling. It means that you provide a custom runtime exception (a subclass of ResponseStatusException) which is mapped with specific http status.

However, Webflux approach is different. Main building blocks are Mono and Flux components, that are chained throughout an app’s flow (note, from here I refer to both Mono and Flux as Mono). Throwing an exception on any level will break an async nature. Also, Spring reactive repositories, such as ReactiveMongoRepository don’t use exceptions to indicate an error situation. Mono container provides a functionality to propagate error condition and empty condition:

  • Mono.empty() = this static method creates a Mono container that completes without emitting any item
  • Mono.error() = this static method creates a Mono container that terminates with an error immediately after being subscribed to

With this knowledge, we can now design a hypothetical login/signup flow to be able to handle situations, when 1) an entity is absent and 2) error occurred. If an error occurs on the repository level, Spring handles it by returning Mono with error state. When the requested data is not found – empty Mono. We also can add some validation for business rules inside the service. Take a look on the refactored code of signup flow from this post:

@Override
public Mono<SignupResponse> signup(SignupRequest request) {

    String email = request.getEmail().trim().toLowerCase();
    String password = request.getPassword();
    String salt = BCrypt.gensalt();
    String hash = BCrypt.hashpw(password, salt);
    String secret = totpManager.generateSecret();
    User user = new User(null, email, hash, salt, secret);

    return repository.findByEmail(email)
            .defaultIfEmpty(user)
            .flatMap(result -> {
                if (result.getUserId() == null) {
                    return repository.save(result).flatMap(result2 -> {
                        String userId = result2.getUserId();
                        String token = tokenManager.issueToken(userId);
                        SignupResponse signupResponse = new SignupResponse(userId, token, secret);
                        return Mono.just(signupResponse);
                    });
                } else {
                    return Mono.error(new AlreadyExistsException());
                }
            });
}

Now, we have a business logic. The next phase is to map results as HTTP responses on the handler level.

Display http responses in handlers

This level can correspond to the old good controllers in Spring MVC. The purpose of handlers is to work with HTTP requests and responses and by this to connect a business logic with the outer world. In the most simple implementation, the handler for signup process looks like this:

// signup handler
Mono<ServerResponse> signup (ServerRequest request){
    Mono<SignupRequest> body = request.bodyToMono(SignupRequest.class);
    Mono<SignupResponse> result = body.flatMap(service::signup);
    return ServerResponse.ok().contentType(json).body(result, SignupResponse.class);
}

This code does essential things:

  1. Accepts a body payload from the HTTP request
  2. Call a business logic component
  3. Returns a result as HTTP response

However, it does not takes an advantage of custom error handling, that we talked about in the previous section. We need to handle a situation, when user already exists. For that, let refactor this code block:

// signup handler refactored
Mono<ServerResponse> signup (ServerRequest request){
    Mono<SignupRequest> body = request.bodyToMono(SignupRequest.class);
    Mono<SignupResponse> result = body.flatMap(service::signup);
    return result.flatMap(data -> ServerResponse.ok().contentType(json).bodyValue(data))
            .onErrorResume(error -> ServerResponse.badRequest().build());
}

Note, that compare to the previous post, I use here bodyValue() instead of body(). This is because body method actually accepts producers, while data object here is entity (SignupResponse). For that I use bodyValue() with passed value data. Read more on this here.

In this code we can specify to the client, what was a reason of the error. If user does exist already in database, we will provide 409 error code to the caller, so she/he have to use an another email address for signup procedure. That is what is about business errors. For technical errors our API displays 500 error code.

We can validate, that when we create a new user, everything works ok and the expected result is 200 success code:

Figure 2. Successful signup response

On the other hand, if you will try to signup with the same email address, API should response with 400 error code, like it is shown on the screenshot below:

Figure 3. Failed signup (user exists already)

Moreover, we don’t need success field for SignupResponse entity anymore, as unsuccessful signup is handled with error codes. There is an another situation, I want to mention is this post – the problem of empty responses. This is what we would observe on a login example.

Special case: response on empty result

Why this is a special case? Well, technically, empty response is not an error, neither business error or technical error. There are different opinions among developers how to handle it properly. I think, that even it is not an exception in a traditional sense, we still need to expose 404 error code to the client, to demonstrate an absence of the requested information.

Let have a look on the login flow. For login flow is common a situation, opposite to the signup flow. For signup flow we have to ensure, that user does not exist yet, however for login we have to know that user does already exist. In the case of the absence we need to return an error response.

Take a look on the login handler initial implementation:

Mono<ServerResponse> login (ServerRequest request){
    Mono<LoginRequest> body = request.bodyToMono(LoginRequest.class);
    Mono<LoginResponse> result = body.flatMap(service::login);
    return ServerResponse.ok().contentType(json).body(result, LoginResponse.class);
}

From the service component’s perspective we could expect three scenarios:

  1. User exists and login is successful = return LoginResponse
  2. User exists but login was denied = return an error
  3. User does not exist = return an empty user

We have already seen how to work with errors in handlers. The empty response situation is managed using switchIfEmpty method. Take a look on the refactored implementation:

Mono<ServerResponse> login (ServerRequest request){
    Mono<LoginRequest> body = request.bodyToMono(LoginRequest.class);
    Mono<LoginResponse> result = body.flatMap(service::login);
    return result.flatMap(data -> ServerResponse.ok().contentType(json).bodyValue(data))
            .switchIfEmpty(ServerResponse.notFound().build())
            .onErrorResume(error -> {
                if (error instanceof LoginDeniedException){
                    return ServerResponse.badRequest().build();
                }
                return ServerResponse.status(500).build();
            });
}

Note, that unlike onErrorResume method, switchIfEmpty accepts as an argument an alternative Mono, rather than function. Now, let check that everything works as expected. The login for existed user entity and valid credentials should return a valid response:

Figure 4. Successful login response

When submitted credentials are wrong (password does not match), but user does exist (case no.2), we will obtain a Bad request error code:

Figure 5. Login denied (wrong password)

Finally, if repository is unable to find a user entity, handler will answer with Not found:

Figure 6. Login denied (not found)

Please note, that this post is focused on the handler level. For what happens inside service, I recommend you to check the previous post and also to look on the complete source code in this github repository. If you have any questions – don’t hesitate to ask them in comments or contact me.

References

  • Dan Newton Doing stuff with Spring WebFlux (2018) Lanky Dan Blog access here
  • Filip Marszelewski Migrating a microservice to Spring WebFlux (2019) Allegro Tech Blog access here
  • Yuri Mednikov Handling Exceptions in Java With Try-Catch Block and Vavr Try (2019) DZone access here
Share via
Copy link
Powered by Social Snap