Как реализовать 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. |
Ключевые правила на собеседовании
- Неблокирующая задержка: Никаких
Thread.sleep(). ИспользоватьdelayedExecutorилиScheduledExecutorService. - Безопасность рекурсии: Асинхронная рекурсия не тратит стек потока и не вызывает
StackOverflowError. - Борьба с Thundering Herd: Всегда добавлять Jitter (случайный разброс времени) при повторах в распределенных системах.
- Фильтрация: Повторять только транзиентные сетевые ошибки (503, 429, timeouts), но не ошибки валидации клиента (4xx).
Антипаттерны
- ❌
Thread.sleep()внутриhandle/exceptionally: блокирует системные потоки пула. - ❌ Бесконечный retry без счетчика попыток: гарантированный каскадный сбой всей системы при перманентной аварии бэкенда.
- ❌ Повтор неидемпотентных POST-запросов: риск дублирования финансовых операций или заказов.