Питання 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]]