⏱️ Розділ 19 · Питання #27

Як реалізувати retry логіку за допомогою CompletableFuture

У CompletableFuture retry реалізується через обробку помилок і повторний запуск асинхронної функції. Починаючи з Java 12, для цього з'явився спеціалізований неблокуючий метод ex...


🟢 Junior Level

Retry (повторна спроба) — це механізм повторного виконання асинхронної операції, що завершилася збоєм (наприклад, тимчасовим мережевим таймаутом або недоступністю мікросервісу).

У CompletableFuture retry реалізується через обробку помилок і повторний запуск асинхронної функції. Починаючи з Java 12, для цього з’явився спеціалізований неблокуючий метод exceptionallyCompose(), який приймає виняток, що виник, і повертає новий CompletableFuture:

public <T> CompletableFuture<T> withRetry(Supplier<CompletableFuture<T>> action, int retriesLeft) {
    return action.get()
        .exceptionallyCompose(throwable -> {
            if (retriesLeft > 0) {
                log.warn("Operation failed, retrying... Remaining attempts: {}", retriesLeft);
                return withRetry(action, retriesLeft - 1); // Асинхронний повтор
            }
            // Спроби вичерпано — прокидаємо помилку далі:
            return CompletableFuture.failedFuture(throwable);
        });
}

// Приклад використання:
withRetry(() -> paymentClient.chargeAsync(orderId), 3)
    .thenAccept(receipt -> log.info("Payment success: {}", receipt));

Головне правило

Ніколи не блокуйте потік викликом Thread.sleep() усередині ланцюжка CompletableFuture. Повторна спроба має плануватися асинхронно, щоб не займати потоки пулу очікуванням.


🟡 Middle Level

1. Додавання неблокуючої затримки (Backoff)

У реальних розподілених системах миттєвий повтор запиту не має сенсу: сервіс, що впав, не встигне відновитися. Потрібна пауза між спробами.

У Java 9+ для цього використовується CompletableFuture.delayedExecutor():

public <T> CompletableFuture<T> withRetryAndDelay(
    Supplier<CompletableFuture<T>> action,
    int retriesLeft,
    long delayMillis,
    ScheduledExecutorService scheduler
) {
    return action.get()
        .exceptionallyCompose(throwable -> {
            if (retriesLeft <= 0) {
                return CompletableFuture.failedFuture(throwable);
            }

            // Створюємо delayed-executor, який запустить завдання через delayMillis:
            Executor delayedExecutor = CompletableFuture.delayedExecutor(
                delayMillis, TimeUnit.MILLISECONDS, scheduler
            );

            // Плануємо наступний запуск без блокування поточного потоку:
            return CompletableFuture.supplyAsync(action, delayedExecutor)
                .thenCompose(Function.identity()) // розгортаємо CompletableFuture<CompletableFuture<T>>
                .exceptionallyCompose(ex -> 
                    withRetryAndDelay(action, retriesLeft - 1, delayMillis * 2, scheduler)
                );
        });
}

2. Фільтрація винятків (Idempotency & Retry Classification)

Повторювати можна лише транзієнтні (тимчасові) помилки та ідемпотентні операції:

  • Варто повторювати: мережеві розриви (ConnectException), таймаути читання (SocketTimeoutException), HTTP 503 (Service Unavailable), HTTP 429 (Too Many Requests).
  • Безглуздо й небезпечно повторювати: HTTP 400 (Bad Request), 401/403 (Security), 404 (Not Found), 422 (Unprocessable Entity) або неідемпотентні списання коштів без ключа ідемпотентності.
public boolean isRetryable(Throwable throwable) {
    Throwable cause = (throwable instanceof CompletionException) 
        ? throwable.getCause() 
        : throwable;

    return cause instanceof TimeoutException 
        || cause instanceof SocketException
        || (cause instanceof HttpResponseException http && http.getStatusCode() == 503);
}

🔴 Senior Level

1. Архітектурна проблема Thundering Herd та алгоритм Full Jitter

Якщо впав мікросервіс, до якого звертаються 10 000 клієнтів, і всі клієнти використовують жорсткий таймер повтору ($1 \text{ с}, 2 \text{ с}, 4 \text{ с}$), усі 10 000 повторних запитів вдарять по сервісу, що піднімається, одночасно синхронними сплесками навантаження (thundering herd problem або retry storm).

Для запобігання цьому в затримку обов’язково вноситься випадковість — Full Jitter:

\[\text{sleep} = \text{random\_between}(0, \; \min(\text{max\_backoff}, \; \text{base\_delay} \times 2^{\text{attempt}}))\]
public class BackoffUtil {
    private static final ThreadLocalRandom RANDOM = ThreadLocalRandom.current();

    public static long calculateFullJitterDelay(int attempt, long baseDelayMs, long maxDelayMs) {
        long exponentialLimit = Math.min(maxDelayMs, baseDelayMs * (1L << attempt));
        return ThreadLocalRandom.current().nextLong(0, exponentialLimit + 1);
    }
}

2. Повна production-ready реалізація Retry Utility

Нижче наведено надійну, неблокуючу утиліту для Java 12+:

public final class AsyncRetry {

    private AsyncRetry() {}

    public static <T> CompletableFuture<T> retry(
        Supplier<CompletableFuture<T>> taskSupplier,
        int maxAttempts,
        Duration initialBackoff,
        Duration maxBackoff,
        Predicate<Throwable> retryPredicate,
        ScheduledExecutorService scheduler
    ) {
        return executeAttempt(taskSupplier, 0, maxAttempts, initialBackoff.toMillis(), 
                              maxBackoff.toMillis(), retryPredicate, scheduler);
    }

    private static <T> CompletableFuture<T> executeAttempt(
        Supplier<CompletableFuture<T>> taskSupplier,
        int currentAttempt,
        int maxAttempts,
        long baseDelayMs,
        long maxDelayMs,
        Predicate<Throwable> retryPredicate,
        ScheduledExecutorService scheduler
    ) {
        return taskSupplier.get()
            .exceptionallyCompose(ex -> {
                int nextAttempt = currentAttempt + 1;
                if (nextAttempt >= maxAttempts || !retryPredicate.test(ex)) {
                    return CompletableFuture.failedFuture(ex);
                }

                long delayMs = BackoffUtil.calculateFullJitterDelay(currentAttempt, baseDelayMs, maxDelayMs);

                // Створюємо таймер через планувальник
                CompletableFuture<T> delayedPromise = new CompletableFuture<>();
                scheduler.schedule(() -> {
                    executeAttempt(taskSupplier, nextAttempt, maxAttempts, baseDelayMs, maxDelayMs, retryPredicate, scheduler)
                        .whenComplete((res, error) -> {
                            if (error != null) delayedPromise.completeExceptionally(error);
                            else delayedPromise.complete(res);
                        });
                }, delayMs, TimeUnit.MILLISECONDS);

                return delayedPromise;
            });
    }
}

3. Інтеграція з Resilience4j (Enterprise-стандарт)

У промислових enterprise-застосунках самописний retry часто замінюють бібліотекою Resilience4j, яка вміє комбінувати Retry, CircuitBreaker і TimeLimiter поверх CompletionStage:

RetryConfig config = RetryConfig.custom()
    .maxAttempts(3)
    .waitDuration(Duration.ofMillis(500))
    .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(500, 2.0, 0.5)) // Exponential + Jitter
    .retryExceptions(SocketTimeoutException.class, IOException.class)
    .ignoreExceptions(IllegalArgumentException.class)
    .build();

Retry retry = RetryRegistry.ofDefaults().retry("paymentRetry", config);
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

// Декоруємо асинхронний виклик:
Supplier<CompletionStage<PaymentResult>> decorated = Retry.decorateCompletionStage(
    retry, 
    scheduler, 
    () -> paymentClient.chargeAsync(orderId)
);

CompletableFuture<PaymentResult> future = decorated.get().toCompletableFuture();

4 Tricky Questions

1. Чи призводить рекурсивний виклик методу retry (withRetry) до StackOverflowError?

Відповідь: Ні, не призводить.

Стек викликів переповнюється лише при синхронній рекурсії, коли фрейми методів накопичуються в стеку потоку ОС. В асинхронному retry кожен крок рекурсії викликається всередині колбека завершення (exceptionallyCompose або таймера scheduler.schedule).

На момент, коли спрацьовує колбек і викликається наступна ітерація withRetry, попередній метод withRetry уже давно повернув керування і його фрейм було знято зі стека потоку. Виконання розподіляється в часі та потоках, тому глибина стека викликів потоку завжди залишається константною ($O(1)$).

2. Чому Thread.sleep(delay) усередині exceptionally() — це критичний антипатерн, і як правильно реалізувати затримку?

Відповідь: Усередині exceptionally() код виконується в пулі потоків, на якому завершився попередній етап (часто це спільний ForkJoinPool.commonPool() або дорогоцінні воркери мережевого введення-виведення Netty/WebFlux).

Виклик Thread.sleep(delay) блокує потік ОС. Якщо кілька сотень запитів почнуть робити retry з паузою в 1–2 секунди через sleep, усі потоки пулу виявляться заблокованими сном. Це викличе повний параліч пулу потоків застосунку (thread starvation), зависання інших непов’язаних завдань і деградацію всієї системи.

Затримка має бути неблокуючою — через планування події в ScheduledExecutorService або CompletableFuture.delayedExecutor().

3. Що таке Full Jitter і чому чистий експоненціальний backoff (delay * 2^attempt) небезпечний у production?

Відповідь: Якщо впав великий сервіс або база даних, сотні паралельних клієнтів отримають збій одночасно в момент часу $T_0$. При «чистому» експоненціальному backoff усі ці 100 клієнтів зачекають рівно 1 секунду і всі одночасно в момент $T_0 + 1000\text{ms}$ надішлють повторний запит. Потім вони знову синхронно отримають відмову і в момент $T_0 + 3000\text{ms}$ дружно повторять атаку.

Це явище називається пульсуючим перевантаженням (Retry Storm / Thundering Herd). Алгоритм Full Jitter рандомізує інтервал: random(0, exponentialDelay). У результаті запити рівномірно розподіляються по всій часовій шкалі, дозволяючи бекенду, що впав, плавно прогріти кеші та відновитися без вторинних перевантажень.

4. Як реалізувати асинхронний retry у Java 8, де методу exceptionallyCompose ще не існувало?

Відповідь: У Java 8 метод exceptionally приймає функцію Function<Throwable, ? extends T>, тобто може повернути лише плоске значення $T$, але не інший CompletableFuture<T>.

У Java 8 асинхронний retry реалізується через комбінацію whenComplete() і зв’язування обіцянок:

// Ідіоматичний Java 8 патерн через ручний promise:
public static <T> CompletableFuture<T> retryJava8(
    Supplier<CompletableFuture<T>> action, int retries, ScheduledExecutorService scheduler
) {
    CompletableFuture<T> promise = new CompletableFuture<>();
    attemptJava8(action, retries, promise, scheduler);
    return promise;
}

private static <T> void attemptJava8(
    Supplier<CompletableFuture<T>> action, int retries, 
    CompletableFuture<T> promise, ScheduledExecutorService scheduler
) {
    action.get().whenComplete((val, ex) -> {
        if (ex == null) {
            promise.complete(val);
        } else if (retries > 0) {
            scheduler.schedule(() -> attemptJava8(action, retries - 1, promise, scheduler), 1, TimeUnit.SECONDS);
        } else {
            promise.completeExceptionally(ex);
        }
    });
}

🎯 Шпаргалка для інтерв’ю

Зведена таблиця підходів до Retry

Версія Java Механізм Особливості
Java 8 whenComplete + явний promise + ScheduledExecutorService Вимагає ручного зв’язування промісів і шедулера.
Java 9+ CompletableFuture.delayedExecutor() Вбудований неблокуючий шедулер затримок у стандартному API.
Java 12+ exceptionallyCompose() Нативна функціональна композиція при збої без блокувань.
Enterprise Resilience4j (Retry.decorateCompletionStage) Production-ready: Exponential backoff + Jitter, метрики Micrometer, Circuit Breaker.

Ключові правила на співбесіді

  1. Неблокуюча затримка: Жодних Thread.sleep(). Використовувати delayedExecutor або ScheduledExecutorService.
  2. Безпека рекурсії: Асинхронна рекурсія не витрачає стек потоку і не викликає StackOverflowError.
  3. Боротьба з Thundering Herd: Завжди додавати Jitter (випадковий розкид часу) при повторах у розподілених системах.
  4. Фільтрація: Повторювати лише транзієнтні мережеві помилки (503, 429, timeouts), але не помилки валідації клієнта (4xx).

Антипатерни

  • ❌ Thread.sleep() усередині handle / exceptionally: блокує системні потоки пулу.
  • ❌ Нескінченний retry без лічильника спроб: гарантований каскадний збій усієї системи при перманентній аварії бекенда.
  • ❌ Повтор неідемпотентних POST-запитів: ризик дублювання фінансових операцій або замовлень.

Пов’язані теми