Retries with @Retryable
Retry transient failures with Spring Framework 7's built-in @Retryable: which failures to retry, how to back off, and what never to retry.
@EnableResilientMethods // on a @Configuration class
@Retryable(includes = SomeException.class, maxRetries = 3, delay = 200, multiplier = 2, maxDelay = 2000, jitter = 50)
public Result call() { ... }Some failures go away if you simply try again a moment later: a dropped connection, a service restarting during a deploy, a brief 503 from an overloaded gateway. Spring Framework 7 has retry support in the core framework. @Retryable lives in org.springframework.resilience.annotation; no extra library is needed.
Switching it on
Add @EnableResilientMethods to a configuration class. Spring Boot does not turn it on for you. Without it the annotation is silently ignored and every call runs exactly once.
@Retryable(includes = ResourceAccessException.class,
maxRetries = 3, delay = 200, multiplier = 2, jitter = 50, maxDelay = 2000)
public Rate rate(String from, String to) { ... }The defaults are three retries (so four attempts in total) with a fixed one-second delay, for any exception. Always narrow that down. includes and excludes take exception types; for anything subtler, point predicate at a MethodRetryPredicate class. Delays are in milliseconds unless you change timeUnit. multiplier gives exponential back-off, maxDelay caps it and jitter adds randomness so a hundred instances do not all retry in the same millisecond. timeout caps the total time spent retrying: once it has passed no further attempt starts, though an attempt already running is not interrupted. The default of 0 means no cap.
When the last attempt fails, the method throws that attempt's exception, so your normal error handling still applies.
What to retry
- Retry network failures (
ResourceAccessException), 502, 503, 504 and 429. - Do not retry other 4xx responses. A 400 or 404 will fail the same way every time; retrying just multiplies load and latency.
- Only retry operations that are safe to repeat. A
GETis. APOSTthat charges a card is not, unless the API accepts an idempotency key and you send the same key on each attempt.
The traps
Like @Transactional, retries work through a proxy that Spring wraps around the bean, and for a class without an interface that proxy is a generated subclass. So a retry only happens when the call arrives from another bean through that proxy, on a method that is not final: a call from inside the same class skips it, and a final method runs once with nothing but a startup log warning, while a final class or a record cannot be subclassed, so the application fails to start or, if the class implements an interface, starts with no retries at all. Keep retried methods outside transactions, or every attempt holds a database connection while it sleeps. Count the worst case: four attempts with a 5 second read timeout and growing delays can take well over 20 seconds, longer than your own caller will wait. Retries at several layers multiply too: three layers of three retries is 64 calls to the bottom service.
Methods returning a reactive type such as Mono are retried by resubscribing to the pipeline, without blocking. For a retry around a few lines rather than a whole method, use RetryTemplate from org.springframework.core.retry.
Example
import java.math.BigDecimal;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
record ExchangeRate(String from, String to, BigDecimal rate) {
}
(proxyBeanMethods = false)
class ResilienceConfig {
}
public class ExchangeRateClient {
private final RestClient rest;
ExchangeRateClient(RestClient.Builder builder, ("${app.rates.base-url}") String baseUrl) {
this.rest = builder.baseUrl(baseUrl).build();
}
// Up to 4 attempts: waits of roughly 200, 400 and 800 ms. The jitter range grows with the
// delay (50 ms at the first wait, scaled by the multiplier after that).
// Only transient failures: network errors, 502, 503, 504 and 429. A plain 500 is not retried.
(includes = {ResourceAccessException.class,
HttpServerErrorException.BadGateway.class,
HttpServerErrorException.ServiceUnavailable.class,
HttpServerErrorException.GatewayTimeout.class,
HttpClientErrorException.TooManyRequests.class},
maxRetries = 3, delay = 200, multiplier = 2, maxDelay = 2000, jitter = 50)
public ExchangeRate rate(String from, String to) {
return rest.get()
.uri("/rates/{from}/{to}", from, to)
.retrieve()
.requiredBody(ExchangeRate.class);
}
}
When to use it
- Retry a currency-rate lookup that occasionally fails while the provider's load balancer swaps instances.
- Retry 429 and 503 responses from an address lookup API with exponential back-off and jitter, but fail fast on 400.
- Lower retry counts through configuration during an incident so your service stops adding load to a struggling dependency.
More examples
A predicate for transient failures only
Exception types alone cannot tell a 503 from a 500, because both are <code>HttpServerErrorException</code>. A <code>MethodRetryPredicate</code> sees the actual failure and the method, so it can retry by status code. A plain 500 is left out on purpose: it usually means a bug in the other service, and hammering it will not fix the bug.
import java.lang.reflect.Method;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.resilience.retry.MethodRetryPredicate;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
record Address(String line1, String town, String postcode) {
}
@Service
public class AddressLookupClient {
public static class TransientFailure implements MethodRetryPredicate {
@Override
public boolean shouldRetry(Method method, Throwable failure) {
if (failure instanceof ResourceAccessException) {
return true; // network problem or timeout
}
if (failure instanceof HttpStatusCodeException ex) {
int status = ex.getStatusCode().value();
return status == 429 || status == 502 || status == 503 || status == 504;
}
return false; // 400, 404, 500 and bugs: fail fast
}
}
private final RestClient rest;
AddressLookupClient(RestClient.Builder builder, @Value("${app.address.base-url}") String baseUrl) {
this.rest = builder.baseUrl(baseUrl).build();
}
@Retryable(predicate = TransientFailure.class, maxRetries = 4, delay = 300, multiplier = 2, jitter = 100)
public Address lookup(String postcode) {
return rest.get().uri("/addresses/{postcode}", postcode).retrieve().requiredBody(Address.class);
}
}
Programmatic retries with RetryTemplate
<code>RetryTemplate</code> wraps exactly the lines you pass it, so there is no proxy and no self-invocation trap. <code>execute</code> throws a checked <code>RetryException</code> whenever it does not return: after the last retry, and also straight away for a failure the policy does not include, such as a 404. Its cause is the last failure, so check it before deciding what to throw, or a missing invoice gets reported as an outage. Build the template once in the constructor and reuse it, as it holds no per-call state.
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.retry.RetryException;
import org.springframework.core.retry.RetryPolicy;
import org.springframework.core.retry.RetryTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
record Invoice(String id, String status) {
}
class InvoiceServiceUnavailableException extends RuntimeException {
InvoiceServiceUnavailableException(Throwable cause) {
super("Invoice service unavailable after retries", cause);
}
}
@Service
public class InvoiceFetcher {
private final RestClient rest;
private final RetryTemplate retry;
InvoiceFetcher(RestClient.Builder builder, @Value("${app.invoices.base-url}") String baseUrl) {
this.rest = builder.baseUrl(baseUrl).build();
this.retry = new RetryTemplate(RetryPolicy.builder()
.includes(ResourceAccessException.class)
.maxRetries(2)
.delay(Duration.ofMillis(250))
.multiplier(2)
.build());
}
public Invoice fetch(String id) {
try {
return retry.execute(() -> rest.get()
.uri("/invoices/{id}", id)
.retrieve()
.requiredBody(Invoice.class));
} catch (RetryException ex) {
// Also thrown at once, without retries, for failures the policy does not include.
if (ex.getCause() instanceof ResourceAccessException) {
throw new InvoiceServiceUnavailableException(ex.getCause());
}
if (ex.getCause() instanceof RuntimeException runtime) {
throw runtime; // e.g. a 404: not an availability problem
}
throw new IllegalStateException(ex.getCause());
}
}
}
Retry settings from configuration
Every numeric attribute has a <code>...String</code> twin that accepts a <code>${...}</code> placeholder. This lets operations staff dial retries down during an incident, when extra traffic is the last thing a struggling service needs, without a new build. The default after the colon keeps the method working when the property is missing.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
record Forecast(String region, int temperature) {
}
@Service
public class WeatherClient {
private final RestClient rest;
WeatherClient(RestClient.Builder builder, @Value("${app.weather.base-url}") String baseUrl) {
this.rest = builder.baseUrl(baseUrl).build();
}
// app.weather.max-retries=5 in one environment, 1 in another; 2 when unset.
@Retryable(includes = ResourceAccessException.class,
maxRetriesString = "${app.weather.max-retries:2}",
delayString = "${app.weather.retry-delay-ms:500}",
multiplier = 2)
public Forecast forecast(String region) {
return rest.get().uri("/forecast/{region}", region).retrieve().requiredBody(Forecast.class);
}
}
Discussion