Networks drop packets. Services rate-limit. Databases hit a deadlock and roll one transaction back. A great many failures are transient, and the correct response is to try again in a moment.
The dangerous part is that retrying an operation that already succeeded is how you charge someone twice.
Spring Framework 7 has retry built in
This changed in Boot 4, and it is worth being precise about because most material online describes the old library:
// Boot 3 + the separate spring-retry dependency
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.annotation.Retryable;
// Boot 4 / Framework 7 — in the core container, no extra dependency
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.resilience.annotation.Retryable;<!-- No spring-retry dependency on purpose: Spring Framework 7 ships retry in
the core container (org.springframework.resilience.annotation.Retryable
plus @EnableResilientMethods), so the separate library is now legacy. -->You still need spring-boot-starter-aspectj, because this is proxy-based like
everything else in lesson 8.
// Spring Framework 7 moved retry into the core container. @EnableResilientMethods activates
// org.springframework.resilience.annotation.Retryable (and @ConcurrencyLimit), which is why this
// project needs no spring-retry dependency and no @EnableRetry.
@EnableResilientMethods
public class ThreadPoolConfig { }The annotation
@Retryable(
includes = {ApiConnectionException.class, RateLimitException.class},
maxRetries = 3,
delay = 200,
multiplier = 2.0,
maxDelay = 2000,
jitter = 100)
public PaymentIntent retrieve(String paymentIntentId) throws StripeException {
requireConfigured();
return stripe.paymentIntents().retrieve(paymentIntentId);
}| Attribute | Meaning |
|---|---|
includes | which exceptions are worth retrying |
excludes | which are not, when includes is too
broad |
maxRetries | attempts after the first |
delay | initial wait, in ms |
multiplier | backoff factor |
maxDelay | ceiling on the wait |
jitter | random ± to spread retries out |
So this one waits roughly 200ms, 400ms, 800ms, each ±100ms, capped at 2s.
Why backoff, and why jitter
Backoff because whatever failed is probably still failing. Three immediate retries just deliver three more requests to a struggling service.
Jitter because of the thundering herd. If a service goes down for two seconds, every client that failed retries at exactly 200ms, then exactly 400ms — synchronised waves of traffic hitting it the instant it recovers, knocking it over again. Randomising spreads them out. It looks like a detail and it is the difference between a blip and an outage.
⚠️ Retry the right exceptions
/**
* <p>Note which exceptions are listed. A dropped connection or a rate limit is worth trying
* again — the request may well succeed a moment later. An {@code InvalidRequestException} is
* not: the request was malformed, and sending it three more times just makes the same mistake
* three more times, slower. Retrying the wrong exception turns a fast failure into a slow one.
*/The test is simple: could this succeed if I tried again unchanged?
- Yes — connection reset, timeout, 429, 503, deadlock. Retry.
- No — 400, 401, 404, validation failure, a null pointer. Never retry.
Retrying a 400 is worse than not retrying: the caller waits four times as long for the same failure, and you have quadrupled load on a service that was already telling you to stop.
⚠️ The part that matters: idempotency
A retry is only safe if repeating the operation is safe. For a read, it always is. For a write, it usually is not — and the failure mode is a customer charged twice.
Consider: you call Stripe, Stripe creates the PaymentIntent, and the response is lost to a network blip. Your client sees a connection failure. It retries. Stripe has no idea this is the same request and creates a second PaymentIntent.
The fix is an idempotency key:
@Retryable(
includes = {ApiConnectionException.class, RateLimitException.class},
maxRetries = 3,
delay = 200,
multiplier = 2.0,
maxDelay = 2000,
jitter = 100)
public PaymentIntent createPaymentIntent(BigDecimal amount, UUID orderId, String receiptEmail)
throws StripeException {
// … build the params …
// THE reason this method is safe to retry. Without an idempotency key, a request that
// succeeded at Stripe but whose response was lost to a network blip would be retried and
// create a SECOND PaymentIntent — the classic double-charge. Keyed on our own order id,
// Stripe recognises the replay and returns the original PaymentIntent instead.
RequestOptions options = RequestOptions.builder()
.setIdempotencyKey("order-" + orderId)
.build();
return stripe.paymentIntents().create(params.build(), options);
}The order's own UUID is a natural key: it is unique, stable across retries, and already exists. The provider stores the key with the result and returns the original response for a replay.
The order of operations in the pizza API supports this — the order row is saved before Stripe is called, precisely so the id exists to key on:
// Saved BEFORE talking to Stripe, so the PaymentIntent can carry a real order id and an
// abandoned checkout still leaves a record.
CustomerOrder saved = orderDAO.save(order);Without an idempotency mechanism, do not retry a write. Retry the read that tells you whether it succeeded, then decide.
⚠️ There is no @Recover
This is the main behavioural difference from the old spring-retry library, and it is
easy to trip over when migrating. Framework 7's built-in retry rethrows the last exception
once attempts are exhausted — there is no fallback-method mechanism.
Handle it at the call site:
try {
PaymentIntent intent = stripeService.createPaymentIntent(
saved.getTotal(), saved.getPublicId(), saved.contactEmail());
saved.setStripePaymentIntentId(intent.getId());
orderDAO.save(saved);
clientSecret = intent.getClientSecret();
} catch (StripeException ex) {
log.error("Stripe rejected the PaymentIntent for order {}", saved.getPublicId(), ex);
throw ApiException.badRequest("Could not start payment: " + ex.getMessage());
}If you genuinely need a declarative fallback, spring-retry and its
@Recover still work on Boot 4 — you just have to add the dependency and use its
annotations, not the built-in ones. Do not mix the two.
⚠️ Retry and transactions
@Retryable(includes = SQLException.class)
@Transactional // WRONG ORDER
public void doWork() { }Both are proxies, and the order decides whether the retry is inside or outside the transaction. If retry is inside, every attempt runs in the same already-doomed transaction — once it is marked rollback-only, retrying achieves nothing.
The safe arrangement is retry on the outside, transaction on the inside, in separate beans:
@Service
@RequiredArgsConstructor
public class OrderRetryFacade {
private final OrderWorker worker;
@Retryable(includes = CannotAcquireLockException.class, maxRetries = 3, delay = 100)
public void process(UUID id) {
worker.processInTransaction(id); // separate bean → real proxy → new transaction
}
}Separate beans, because a self-invocation would bypass both proxies (lesson 8).
Retry is not a circuit breaker
Retries help with brief failures. When a dependency is properly down, retrying makes it worse — every client tripling its request rate against a service that cannot answer.
A circuit breaker notices sustained failure and stops calling for a while, failing
fast instead. Framework 7 ships @ConcurrencyLimit for bounding in-flight calls;
Resilience4j remains the standard choice for full circuit breaking, bulkheads and rate limiting.
Retry for the blip, circuit-break for the outage. Most systems need both, and retry alone is the more common mistake.
What to take from this
- Framework 7 has retry built in — no
spring-retry, and@EnableResilientMethodsrather than@EnableRetry. - Retry only what could succeed unchanged.
- Backoff with jitter, or you build a thundering herd.
- Never retry a write without an idempotency key.
- No
@Recover— handle exhaustion at the call site. - Retry outside, transaction inside, in separate beans.
Next: messaging with JMS — handing work to a queue so the request can return.