Introduction

In my previous post on the subject of CompletableFutures and CompletionStages, I gave a brief introduction to the concepts. I also focused primarily on non-exceptional completion of futures, despite the fact that the original driver for writing the posts was because I’d been learning about exceptional behaviours in CompletionStage pipelines.

The primary reason for the previous post was because it was hard to cover exceptional behaviours without providing a simple introduction and building some kind of representation (in this case graphical) of the elements of such a pipeline.

This post moves on from that to discuss exceptional behaviours (what happens when a CompletionStage fails), and extends that graphical represention. Again, the basis for the information provided in this post is work I have been doing on the asynchronous version of the pac4j pac4j API and library, and settled on CompletableFutures as the way to represent the async API.

Simple exceptional completion

Let’s start by considering one of the simplest scenarios - a future which completes exceptionally but is not further transformed using methods such as thenApply or thenCompose. What happens when we call join()? Firstly let’s take a quick look at the join() method, which wraps exceptional completion in a CompletionException (unchecked), spits out a CancellationException if the operation was cancelled or returns the completion value on completion (but blocks the current thread until then). The CompletionException thus raised wraps the triggering Throwable, which can therefore be exposed via getCause().

So firstly let’s create some convenience methods to let us represent exceptional completion:-

DelayedExceptionalCompletion.java
public CompletableFuture<Integer> delayedExceptionalCompletion(final Throwable t) {
    return delayedExceptionalCompletion(t, 1000);
}

public CompletableFuture<Integer> delayedExceptionalCompletion(final Throwable t, final int delayMs) {

    final CompletableFuture<Integer> future = new CompletableFuture<>();

    try {
        Thread.sleep(delayMs);
        future.completeExceptionally(t);
    } catch (InterruptedException ex) {
        throw new RuntimeException("Problem waiting to complete future exceptionally");
    }
    return future;
}

These permit us to create a future which will complete with a selected exception following a delay.

We’ll also define a trivial exception class for the tests:-

IntentionalException.java
public class IntentionalException extends RuntimeException {

    public IntentionalException() {
        super("Intentional exception");
    }

}

Now let’s implement a simple test (keeping in mind the statement above about join() which makes it clear that any exception supplied in completeExceptionally() will be wrapped in a CompletionException):-

SimpleExceptionalCompletionDemo.java
Test(expected = IntentionalException.class)

public void testExceptionalCompletion() throws Throwable {

    final CompletableFuture<Integer> future = delayedExceptionalCompletion(new IntentionalException());
    try {
        future.join();
    } catch (CompletionException ex) {
        throw (ex.getCause());
    }
}

As one might expect, this test will pass as we unwrap the exceptional completion and throw it outwards for the test to deal with. However, there is another (related) scenario. Calling join() is specified to lead to wrapping in a CompletionException, when we wait for the result. However, we might instead do some subsequent processing (for example via thenAccept). What does the exception look like in the next CompletionStage down the line?

The following test demonstrates this:-

SimpleExceptionalCompletionDemo.java
@Test(expected = IntentionalException.class)
public void exceptionCompletionAsSeenFromNextStage() throws Throwable {

    // This is where we'll store the exception seen in the next stage
    final AtomicReference<Throwable> thrownException = new AtomicReference<>(null);

    final CompletableFuture<Integer> future = delayedExceptionalCompletion(new IntentionalException())
            .whenComplete((v, t) -> {
                if (t != null) {
                    thrownException.set(t);
                }
            });

    try {
        future.join();
    } catch (CompletionException ex) {
        throw (Optional.ofNullable(thrownException.get()).orElse(ex));
    }
}

Again this test passes. Since whenComplete is effectively the next completion stage (or could represent subsequent processing) we can see that the view of the exception in this stage is still an unwrapped IntentionalException, as we might naively expect. Therefore we can infer that, when completeExceptionally is called, its argument is passed directly to the next stage.

Revisiting our graphical notation from the previous article, this identifies representations for a new completion scenario - a CompletionStage which will completeExceptionally with the exception e (represented by a red box inside the resulting CompletionStage). Fig. 1 shows this representation:-

CFExceptionallyCompletedFuture
Figure 1. CompletionStage on which completeExceptionally has been called with exception e.

Exceptions during thenApply

Now let’s think about what happens when an exception occurs during a thenApply transformation. We would assume that when an exception is thrown by function application during a thenApply call, that the resulting stage will complete exceptionally and therefore we will see something similar to our completeExceptionally exploration above.

So first let’s mimic the test where we wait for the result via join() - in this case if we throw an IntentionalException during the transformation function, we would expect to observe a CompletionException wrapping our IntentionalException.

thenApplyExceptionNaive.java
@Test(expected = IntentionalException.class)
public void exceptionCallingThenApply() throws Throwable {
    final CompletableFuture<Integer> future = CompletableFuture.supplyAsync(delayedValueSupplier(1), executor)
        .thenApply(i -> {
            throw new IntentionalException();
        });

    try {
        future.join();
    } catch (CompletionException ex) {
        throw ex.getCause();
    }

}

This test passes, so we can be happy that use of join() behaves consistently when applied to a transformation-derived CompletableFuture where the exception occurs during the transformation.

Now let’s look at how the subsequent completion stage sees an exception which occurs during thenApply processing. Here is the code for a test analogous to the one shown above for completeExceptionally().

Exceptional Completion Observed in Subsequent Stage
@Test(expected = IntentionalException.class)
public void exceptionCallingThenApplyAsObservedFromNextStage() throws Throwable {

    final AtomicReference<Throwable> thrownException = new AtomicReference<>(null);

    final CompletableFuture<Object> future = CompletableFuture.supplyAsync(delayedValueSupplier(1), executor)
        .thenApply(i -> {
            throw new IntentionalException();
        })
        .whenComplete((v, t) -> {
            if (t != null) {
                thrownException.set(t);
            }
        });
    try {
        future.join();
    } catch (CompletionException ex) {
        throw (Optional.ofNullable(thrownException.get()).orElse(ex));
    }
}

However, running this test actually fails, because the exception now observed during the whenComplete processing is now the original IntentionalException, wrapped in a CompletionException. In other words, although a call to completeExceptionally leads the the causing exception being passed to the next stage, an exception during thenApply transformation leads to the exception being wrapped in a CompletionException. This is a subtle distinction but it is worth keeping in mind. We will revisit this a little further when we discuss longer pipelines later in this article.

If we change this test to unwrap the exception stored in thrownException, we do indeed see that the IntentionalException we threw is still accessible:-

Unwrapping the Exception Observed in Subsequent Stage
@Test(expected = IntentionalException.class)
public void exceptionCallingThenApplyAsObservedFromNextStage() throws Throwable {
    final AtomicReference<Throwable> thrownException = new AtomicReference<>(null);
    final CompletableFuture<Object> future = CompletableFuture.supplyAsync(delayedValueSupplier(1), executor)
            .thenApply(i -> {
                throw new IntentionalException();
            })
            .whenComplete((v, t) -> {
                if (t != null) {
                    thrownException.set(t);
                }
            });
    try {
        future.join();
    } catch (CompletionException ex) {
        throw (Optional.ofNullable(thrownException.get().getCause()).orElse(new RuntimeException("No thrown exception")));
    }
}

This also leads to an extension of our graphical notation which we should cover here. Fig. 2 shows a representation of this scenario:-

CFthenApplyFailure
Figure 2. Exceptional completion during a thenApply call

We add the concept of a pink-coloured arrow to represent a thenApply call using a function which will throw an exception e. The outcome of this (from a successfully completing CompletionStage) is a CompletionStage which will completeExceptionally with a CompletionException (red box) wrapping the exception e (yellow box).

We’ll refer back to this notation when we come to discuss propagation in pipelines of stages.

Exceptions during thenCompose

The above discussion of exceptional behaviour during thenApply leads us to predictions of what will happen when an exception is thrown during a thenCompose call. We would expect that when an exception E is thrown during application of a thenCompose transformation, then use of join() will lead to the ultimately thrown exception being E wrapped in a CompletionException.

The test below demonstrates this:-

thenComposeException.java
@Test(expected = IntentionalException.class)
public void exceptionCallingThenCompose() throws Throwable {
    final CompletableFuture<Integer> future = CompletableFuture.supplyAsync(delayedValueSupplier(1), executor)
            .thenCompose(i -> {
                throw new IntentionalException();
            });

    try {
        future.join();
    } catch (CompletionException ex) {
        throw ex.getCause();
    }

}

Indeed, this test passes and the behaviour is as we predicted, and we can therefore draw the following diagram:-

CFthenComposeFailure
Figure 3. Exception during thenCompose processing

So now we have predictable behaviours for both failing transformation steps and failing computations.

Exceptions in pipelines

Now let’s put this together. In the previous article we talked about combining multiple steps (thenApply/thenCompose) into pipelines and drew up a simple schematic based on a CompletionStage which we transformed using thenApply, and then again using thenCompose. We can use the same pipeline to illustrate exception propagation through the pipeline.

Exception at start of pipeline

Firstly let’s consider the simple case of a CompletionStage which will complete exceptionally, and on this we call thenApply (but with a function that would never fail) and to the result of this we call thenCompose (with a function that would never fail).

Given that we established earlier that the next stage in a pipeline sees the exception with which the previous stage completed in an unwrapped form, we might reasonably expect the following test to pass:-

transformationsOfFailedFutureNaive.java
@Test(expected = IntentionalException.class)
public void exceptionCompletionFollowedByPipeline() throws Throwable {
    // This is where we'll store the exception seen in the next stage
    final AtomicReference<Throwable> thrownException = new AtomicReference<>(null);

    final CompletableFuture<Integer> future = delayedExceptionalCompletion(new IntentionalException())
            .thenApply(i -> i + 1)
            .thenCompose(i -> CompletableFuture.supplyAsync(delayedValueSupplier(1), executor))
            .whenComplete((v, t) -> {
                if (t != null) {
                    thrownException.set(t);
                }
            });

    try {
        future.join();
    } catch (CompletionException ex) {
            throw (Optional.ofNullable(thrownException.get()).orElse(new RuntimeException("UnexpectedException")));
    }
}

However, running this test leads to test failure, because the result of the final thrownException.get() call is in fact a CompletionException whose cause is the IntentionalException with which we completed the original future.

Commenting out the thenApply and thenCompose lines enables the test to pass. Therefore we can infer that once a failed future is transformed then the cause of the failure will be wrapped in a CompletionException, exactly as occurred when an exception was thrown during a transformation. This might seem counterintuitive, but is actually useful as it gives us consistency - as soon as we perform transformation, then we know we need to unwrap a CompletionException to get to the underlying cause.

Modifying this approach so that we unwrap the exception we stored in the whenComplete call leads to the test passing, exactly as we did with thenApply/thenCompose:-

transformationsOfFailedFutureNaive.java
@Test(expected = IntentionalException.class)
public void exceptionCompletionFollowedByPipeline() throws Throwable {
    // This is where we'll store the exception seen in the next stage
    final AtomicReference<Throwable> thrownException = new AtomicReference<>(null);

    final CompletableFuture<Integer> future = delayedExceptionalCompletion(new IntentionalException())
            .thenApply(i -> i + 1)
            .thenCompose(i -> CompletableFuture.supplyAsync(delayedValueSupplier(1), executor))
            .whenComplete((v, t) -> {
                if (t != null) {
                    thrownException.set(t);
                }
            });

    try {
        future.join();
    } catch (CompletionException ex) {
            throw (Optional.ofNullable(thrownException.get().getCause()).orElse(new RuntimeException("UnexpectedException")));
    }
}

There is another point to note about this. Although we have two transformation steps (thenApply and thenCompose), we do not produce a CompletionException wrapping another CompletionException which wraps the original IntentionalException. In other words, CompletionExceptions will not be wrapped, as they already wrap the underlying cause.

This scenario (and its behaviour) are shown in the following diagram:-

CFPipelineStartingWithExceptionalCompletion
Figure 4. Exceptional completion at start of pipeline

Exception on subsequent step of pipeline

We’ve already seen what happens when a thenApply or a thenCompose call is applied to a successful future result and itself throws an exception e - the subsequent stage will be a failed future which holds a CompletionException whose cause is e. We’ve also seen in the immediately preceding section that CompletionExceptions will not be further wrapped. Therefore we would infer that if in the pipeline outlined above, the initial CompletionStage completed successfully but the thenApply step were to throw an exception e, the outcome of the final stage would be exceptional completion with a CompletionException whose cause would be the exception e thrown during the thenApply conversion.

This (predicted) outcome is shown in the following diagram:-

CFPipelineFailureDuringThenApply
Figure 5. Exceptional completion on intermediate step of pipeline

We would also expect the following test to pass:-

failureOnMiddleStepOfPipeline.java
@Test(expected = IntentionalException.class)
public void exceptionDuringFirstTransformationInPipeline() throws Throwable {
    // This is where we'll store the exception seen in the next stage
    final AtomicReference<Throwable> thrownException = new AtomicReference<>(null);

    final CompletableFuture<Integer> future = CompletableFuture.supplyAsync(delayedValueSupplier(1), executor)
            .thenApply(i -> {
                throw new IntentionalException();
            })
            .thenCompose(i -> CompletableFuture.supplyAsync(delayedValueSupplier(1), executor))
            .whenComplete((v, t) -> {
                if (t != null) {
                    thrownException.set(t);
                }
            });
    try {
        future.join();
    } catch (CompletionException ex) {
        throw (Optional.ofNullable(thrownException.get().getCause()).orElse(ex));
    }
}

This test passes, as expected, so the diagram above is a correct expression of the behaviour.

The final case to consider is pretty straightforward, which is what happens in the pipeline above when the initial future will complete without error, the thenApply call will be successful, but then the subsequent thenCompose call will throw an exception e. This is exactly the same scenario as a failing thenCompose/thenApply on a successfully completing CompletionStage, which we have already established leads to a CompletionStage which will complete exceptionally, with a CompletionException wrapping the exception e.

The diagram for this scenario is:-

CFPipelineFailureDuringThenCompose
Figure 6. Exception thrown on final transformation of pipeline

It hardly seems worth writing a test to demonstrate this, but for completeness here it is:-

failureOnTerminalStepOfPipeline.java
@Test(expected = IntentionalException.class)
public void exceptionDuringLastTransformationInPipeline() throws Throwable {
    // This is where we'll store the exception seen in the next stage
    final AtomicReference<Throwable> thrownException = new AtomicReference<>(null);

    final CompletableFuture<Integer> future = CompletableFuture.supplyAsync(delayedValueSupplier(1), executor)
            .thenApply(i -> i + 1)
            .<Integer>thenCompose(i -> {
                throw new IntentionalException();
            })
            .whenComplete((v, t) -> {
                if (t != null) {
                    thrownException.set(t);
                }
            });
    try {
        future.join();
    } catch (CompletionException ex) {
        throw (Optional.ofNullable(thrownException.get().getCause()).orElse(ex));
    }
}

This test indeed passes, so no awkward surprises.

Conclusions

The primary conclusion is that it’s actually very easy to define a rule for what the ultimate exception will look like in the event of failing pipeline. This rule can be outlined as follows:-

"The exception causing the failure will always be wrapped in a CompletionException, except when you observe it in the completion stage immediately following a CompletionFuture on which completeExceptionally has been called, in which case the observed exception will be the one passed to completeExceptionally"

The significance of this is as follows. In any multi-stage pipeline which does not terminate in thenCompose (which yields a new CompletableFuture which can complete exceptionally), you can assume you will always have to unwrap a CompletionException. However, where the final stage of the pipeline itself yields a future on which completeExceptionally may be called (true for a single step or for a pipeline which ends with thenCompose or one of its async variancts) you may or may not have to unwrap a CompletionException.

The next article in this series will cover methods for consuming exceptions in these pipelines. The CompletionStage/CompletableFuture APIs offer multiple options for doing this.