⏱️ Раздел 19 · Вопрос #6

Как обрабатывать исключения в цепочке CompletableFuture

В синхронном коде ошибки обрабатываются через блоки try-catch-finally. В CompletableFuture конвейер выполняется асинхронно в разных потоках, поэтому обычный try-catch перехватит...


🟢 Junior Level

В синхронном коде ошибки обрабатываются через блоки try-catch-finally. В CompletableFuture конвейер выполняется асинхронно в разных потоках, поэтому обычный try-catch перехватить ошибку не может.

Для декларативной обработки исключений в CompletableFuture есть три базовых метода:

  1. exceptionally(Function<Throwable, T> fallback) (аналог блока catch):
    • Вызывается только при возникновении ошибки.
    • Перехватывает исключение и возвращает дефолтное (запасное) значение типа T, возвращая конвейер в нормальное русло.
  2. handle(BiFunction<T, Throwable, R> fn) (универсальный обработчик):
    • Вызывается всегда — как при успехе, так и при аварии.
    • Позволяет проанализировать оба аргумента (result и exception) и преобразовать результат в новый тип R.
  3. whenComplete(BiConsumer<T, Throwable> action) (аналог блока finally):
    • Вызывается всегда для выполнения побочных эффектов (логирование, закрытие ресурсов, сбор метрик).
    • Не изменяет результат или ошибку конвейера (прозрачный наблюдатель).

Простой пример

CompletableFuture.supplyAsync(() -> {
    if (new Random().nextBoolean()) {
        throw new IllegalStateException("Сбой внешнего сервиса");
    }
    return "Успешный ответ";
})
// Перехват ошибки и возврат запасного значения:
.exceptionally(ex -> {
    System.err.println("Произошла ошибка: " + ex.getMessage());
    return "Резервное значение (кэш)";
})
// Следующий этап получает либо результат, либо fallback:
.thenAccept(data -> System.out.println("Получено: " + data));

🟡 Middle Level

Оборачивание в CompletionException

Любое исключение (как проверяемое checked, так и непроверяемое runtime), возникшее внутри лямбды любого этапа, автоматически оборачивается средой исполнения в java.util.concurrent.CompletionException:

CompletableFuture.supplyAsync(() -> {
    throw new IllegalArgumentException("Неверный ID");
}).exceptionally(ex -> {
    // ex имеет тип Throwable, но фактический экземпляр — CompletionException!
    Throwable rootCause = (ex instanceof CompletionException) ? ex.getCause() : ex;
    System.out.println("Исходная причина: " + rootCause.getClass().getSimpleName()); // IllegalArgumentException
    return "Default";
});

Короткое замыкание (Short-Circuiting)

Если на промежуточном этапе конвейера возникает исключение:

  1. Все последующие стандартные этапы трансформации (thenApply, thenAccept, thenCompose) немедленно пропускаются.
  2. Конвейер находится в аварийном состоянии, пока в цепочке не встретится восстанавливающий узел (exceptionally или handle).
  3. После восстановления с помощью exceptionally или handle все последующие этапы возобновляют нормальное выполнение.
CompletableFuture.supplyAsync(() -> "Step1")
    .thenApply(s -> { 
        throw new RuntimeException("Crash"); // Шаг 2 упал
    })
    .thenApply(s -> s + " -> Step3") // ПРОПУСКАЕТСЯ!
    .thenApply(s -> s + " -> Step4") // ПРОПУСКАЕТСЯ!
    .exceptionally(ex -> "Recovered") // ПЕРЕХВАТ ОШИБКИ И ВОССТАНОВЛЕНИЕ
    .thenApply(s -> s + " -> Step5") // ВЫПОЛНЯЕТСЯ: "Recovered -> Step5"
    .thenAccept(System.out::println);

Восстановление через асинхронный Fallback: exceptionallyCompose (Java 12+)

До Java 12 метод exceptionally позволял возвращать только готовое синхронное значение T. Если же для восстановления требовалось обратиться к резервному микросервису (асинхронно), приходилось писать громоздкие обходные пути.

В Java 12 появился метод exceptionallyCompose (аналог thenCompose для ошибок):

// Java 12+: асинхронный fallback
primaryService.fetchDataAsync(id)
    .exceptionallyCompose(ex -> {
        log.warn("Основной сервис недоступен, идем в резервный...", ex);
        return backupService.fetchDataAsync(id); // Возвращает CompletableFuture<Data>
    });

🔴 Senior Level

Внутренний механизм распространения: AltResult и postComplete

Под капотом CompletableFuture не использует механизм раскрутки стека JVM (stack unwinding):

  1. Когда задача в потоке пула выбрасывает Throwable t, метод перехватывает его через catch (Throwable ex).
  2. Создаётся объект-маркер AltResult(ex instanceof CompletionException ? ex : new CompletionException(ex)).
  3. С помощью атомарной операции CAS поле result устанавливается в этот AltResult.
  4. Метод postComplete() обходит зависимый стек подписчиков (Treiber Stack). Для узлов типа UniApply, UniAccept и UniCompose метод tryFire() проверяет: если result instanceof AltResult, вызов пользовательской лямбды не производится, а ссылка на AltResult просто передаётся дальше в выходной фьючер.
  5. И только у узлов UniExceptionally и UniHandle метод tryFire() распознаёт ошибку, извлекает ex и передаёт его в пользовательский обработчик.

Конфликт исключений в whenComplete: подавленные исключения (Suppressed)

Метод whenComplete принимает BiConsumer<T, Throwable>. Возникает фундаментальный вопрос: что произойдёт, если и родительский фьючерс завершился с ошибкой $E_1$, и сама лямбда внутри whenComplete выбросила исключение $E_2$?

В спецификации CompletableFuture реализована строгая логика сохранения обеих ошибок:

CompletableFuture<String> cf = CompletableFuture.failedFuture(new IOException("Disk error"));

CompletableFuture<String> result = cf.whenComplete((val, ex) -> {
    throw new IllegalStateException("Telemetry logger crashed");
});

try {
    result.join();
} catch (CompletionException e) {
    Throwable cause = e.getCause(); // IllegalStateException (ошибка из whenComplete)
    Throwable[] suppressed = cause.getSuppressed(); // Содержит IOException ("Disk error")!
}

Ошибка из whenComplete замещает исходное исключение как главное, но исходное исключение обязательно прикрепляется к нему через механизм addSuppressed().

Правильная распаковка цепочки причин (Root Cause Unwrapping)

В высоконагруженных распределённых системах исключения могут многократно заворачиваться в CompletionException и ExecutionException на каждом сетевом барьере:

public static Throwable unwrap(Throwable ex) {
    Throwable current = ex;
    while (current instanceof CompletionException || current instanceof ExecutionException) {
        if (current.getCause() == null) break;
        current = current.getCause();
    }
    return current;
}

4 Tricky Questions

1. Что произойдёт, если внутри лямбды .exceptionally(fn) возникнет новое непроверяемое исключение?

Ответ: Если обработчик exceptionally сам выбрасывает исключение, возвращённый им CompletableFuture переходит в состояние ошибки с этим новым исключением (обёрнутым в CompletionException). Исходная ошибка теряется, а новая начинает каскадно распространяться дальше вниз по цепочке. Перехватить её сможет только следующий exceptionally или handle, расположенный ниже по конвейеру.

2. В чём разница между поведением исключений при вызове cf.join() и cf.get()?

Ответ:

  • get() объявлен в интерфейсе Future<T>: при ошибке бросает проверяемое исключение java.util.concurrent.ExecutionException, требующее обязательного блока try-catch (ExecutionException | InterruptedException e). При прерывании потока ожидания бросает InterruptedException.
  • join() объявлен в классе CompletableFuture<T>: при ошибке бросает непроверяемое (unchecked) исключение java.util.concurrent.CompletionException. Если поток был прерван, прерывание восстанавливается через Thread.currentThread().interrupt(), но наружу пробрасывается CompletionException(new InterruptedException()). Это делает join() пригодным для использования в лямбдах Stream API.

3. Вызовется ли метод .whenComplete(action), если CompletableFuture был отменён через cf.cancel(true)?

Ответ: Да, обязательно вызовется! Отмена фьючера в CompletableFuture трактуется как аварийное завершение с исключением java.util.concurrent.CancellationException. В метод whenComplete((res, ex) -> ...) параметр res придёт равным null, а параметр ex будет содержать экземпляр CancellationException. Это позволяет корректно освободить дескрипторы, сокеты или файлы даже при отмене задачи.

4. Как организовать асинхронный Fallback (вызов другого сервиса) при ошибке в Java 8, где ещё не было exceptionallyCompose?

Ответ: В Java 8 для этого использовали связку handle и ручного уплощения:

// Java 8 паттерн асинхронного fallback:
primaryService.fetchAsync()
    .handle((res, ex) -> {
        if (ex != null) {
            return backupService.fetchAsync(); // возвращает CompletableFuture<Data>
        }
        return CompletableFuture.completedFuture(res);
    })
    .thenCompose(Function.identity()); // "Раскрываем" CompletableFuture<CompletableFuture<Data>>

В Java 12 этот двухэтапный костыль заменили нативным методом exceptionallyCompose.


🎯 Шпаргалка для интервью

Главное за 60 секунд

  • Ошибки в CompletableFuture не летят в стек вызовов, а оборачиваются в CompletionException и текут вниз по цепочке, минуя обычные этапы thenApply/thenAccept.
  • exceptionally(fn) — аналог catch: вызывается только при сбое, возвращает fallback-значение.
  • handle(fn) — универсальный обработчик: вызывается всегда, получает (result, throwable), может трансформировать результат и восстанавливать цепочку.
  • whenComplete(action) — аналог finally: вызывается всегда, не может изменить результат (side effect only). Если сам выбросит ошибку — она станет главной, а исходная попадёт в suppressed.
  • Начиная с Java 12, для асинхронного восстановления используется exceptionallyCompose.

Типичные ошибки на интервью

  • Полагать, что whenComplete может вернуть дефолтное значение при ошибке (он принимает BiConsumer и ничего не возвращает).
  • Забывать о разнице между ExecutionException (в get()) и CompletionException (в join()).
  • Не знать, что cf.cancel() вызывает срабатывание whenComplete и handle с CancellationException.

Связанные вопросы