Retry Executor Utility for Java
Configurable Java retry helper for flaky operations. Supports max attempts, fixed delays, exception whitelists, result validation, and failure hooks.
RetryExecutorUtil is a stateless static helper that re-executes a Callable, Supplier, or Runnable until it succeeds, runs out of attempts, or throws a non-retryable exception. Configuration is fluent (RetryOptions.builder()), the retry-on list is exception-type-based, and you can optionally validate the result and register a terminal failure hook for metrics or logging.
Tested on Java 21.
When to Use This
- Calling a flaky HTTP endpoint where 1-2 retries usually fixes things
- Wrapping a database materialised-view refresh that occasionally deadlocks
- Polling for an eventually-consistent state in tests (with a small delay)
- Library code that should not pull in
resilience4j-retryfor one retry loop
Don't use this when you need exponential backoff, jitter, or a circuit breaker. Use Resilience4j's Retry for that, or Spring Retry's @Retryable if you are already in Spring. Also avoid retrying any operation with side effects you cannot make idempotent.
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() {}
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,
() -> "Attempt " + attempt + " invalid result; retrying after " + 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,
() -> "Attempt " + attempt + " failed (" + ex.getClass().getSimpleName()
+ "): " + ex.getMessage() + ". Retrying after " + 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 b) {
this.maxAttempts = b.maxAttempts;
this.delay = b.delay;
this.retryOn = Set.copyOf(b.retryOn);
this.resultValidator = b.resultValidator;
this.onFailure = b.onFailure;
this.logger = b.logger;
}
public int maxAttempts() { return maxAttempts; }
public Duration delay() { return delay; }
public Logger logger() { return logger; }
boolean shouldRetryFor(Throwable t) {
return retryOn.stream().anyMatch(type -> type.isInstance(t));
}
boolean validateResult(Object r) {
return resultValidator == null || resultValidator.test(r);
}
void triggerFailureHook(Throwable t) {
if (onFailure != null) onFailure.accept(t);
}
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() { retryOn.add(Exception.class); }
public Builder withMaxAttempts(int n) {
if (n <= 0) throw new IllegalArgumentException("maxAttempts > 0");
this.maxAttempts = n; return this;
}
public Builder withDelay(Duration d) {
Objects.requireNonNull(d, "delay");
if (d.isNegative()) throw new IllegalArgumentException("delay >= 0");
this.delay = d; return this;
}
@SafeVarargs
public final Builder retryOn(Class<? extends Throwable>... types) {
if (types == null || types.length == 0)
throw new IllegalArgumentException("at least one exception type");
this.retryOn.clear();
this.retryOn.addAll(Arrays.asList(types));
return this;
}
@SuppressWarnings("unchecked")
public <T> Builder withResultValidator(Predicate<T> v) {
this.resultValidator = (Predicate<Object>) Objects.requireNonNull(v);
return this;
}
public Builder onFailure(Consumer<Throwable> hook) {
this.onFailure = Objects.requireNonNull(hook); return this;
}
public Builder withLogger(Logger l) {
this.logger = Objects.requireNonNull(l); return this;
}
public RetryOptions build() { return new RetryOptions(this); }
}
}
private static final class ResultValidationException extends RuntimeException {
private ResultValidationException(String m) { super(m); }
}
}The two retry triggers are exception types and an optional resultValidator. If the task returns a value that fails validation (e.g. null when you require non-null), it counts as a retry attempt without throwing. The class is final and stateless, so one shared instance is enough for the whole app.
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(err -> logger.severe("Exhausted retries: " + err.getMessage()))
.build();
String payload = RetryExecutorUtil.execute(() -> httpClient.fetch(), options);
RetryExecutorUtil.execute(
() -> { database.refreshMaterialisedView(); },
options
);Objects::nonNull as a validator means a null response counts as a soft failure and retries. The failure hook fires once at the end, perfect for incrementing a Micrometer counter.
Pitfalls
- Fixed delay only. No exponential backoff, no jitter. Hammering a recovering service with 5 retries every 2 seconds can prolong an outage. For real production retries, use Resilience4j.
- Default
retryOn(Exception.class)is too broad. It retriesIllegalArgumentException,NullPointerException, and similar permanent failures. Always narrowretryOnto known transient exceptions. Thread.sleepblocks the calling thread. Inside a Spring WebFlux pipeline or virtual-thread workload this can stall the carrier. For reactive flows useMono#retryWhen.- Result validator runs after the task returns. If your task has side effects (DB write), those happen on every retry. Make sure the operation is idempotent before retrying.
onFailurefires once at the end, not per attempt. If you need per-attempt metrics, increment them inside the task itself.
Related Snippets & Reading
- Java Libraries Beyond Lombok — utility libraries that pair with this retry helper
- Environment Utility for Java — same stateless-utility pattern
- Outbox Pattern with JDBC SKIP LOCKED — retries are the wrong tool for at-least-once delivery; this is the right one
Frequently Asked Questions
When should you use this instead of Resilience4j Retry or Spring Retry?
Use this when you want zero new dependencies and a single static method call, typically in tools, CLIs, or library code where adding a Resilience4j module is overkill. For production Spring services, Resilience4j gives you exponential backoff, circuit breakers, and Micrometer metrics out of the box, so prefer it whenever you already have it on the classpath.
Does this retry every exception by default?
Yes. The default builder registers `Exception.class` in `retryOn`, so any checked or unchecked exception triggers a retry. Override it with `.retryOn(IOException.class, TimeoutException.class)` to only retry on transient errors. Retrying everything is dangerous if your task throws `IllegalArgumentException` on bad input — you would burn attempts on a permanent failure.