java 150 lines · 3 tabs

Async Retry With Exponential Backoff and Full Jitter Using ScheduledExecutorService

Shared by codesnips Jul 2026
3 tabs
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Predicate;

public final class RetryPolicy {
    private final int maxAttempts;
    private final Duration baseDelay;
    private final Duration maxDelay;
    private final Predicate<Throwable> retryOn;

    public RetryPolicy(int maxAttempts, Duration baseDelay, Duration maxDelay,
                       Predicate<Throwable> retryOn) {
        if (maxAttempts < 1) throw new IllegalArgumentException("maxAttempts >= 1");
        this.maxAttempts = maxAttempts;
        this.baseDelay = baseDelay;
        this.maxDelay = maxDelay;
        this.retryOn = retryOn;
    }

    public int maxAttempts() {
        return maxAttempts;
    }

    public boolean shouldRetry(Throwable error) {
        return retryOn.test(error);
    }

    // attempt is zero-based: 0 for the delay before the first retry
    public long nextDelayMillis(int attempt) {
        long base = baseDelay.toMillis();
        long cap = maxDelay.toMillis();
        long exp = base << Math.min(attempt, 30); // base * 2^attempt, guarded shift
        long cappedExp = Math.min(cap, Math.max(exp, base));
        return ThreadLocalRandom.current().nextLong(0, cappedExp + 1); // full jitter
    }
}
3 files · java Explain with highlit

This snippet shows a non-blocking retry mechanism for a flaky external HTTP call, built on CompletableFuture and a ScheduledExecutorService rather than sleeping a worker thread. Blocking retries waste a thread for the entire backoff window; scheduling the next attempt instead frees the calling thread and lets a small pool coordinate many in-flight retries cheaply.

In RetryPolicy, the retry parameters are captured as an immutable value: maxAttempts, a baseDelay, a maxDelay cap, and a predicate retryOn that decides which failures are worth retrying. The core is nextDelay, which computes base * 2^attempt and clamps it to maxDelay, then applies full jitter by choosing a random value in [0, cappedExp]. Full jitter is preferred over fixed exponential delays because when many clients fail at once they would otherwise retry in lockstep, producing synchronized thundering-herd spikes against a recovering service; randomizing the interval spreads that load out.

AsyncRetryExecutor drives the loop. execute accepts a Supplier<CompletableFuture<T>> — a fresh attempt factory, so each retry actually re-invokes the operation rather than observing the same stale future. attempt invokes the supplier, and on completion inspects the outcome with handle. On success it completes the result future; on failure it consults policy.retryOn and the remaining attempt budget. When a retry is warranted, schedule submits the next attempt to the scheduler after the jittered delay via scheduler.schedule(..., MILLISECONDS), without occupying any thread in between.

A subtle correctness detail is unwrapping: a failed CompletableFuture reports its cause wrapped in CompletionException, so unwrap peels that off before the predicate sees the real exception. Exhausting attempts completes the returned future exceptionally with the last error, preserving the failure for the caller.

FlakyPaymentClient demonstrates realistic wiring: a RetryPolicy that retries only on IOException or HTTP 5xx (RetryableHttpException), backing off from 200ms up to a 5s ceiling. The client returns the retry-wrapped future directly, so callers compose it like any other async result. This pattern fits idempotent reads and safe writes; non-idempotent operations need an idempotency key to avoid duplicate side effects, and the scheduler should be shut down on application stop to release its threads.


Related snips

Share this code

Here's the card — post it anywhere.

Async Retry With Exponential Backoff and Full Jitter Using ScheduledExecutorService — share card
Link copied