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
}
}
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
public final class AsyncRetryExecutor {
private final ScheduledExecutorService scheduler;
private final RetryPolicy policy;
public AsyncRetryExecutor(ScheduledExecutorService scheduler, RetryPolicy policy) {
this.scheduler = scheduler;
this.policy = policy;
}
public <T> CompletableFuture<T> execute(Supplier<CompletableFuture<T>> operation) {
CompletableFuture<T> result = new CompletableFuture<>();
attempt(operation, 0, result);
return result;
}
private <T> void attempt(Supplier<CompletableFuture<T>> operation, int attemptIndex,
CompletableFuture<T> result) {
operation.get().handle((value, error) -> {
if (error == null) {
result.complete(value);
return null;
}
Throwable cause = unwrap(error);
boolean canRetry = attemptIndex + 1 < policy.maxAttempts();
if (canRetry && policy.shouldRetry(cause)) {
schedule(operation, attemptIndex + 1, result);
} else {
result.completeExceptionally(cause);
}
return null;
});
}
private <T> void schedule(Supplier<CompletableFuture<T>> operation, int nextIndex,
CompletableFuture<T> result) {
long delay = policy.nextDelayMillis(nextIndex - 1);
scheduler.schedule(
() -> attempt(operation, nextIndex, result),
delay,
TimeUnit.MILLISECONDS);
}
private static Throwable unwrap(Throwable error) {
if (error instanceof CompletionException && error.getCause() != null) {
return error.getCause();
}
return error;
}
}
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
public final class FlakyPaymentClient {
private final HttpClient http = HttpClient.newHttpClient();
private final ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(2, r -> {
Thread t = new Thread(r, "retry-scheduler");
t.setDaemon(true);
return t;
});
private final AsyncRetryExecutor retry;
public FlakyPaymentClient() {
RetryPolicy policy = new RetryPolicy(
5,
Duration.ofMillis(200),
Duration.ofSeconds(5),
error -> error instanceof IOException
|| error instanceof RetryableHttpException);
this.retry = new AsyncRetryExecutor(scheduler, policy);
}
public CompletableFuture<String> fetchStatus(String paymentId) {
return retry.execute(() -> callOnce(paymentId));
}
private CompletableFuture<String> callOnce(String paymentId) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://payments.example.com/v1/" + paymentId))
.timeout(Duration.ofSeconds(2))
.GET()
.build();
return http.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(response -> {
if (response.statusCode() >= 500) {
throw new RetryableHttpException(response.statusCode());
}
return response.body();
});
}
public void shutdown() {
scheduler.shutdownNow();
}
static final class RetryableHttpException extends RuntimeException {
RetryableHttpException(int status) {
super("retryable upstream status: " + status);
}
}
}
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
Share this code
Here's the card — post it anywhere.