Class 40: Exception Handling with CompletableFuture
Part 7 — Asynchronous Programming and Modern Java
In the previous classes, we learned how to create asynchronous tasks using CompletableFuture, chain multiple operations, and combine the results of independent tasks.
But let's think about something important.
What happens when an asynchronous operation fails?
Imagine that an application is trying to fetch customer details from a remote service. The service might be unavailable, the network might fail, or the operation might throw an exception.
We should not allow one failure to leave our application without a proper response.
CompletableFuture provides methods that help us handle exceptions and decide what should happen after a failure.
Today, we will learn three important methods:
- exceptionally()
- handle()
- whenComplete()
1. Understanding What Happens When a CompletableFuture Fails
Let's first create a CompletableFuture that throws an exception.
import java.util.concurrent.CompletableFuture;
public class CompletableFutureFailureExample {
public static void main(String[] args) {
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
System.out.println("Calculating result");
throw new RuntimeException(
"Something went wrong!"
);
});
try {
Integer result = future.join();
System.out.println("Result: " + result);
} catch (Exception e) {
System.out.println("Operation failed!");
System.out.println(e.getMessage());
}
}
}
Here, supplyAsync() starts an asynchronous operation.
Instead of returning a value, the operation throws a RuntimeException.
CompletableFuture captures this failure and completes exceptionally.
When we call join(), the exception is reported to the calling thread through a CompletionException.
The original exception can be accessed using
getCause().
Remember that the exception is not silently lost. CompletableFuture keeps track of the failure.
2. Handling Exceptions with exceptionally()
Imagine that our application tries to fetch a product price.
If the operation fails, we want to use a default price instead of allowing the failure to stop the entire workflow.
We can use exceptionally() for this.
import java.util.concurrent.CompletableFuture;
public class ExceptionallyExample {
public static void main(String[] args) {
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
System.out.println("Fetching product price");
throw new RuntimeException(
"Product service unavailable"
);
}).exceptionally(exception -> {
System.out.println(
"Error: " + exception.getMessage()
);
System.out.println(
"Using default product price"
);
return 500;
});
Integer price = future.join();
System.out.println("Product Price: " + price);
}
}
Let's understand the flow.
- supplyAsync() tries to fetch the product price.
- The operation throws an exception.
- exceptionally() receives the failure.
- We return a default price of 500.
- join() returns the recovered value.
The important point is that exceptionally() allows us to recover from a failure by providing another value.
If the previous operation completes normally, exceptionally() does not execute its recovery function.
Remember:
exceptionally(exception -> {
// Handle the failure
return defaultValue;
});
It is especially useful when we have a reasonable fallback value.
3. Handling Both Success and Failure with handle()
Now let's consider another situation.
What if we want to perform an operation regardless of whether the previous task succeeded or failed?
We can use handle().
The handle() method receives two parameters:
- The result of the previous operation.
- The exception, if the previous operation failed.
Let's understand this with an example.
import java.util.concurrent.CompletableFuture;
public class HandleExample {
public static void main(String[] args) {
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
System.out.println("Calculating price");
throw new RuntimeException(
"Price calculation failed"
);
}).handle((result, exception) -> {
if (exception != null) {
System.out.println(
"Something went wrong: "
+ exception.getMessage()
);
return 0;
}
System.out.println("Calculation successful");
return result;
});
System.out.println(
"Final Result: " + future.join()
);
}
}
Here, the original operation fails.
handle() receives:
- result as null, because the operation did not produce a result.
- exception containing information about the failure.
We check whether the exception is present. If it is, we return 0 as a fallback value.
If the operation succeeds, we can use its result instead.
The important difference is that handle() executes for both successful and failed completions.
4. Understanding whenComplete()
Let's imagine that we want to log the outcome of an asynchronous operation.
Whether the operation succeeds or fails, we want to print a message for monitoring purposes.
We can use whenComplete().
import java.util.concurrent.CompletableFuture;
public class WhenCompleteExample {
public static void main(String[] args) {
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
System.out.println("Calculating result");
return 100;
}).whenComplete((result, exception) -> {
if (exception != null) {
System.out.println(
"Operation failed: "
+ exception.getMessage()
);
} else {
System.out.println(
"Operation completed successfully"
);
System.out.println(
"Result: " + result
);
}
});
System.out.println("Final Result: " + future.join());
}
}
In this example, the original operation succeeds and returns 100.
whenComplete() receives the result and prints a success message.
If the original operation had failed, it would receive the exception instead.
There is one important thing to understand:
whenComplete() is mainly used to observe the outcome of an operation. It does not normally recover from an exception or replace the original result.
If the original operation fails, that failure is still present in the resulting CompletableFuture, unless the whenComplete action itself throws another exception.
5. exceptionally() vs. handle() vs. whenComplete()
These three methods may look similar at first. Let's clearly understand the difference.
| Method | When Does It Execute? | Main Purpose |
|---|---|---|
| exceptionally() | When the previous operation fails. | Recover from failure by returning a fallback result. |
| handle() | On success or failure. | Process the result or recover from an exception. |
| whenComplete() | On success or failure. | Observe the outcome, for example, logging it. |
Let's remember them with simple examples:
- exceptionally(): Something failed. Give me a fallback value.
- handle(): Whether it succeeded or failed, let me decide what result to return.
- whenComplete(): Whether it succeeded or failed, let me know what happened.
6. Handling Exceptions in a Chain
We already know that CompletableFuture allows us to connect multiple operations.
But what if one operation in the chain fails?
Let's understand this with a simple example.
import java.util.concurrent.CompletableFuture;
public class ChainedExceptionExample {
public static void main(String[] args) {
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> {
System.out.println("Fetching product price");
return 1000;
}).thenApply(price -> {
System.out.println("Applying discount");
throw new RuntimeException(
"Discount service failed"
);
}).thenApply(discountedPrice -> {
System.out.println("Adding delivery charge");
return discountedPrice + 100;
}).exceptionally(exception -> {
System.out.println(
"Handling failure: "
+ exception.getMessage()
);
return 0;
});
System.out.println("Final Result: " + future.join());
}
}
Let's follow the execution.
- The first operation returns 1000.
- The next operation tries to apply a discount.
- It throws an exception.
- The following thenApply() operation is skipped because its previous stage failed.
- exceptionally() receives the failure and returns 0.
The final result is 0.
This is an important feature of CompletableFuture: a failure can move through the chain until an exception-handling operation deals with it.
7. Important: Exception Handling Does Not Automatically Fix the Problem
One thing we should always remember is that handling an exception does not mean the original operation was successful.
For example, if a payment service is unavailable, returning a default value does not mean the payment was completed.
We should choose fallback values carefully. In some situations, the correct action might be to report the failure, retry through an appropriate application workflow, or ask the user to try again.
Also, when using exceptionally(), handle(), or whenComplete(), remember that the exception may be wrapped in CompletionException.
You can inspect the underlying cause using getCause() when necessary.
8. Quick Revision
Let's take one final look at the three methods we focused on today.
// Runs only when the previous stage fails.
// Can provide a fallback result.
.exceptionally(exception -> fallbackValue)
// Runs on success or failure.
// Can transform the result or recover from failure.
.handle((result, exception) -> newResult)
// Runs on success or failure.
// Mainly used to observe the outcome.
.whenComplete((result, exception) -> {
// Log or inspect the outcome
})
Practise these examples and try changing the code so that some operations succeed and others fail.
Understanding how failures move through an asynchronous chain is an important part of writing reliable Java applications.
Test Your Understanding
Let's see how well you understood this class!
Question 1: Which method is executed when the previous CompletableFuture operation fails and can provide a fallback value?
Question 2: Which method receives both the result and the exception, and executes whether the previous operation succeeds or fails?
Question 3: What is the main purpose of whenComplete()?
Question 4: What happens to the next thenApply() stage if the previous stage completes exceptionally and no recovery has occurred?
Question 5: What does join() typically throw when a CompletableFuture completes exceptionally?
Class Summary
Let's quickly revise what we learned today.
- CompletableFuture captures failures from asynchronous operations.
- join() reports exceptional completion through CompletionException.
- exceptionally() handles failures and can provide a fallback result.
- handle() executes on success or failure and can produce a new result.
- whenComplete() observes the outcome without normally recovering from the failure.
- An unhandled failure prevents subsequent normal stages from executing.
- Exception handling helps us build more reliable asynchronous workflows.
Remember: A good application does not just handle successful operations. It also knows how to respond when something goes wrong.
You have completed 40 out of 46 classes.