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 plain Runnable invocations.
  • 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.