Spring Boot: Retry with @Retryable
Spring Retry provides declarative retry support for Spring applications. With @Retryable you can automatically re-invoke a failed method a configurable number of times with backoff delays between attempts. When all retries are exhausted, @Recover provides a fallback path. This is essential for handling transient failures in distributed systems: network timeouts, temporary service unavailability, and database lock contention.
Contents
Why Retry?
In distributed systems, transient failures are inevitable. A network call may time out, a downstream service may be momentarily overloaded, or a database connection may be briefly unavailable. These failures are temporary: the same request would succeed if attempted again a few moments later.
Without a retry mechanism, every transient failure becomes a user-visible error. Spring Retry allows you to absorb these glitches transparently by re-executing the failed operation before giving up.
Retry is appropriate when:
- The failure is transient: network timeouts, HTTP 503, connection refused, temporary lock contention
- The operation is idempotent: repeating it produces the same result without side effects
- The downstream service is expected to recover quickly
Retry is not appropriate when:
- The failure is deterministic: validation errors, authentication failures, 4xx HTTP responses
- The operation is not idempotent: duplicate charges, duplicate order creation
- The downstream service is experiencing a prolonged outage (use a circuit breaker instead)
Setup & Dependencies
Spring Retry requires two dependencies: the retry library itself and Spring AOP (which provides the proxy-based interception for @Retryable
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
If you use Gradle:
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework.boot:spring-boot-starter-aop'
Next, enable retry processing by adding @EnableRetry@SpringBootApplication
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;
@SpringBootApplication
@EnableRetry
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@EnableRetry@Retryable@Retryable: Declarative Retry
Annotate any public method in a Spring-managed bean with @Retryable
Basic Usage
By default, @RetryableException
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
@Service
public class ExternalApiService {
private final RestTemplate restTemplate;
public ExternalApiService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Retryable
public String fetchData(String endpoint) {
return restTemplate.getForObject(endpoint, String.class);
}
}
Configuring maxAttempts
Use maxAttempts
@Retryable(maxAttempts = 5)
public String fetchData(String endpoint) {
return restTemplate.getForObject(endpoint, String.class);
}
Targeting Specific Exceptions
Use retryForincludenoRetryForexclude
import org.springframework.retry.annotation.Retryable;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
@Retryable(
retryFor = { ResourceAccessException.class, HttpServerErrorException.class },
noRetryFor = { IllegalArgumentException.class },
maxAttempts = 4
)
public OrderResponse placeOrder(OrderRequest request) {
return orderClient.submit(request);
}
With this configuration, the method retries up to 4 times for network errors (ResourceAccessExceptionHttpServerErrorExceptionIllegalArgumentException
Full Example: Service with Retry
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.retry.annotation.Recover;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
private final PaymentGateway paymentGateway;
public PaymentService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
@Retryable(
retryFor = ResourceAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2)
)
public PaymentResult processPayment(PaymentRequest request) {
log.info("Attempting payment for order {}", request.getOrderId());
return paymentGateway.charge(request);
}
@Recover
public PaymentResult recoverPayment(ResourceAccessException e, PaymentRequest request) {
log.error("All retries exhausted for order {}. Error: {}", request.getOrderId(), e.getMessage());
return PaymentResult.failed("Payment gateway unreachable after retries");
}
}
@Retryable@Backoff: Delay Strategies
The @Backoff
Fixed Delay
A constant delay between each retry attempt.
// Wait 1 second between retries
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public String callService() {
return externalService.getData();
}
Exponential Backoff
Each successive delay is multiplied by the multiplier
// Attempt 1: immediate
// Attempt 2: wait 1s
// Attempt 3: wait 2s (1000 * 2)
// Attempt 4: wait 4s (2000 * 2)
@Retryable(
maxAttempts = 4,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public String callService() {
return externalService.getData();
}
Exponential Backoff with maxDelay
Use maxDelay
// Delays: 500ms, 1000ms, 2000ms, 4000ms, 5000ms (capped), 5000ms (capped)
@Retryable(
maxAttempts = 7,
backoff = @Backoff(delay = 500, multiplier = 2, maxDelay = 5000)
)
public String callService() {
return externalService.getData();
}
Random Backoff
Set random = true
// Random delay between 1000ms and 3000ms on each retry
@Retryable(
maxAttempts = 4,
backoff = @Backoff(delay = 1000, maxDelay = 3000, random = true)
)
public String callService() {
return externalService.getData();
}
Exponential Random Backoff
Combine exponential growth with randomization for the most resilient backoff strategy. The delay grows exponentially, but each value is randomized within a range.
// Exponential backoff with jitter — ideal for high-concurrency scenarios
@Retryable(
maxAttempts = 5,
backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000, random = true)
)
public String callService() {
return externalService.getData();
}
@Recover: Fallback Methods
When all retry attempts are exhausted, the @Recover
Basic Recovery
@Service
public class InventoryService {
@Retryable(retryFor = ResourceAccessException.class, maxAttempts = 3)
public int getStockCount(String productId) {
return inventoryClient.getStock(productId);
}
@Recover
public int recoverStockCount(ResourceAccessException e, String productId) {
log.warn("Inventory service unavailable for product {}. Returning cached value.", productId);
return cacheService.getCachedStock(productId);
}
}
Matching Rules
The @Recover
- The return type must match the
method's return type@Retryable - The first parameter must be the exception type (or a parent type) that triggered the recovery
- The remaining parameters must match the
method's parameters in order and type@Retryable - The method must be in the same class as the
method@Retryable
Multiple Recovery Methods
You can define multiple @Recover
@Service
public class CatalogService {
@Retryable(
retryFor = { ResourceAccessException.class, HttpServerErrorException.class },
maxAttempts = 3
)
public List<Product> searchProducts(String query) {
return catalogClient.search(query);
}
@Recover
public List<Product> recoverFromNetworkError(ResourceAccessException e, String query) {
log.warn("Network error searching for '{}'. Returning cached results.", query);
return cacheService.getCachedProducts(query);
}
@Recover
public List<Product> recoverFromServerError(HttpServerErrorException e, String query) {
log.error("Server error {} searching for '{}'.", e.getStatusCode(), query);
return Collections.emptyList();
}
}
Spring selects the most specific @RecoverResourceAccessExceptionHttpServerErrorException
@RecoverExceptionRetryTemplate: Programmatic Retry
When you need more control than @RetryableRetryTemplate
Creating a RetryTemplate Bean
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
@Configuration
public class RetryConfig {
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate template = new RetryTemplate();
// Retry up to 4 times
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
retryPolicy.setMaxAttempts(4);
template.setRetryPolicy(retryPolicy);
// Exponential backoff: 1s, 2s, 4s
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(1000);
backOffPolicy.setMultiplier(2.0);
backOffPolicy.setMaxInterval(10000);
template.setBackOffPolicy(backOffPolicy);
return template;
}
}
Using RetryTemplate with RetryCallback
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
@Service
public class DataSyncService {
private final RetryTemplate retryTemplate;
private final ExternalApi externalApi;
public DataSyncService(RetryTemplate retryTemplate, ExternalApi externalApi) {
this.retryTemplate = retryTemplate;
this.externalApi = externalApi;
}
public SyncResult syncData(String datasetId) {
return retryTemplate.execute(context -> {
log.info("Sync attempt {} for dataset {}", context.getRetryCount() + 1, datasetId);
return externalApi.sync(datasetId);
});
}
}
RetryTemplate with RecoveryCallback
Pass a second lambda as the recovery callback: this is the programmatic equivalent of @Recover
public SyncResult syncData(String datasetId) {
return retryTemplate.execute(
// RetryCallback — the operation to retry
context -> {
log.info("Attempt {} for dataset {}", context.getRetryCount() + 1, datasetId);
return externalApi.sync(datasetId);
},
// RecoveryCallback — invoked when all retries are exhausted
context -> {
log.error("All retries failed for dataset {}. Last error: {}",
datasetId, context.getLastThrowable().getMessage());
return SyncResult.failed("Sync failed after " + context.getRetryCount() + " attempts");
}
);
}
RetryTemplate with Listeners
Attach listeners to hook into the retry lifecycle for logging, metrics, or alerting.
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;
public class LoggingRetryListener implements RetryListener {
private static final Logger log = LoggerFactory.getLogger(LoggingRetryListener.class);
@Override
public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
log.info("Retry operation starting");
return true; // return false to abort before the first attempt
}
@Override
public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable t) {
log.warn("Attempt {} failed: {}", context.getRetryCount(), t.getMessage());
}
@Override
public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable t) {
if (t == null) {
log.info("Retry operation succeeded after {} attempts", context.getRetryCount());
} else {
log.error("Retry operation exhausted after {} attempts", context.getRetryCount());
}
}
}
// Register the listener on the RetryTemplate
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate template = new RetryTemplate();
template.setRetryPolicy(new SimpleRetryPolicy(4));
template.setBackOffPolicy(new ExponentialBackOffPolicy());
template.registerListener(new LoggingRetryListener());
return template;
}
RetryTemplateRetry Policies
Retry policies determine whether a failed operation should be retried. Spring Retry ships with several built-in policies.
SimpleRetryPolicy
Retries a fixed number of times regardless of the exception type. This is the default policy used by @Retryable
import org.springframework.retry.policy.SimpleRetryPolicy;
SimpleRetryPolicy policy = new SimpleRetryPolicy();
policy.setMaxAttempts(5); // 1 initial + 4 retries
SimpleRetryPolicy with Exception Map
Control which exceptions are retryable by passing a map of exception types to boolean values.
import org.springframework.retry.policy.SimpleRetryPolicy;
import java.util.Map;
import java.util.HashMap;
Map<Class<? extends Throwable>, Boolean> exceptionMap = new HashMap<>();
exceptionMap.put(ResourceAccessException.class, true); // retry
exceptionMap.put(HttpServerErrorException.class, true); // retry
exceptionMap.put(IllegalArgumentException.class, false); // do not retry
SimpleRetryPolicy policy = new SimpleRetryPolicy(4, exceptionMap);
MaxAttemptsRetryPolicy
A simpler alternative that only specifies the maximum number of attempts.
import org.springframework.retry.policy.MaxAttemptsRetryPolicy;
MaxAttemptsRetryPolicy policy = new MaxAttemptsRetryPolicy(3);
ExceptionClassifierRetryPolicy
Uses a classifier to select different retry policies based on the exception type. This allows fine-grained control: for example, retry network errors 5 times but server errors only 2 times.
import org.springframework.retry.policy.ExceptionClassifierRetryPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.policy.NeverRetryPolicy;
import org.springframework.classify.SubclassClassifier;
import java.util.Map;
import java.util.HashMap;
ExceptionClassifierRetryPolicy classifierPolicy = new ExceptionClassifierRetryPolicy();
Map<Class<? extends Throwable>, org.springframework.retry.RetryPolicy> policyMap = new HashMap<>();
policyMap.put(ResourceAccessException.class, new SimpleRetryPolicy(5));
policyMap.put(HttpServerErrorException.class, new SimpleRetryPolicy(2));
policyMap.put(IllegalArgumentException.class, new NeverRetryPolicy());
classifierPolicy.setPolicyMap(policyMap);
CompositeRetryPolicy
Combines multiple policies. In optimistic mode (default), a retry is attempted if any policy allows it. In pessimistic mode, all policies must agree.
import org.springframework.retry.policy.CompositeRetryPolicy;
CompositeRetryPolicy composite = new CompositeRetryPolicy();
composite.setPolicies(new org.springframework.retry.RetryPolicy[]{
new SimpleRetryPolicy(5),
new TimeoutRetryPolicy() // also limits by total elapsed time
});
composite.setOptimistic(false); // pessimistic — ALL policies must allow
Backoff Policies
Backoff policies control the delay between retry attempts when using RetryTemplate
FixedBackOffPolicy
A constant delay between each retry.
import org.springframework.retry.backoff.FixedBackOffPolicy;
FixedBackOffPolicy backOff = new FixedBackOffPolicy();
backOff.setBackOffPeriod(2000); // 2 seconds between retries
ExponentialBackOffPolicy
The delay doubles (or grows by a custom multiplier) after each retry, up to a maximum interval.
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
backOff.setInitialInterval(500); // first delay: 500ms
backOff.setMultiplier(2.0); // each subsequent delay doubles
backOff.setMaxInterval(10000); // cap at 10 seconds
// Delays: 500ms, 1000ms, 2000ms, 4000ms, 8000ms, 10000ms, 10000ms...
ExponentialRandomBackOffPolicy
Adds randomization (jitter) to exponential backoff. This is the most robust choice for distributed systems because it prevents synchronized retries from multiple clients.
import org.springframework.retry.backoff.ExponentialRandomBackOffPolicy;
ExponentialRandomBackOffPolicy backOff = new ExponentialRandomBackOffPolicy();
backOff.setInitialInterval(1000);
backOff.setMultiplier(2.0);
backOff.setMaxInterval(15000);
// Each delay is randomized between the previous and next exponential value
UniformRandomBackOffPolicy
Generates a random delay within a fixed range for each retry.
import org.springframework.retry.backoff.UniformRandomBackOffPolicy;
UniformRandomBackOffPolicy backOff = new UniformRandomBackOffPolicy();
backOff.setMinBackOffPeriod(500); // minimum 500ms
backOff.setMaxBackOffPeriod(3000); // maximum 3 seconds
Applying Backoff Policies to RetryTemplate
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate template = new RetryTemplate();
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(5);
template.setRetryPolicy(retryPolicy);
ExponentialRandomBackOffPolicy backOffPolicy = new ExponentialRandomBackOffPolicy();
backOffPolicy.setInitialInterval(1000);
backOffPolicy.setMultiplier(2.0);
backOffPolicy.setMaxInterval(10000);
template.setBackOffPolicy(backOffPolicy);
return template;
}
Retry with Circuit Breaker
Retry alone can be harmful during prolonged outages: it hammers the failing service with repeated requests. Combining retry with a circuit breaker prevents this by stopping retries entirely when the failure rate is too high.
Spring Retry includes a built-in circuit breaker via @CircuitBreaker@Retryable
import org.springframework.retry.annotation.CircuitBreaker;
import org.springframework.retry.annotation.Recover;
import org.springframework.stereotype.Service;
@Service
public class NotificationService {
private final SmsGateway smsGateway;
public NotificationService(SmsGateway smsGateway) {
this.smsGateway = smsGateway;
}
@CircuitBreaker(
maxAttempts = 3,
openTimeout = 15000, // circuit stays open for 15 seconds
resetTimeout = 30000 // circuit resets after 30 seconds
)
public void sendSms(String phoneNumber, String message) {
smsGateway.send(phoneNumber, message);
}
@Recover
public void recoverSendSms(Exception e, String phoneNumber, String message) {
log.error("SMS circuit open. Queuing message for {} : {}", phoneNumber, e.getMessage());
messageQueue.enqueue(new SmsMessage(phoneNumber, message));
}
}
How the Spring Retry circuit breaker works:
- CLOSED: the method executes normally with retries (up to
)maxAttempts - OPEN: if failures occur within the
window, the circuit opens and immediately delegates toopenTimeout without attempting the method@Recover - HALF_OPEN: after
elapses, the next call is allowed through. If it succeeds, the circuit closes; if it fails, it reopensresetTimeout
Using RetryTemplate with CircuitBreakerRetryPolicy
import org.springframework.retry.policy.CircuitBreakerRetryPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
@Bean
public RetryTemplate circuitBreakerRetryTemplate() {
RetryTemplate template = new RetryTemplate();
CircuitBreakerRetryPolicy circuitBreaker = new CircuitBreakerRetryPolicy(
new SimpleRetryPolicy(3)
);
circuitBreaker.setOpenTimeout(15000); // 15 seconds
circuitBreaker.setResetTimeout(30000); // 30 seconds
template.setRetryPolicy(circuitBreaker);
return template;
}
Best Practices
1. Ensure operations are idempotent
Any operation you retry must be safe to repeat. For HTTP calls, use idempotency keys. For database writes, use upserts or check-before-write patterns. If you cannot make an operation idempotent, do not retry it.
2. Log every retry attempt
Retries can mask underlying problems. Use a RetryListener@Retryable
@Retryable(
retryFor = ResourceAccessException.class,
maxAttempts = 3,
listeners = "loggingRetryListener"
)
public String fetchData(String endpoint) {
return restTemplate.getForObject(endpoint, String.class);
}
3. Only retry transient exceptions
Always specify retryForNullPointerExceptionIllegalArgumentException
4. Use exponential backoff with jitter
Fixed-interval retries can overwhelm a recovering service. Exponential backoff spreads out retry load, and jitter prevents synchronized retry storms from multiple instances.
5. Set a maxDelay
Without a cap, exponential backoff can produce delays of minutes or hours. Always set maxDelay
6. Keep maxAttempts low
Three to five attempts is usually sufficient. More than that suggests the failure is not transient, and a circuit breaker would be more appropriate.
7. Always define a @Recover method
Without a recovery method, the last exception propagates to the caller after retries are exhausted. A @Recover
8. Test your retry configuration
Write integration tests that simulate failures and verify that the correct number of retries occurs, the backoff timing is reasonable, and the recovery method is invoked.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.web.client.ResourceAccessException;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest
class PaymentServiceRetryTest {
@Autowired
private PaymentService paymentService;
@MockBean
private PaymentGateway paymentGateway;
@Test
void shouldRetryAndRecover() {
when(paymentGateway.charge(any()))
.thenThrow(new ResourceAccessException("Connection refused"));
PaymentResult result = paymentService.processPayment(new PaymentRequest("order-1"));
// Verify 3 attempts were made (maxAttempts = 3)
verify(paymentGateway, times(3)).charge(any());
assertThat(result.isSuccess()).isFalse();
}
@Test
void shouldSucceedOnSecondAttempt() {
when(paymentGateway.charge(any()))
.thenThrow(new ResourceAccessException("Timeout"))
.thenReturn(PaymentResult.success("txn-123"));
PaymentResult result = paymentService.processPayment(new PaymentRequest("order-2"));
verify(paymentGateway, times(2)).charge(any());
assertThat(result.isSuccess()).isTrue();
}
}
9. Combine with circuit breaker for production resilience
Retries handle individual transient failures. Circuit breakers handle sustained outages. Together they provide a complete fault tolerance strategy: retry a few times for quick recovery, then trip the circuit breaker to stop wasting resources on a service that is clearly down.
10. Be cautious with retry on write operations
Read operations are naturally idempotent, but writes require careful design. Consider these patterns:
- Use idempotency keys in API calls so the server deduplicates repeated requests
- Check the resource state before retrying a create operation to avoid duplicates
- Use database transactions with appropriate isolation levels for retry safety
- For messaging systems, enable producer idempotence (e.g. Kafka's
)enable.idempotence=true