Spring Boot – Retries

August 6, 20265 min readUpdated 8/18/2026

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);
}
AttributeMeaning
includeswhich exceptions are worth retrying
excludeswhich are not, when includes is too broad
maxRetriesattempts after the first
delayinitial wait, in ms
multiplierbackoff factor
maxDelayceiling on the wait
jitterrandom ± 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 @EnableResilientMethods rather 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.