Mastering Spring Retry: Deep Dive into Retry and Back‑off Strategies

This article explains Spring Retry's architecture, how @EnableRetry, @Retryable, @Backoff and @Recover work together, details the available retry and back‑off policies, and walks through the core implementation using RetryTemplate and interceptors to provide flexible, AOP‑based retry handling in Java backend applications.

Programmer DD
Programmer DD
Programmer DD
Mastering Spring Retry: Deep Dive into Retry and Back‑off Strategies

Overview

Spring provides a simple yet powerful retry mechanism. Spring Retry, extracted from Spring Batch, is widely used in Spring Batch, Spring Integration, Spring for Apache Hadoop and other projects. This article explains how to use Spring Retry and its implementation principles.

Background

Retry is often needed to ensure fault tolerance, availability, and consistency, especially when dealing with unpredictable external system responses, exceptions, network latency, or interruptions. In micro‑service governance frameworks like Dubbo, retry and timeout configurations are common. Manually coding retry logic mixes it with business code, making maintenance hard; therefore, retry should be abstracted.

Usage Introduction

Basic Usage

Example:

@Configuration
@EnableRetry
public class Application {

    @Bean
    public RetryService retryService() {
        return new RetryService();
    }

    public static void main(String[] args) throws Exception {
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext("springretry");
        RetryService service1 = applicationContext.getBean("service", RetryService.class);
        service1.service();
    }
}

@Service("service")
public class RetryService {

    @Retryable(value = IllegalAccessException.class, maxAttempts = 5,
            backoff = @Backoff(value = 1500, maxDelay = 100000, multiplier = 1.2))
    public void service() throws IllegalAccessException {
        System.out.println("service method...");
        throw new IllegalAccessException("manual exception");
    }

    @Recover
    public void recover(IllegalAccessException e) {
        System.out.println("service retry after Recover => " + e.getMessage());
    }
}

@EnableRetry enables the retry mechanism. @Retryable marks a method for retry with many configurable parameters. @Backoff defines the back‑off strategy. @Recover provides a fallback method when all retries fail.

Spring‑Retry offers rich features such as various retry policies, back‑off policies, recover callbacks, and listeners.

Retry Strategies

SimpleRetryPolicy – default max 3 attempts

TimeoutRetryPolicy – retries failures within 1 second

ExpressionRetryPolicy – retries when an expression matches

CircuitBreakerRetryPolicy – adds circuit‑breaker behavior

CompositeRetryPolicy – combines multiple policies

NeverRetryPolicy – never retries

AlwaysRetryPolicy – always retries

Back‑off Strategies

FixedBackOffPolicy – fixed delay (default 1 s)

ExponentialBackOffPolicy – exponential increase (initial 0.1 s, multiplier 2, max 30 s)

ExponentialRandomBackOffPolicy – exponential with randomness

UniformRandomBackOffPolicy – random delay within a fixed range

StatelessBackOffPolicy – stateless back‑off

Implementation Principles

Entry Point – @EnableRetry

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@EnableAspectJAutoProxy(proxyTargetClass = false)
@Import(RetryConfiguration.class)
@Documented
public @interface EnableRetry {
    /**
     * Indicate whether subclass‑based (CGLIB) proxies are to be created as opposed
     * to standard Java interface‑based proxies. The default is {@code false}.
     *
     * @return whether to proxy the class
     */
    boolean proxyTargetClass() default false;
}

@EnableRetry imports RetryConfiguration, which registers an AbstractPointcutAdvisor containing a pointcut and an advice. The pointcut matches methods annotated with @Retryable.

RetryConfiguration

During bean initialization it builds the pointcut for @Retryable and creates an AnnotationAwareRetryOperationsInterceptor as the advice.

AnnotationAwareRetryOperationsInterceptor

This class implements MethodInterceptor. Its invoke method delegates to a stateful or stateless interceptor based on configuration.

@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
    MethodInterceptor delegate = getDelegate(invocation.getThis(), invocation.getMethod());
    if (delegate != null) {
        return delegate.invoke(invocation);
    } else {
        return invocation.proceed();
    }
}

Stateless Interceptor Creation

private MethodInterceptor getStatelessInterceptor(Object target, Method method, Retryable retryable) {
    // generate a RetryTemplate
    RetryTemplate template = createTemplate(retryable.listeners());
    // set retryPolicy
    template.setRetryPolicy(getRetryPolicy(retryable));
    // set backOffPolicy
    template.setBackOffPolicy(getBackoffPolicy(retryable.backoff()));
    return RetryInterceptorBuilder.stateless()
            .retryOperations(template)
            .label(retryable.label())
            .recoverer(getRecoverer(target, method))
            .build();
}

It builds a RetryTemplate, sets the appropriate RetryPolicy and BackOffPolicy, and configures a recoverer.

RetryPolicy Construction

private RetryPolicy getRetryPolicy(Annotation retryable) {
    Map<String, Object> attrs = AnnotationUtils.getAnnotationAttributes(retryable);
    Class<? extends Throwable>[] includes = (Class<? extends Throwable>[]) attrs.get("value");
    String exceptionExpression = (String) attrs.get("exceptionExpression");
    boolean hasExpression = StringUtils.hasText(exceptionExpression);
    if (includes.length == 0) {
        includes = (Class<? extends Throwable>[]) attrs.get("include");
    }
    Class<? extends Throwable>[] excludes = (Class<? extends Throwable>[]) attrs.get("exclude");
    Integer maxAttempts = (Integer) attrs.get("maxAttempts");
    String maxAttemptsExpression = (String) attrs.get("maxAttemptsExpression");
    if (StringUtils.hasText(maxAttemptsExpression)) {
        maxAttempts = PARSER.parseExpression(resolve(maxAttemptsExpression), PARSER_CONTEXT)
                .getValue(evaluationContext, Integer.class);
    }
    if (includes.length == 0 && excludes.length == 0) {
        SimpleRetryPolicy simple = hasExpression ?
                new ExpressionRetryPolicy(resolve(exceptionExpression)).withBeanFactory(beanFactory) :
                new SimpleRetryPolicy();
        simple.setMaxAttempts(maxAttempts);
        return simple;
    }
    Map<Class<? extends Throwable>, Boolean> policyMap = new HashMap<>();
    for (Class<? extends Throwable> type : includes) {
        policyMap.put(type, true);
    }
    for (Class<? extends Throwable> type : excludes) {
        policyMap.put(type, false);
    }
    boolean retryNotExcluded = includes.length == 0;
    if (hasExpression) {
        return new ExpressionRetryPolicy(maxAttempts, policyMap, true, exceptionExpression, retryNotExcluded)
                .withBeanFactory(beanFactory);
    } else {
        return new SimpleRetryPolicy(maxAttempts, policyMap, true, retryNotExcluded);
    }
}

Based on @Retryable attributes it creates SimpleRetryPolicy, ExpressionRetryPolicy or a composite policy, handling include/exclude exception lists and max attempts.

BackOffPolicy Construction

private BackOffPolicy getBackoffPolicy(Backoff backoff) {
    long min = backoff.delay() == 0 ? backoff.value() : backoff.delay();
    if (StringUtils.hasText(backoff.delayExpression())) {
        min = PARSER.parseExpression(resolve(backoff.delayExpression()), PARSER_CONTEXT)
                .getValue(evaluationContext, Long.class);
    }
    long max = backoff.maxDelay();
    if (StringUtils.hasText(backoff.maxDelayExpression())) {
        max = PARSER.parseExpression(resolve(backoff.maxDelayExpression()), PARSER_CONTEXT)
                .getValue(evaluationContext, Long.class);
    }
    double multiplier = backoff.multiplier();
    if (StringUtils.hasText(backoff.multiplierExpression())) {
        multiplier = PARSER.parseExpression(resolve(backoff.multiplierExpression()), PARSER_CONTEXT)
                .getValue(evaluationContext, Double.class);
    }
    if (multiplier > 0) {
        ExponentialBackOffPolicy policy = backoff.random() ? new ExponentialRandomBackOffPolicy() : new ExponentialBackOffPolicy();
        policy.setInitialInterval(min);
        policy.setMultiplier(multiplier);
        policy.setMaxInterval(max > min ? max : ExponentialBackOffPolicy.DEFAULT_MAX_INTERVAL);
        if (sleeper != null) {
            policy.setSleeper(sleeper);
        }
        return policy;
    }
    if (max > min) {
        UniformRandomBackOffPolicy policy = new UniformRandomBackOffPolicy();
        policy.setMinBackOffPeriod(min);
        policy.setMaxBackOffPeriod(max);
        if (sleeper != null) {
            policy.setSleeper(sleeper);
        }
        return policy;
    }
    FixedBackOffPolicy policy = new FixedBackOffPolicy();
    policy.setBackOffPeriod(min);
    if (sleeper != null) {
        policy.setSleeper(sleeper);
    }
    return policy;
}

It interprets @Backoff attributes to create ExponentialBackOffPolicy, UniformRandomBackOffPolicy, or FixedBackOffPolicy instances.

Core Retry Logic (RetryTemplate.doExecute)

protected <T, E extends Throwable> T doExecute(RetryCallback<T, E> retryCallback,
        RecoveryCallback<T> recoveryCallback, RetryState state) throws E, ExhaustedRetryException {
    RetryPolicy retryPolicy = this.retryPolicy;
    BackOffPolicy backOffPolicy = this.backOffPolicy;
    RetryContext context = open(retryPolicy, state);
    RetrySynchronizationManager.register(context);
    Throwable lastException = null;
    boolean exhausted = false;
    try {
        boolean running = doOpenInterceptors(retryCallback, context);
        if (!running) {
            throw new TerminatedRetryException("Retry terminated abnormally by interceptor before first attempt");
        }
        BackOffContext backOffContext = null;
        Object resource = context.getAttribute("backOffContext");
        if (resource instanceof BackOffContext) {
            backOffContext = (BackOffContext) resource;
        }
        if (backOffContext == null) {
            backOffContext = backOffPolicy.start(context);
            if (backOffContext != null) {
                context.setAttribute("backOffContext", backOffContext);
            }
        }
        while (canRetry(retryPolicy, context) && !context.isExhaustedOnly()) {
            try {
                lastException = null;
                return retryCallback.doWithRetry(context);
            } catch (Throwable e) {
                lastException = e;
                try {
                    registerThrowable(retryPolicy, state, context, e);
                } catch (Exception ex) {
                    throw new TerminatedRetryException("Could not register throwable", ex);
                } finally {
                    doOnErrorInterceptors(retryCallback, context, e);
                }
                if (canRetry(retryPolicy, context) && !context.isExhaustedOnly()) {
                    try {
                        backOffPolicy.backOff(backOffContext);
                    } catch (BackOffInterruptedException ex) {
                        lastException = e;
                        throw ex;
                    }
                    if (shouldRethrow(retryPolicy, context, state)) {
                        throw RetryTemplate.<E>wrapIfNecessary(e);
                    }
                }
                if (state != null && context.hasAttribute(RetrySynchronizationManager.GLOBAL_STATE)) {
                    break;
                }
            }
        }
        exhausted = true;
        return handleRetryExhausted(recoveryCallback, context, state);
    } catch (Throwable e) {
        throw RetryTemplate.<E>wrapIfNecessary(e);
    } finally {
        close(retryPolicy, context, state, lastException == null || exhausted);
        doCloseInterceptors(retryCallback, context, lastException);
        RetrySynchronizationManager.clear();
    }
}

The template opens a RetryContext, invokes listeners, checks RetryPolicy.canRetry, executes the target method, applies the back‑off, and finally calls a recovery callback or rethrows the exception.

RetryContext

Each retry operation has its own RetryContext which stores retry count, last exception, and back‑off state. Context objects are cached to avoid creating many policy instances.

Summary

Spring Retry leverages AOP to inject retry behavior into business code. The core logic resides in RetryTemplate, while a variety of retry and back‑off policies provide flexible configuration.

References

http://www.10tiao.com/html/164/201705/2652898434/1.html

https://www.jianshu.com/p/58e753ca0151

https://paper.tuisec.win/detail/90bd660fad92183

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaaopspringRetrySpringBootBackoffRetryPolicy
Programmer DD
Written by

Programmer DD

A tinkering programmer and author of "Spring Cloud Microservices in Action"

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.