INTERMEDIATEJAVARESILIENCE
Retry Executor Utility
Robust retry helper for wrapping flaky operations with configurable attempts, delays, validation, and failure hooks.
Published Oct 17, 2025
retryutilityresilienceconcurrency
Retry Executor Utility
A thread-safe utility that repeatedly executes a task until it succeeds or retry limits are exhausted. It supports granular control over retry behaviour, optional result validation, and hooks for logging or metrics.
Highlights
- Generic contract works with
Callable<T>,Supplier<T>, or plainRunnableinvocations. - Configure max attempts, fixed delay, retryable exception types, and optional result predicates.
- Captures and rethrows the last failure while optionally invoking a terminal failure callback.
Code
import java.time.Duration;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class RetryExecutorUtil {
private RetryExecutorUtil() {
// Utility class
}
public static <T> T execute(Callable<T> task, RetryOptions options) throws Exception {
Objects.requireNonNull(task, "task");
RetryOptions cfg = options != null ? options : RetryOptions.builder().build();
int attempt = 0;
Exception lastException = null;
while (attempt < cfg.maxAttempts()) {
attempt++;
try {
T result = task.call();
if (!cfg.validateResult(result)) {
lastException = new ResultValidationException("Result validation failed.");
if (attempt >= cfg.maxAttempts()) {
cfg.triggerFailureHook(lastException);
throw lastException;
}
cfg.logger().log(
Level.WARNING,
() -> String.format(
"Attempt %d produced an invalid result; retrying after %s.",
attempt,
cfg.delay()
)
);
sleep(cfg.delay());
continue;
}
if (attempt > 1) {
cfg.logger().log(Level.FINE, "Task succeeded on attempt {0}.", attempt);
}
return result;
} catch (Exception ex) {
if (!cfg.shouldRetryFor(ex) || attempt >= cfg.maxAttempts()) {
cfg.triggerFailureHook(ex);
throw ex;
}
lastException = ex;
cfg.logger().log(
Level.WARNING,
() -> String.format(
"Attempt %d failed (%s: %s). Retrying after %s...",
attempt,
ex.getClass().getSimpleName(),
ex.getMessage(),
cfg.delay()
)
);
sleep(cfg.delay());
}
}
Exception failure = lastException != null
? lastException
: new IllegalStateException("Retry attempts exhausted.");
cfg.triggerFailureHook(failure);
throw failure;
}
public static <T> T execute(Supplier<T> supplier, RetryOptions options) throws Exception {
Objects.requireNonNull(supplier, "supplier");
return execute((Callable<T>) supplier::get, options);
}
public static void execute(Runnable runnable, RetryOptions options) throws Exception {
Objects.requireNonNull(runnable, "runnable");
execute(
() -> {
runnable.run();
return null;
},
options
);
}
private static void sleep(Duration delay) {
if (delay.isZero()) {
return;
}
try {
Thread.sleep(delay.toMillis());
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Retry interrupted.", interrupted);
}
}
public static final class RetryOptions {
private final int maxAttempts;
private final Duration delay;
private final Set<Class<? extends Throwable>> retryOn;
private final Predicate<Object> resultValidator;
private final Consumer<Throwable> onFailure;
private final Logger logger;
private RetryOptions(Builder builder) {
this.maxAttempts = builder.maxAttempts;
this.delay = builder.delay;
this.retryOn = Set.copyOf(builder.retryOn);
this.resultValidator = builder.resultValidator;
this.onFailure = builder.onFailure;
this.logger = builder.logger;
}
public int maxAttempts() {
return maxAttempts;
}
public Duration delay() {
return delay;
}
public Logger logger() {
return logger;
}
public Set<Class<? extends Throwable>> retryOn() {
return retryOn;
}
boolean shouldRetryFor(Throwable throwable) {
return retryOn.stream().anyMatch(type -> type.isInstance(throwable));
}
boolean validateResult(Object result) {
return resultValidator == null || resultValidator.test(result);
}
void triggerFailureHook(Throwable throwable) {
if (onFailure != null) {
onFailure.accept(throwable);
}
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private int maxAttempts = 3;
private Duration delay = Duration.ofMillis(500);
private final Set<Class<? extends Throwable>> retryOn = new LinkedHashSet<>();
private Predicate<Object> resultValidator;
private Consumer<Throwable> onFailure;
private Logger logger = Logger.getLogger(RetryExecutorUtil.class.getName());
private Builder() {
this.retryOn.add(Exception.class);
}
public Builder withMaxAttempts(int maxAttempts) {
if (maxAttempts <= 0) {
throw new IllegalArgumentException("maxAttempts must be greater than zero.");
}
this.maxAttempts = maxAttempts;
return this;
}
public Builder withDelay(Duration delay) {
Objects.requireNonNull(delay, "delay");
if (delay.isNegative()) {
throw new IllegalArgumentException("delay cannot be negative.");
}
this.delay = delay;
return this;
}
@SafeVarargs
public final Builder retryOn(Class<? extends Throwable>... exceptionTypes) {
if (exceptionTypes == null || exceptionTypes.length == 0) {
throw new IllegalArgumentException("At least one exception type must be provided.");
}
this.retryOn.clear();
this.retryOn.addAll(Arrays.asList(exceptionTypes));
return this;
}
public Builder addRetryOn(Class<? extends Throwable> exceptionType) {
this.retryOn.add(Objects.requireNonNull(exceptionType, "exceptionType"));
return this;
}
@SuppressWarnings("unchecked")
public <T> Builder withResultValidator(Predicate<T> validator) {
this.resultValidator = (Predicate<Object>) Objects.requireNonNull(validator, "validator");
return this;
}
public Builder onFailure(Consumer<Throwable> hook) {
this.onFailure = Objects.requireNonNull(hook, "hook");
return this;
}
public Builder withLogger(Logger logger) {
this.logger = Objects.requireNonNull(logger, "logger");
return this;
}
public RetryOptions build() {
return new RetryOptions(this);
}
}
}
private static final class ResultValidationException extends RuntimeException {
private ResultValidationException(String message) {
super(message);
}
}
}Usage
import java.io.IOException;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.TimeoutException;
import java.util.logging.Logger;
Logger logger = Logger.getLogger("retry-demo");
RetryExecutorUtil.RetryOptions options = RetryExecutorUtil.RetryOptions.builder()
.withMaxAttempts(5)
.withDelay(Duration.ofSeconds(2))
.retryOn(IOException.class, TimeoutException.class)
.withResultValidator(Objects::nonNull)
.withLogger(logger)
.onFailure(error -> logger.severe("Exhausted retries: " + error.getMessage()))
.build();
String payload = RetryExecutorUtil.execute(() -> httpClient.fetch(), options);
RetryExecutorUtil.execute(
() -> {
database.refreshMaterialisedView();
},
options
);💡 Because the utility is stateless and all configuration lives inside immutable
RetryOptions, it is safe to share the same instance across threads.