Contents

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:

Retry is not appropriate when:

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:

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:

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: