CompletionException and JAX-RS handling
Since Java 8 and its CompletionStage/CompletionFuture API, it is more and more common to write asynchronous code using these promise-like API. However it can sometimes lead to some unexpected behavior.
One example is when you write a kind of proxy for a microservice or just connect multiple microservices together using JAX-RS client API.
Very high level, it looks like that kind of endpoint:
@ApplicationScoped
@Path("my-proxy")
public class MyProxy {
@Inject // 1
private WebTarget target; // @produces in another bean
@GET // 2
@Produces(APPLICATION_JSON)
public CompletionStage<MyProxyResult> getResult() { // 3
return target.path("real-endpoint")
.request(APPLICATION_JSON_TYPE)
.rx() // 4
.get(MyResult.class)
.thenApply(this::mapToProxyResult); // 5
}
}
- For this example we assume the WebTarget instance was created from a Client but is produced in another CDI bean. Note that the client was set with a custom ExecutorService instance to ensure asynchronous calls will work as expected and not fallback on the forkjoin common pool,
- The endpoint which will proxy a service in another instance,
- We return a CompletionStage since JAX-RS now support it and it enables us to integrate well for asynchronous call. Concretely the implementation will wait the processing to be done before sending back the response to the client without holding the HTTP thread,
- rx() call enables us to become asynchronous and to get a CompletionStage as result of our invocation,
- Finally we map the response from the server to something specific to the proxy, it can be a passthrough as well as a complete rewrite of the payload (useful to handle migration/versions for instance).
The client part is generally fine and if a WebApplicationException or ProcessingException is thrown, the implementation will wire it to the caller. Same applies to your custom code in then stages (step 5).
To understand let's get back to CompletionFuture usage.
final CompletableFuture<String> future = new CompletableFuture<>();
future.completeExceptionally(new IllegalStateException("oops"));
future.handle((r, t) -> {
t.printStackTrace();
return r;
});
If you run that code - which is synchronous cause we handle the future once it is completed - then you will see the exception directly:
java.lang.IllegalStateException: oops
at Main.main(Main.java:7)
However, if you move to a CompletionStage factory method like runAsync or SupplyAsync then you will see something else. Let's modify our source to:
final CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
throw new IllegalStateException("oops");
});
future.handle((r, t) -> {
t.printStackTrace();
return r;
});
It should do exactly the same as our previous code...except that if you run it then you get:
java.util.concurrent.CompletionException: java.lang.IllegalStateException: oops
at java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:273)
at java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:280)
at java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1629)
at java.util.concurrent.CompletableFuture$AsyncRun.exec(CompletableFuture.java:1618)
at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1056)
at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1692)
at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:157)
Caused by: java.lang.IllegalStateException: oops
at Main.lambda$main$0(Main.java:7)
at java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1626)
... 5 more
The exception has been wrapped into a CompletionException. We will not enter into the details but the rational behind that is to distinguish between an expected and unexpected error in the API. Here the runnable was note expected to fail so the wrapper enables to know it was during the execuition and not that the future was completed exceptionally "manually" as in the first snippet. Concretely it calls CompletableFuture#completeThrowable(java.lang.Throwable) instead of completeExceptionally(t).
This means that if an implementation of a JAX-RS client uses these methods - and several do like CXF - then our proxy can get CompletionException wrapping the actual client exception which means our previous code will have an unexpected exception handling.
The nice thing about writing a proxy using JAX-RS on both the server and client side is that the client will throw WebApplicationException in case of errors which means the exception handling of the server will propagate the exception properly most of the time. This is a lot of code to not write which is quite nice.
That said, in case of rx() usage in the proxy this is no more true since you can get a CompletionException.
To solve it the simplest - and not breaking change if your implementation fixes it - is to add an ExceptionMapper for the CompletionException. This one will unwrap the cause and delegates to JAX-RS runtime through Providers context instance the cause handling:
@Provider // 1
@Dependent
public class CompletionExceptionExceptionMapper implements ExceptionMapper<CompletionException> { // 2
@Context // 3
private Providers providers;
@Override
public Response toResponse(final CompletionException exception) {
final Throwable cause = exception.getCause();
if (cause != null) { // 4
final Class type = cause.getClass();
return providers.getExceptionMapper(type).toResponse(cause); // 5
}
// 6
return Response.serverError()
// optional: .entity(new UnexpectedError(exception.getMessage()))
.build();
}
}
- We register our provider to be automatically added to the application,
- We implement ExceptionMapper<CompletionException> to handle CompletionExceptions only,
- We inject the JAX-RS Providers which will enable us to get back another ExceptionMapper,
- If the completion exception has a cause then we will use it to create the response,
- In this last case we fully delegate the response mapping to the exception mapper more specific to the exception type,
- If there is no cause - quite unlikely - then we just send back a HTTP 500 - optionally with some message - to the client.
With this simple exception mapper the client will get back the expected error and not a 500 for a HTTP 400 or 404 - for instance - which would have been the case without that mapper.
From the same author:
In the same category: