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

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

В CompletableFuture есть 3 основных способа обработки исключений:

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

В CompletableFuture есть 3 основных способа обработки исключений:

  1. exceptionally() — вернуть значение по умолчанию при ошибке
  2. handle() — обработать и результат, и ошибку
  3. whenComplete() — выполнить действие при завершении (не меняет результат)
// 1. exceptionally — fallback значение
CompletableFuture.supplyAsync(() -> {
    throw new RuntimeException("Error");
}).exceptionally(ex -> "default value");  // вернёт "default value"

// 2. handle — обработка и успеха, и ошибки
CompletableFuture.supplyAsync(() -> {
    throw new RuntimeException("Error");
}).handle((result, ex) -> {
    if (ex != null) return "error occurred";
    return result;
});

// 3. whenComplete — просто узнать о завершении
CompletableFuture.supplyAsync(() -> "Hello")
    .whenComplete((result, ex) -> {
        if (ex != null) {
            System.err.println("Error: " + ex);
        } else {
            System.out.println("Result: " + result);
        }
    });

🟡 Middle Level

Детальное сравнение

exceptionally — как try-catch:

CompletableFuture<String> cf = fetchDataAsync()
    .exceptionally(ex -> {
        log.error("Fetch failed", ex);
        return "default";  // fallback значение
    });

handle — полный контроль:

CompletableFuture<String> cf = fetchDataAsync()
    .handle((result, ex) -> {
        if (ex != null) {
            log.error("Failed", ex);
            return "error";
        }
        return result.toUpperCase();  // можно изменить результат
    });

whenComplete — side effect:

CompletableFuture<String> cf = fetchDataAsync()
    .whenComplete((result, ex) -> {
        // Не меняет результат — только side effect
        if (ex == null) {
            metrics.increment("success");
        } else {
            metrics.increment("failure");
        }
    })
    .thenApply(s -> s.toUpperCase());  // результат не изменён

Типичные ошибки

  1. exceptionally ЛОВИТ исключения из thenApply: ```java // ✅ Исключение в thenApply поймаётся в downstream exceptionally cf.thenApply(s -> { throw new RuntimeException(“Error”); }).exceptionally(ex -> “default”); // ✅ OK, ловит

// ✅ Но лучше handle для полного контроля cf.handle((result, ex) -> { if (ex != null) return “default”; return result; });


2. **CompletionException wrapping:**
```java
// Исключения оборачиваются в CompletionException
cf.exceptionally(ex -> {
    // ex — CompletionException, не оригинальное исключение!
    Throwable cause = ex.getCause();  // оригинальное
    return "default";
});

🔴 Senior Level

Internal Implementation

exceptionally:

public CompletableFuture<T> exceptionally(Function<Throwable, ? extends T> fn) {
    return uniExceptionallyStage(fn);
}

// Вызывается только при ошибке
// Если успех — fn не вызывается
// Возвращает fallback значение

handle:

public <U> CompletableFuture<U> handle(BiFunction<? super T, Throwable, ? extends U> fn) {
    return uniHandleStage(null, fn);
}

// Вызывается ВСЕГДА — и при успехе, и при ошибке
// result — значение (null при ошибке)
// ex — исключение (null при успехе)

whenComplete:

public CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action) {
    return uniWhenCompleteStage(null, action);
}

// Вызывается ВСЕГДА
// НЕ меняет результат — просто side effect
// Исключение из action оборачивается

Архитектурные Trade-offs

Метод Всегда вызывается Меняет результат Ловит ошибки
exceptionally ❌ Только при ошибке ✅ Fallback
handle ✅ Полный контроль
whenComplete ❌ Side effect only

Edge Cases

1. Multiple error handlers:

cf.exceptionally(ex -> {
    log.error("First handler", ex);
    return "first";
}).exceptionally(ex -> {
    // Не вызывается — первый уже обработал
    return "second";
});

2. Exception in handler:

cf.exceptionally(ex -> {
    throw new RuntimeException("Handler error");  // Новое исключение
}).exceptionally(ex -> {
    // Поймает исключение из первого handler
    return "recovered";
});

3. handle для трансформации:

// handle может изменить тип
cf.<String>handle((result, ex) -> {
    if (ex != null) {
        return "Error: " + ex.getMessage();
    }
    return "Success: " + result;
});

Производительность

exceptionally: ~5 ns (только при ошибке)
handle: ~10 ns (всегда)
whenComplete: ~8 ns (всегда)

Overhead negligible compared to async operations

Production Experience

Retry logic:

public <T> CompletableFuture<T> withRetry(
    Supplier<CompletableFuture<T>> operation,
    int maxRetries
) {
    return operation.get()
        .exceptionallyCompose(ex -> {
            if (maxRetries > 0 && isRetryable(ex)) {
                log.warn("Retry after error", ex);
                // В production добавьте задержку:
                return CompletableFuture.delayedExecutor(100, MILLISECONDS)
                    .thenCompose(v -> withRetry(operation, maxRetries - 1));
            }
            return CompletableFuture.failedFuture(ex);
        });
}

// Java 12+
cf.exceptionallyCompose(ex -> {
    if (shouldRetry(ex)) {
        return retryOperation();
    }
    return CompletableFuture.failedFuture(ex);
});

Circuit breaker pattern:

public CompletableFuture<Response> callWithCircuitBreaker(Request req) {
    if (circuitBreaker.isOpen()) {
        return CompletableFuture.failedFuture(
            new CircuitBreakerOpenException());
    }
    
    return callAsync(req)
        .handle((result, ex) -> {
            if (ex != null) {
                circuitBreaker.recordFailure();
                throw new CompletionException(ex);
            }
            circuitBreaker.recordSuccess();
            return result;
        });
}

Best Practices

// ✅ exceptionally для fallback
cf.exceptionally(ex -> defaultValue);

// ✅ handle для полного контроля
cf.handle((result, ex) -> ex != null ? fallback : result);

// ✅ whenComplete для мониторинга
cf.whenComplete((r, ex) -> metrics.record(r, ex));

// ❌ Игнорирование ошибок
// ❌ Блокирующие операции в обработчиках
// ❌ Создание новых исключений без причины

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

Обязательно знать:

  • exceptionally(Function<Throwable, T>) — fallback при ошибке, как catch
  • handle(BiFunction<T, Throwable, U>) — всегда вызывается, полный контроль
  • whenComplete(BiConsumer<T, Throwable>) — side effect only, не меняет результат
  • Исключения оборачиваются в CompletionException, оригинал через getCause()
  • exceptionallyCompose (Java 12+) — возврат другого CF при ошибке (для retry)

Частые уточняющие вопросы:

  • whenComplete перехватывает исключения? — Нет, только наблюдает. Исключение остаётся в CF
  • exceptionally вызывается при успехе? — Нет, только при ошибке
  • Как сделать retry? — exceptionallyCompose (Java 12+) или рекурсия в exceptionally
  • Можно ли обработать ошибку в нескольких handler? — Да, но первый перехвативший поглотит

Красные флаги (НЕ говорить):

  • «whenComplete ловит исключения» — он только наблюдает, не перехватывает
  • «exceptionally вызывается всегда» — только при ошибке, handle вызывается всегда
  • «Исключение в handler не обработается» — handler исключения тоже ловится downstream exceptionally

Связанные темы:

  • [[7. В чём разница между handle(), exceptionally() и whenComplete()]]
  • [[18. Что произойдёт, если в цепочке CompletableFuture возникнет исключение]]
  • [[27. Как реализовать retry логику с помощью CompletableFuture]]
  • [[1. Что такое CompletableFuture и чем он отличается от Future]]