Here’s the English translation of the article body:


When you call external APIs, you occasionally get a 503 back, or the connection times out. Most of the time these are temporary, and if you wait a moment and try again, the call succeeds. Hand-writing retries by wrapping a try-catch in a loop for cases like this is quietly tedious, and managing backoff and retry counts tends to clutter your code.

This is where spring-retry comes in handy. Just by adding the @Retryable annotation, you can achieve retries declaratively. In this article, we’ll walk through everything from the basics of @Retryable to fallbacks with @Recover, the easy-to-hit self-invocation pitfall, and finally how to choose between this and Resilience4j—all with implementation examples. Note that this article assumes synchronous processing with Spring Boot 3.x (spring-retry 2.x), and does not cover reactive retries in WebFlux.

When Should You Use Spring Retry

spring-retry is an AOP-based library: when you annotate a method, it automatically retries the execution of that method.

It’s a good fit for failures where “trying again might succeed.” Concretely, that means 503s and 502s from external APIs, connection timeouts, temporary DB connection errors, and the like. Conversely, cases like a 400 returned because the input is invalid will produce the same result no matter how many times you try, so they should not be retry targets. Properly narrowing down which exceptions to retry is the key to using this safely.

Adding Dependencies and Enabling @EnableRetry

First, add the dependencies. In addition to spring-retry itself, you also need spring-aspects to make AOP work. Be careful—if you forget this, the annotation won’t take effect. If you leave the version to the Spring Boot BOM, Spring Boot 3.x will select spring-retry 2.x.

dependencies {
    implementation 'org.springframework.retry:spring-retry'
    implementation 'org.springframework:spring-aspects'
}

Next, enable @EnableRetry. You can add it to either a configuration class or the main class annotated with @SpringBootApplication.

@Configuration
@EnableRetry
public class RetryConfig {
}

That’s all the setup. After this, you just add the annotation to the methods you want to retry.

The Basics of @Retryable (maxAttempts and retryFor)

Let’s add @Retryable to a service that calls an external API.

@Service
public class PaymentApiClient {

    @Retryable(
        retryFor = { HttpServerErrorException.class },
        maxAttempts = 3
    )
    public PaymentResult charge(ChargeRequest request) {
        return restClient.post()
            .uri("/charges")
            .body(request)
            .retrieve()
            .body(PaymentResult.class);
    }
}

There are two key points. maxAttempts is the total number of attempts including the initial call, so 3 means the first call plus 2 retries. The default is 3.

And you specify the exceptions to retry with retryFor. When RestClient receives a 5xx via retrieve(), it throws HttpServerErrorException (a subclass of RestClientException), so you can specify it directly as shown above. If you want to wrap it in a custom ApiTemporaryException or similar to make the retry target explicit, you can inspect the status with onStatus, convert it, and rethrow. Conversely, when you want to exclude only specific exceptions, use noRetryFor.

retryFor / noRetryFor are attributes added in spring-retry 2.0, and they are the new aliases for the older include / exclude. The old attributes still work, but for new code we recommend using retryFor / noRetryFor. For the external API calling code itself, also check out the Spring Boot RestClient guide.

Controlling Delays with @Backoff

If you fire off retries in rapid succession, you can pile on when the other server is already down. Add wait time with @Backoff.

@Retryable(
    retryFor = { HttpServerErrorException.class },
    maxAttempts = 4,
    backoff = @Backoff(delay = 500, multiplier = 2.0, maxDelay = 5000, random = true)
)
public PaymentResult charge(ChargeRequest request) {
    // ...
}

Specifying only delay gives you a fixed delay. As in the example above, adding multiplier makes it exponential backoff, so the wait time grows 500ms → 1000ms → 2000ms. Left unchecked it would grow without bound, so it’s practical to set an upper limit with maxDelay.

random = true specifies jitter. When multiple clients fail at the same time, this adds variation to the wait time to avoid having all the retries concentrate at the same instant and hammer the other side again (thundering herd). For external APIs, it’s reassuring to include it.

Writing a Final Fallback with @Recover

What do you do when all attempts fail? You could just rethrow the exception, but sometimes you want to return a default value or fall back to a cache. That’s when you use @Recover.

@Service
public class PaymentApiClient {

    @Retryable(retryFor = { HttpServerErrorException.class }, maxAttempts = 3)
    public PaymentResult charge(ChargeRequest request) {
        // ...
    }

    @Recover
    public PaymentResult recoverCharge(HttpServerErrorException e, ChargeRequest request) {
        log.warn("Retry limit reached, falling back", e);
        return PaymentResult.pending(request.orderId());
    }
}

@Recover methods have signature-matching rules. If these are off, @Recover won’t be called and the original exception will fly straight out, so be careful.

  • Match the return type to the @Retryable method
  • Take the retry target exception type as the first argument
  • After the first argument, you can list the original method’s arguments in the same order

The “my fallback isn’t being called” pitfall is usually caused either by a mismatched return type or by the first-argument exception type not matching the exception actually thrown. Check these two points first.

The Pitfall Where Retries Don’t Work Due to self-invocation

The most common pitfall with spring-retry is self-invocation. Because @Retryable works via an AOP proxy, calling it directly from another method within the same class means the call doesn’t go through the proxy and retries don’t take effect.

@Service
public class OrderService {

    public void process(Order order) {
        // This doesn't go through the proxy, so it isn't retried
        callExternalApi(order);
    }

    @Retryable(retryFor = HttpServerErrorException.class)
    public void callExternalApi(Order order) {
        // ...
    }
}

process calls callExternalApi, but since this is an internal call, @Retryable has no effect at all. The annotation is there, but it doesn’t work.

The workaround is simple: extract the retry-target method into a separate Bean.

@Service
public class ExternalApiClient {

    @Retryable(retryFor = HttpServerErrorException.class)
    public void call(Order order) {
        // ...
    }
}

@Service
public class OrderService {

    private final ExternalApiClient apiClient;

    public OrderService(ExternalApiClient apiClient) {
        this.apiClient = apiClient;
    }

    public void process(Order order) {
        apiClient.call(order); // Goes through a separate Bean, so the proxy works
    }
}

This way the call goes through the proxy, so retries work properly. Also, methods annotated with @Retryable need to be public. Proxies are not applied to private methods. This “split into a separate Bean” idea is common to AOP in general, and the same caution applies in the JPA optimistic locking guide, where you combine retries with optimistic locking.

Writing It Programmatically with RetryTemplate

Annotations are convenient and declarative, but there are situations where you want finer control, such as “dynamically deciding whether to retry based on the contents of the response.” In those cases you can use RetryTemplate to build retries in code.

import org.springframework.retry.RetryContext;
import org.springframework.retry.support.RetryTemplate;

RetryTemplate retryTemplate = RetryTemplate.builder()
    .maxAttempts(3)
    .exponentialBackoff(500, 2.0, 5000)
    .retryOn(HttpServerErrorException.class)
    .build();

PaymentResult result = retryTemplate.execute((RetryContext context) -> {
    // You can reference the current attempt count via context.getRetryCount()
    return apiClient.charge(request);
});

The lambda passed to execute is a RetryCallback, and the context argument is a RetryContext. You can pull state such as the attempt count from it, so it’s useful for dynamic decisions. That said, annotations are enough for most cases. Unless you have special circumstances, we recommend starting with @Retryable.

Choosing Between Spring Retry and Resilience4j

Many people think of Resilience4j when it comes to retries. Let’s roughly organize which one you should use.

AspectSpring RetryResilience4j
Main responsibilityRetryRetry + Circuit Breaker + Bulkhead + RateLimiter
Ease of adoptionComplete with just annotationsMore configuration options
Good fit forSimple automatic retriesStopping cascading failures / needing many features

The decision is simple. If what you want is just simple retries, spring-retry is enough. On the other hand, if you need a Circuit Breaker that cuts off the call itself when failures continue, or a Bulkhead that limits the number of concurrent executions, choose Resilience4j. For details, see the guide to implementing a circuit breaker with Resilience4j.

Note that messaging platforms like Kafka have their own retry mechanisms (such as DLT), which are covered in the Kafka Producer/Consumer guide.

Summary

With spring-retry, you can achieve declarative retries just by adding the dependency, enabling @EnableRetry, and adding @Retryable. Narrow the target with maxAttempts and retryFor, apply exponential backoff with @Backoff, and prepare a final fallback with @Recover. Once you have this flow down, you can cleanly handle temporary failures in external API calls.

The thing to watch out for is self-invocation: retries don’t work for calls within the same class, so extract them into a separate Bean. And the trick is to choose by responsibility—Resilience4j if you need advanced fault tolerance like a Circuit Breaker, spring-retry if simple retries are enough. Start by trying the easy-to-use spring-retry first.