Contents
- Why Retry?
- Setup & Dependencies
- @Retryable — Declarative Retry
- @Backoff — Delay Strategies
- @Recover — Fallback Methods
- RetryTemplate — Programmatic Retry
- Retry Policies
- Backoff Policies
- Retry with Circuit Breaker
- Best Practices
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)
Never retry non-idempotent operations blindly. If a payment call times out, you cannot be certain whether the charge was applied. Use idempotency keys or check the state before retrying.
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 to a configuration class or directly on your @SpringBootApplication class.
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 activates the AOP advice that intercepts @Retryable method calls. Without it, the annotation is silently ignored and no retries occur.
Annotate any public method in a Spring-managed bean with @Retryable to enable automatic retries on failure.
Basic Usage
By default, @Retryable retries up to 3 times (the initial call plus 2 retries) on any Exception, with a 1-second fixed delay between attempts.
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 to control how many times the method is invoked in total (including the first call).
@Retryable(maxAttempts = 5)
public String fetchData(String endpoint) {
return restTemplate.getForObject(endpoint, String.class);
}
Targeting Specific Exceptions
Use retryFor (or include) to retry only on certain exception types. Use noRetryFor (or exclude) to skip retries for specific exceptions.
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 (ResourceAccessException) and server errors (HttpServerErrorException), but immediately propagates an IllegalArgumentException without any retry.
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 only works on public methods called through the Spring proxy. Self-invocation (calling the method from within the same class) bypasses the proxy and no retries occur.
The @Backoff annotation controls the delay between retry attempts. It supports fixed delays, exponential backoff, and randomized 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. This gradually increases wait times, giving the downstream service more time to recover.
// 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 to cap the backoff duration. Without a cap, exponential growth can produce extremely long waits.
// 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 to add jitter to the delay. This prevents the thundering herd problem where many clients retry simultaneously after a shared failure.
// 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();
}
Exponential backoff with jitter is the recommended strategy for production systems. It prevents synchronized retry storms and distributes retry load evenly over time.
When all retry attempts are exhausted, the @Recover method provides a fallback. Spring matches the recovery method based on the exception type and the original method's parameters and return type.
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 method must satisfy these conditions:
- The return type must match the @Retryable method's return type
- The first parameter must be the exception type (or a parent type) that triggered the recovery
- The remaining parameters must match the @Retryable method's parameters in order and type
- The method must be in the same class as the @Retryable method
Multiple Recovery Methods
You can define multiple @Recover methods to handle different exception types with different fallback logic.
@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 @Recover method based on the exception type. If a ResourceAccessException causes the final failure, the first recovery method is invoked. If a HttpServerErrorException is the cause, the second is invoked.
If no matching @Recover method exists, the exception from the last retry attempt propagates to the caller. Always define a recovery method for each exception type you retry on, or use a broad parent type like Exception.
When you need more control than @Retryable provides — such as conditional retries based on response content, dynamic policy selection, or retrying a block of code rather than a single method — use RetryTemplate directly.
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;
}
RetryTemplate is thread-safe and can be shared across multiple beans. Define it once as a Spring bean and inject it wherever needed.
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 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 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, which is a specialized form of @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 openTimeout window, the circuit opens and immediately delegates to @Recover without attempting the method
- HALF_OPEN — after resetTimeout elapses, the next call is allowed through. If it succeeds, the circuit closes; if it fails, it reopens
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;
}
For more advanced circuit breaker features (sliding windows, failure rate thresholds, half-open call limits), consider using Resilience4j alongside Spring Retry. Spring Retry's circuit breaker is simpler but sufficient for many use cases.
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 or add logging inside @Retryable methods to track retry counts, exception types, and timing. This visibility is critical for diagnosing intermittent issues.
@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 retryFor to target transient failures. Retrying a NullPointerException, IllegalArgumentException, or HTTP 400 wastes time because the result will be the same every time.
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 to a reasonable upper bound (e.g. 10–30 seconds).
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 method lets you return a safe default, throw a domain-specific exception, or queue the work for later processing.
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)