intermediatejava
Retry Executor Utility
Robust retry helper for wrapping flaky operations with configurable attempts, delays, validation, and failure hooks.
Published October 17, 2025
Updated October 17, 2025
Resilienceretryutilityresilienceconcurrency
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.