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

Как реализовать retry логику с помощью CompletableFuture

В CompletableFuture retry реализуется через обработку ошибок и повторный запуск асинхронной функции. Начиная с Java 12, для этого появился специализированный неблокирующий метод...


🟢 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{ c}, 2 \text{ c}, 4 \text{ c}$), все 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 реализуется через комбинацию handle() и связывания обещаний:

// Идиоматичный 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-запросов: риск дублирования финансовых операций или заказов.

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