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