In this post, we’ll build a retry strategy in Spring Boot using Spring Retry.
· Prerequisites
· Overview
∘ What is Spring Retry?
∘ Why Do We Need Spring Retry?
· Real-World Example Scenario
∘ Example: Online Shopping Website
∘ Without Spring Retry
· Let’s code
∘ Add Dependency
∘ Enabling Spring Retry
∘ Project Structure
∘ Testing
· Advanced Retry Strategies for Production Environments
· Conclusion
· References
Prerequisites
This is the list of all the prerequisites:
- Spring Boot 3
- Maven 3.6.3 or later
- Java 21 or later
- Postman / insomnia or any other API testing tool.
- IntelliJ IDEA, Visual Studio Code, or another IDE
Overview
What is Spring Retry?
Spring Retry is a lightweight library in the Spring ecosystem that provides automatic retry functionality for operations that may fail due to transient issues (temporary problems). It integrates seamlessly with Spring Boot’s auto-configuration and AOP (Aspect-Oriented Programming) capabilities .
Why Do We Need Spring Retry?
In modern applications — especially microservices — failures happen frequently due to:
- Network timeouts
- Temporary database unavailability
- External API rate limits
- Service overload (HTTP 503 errors)
Many of these failures are transient, meaning they might succeed if retried after a short delay.
Real-World Example Scenario
Example: Online Shopping Website
Imagine you are building an online store.
When a customer places an order:
- Your system saves the order.
- Then it calls a Payment Service.
- The Payment Service calls a Bank API.
Now imagine this situation:
- The bank API is temporarily slow.
- It throws a timeout error.
- But after 2 seconds, it works again.
Without Spring Retry
- The first failure stops everything.
- The customer sees: “Payment Failed”
- Customer gets frustrated 😠
Even though the bank was available again 2 seconds later.
Let’s code
We’ll start by creating a simple Spring Boot project from start.spring.io, with the following dependencies: Spring Web, Lombok.

Add Dependency
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Spring Retry requires Aspect-Oriented Programming (AOP) support to work with annotations.
Enabling Spring Retry
Add @EnableRetry to the main application class or any @Configuration class. This activates the AOP proxy that intercepts @Retryable methods.
@SpringBootApplication
@EnableRetry
public class SpringRetryDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringRetryDemoApplication.class, args);
}
}
@EnableRetry registers a RetryOperationsInterceptor as an AOP advice. When a @Retryable method throws a matching exception, the interceptor catches it and invokes the method again according to the configured policy — all transparently, without modifying your business logic.
Project Structure
.
└── main
├── java
│ └── com
│ └── bootlabs
│ └── demo
│ ├── client
│ │ └── BankApiClient.java
│ │
│ ├── controller
│ │ └── OrderController.java
│ │
│ ├── exception
│ │ └── BankApiException.java
│ │
│ ├── service
│ │ ├── OrderService.java
│ │ └── PaymentService.java
│ │
│ └── SpringRetryDemoApplication.java
│
└── resources
├── application.yaml
├── static
└── templates
└── test
└── java
Exception Layer
We create a custom exception to represent bank API failures. It helps distinguish bank errors from other system errors.
public class BankApiException extends RuntimeException {
public BankApiException(String message) {
super(message);
}
}
Client Layer — BankApiClient.java
It acts as a wrapper around the external Bank API, centralizing and encapsulating all communication with it. It handles low-level network concerns such as making HTTP calls, managing endpoints, authentication, and other API-specific details, while converting technical or network-related errors into application-specific exceptions.
@Component
public class BankApiClient {
private static final Logger log = LoggerFactory.getLogger(BankApiClient.class);
// Simulates a counter to track how many times the bank was called
private final AtomicInteger callCount = new AtomicInteger(0);
public String charge(String orderId, double amount) {
int attempt = callCount.incrementAndGet();
log.warn("[BankAPI] Attempt #{} — processing payment for order: {}", attempt, orderId);
// Simulate: first 2 attempts fail, 3rd succeeds
if (attempt <= 2) {
log.error("[BankAPI] Attempt #{} FAILED — Bank timeout simulated", attempt);
throw new BankApiException(
"Bank API timeout on attempt " + attempt
);
}
log.info("[BankAPI] Attempt #{} SUCCEEDED — Payment of ${} approved for order: {}",
attempt, amount, orderId);
// Reset for next test run
callCount.set(0);
return "TXN-" + orderId + "-APPROVED";
}
}
For demonstration purposes, it can also simulate various bank behaviors, ensuring the rest of the application remains decoupled from external service complexities and implementation details.
Payment Service — The Heart of the Solution
This is where Spring Retry does its magic. The @Retryable annotation tells Spring to retry processPayment() up to 3 times when a BankApiException is thrown, waiting 2 seconds between attempts. The @Recover method is the fallback if all retries are exhausted.
@Service
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
private final BankApiClient bankApiClient;
public PaymentService(BankApiClient bankApiClient) {
this.bankApiClient = bankApiClient;
}
/**
* Attempts to charge the bank for an order.
*
* @Retryable configuration:
* - retryFor: Only retry on BankApiException (transient errors)
* - maxAttempts: Try up to 3 times total (1 initial + 2 retries)
* - backoff: Wait 2 seconds between attempts (fixed delay)
*/
@Retryable(
retryFor = {BankApiException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 2000) // 2-second fixed delay
)
public String processPayment(String orderId, double amount) {
log.info("[PaymentService] Attempting payment — Order: {}, Amount: ${}",
orderId, amount);
String transactionId = bankApiClient.charge(orderId, amount);
log.info("[PaymentService] Payment SUCCESS — TxnId: {}", transactionId);
return transactionId;
}
/**
* Called automatically when ALL retry attempts are exhausted.
* Rules for @Recover:
* 1. Must be in the same class as @Retryable
* 2. Return type must match the @Retryable method
* 3. First parameter must be the exception type
* 4. Remaining parameters must match @Retryable method signature
*/
@Recover
public String recoverPayment(BankApiException ex, String orderId, double amount) {
log.error("[PaymentService] ALL RETRIES EXHAUSTED for order: {}. Reason: {}",
orderId, ex.getMessage());
// In production: save to a dead-letter queue, trigger manual review,
// or schedule for async reprocessing
return "PAYMENT_FAILED_PENDING_REVIEW";
}
}
We retry only on
BankApiException— not every exception. This is intentional. AValidationExceptionorInsufficientFundsExceptionshould not be retried because retrying won’t fix them. Always be selective about which exceptions trigger retry.
Order Service — Orchestrating the Flow
Order Service contains the core business logic for order processing and acts as an orchestrator between different services. It is responsible for creating and managing orders, coordinating payment processing with order management, maintaining and updating order states (such as PENDING, COMPLETED, or FAILED).
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
public String placeOrder(String customerId, double amount) {
String orderId = "ORD-" + System.currentTimeMillis();
log.info("[OrderService] Saving order {} for customer {} — amount: ${}",
orderId, customerId, amount);
// Step 1: Save order (in a real app, persist to DB here)
log.info("[OrderService] Order {} saved successfully", orderId);
// Step 2: Process payment — Spring Retry handles failures transparently
String transactionId = paymentService.processPayment(orderId, amount);
if (transactionId.startsWith("PAYMENT_FAILED")) {
log.warn("[OrderService] Payment could not be processed for {}. Queued for review.", orderId);
return "Order placed but payment pending review. Order ID: " + orderId;
}
log.info("[OrderService] Order {} COMPLETED. Transaction: {}", orderId, transactionId);
return "Order confirmed! Order ID: " + orderId + " | Transaction: " + transactionId;
}
}
Order Controller — REST Endpoint
This is the entry point for all HTTP requests from clients (web browsers, mobile apps, etc.).
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<String> placeOrder(@RequestParam String customerId, @RequestParam double amount) {
String result = orderService.placeOrder(customerId, amount);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
}
Testing
Start your application and hit the endpoint.

Watch the logs — you’ll see the retry attempts in real time:

Advanced Retry Strategies for Production Environments
Exponential Backoff
Fixed delays are a good start, but in high-traffic systems they can cause retry storms — all services retrying at exactly the same moment and overwhelming the recovering dependency. Exponential backoff with jitter is the production-grade solution.
@Retryable(
retryFor = {BankApiException.class},
maxAttempts = 5,
backoff = @Backoff(
delay = 1000, // Start: 1 second
multiplier = 2.0, // Each retry doubles the wait
maxDelay = 30000, // Cap at 30 seconds
random = true // Add jitter to prevent retry storms
)
)
public String processPayment(String orderId, double amount) {
// ...
}
RetryTemplate (Programmatic API)
When you need more control — conditional retry logic, dynamic configuration, or usage outside of Spring beans — use RetryTemplate programmatically.
@Slf4j
@Configuration
public class RetryConfig {
@Bean
public RetryTemplate bankApiRetryTemplate() {
return RetryTemplate.builder()
.maxAttempts(3)
.exponentialBackoff(1000, 2, 10000) // initial, multiplier, max
.retryOn(BankApiException.class)
.withListener(new RetryListener() {
@Override
public <T, E extends Throwable> boolean open(
RetryContext context,
RetryCallback<T, E> callback) {
return true; // allow retry process to start
}
@Override
public <T, E extends Throwable> void onError(
RetryContext context,
RetryCallback<T, E> callback,
Throwable throwable) {
log.warn("Retry attempt {} failed: {}",
context.getRetryCount(),
throwable.getMessage());
}
@Override
public <T, E extends Throwable> void close(
RetryContext context,
RetryCallback<T, E> callback,
Throwable throwable) {
// Optional: final logic after retries complete
}
})
.build();
}
}
@Service
public class PaymentServiceV2 {
private final BankApiClient bankApiClient;
private final RetryTemplate retryTemplate;
public PaymentServiceV2(BankApiClient bankApiClient,
RetryTemplate retryTemplate) {
this.bankApiClient = bankApiClient;
this.retryTemplate = retryTemplate;
}
public String processPayment(String orderId, double amount) {
return retryTemplate.execute(
context -> {
// Retry callback
log.info("Payment attempt #{}", context.getRetryCount() + 1);
return bankApiClient.charge(orderId, amount);
},
context -> {
// Recovery callback — called when retries exhausted
log.error("All retries failed for order {}", orderId);
return "PAYMENT_FAILED_PENDING_REVIEW";
}
);
}
}
The RetryTemplate lets you register RetryListener instances, and they are given callbacks with the RetryContext and Throwable (where available during the iteration).
Production Best Practices
- Retry only idempotent operations
Retrying a non-idempotent call (such as a payment charge without deduplication) can lead to double charges. Ensure the Bank API supports idempotency keys before enabling retries. - Use exponential backoff with jitter
This prevents retry storms when a dependency is recovering. Fixed delays can unintentionally synchronize retries across many service instances, amplifying the problem. - Be selective with
retryFor
Only retry transient exceptions (timeouts, connection issues, temporary unavailability). Business logic errors (validation failures, insufficient funds) should fail fast. - Always implement
@Recover
Define a meaningful fallback strategy. For example: enqueue the request into a dead-letter queue, persist it for asynchronous reprocessing, or return a graceful degraded response. - Log every retry attempt
Observability is critical. Use structured logging that includes identifiers likeorderId, retry attempt number, and the exception message. Integrate logs into your monitoring stack. - Add retry metrics
Track retry rates using tools like Micrometer. A sudden spike in retries is often an early warning sign of a downstream issue. Configure alerts accordingly. - Combine with a Circuit Breaker
Use Spring Retry’s@CircuitBreakeror Resilience4j to stop retrying when the failure rate exceeds a defined threshold. This protects the downstream service from overload. - Set a sensible
maxDelay
Cap exponential backoff delays to prevent excessive waiting times. Always pair retry logic with proper HTTP request timeouts.
Conclusion
🏁 Well done !!. In this post, we implemented a production-ready retry mechanism in Spring Boot using Spring Retry.
Spring Retry is a lightweight but powerful addition to any production Spring Boot application. With just @EnableRetry, @Retryable, and @Recover, you transform brittle point-to-point integrations into resilient, self-healing service calls — without cluttering your business logic with retry loops.
The complete source code is available on GitHub.
Support me through GitHub Sponsors.
Thank you for reading!! See you in the next post.