Back to CompletableFuture & Resilience
Q4Question 4 of 19

Exception handling in CompletableFuture?

Key Concepts
  • exceptionally(Function<Throwable, T>) catches errors and returns a recovery value; only called on failure.
  • handle(BiFunction<T, Throwable, U>) is called on both success and failure, transforming either case into a new value.
  • whenComplete(BiConsumer<T, Throwable>) is called on both success and failure for side effects only; it does not change the result.
  • exceptionally is like try-catch with a default return. handle is like try-catch-finally that always transforms. whenComplete is like finally for observation.
  • Chain them in pipelines: thenApply for transforms, exceptionally for recovery, whenComplete for logging.
  • Java 12 added exceptionallyCompose() for async recovery (returns CF instead of plain value).

Definition

exceptionally() recovers from errors with a fallback value, handle() transforms both success and failure into a new value, and whenComplete() observes both outcomes for side effects without changing the result.
  • exceptionally(Function) – catches exceptions and returns a recovery value; only invoked on failure; like try-catch returning a default.
  • handle(BiFunction) – invoked on both success and failure; receives the result (or null) and exception (or null); returns a new transformed value.
  • whenComplete(BiConsumer) – invoked on both success and failure for side effects (logging, metrics); does not change the result or recover from errors.
  • Pipeline integration – these methods integrate with thenApply, thenCompose, and thenCombine in async pipelines.
  • exceptionallyCompose() – Java 12 addition that allows async recovery by returning a CompletableFuture instead of a plain value.

At National Bank of India: exceptionally is "if the fund transfer fails, return a default response: Transfer failed, try later." Only activates on error. handle is "whether the transfer succeeds or fails, create a StatusReport with the outcome." Always runs, always produces output. whenComplete is "whether it succeeds or fails, log the result to the audit trail." No transformation, just observation.

Understanding the Concepts

How does exceptionally() work?

exceptionally() catches exceptions in the pipeline and returns a recovery value. It is only called when the previous stage completed with an error. On success, it is skipped entirely.

JAVA
package com.bank.service;

import java.util.concurrent.CompletableFuture;

public class ExceptionallyDemo {
    public static void main(String[] args) {
        // Success case: exceptionally NOT called
        String success = CompletableFuture
            .supplyAsync(() -> "SETTLED:TRD-001")
            .exceptionally(ex -> "FAILED:UNKNOWN")
            .join();
        System.out.println(success); // SETTLED:TRD-001

        // Failure case: exceptionally returns recovery value
        String failure = CompletableFuture.<String>supplyAsync(() -> {
                throw new RuntimeException("DB connection failed");
            })
            .exceptionally(ex -> {
                System.out.println("Recovering: " + ex.getMessage());
                return "FAILED:TRD-001";
            })
            .join();
        System.out.println(failure); // FAILED:TRD-001
    }
}
TEXT
SETTLED:TRD-001
Recovering: java.lang.RuntimeException: DB connection failed
FAILED:TRD-001

How does handle() work?

handle() is called on both success and failure. It receives the result (or null if failed) and the exception (or null if succeeded). It returns a new value, transforming either case.

JAVA
package com.bank.service;

import java.util.concurrent.CompletableFuture;

public class HandleDemo {
    public static void main(String[] args) {
        CompletableFuture<String> status = CompletableFuture
            .supplyAsync(() -> {
                if (Math.random() > 0.5)
                    throw new RuntimeException("Settlement failed");
                return "SETTLED:TRD-001";
            })
            .handle((result, ex) -> {
                if (ex != null) {
                    return "ERROR: " + ex.getCause().getMessage();
                }
                return "OK: " + result;
            });

        System.out.println(status.join());
    }
}

How does whenComplete() work?

whenComplete() is called on both success and failure for side effects only. It does not change the result. The original value or exception passes through unchanged.

JAVA
package com.bank.service;

import java.util.concurrent.CompletableFuture;

public class WhenCompleteDemo {
    public static void main(String[] args) {
        String result = CompletableFuture
            .supplyAsync(() -> "SETTLED:TRD-001")
            .whenComplete((r, ex) -> {
                if (ex != null) {
                    System.out.println("[AUDIT] Failed: " + ex.getMessage());
                } else {
                    System.out.println("[AUDIT] Success: " + r);
                }
                // No return! Side effect only.
            })
            .join(); // original result passes through

        System.out.println(result); // SETTLED:TRD-001
    }
}
TEXT
[AUDIT] Success: SETTLED:TRD-001
SETTLED:TRD-001

How do the three methods compare?

| Method | Called when | Returns | Purpose |
|---|---|---|---|
| exceptionally | Error only | Recovery T | Recover from error |
| handle | Always | New U | Transform both cases |
| whenComplete | Always | Same T/error | Observe (log/metrics) |

How do you chain them in a pipeline?

JAVA
CompletableFuture<String> result = CompletableFuture
    .supplyAsync(() -> settleTradeOrThrow("TRD-001"))  // may throw
    .thenApply(r -> "Confirmed: " + r)                  // transform
    .exceptionally(ex -> "Pending: retry later")         // recover
    .whenComplete((r, ex) -> {                           // log
        System.out.println("[METRIC] outcome=" + r);
    });

On success: thenApply transforms, exceptionally is skipped, whenComplete logs.
On failure: thenApply is skipped, exceptionally provides a fallback, whenComplete logs the fallback.

What is the wrong way to handle errors?

JAVA
// WRONG: using whenComplete to recover
cf.whenComplete((r, ex) -> {
    if (ex != null) return "fallback"; // DOES NOTHING! No return value.
});
// whenComplete cannot change the result. Use exceptionally or handle.

// WRONG: swallowing exception in handle without logging
cf.handle((r, ex) -> {
    if (ex != null) return defaultValue; // silently swallowed!
    return r;
});
// At least log the error before recovering.

What about exceptionallyCompose (Java 12)?

exceptionallyCompose() allows async recovery by returning a CompletableFuture instead of a plain value.

JAVA
// Java 12+
CompletableFuture<Integer> score = fetchCreditScore("SAV2001")
    .exceptionallyCompose(ex -> fetchCachedScoreAsync("SAV2001"));
// If live score fails, fetch cached score asynchronously

What are common interview follow-up questions?

  • Does exceptionally recover the pipeline for subsequent stages? Yes, the next thenApply receives the recovery value.
  • Can handle() change the return type? Yes, handle returns BiFunction where U can be different from T.
  • Is whenComplete called if exceptionally already recovered? If whenComplete is chained after exceptionally, it sees the recovered value, not the original error.

Visual Overview

DIAGRAM
  CompletableFuture Pipeline with Error Handling

  supplyAsync()
       │
  ┌────┴────┐
  │ Success │ Failure
  ▼         ▼
  thenApply  (skipped on failure)
       │
  ┌────┴────┐
  │ Success │ Failure
  ▼         ▼
  exceptionally (skipped on success)
       │
       ▼
  whenComplete (always: log/metrics)
       │
       ▼
  Final result

  exceptionally: error → recovery value
  handle:        always → new value
  whenComplete:  always → observe only

Key Takeaway

CompletableFuture provides three exception handling methods, each serving a distinct purpose. exceptionally() catches errors and returns a recovery value. It only runs on failure, like try-catch with a default return. handle() runs on both success and failure, transforming either case into a new value. It is the most flexible option, like a try-catch that always produces output. whenComplete() runs on both outcomes for side effects only: logging, metrics, alerting. It does not change the result.

The pipeline pattern is clean: supplyAsync for the initial task, thenApply for transformations, exceptionally for fallbacks, whenComplete for logging. On success, thenApply stages transform the data, exceptionally is skipped, and whenComplete logs the outcome. On failure, thenApply stages are skipped, exceptionally provides a fallback, and whenComplete logs the failure.

At National Bank of India, every payment pipeline uses exceptionally for cached fallbacks when the credit bureau is down, handle for creating StatusReport objects regardless of outcome, and whenComplete for incrementing Grafana counters.

Remember one sentence: exceptionally recovers, handle transforms both cases, whenComplete just observes.

---

Share
Use ← → arrow keys to navigate