A retry repeats an operation after a transient failure, up to a defined limit. A shipping provider might briefly time out while a checkout asks for a rate. A second read-only request can succeed, but a malformed destination will not become valid through repetition. This tutorial distinguishes those failures and tests the resulting call counts with Spring Boot 4.1.2.
Spring Framework 7 includes a Retryable annotation. Its package and attempt-count options differ from the older Spring Retry module. The example below uses the current Framework 7 feature throughout.
1. Add Dependencies and Enable Retry
The application needs Spring Boot’s core starter and AspectJ support for its method proxy. The test starter provides JUnit and Mockito. The runnable project’s Spring Boot 4.1.2 parent manages the dependency versions.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Add EnableResilientMethods to the application class. Without it, the retry annotation remains metadata and the service method runs only once.
@SpringBootApplication
@EnableResilientMethods
public class RetryApplication {
public static void main(String[] args) {
SpringApplication.run(RetryApplication.class, args);
}
}
Both annotations appear in the runnable project. The Retryable annotation below belongs to org.springframework.resilience.annotation, not org.springframework.retry.annotation.
2. Create a Shipping-Rate Lookup
The checkout asks a gateway for a destination’s shipping rate. A temporary provider failure raises TransientRateException. A blank destination raises IllegalArgumentException. We retry only the temporary failure because another call cannot repair invalid input.
public interface RateGateway {
int fetchRate(String destination);
}
public class TransientRateException extends RuntimeException {
public TransientRateException(String message) {
super(message);
}
}
@Component
public class DemoRateGateway implements RateGateway {
@Override
public int fetchRate(String destination) {
if (destination == null || destination.isBlank()) {
throw new IllegalArgumentException("Destination is required");
}
return 12;
}
}
The demo gateway normally returns 12. The tests replace it with a mock that fails on specific calls, so retry behavior can be observed without a live shipping provider.
The service calls the gateway. Its includes option selects the exception to retry. maxRetries = 2 permits two retries after the initial call, for at most three calls. A zero delay keeps this test example fast; production delay and overall timeout should match the provider’s behavior.
@Service
public class RateService {
private final RateGateway gateway;
public RateService(RateGateway gateway) {
this.gateway = gateway;
}
@Retryable(includes = TransientRateException.class, maxRetries = 2, delay = 0)
public int quote(String destination) {
return gateway.fetchRate(destination);
}
}
Another Spring bean must call quote() through the service proxy. A call from one RateService method to another method on the same instance bypasses proxy interception and therefore bypasses retry.
3. Test Success, Exhaustion, and Invalid Input
The test starts the Spring context and injects RateService, so its call passes through the actual retry proxy. MockitoBean replaces the gateway and gives each attempt a controlled outcome.
@SpringBootTest
class RetryApplicationTest {
@Autowired RateService service;
@MockitoBean RateGateway gateway;
@Test
void retriesTransientFailureThenReturnsRate() {
when(gateway.fetchRate("Delhi"))
.thenThrow(new TransientRateException("Temporary timeout"))
.thenReturn(12);
assertEquals(12, service.quote("Delhi"));
verify(gateway, times(2)).fetchRate("Delhi");
}
}
The first call throws; Spring intercepts the exception and calls quote() again. The second call returns 12. The verification checks that the returned rate followed an actual retry.
If every attempt fails, the third failure reaches the caller. The framework does not silently substitute a fallback value.
when(gateway.fetchRate("Delhi"))
.thenThrow(new TransientRateException("Provider unavailable"));
assertThrows(TransientRateException.class, () -> service.quote("Delhi"));
verify(gateway, times(3)).fetchRate("Delhi");
An invalid destination raises a different exception. It is not included in retry selection, so it escapes after one gateway call.
when(gateway.fetchRate(""))
.thenThrow(new IllegalArgumentException("Destination is required"));
assertThrows(IllegalArgumentException.class, () -> service.quote(""));
verify(gateway, times(1)).fetchRate("");
| Gateway outcome | Calls | Result |
|---|---|---|
| Temporary failure, then rate | 2 | Returns 12 |
| Temporary failure on every attempt | 3 | Throws TransientRateException |
| Invalid destination | 1 | Throws IllegalArgumentException |
4. Handle the Exhausted Failure
The checkout caller decides what to show when the provider remains unavailable. QuoteFacade catches only the transient failure after retries finish and returns a result that marks the rate unavailable. It lets invalid input continue as an error.
@Component
public class QuoteFacade {
private final RateService rateService;
public QuoteFacade(RateService rateService) {
this.rateService = rateService;
}
public record QuoteResult(boolean available, Integer amount) {}
public QuoteResult lookup(String destination) {
try {
return new QuoteResult(true, rateService.quote(destination));
} catch (TransientRateException ex) {
return new QuoteResult(false, null);
}
}
}
The facade calls a separate Spring-managed service, so all three attempts happen before the exception reaches its catch block. The accompanying test verifies that order. A real checkout should display an unavailable quote rather than treating the missing amount as zero.
5. Migrate from the Older Spring Retry API
The separate Spring Retry project was archived in July 2026. Its similarly named annotations cannot be copied into a Framework 7 example without checking imports and options.
| Older Spring Retry | Spring Framework 7 used here |
|---|---|
| org.springframework.retry.annotation.Retryable | org.springframework.resilience.annotation.Retryable |
| EnableRetry | EnableResilientMethods |
| maxAttempts = 3 counts the first call | maxRetries = 2 adds two calls after the first |
| retryFor selects exceptions | includes selects exceptions |
| Recover supplies a recovery method | The final exception propagates to the caller |
Both count settings in the table allow three total calls. Copying maxAttempts = 3 as maxRetries = 3 would allow four. The Spring Framework resilience reference describes the full option set and programmatic retry API.
Retries are suitable only when repeating the operation is safe. A read-only lookup meets that condition. Before retrying a write such as a payment, establish idempotency with the remote provider: a timeout does not prove the first request failed.
6. Frequently Asked Questions
6.1. Does maxRetries = 2 mean two or three calls?
It permits one initial call and two further calls if the selected exception persists. A successful first or second call ends the sequence early.
6.2. Why does my annotated method run only once?
Check EnableResilientMethods and make sure another Spring bean calls the method through its proxy. A direct call within the same class bypasses the proxy.
6.3. Where should a fallback be handled?
With Framework 7’s annotation, the last exception reaches the caller. A caller such as QuoteFacade can distinguish provider unavailability from invalid input and choose the appropriate response.
7. Conclusion
Spring Framework 7 lets a Spring Boot service retry selected transient failures without retrying every error. The shipping-rate tests verify the call count for a successful retry, exhausted retry and permanent input failure. The caller handles an exhausted failure after the retry sequence ends.
The complete Spring Boot retry example contains the Maven project and four integration tests.
Where is the retrytemplate used ?
Added few usecases for reference.