CodeGym /Courses /JAVA 25 SELF /Error handling in asynchronous code: exceptionally, handl...

Error handling in asynchronous code: exceptionally, handle

JAVA 25 SELF
Level 55 , Lesson 3
Available

1. The problem: exceptions in asynchronous code

In regular (synchronous) code it’s simple: if an exception occurs in a method, it “bubbles up” the call stack, and we can catch it using try-catch. For example:

try {
    int x = 1 / 0;
} catch (ArithmeticException ex) {
    System.out.println("Division by zero!");
}

In asynchronous code, the situation is more complicated. When we launch a task via CompletableFuture.supplyAsync, it runs in another thread. If an exception occurs there, it will not be thrown into the main thread! Instead, it gets “packaged” inside the CompletableFuture, and if you later call get() or join(), you will receive this exception as an ExecutionException.

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    // Oops, there's an error here!
    return 1 / 0;
});

try {
    Integer result = future.get(); // an exception will be thrown here!
} catch (Exception ex) {
    System.out.println("An error occurred: " + ex.getMessage());
}

But if you don’t call get() (which, by the way, isn’t very asynchronous on its own) and instead build chains with thenApply and other methods, the error can get “lost.” That’s why in asynchronous programming it’s very important to catch and handle errors directly in CompletableFuture chains.

2. The exceptionally method: error handling and returning a value

The exceptionally method lets you catch an exception if it occurred in previous stages of the chain, handle it, and return an alternative value. It’s like catch, but for an asynchronous data flow.

Signature:

CompletableFuture<T> exceptionally(Function<Throwable, ? extends T> fn)

Usage example

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    System.out.println("Performing a risky computation...");
    if (Math.random() > 0.5) {
        throw new RuntimeException("Something went wrong!");
    }
    return 42;
});

future = future.exceptionally(ex -> {
    System.out.println("An error occurred: " + ex.getMessage());
    return 0; // Return a "safe" value
});

Example with thenAccept

future.thenAccept(result -> System.out.println("Result: " + result));

Output (approximate):

Performing a risky computation...
An error occurred: Something went wrong!
Result: 0
Performing a risky computation...
Result: 42

Important! The exceptionally method triggers only if there was an unhandled exception earlier in the chain. If everything goes well, it simply passes the result through.

3. The handle method: a universal handler for result and error

Sometimes we need to handle both the result and the error at the same time. For example, if everything is fine — return the result; if there’s an error — return a fallback or log the error.

Signature:

CompletableFuture<U> handle(BiFunction<? super T, Throwable, ? extends U> fn)
  • The first argument — the result (or null if there was an error),
  • The second — the exception (or null if everything is fine).

Usage example

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    if (Math.random() > 0.5) throw new RuntimeException("Random error!");
    return 100;
});

CompletableFuture<Integer> safeFuture = future.handle((result, ex) -> {
    if (ex != null) {
        System.out.println("Error detected: " + ex.getMessage());
        return -1;
    }
    return result;
});

safeFuture.thenAccept(r -> System.out.println("Final result: " + r));

Output:

Error detected: Random error!
Final result: -1
Final result: 100

Use handle when you want to act regardless of how the task ended — successfully or with an error. It’s a universal outcome handler that is always invoked and receives two arguments: the result (if everything is fine) and the exception (if something went wrong).

This method is perfect when you need to centralise logging, return a default value without breaking the chain, or simply finish an asynchronous scenario gracefully.

Example:

CompletableFuture<Integer> future = CompletableFuture
    .supplyAsync(() -> 10 / 0) // an error will occur here
    .handle((result, ex) -> {
        if (ex != null) {
            System.out.println("Error: " + ex.getMessage());
            return 0; // default value
        }
        return result;
    });

System.out.println(future.join()); // will print 0

Unlike exceptionally, which reacts only to errors, handle fires always, allowing you to handle both outcomes in one place and keep the entire chain smooth.

4. The whenComplete method: side effects after completion

Sometimes we don’t need to change the result; we just want to perform an action after the task completes — for example, log that the task is finished, regardless of success or failure.

Signature:

CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action)
  • The first argument — the result (or null on error),
  • The second — the exception (or null on success).

Usage example

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    if (Math.random() > 0.5) throw new RuntimeException("Error!");
    return 10;
});

future.whenComplete((result, ex) -> {
    if (ex != null) {
        System.out.println("Error during execution: " + ex.getMessage());
    } else {
        System.out.println("Completed successfully, result: " + result);
    }
});

Important difference:
whenComplete does not change the result or error; it only performs an action. If an exception occurs inside whenComplete, it will be attached to the existing one.

Example: log but do not interfere

future
    .whenComplete((res, ex) -> {
        System.out.println("Task finished. Error? " + (ex != null));
    })
    .thenAccept(r -> System.out.println("Result for the user: " + r));

5. Details and implementation nuances

Best practices: how to handle errors in CompletableFuture correctly

  • Always add error handling (exceptionally, handle, or whenComplete) to chains of asynchronous tasks. Otherwise an error may go unnoticed and the application will behave unpredictably.
  • Do not use get() or join() on the main thread without try-catch — this turns asynchronous code into synchronous and can lead to blocking.
  • If you need to return a “fallback” value on error — use exceptionally or handle.
  • For side effects (logging, notifying the user) — use whenComplete.
  • You can combine methods in chains: for example, first handle the error via exceptionally, then log via whenComplete, then continue processing the result.
  • Remember that if an error is not handled, it will “flow” into the next get()/join() call and can cause the application to crash.

Order of methods

  • If you use exceptionally, it intercepts only errors that occurred before it in the chain.
  • If another error happens after exceptionally (for example, in thenApply), you need to handle it separately.
  • handle is universal — it always fires, whether there was an error or not.

Combining methods

CompletableFuture.supplyAsync(() -> {
    // ...
})
.handle((result, ex) -> {
    if (ex != null) return "Error: " + ex.getMessage();
    return result;
})
.whenComplete((res, ex) -> {
    System.out.println("Task completed, result: " + res);
});

What happens if you don’t handle an error?

If an exception is not handled and you call get() or join(), it will be thrown as an ExecutionException (or CompletionException), and the application may terminate with an error.

6. Common mistakes when handling errors in CompletableFuture

Mistake #1: no error handling. If you don’t add exceptionally, handle, or whenComplete, the error will simply “get lost” until the next get()/join() call, which may be far from the place it originated.

Mistake #2: using get()/join() on the main thread without try-catch. This makes asynchronous code synchronous and can lead to blocking or unexpected application crashes.

Mistake #3: misunderstanding where exactly the handler fires. exceptionally catches only errors that occurred before it in the chain. If another error occurs after it, that method will not handle it.

Mistake #4: handling an error but not returning a value. In exceptionally or handle you must return a value; otherwise the next stage of the chain will get null (or get nothing).

Mistake #5: confusing handle and whenComplete. handle can change the result, while whenComplete can only perform an action (for example, logging). If you want to change the result — use handle.

Mistake #6: duplicating error-handling logic. You can often consolidate error handling in one place to avoid code duplication — for example, via a central handle or a shared handler.

1
Task
JAVA 25 SELF, level 55, lesson 3
Locked
Sensor Reports: Resilient Data Processing with Recovery
Sensor Reports: Resilient Data Processing with Recovery
1
Task
JAVA 25 SELF, level 55, lesson 3
Locked
System Monitoring: Detailed Event Logging
System Monitoring: Detailed Event Logging
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION