Definition
- @Retryable -- wraps a method so that if it throws a configured exception, Spring re-invokes it up to
maxAttemptstimes with spacing controlled by@Backoff. - retryFor -- lists which exception types trigger a retry, such as
TransientDataAccessExceptionorResourceAccessException. - noRetryFor -- lists exception types that should fail fast without retrying, such as
IllegalArgumentException. - maxAttempts -- total attempts including the first, so
maxAttempts = 4means one initial try plus three retries. - @Backoff -- controls the delay between attempts:
delayis the initial wait,multipliermakes it exponential,maxDelaycaps the growth, andrandomadds jitter. - @Recover -- the fallback method called when all retries are exhausted; its first parameter is the exception, remaining parameters match the original method.
When National Bank of India's nightly FX-rate sync calls the external rate service and it briefly times out, @Retryable is like the clerk trying the phone again a few times with a growing pause between calls (backoff) instead of giving up at once. retryFor says "only redial for a busy signal, not for a wrong number" (retry transient errors, fail fast on permanent ones). @Recover is the standing instruction for when all redials fail: "log it, raise a ticket, and move on to the next currency."
Understanding the Concepts
What is @Retryable and how does it work?
@Retryable is an annotation from the spring-retry library that wraps a method in retry logic. When the method throws a configured exception, Spring catches it and re-invokes the method up to the configured number of attempts. It works through AOP proxies, just like @Transactional.
package com.bank.service;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.dao.TransientDataAccessException;
import org.springframework.web.client.ResourceAccessException;
@Service
public class RateSyncService {
@Retryable(
retryFor = { TransientDataAccessException.class, ResourceAccessException.class },
noRetryFor = { IllegalArgumentException.class },
maxAttempts = 4,
backoff = @Backoff(delay = 500, multiplier = 2, maxDelay = 10_000))
public void syncRateForCurrency(String currencyCode) {
// Calls external rate service -- may throw transient timeout
externalRateClient.fetchRate(currencyCode);
}
}
Expected behavior:
- Attempt 1: call fails with ResourceAccessException (timeout)
- Wait 500ms
- Attempt 2: call fails again
- Wait 1000ms (500 * 2)
- Attempt 3: call fails again
- Wait 2000ms (1000 * 2)
- Attempt 4: call succeeds -- rate synced
How do you enable Spring Retry?
Add the spring-retry dependency and annotate a configuration class with @EnableRetry. This activates the AOP advice that intercepts @Retryable methods.
package com.bank.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.annotation.EnableRetry;
@Configuration
@EnableRetry
public class RetryConfig {
// Enables @Retryable and @Recover across the application
}
<!-- pom.xml --syn-kw">>
syn-kw"><dependencysyn-kw">>
syn-kw"><groupIdsyn-kw">>org.springframework.retrysyn-kw"></groupIdsyn-kw">>
syn-kw"><artifactIdsyn-kw">>spring-retrysyn-kw"></artifactIdsyn-kw">>
syn-kw"></dependencysyn-kw">>
syn-kw"><dependencysyn-kw">>
syn-kw"><groupIdsyn-kw">>org.springframeworksyn-kw"></groupIdsyn-kw">>
syn-kw"><artifactIdsyn-kw">>spring-aspectssyn-kw"></artifactIdsyn-kw">>
syn-kw"></dependencysyn-kw">>
Expected behavior:
- Spring creates AOP proxies around @Retryable methods
- Without @EnableRetry, the annotation is silently ignored
What is the difference between retryFor and noRetryFor?
retryFor encodes the crucial distinction between transient and permanent failures. A transient failure (timeout, connection reset, deadlock) may succeed on retry. A permanent failure (invalid input, business-rule violation, 400 from an API) will never succeed on retry, so retrying wastes time.
| Attribute | Purpose | Example Exceptions |
|-----------|---------|-------------------|
| retryFor | Retry on these transient exceptions | TransientDataAccessException, ResourceAccessException, SocketTimeoutException |
| noRetryFor | Fail fast on these permanent exceptions | IllegalArgumentException, DataIntegrityViolationException, HttpClientErrorException.BadRequest |
package com.bank.service;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
@Service
public class LoanApprovalService {
// WRONG: retries everything, including permanent errors
@Retryable(maxAttempts = 3)
public void approveWrong(String loanId) {
// IllegalArgumentException retried 3 times -- wastes time
}
// CORRECT: retry only transient errors, fail fast on permanent
@Retryable(
retryFor = { java.net.SocketTimeoutException.class },
noRetryFor = { IllegalArgumentException.class },
maxAttempts = 3)
public void approveCorrect(String loanId) {
// Only timeouts are retried; bad input fails immediately
}
}
WRONG approach: IllegalArgumentException retried 3 times, same failure each time
CORRECT approach: IllegalArgumentException fails immediately, no wasted retries
How does @Backoff control retry spacing?
@Backoff controls the delay between attempts. Exponential backoff is the sensible default when retrying a shared dependency, because it gives the struggling service room to recover.
package com.bank.service;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
@Service
public class StatementService {
// Fixed delay: 1 second between each retry
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void generateFixed(String accountId) { }
// Exponential: 500ms, 1000ms, 2000ms, capped at 10s
@Retryable(maxAttempts = 5,
backoff = @Backoff(delay = 500, multiplier = 2, maxDelay = 10_000))
public void generateExponential(String accountId) { }
// Exponential with jitter: avoids thundering herd
@Retryable(maxAttempts = 5,
backoff = @Backoff(delay = 500, multiplier = 2, maxDelay = 10_000, random = true))
public void generateWithJitter(String accountId) { }
}
Exponential backoff timeline:
Attempt 1: immediate
Attempt 2: wait 500ms
Attempt 3: wait 1000ms
Attempt 4: wait 2000ms
Attempt 5: wait 4000ms (capped at maxDelay if exceeded)
How does @Recover work as a fallback?
The @Recover method is called when all retries are exhausted. Its signature must match: the first parameter is the exception type, and the remaining parameters match the original method's arguments. The return type must also match.
package com.bank.service;
import com.bank.repository.FailureRecordRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
@Service
public class FxRateSyncService {
private static final Logger log = LoggerFactory.getLogger(FxRateSyncService.class);
private final FailureRecordRepository failureRepo;
public FxRateSyncService(FailureRecordRepository failureRepo) {
this.failureRepo = failureRepo;
}
@Retryable(
retryFor = ResourceAccessException.class,
maxAttempts = 4,
backoff = @Backoff(delay = 500, multiplier = 2))
public void syncRate(String currencyCode) {
// Calls external rate service -- may timeout
throw new ResourceAccessException("Connection timed out");
}
@Recover
public void recoverSyncRate(ResourceAccessException ex, String currencyCode) {
log.error("Rate sync failed for {} after all retries: {}",
currencyCode, ex.getMessage());
failureRepo.recordFailure("rate-sync", currencyCode, ex.getMessage());
// Do NOT rethrow -- let the job continue with other currencies
}
}
Expected output:
ERROR Rate sync failed for USD after all retries: Connection timed out
-- Failure recorded in database, job continues with EUR, GBP, etc. --
Why must @Retryable methods be called through the proxy?
Spring Retry works through AOP proxies, just like @Transactional. A self-invocation within the same class bypasses the proxy, and the retry logic never fires.
package com.bank.service;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
@Service
public class BrokenRetryService {
// WRONG: self-invocation bypasses the proxy
public void orchestrate() {
syncRate("USD"); // calls this.syncRate() -- no proxy, no retry!
}
@Retryable(maxAttempts = 3)
public void syncRate(String currency) {
throw new RuntimeException("Timeout");
}
}
package com.bank.service;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
// CORRECT: separate bean so the call goes through the proxy
@Service
public class RateSyncWorker {
@Retryable(maxAttempts = 3)
public void syncRate(String currency) {
throw new RuntimeException("Timeout");
}
}
WRONG: self-invocation -- RuntimeException thrown once, no retry
CORRECT: cross-bean call -- RuntimeException retried 3 times
How do @Retryable and @Transactional interact?
If the retryable method is also @Transactional, each retry should get a fresh transaction. The failed attempt's transaction must roll back before the next attempt. Structure retry around a method that starts a fresh transaction per attempt.
package com.bank.service;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TransferRetryService {
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void transferWithRetry(String fromId, String toId, java.math.BigDecimal amount) {
// Each retry gets a fresh transaction
// If attempt 1 fails, its TX rolls back before attempt 2
accountService.debit(fromId, amount);
accountService.credit(toId, amount);
}
}
Expected behavior:
- Attempt 1: debit succeeds, credit throws deadlock, TX rolls back
- Wait 1000ms
- Attempt 2: fresh TX, both debit and credit succeed, TX commits
When should you use programmatic RetryTemplate instead?
For retry logic that cannot be expressed declaratively, or when you need retry inside a lambda or a loop, use RetryTemplate programmatically.
package com.bank.service;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Map;
@Service
public class ProgrammaticRetryService {
private final RetryTemplate retryTemplate;
public ProgrammaticRetryService() {
SimpleRetryPolicy policy = new SimpleRetryPolicy(
3, Map.of(RuntimeException.class, true));
ExponentialBackOffPolicy backoff = new ExponentialBackOffPolicy();
backoff.setInitialInterval(500);
backoff.setMultiplier(2.0);
backoff.setMaxInterval(10_000);
this.retryTemplate = RetryTemplate.builder()
.customPolicy(policy)
.customBackoff(backoff)
.build();
}
public BigDecimal fetchRateWithRetry(String currency) {
return retryTemplate.execute(context -> {
// Retry logic inside a lambda
return externalRateClient.fetchRate(currency);
});
}
}
Expected behavior:
- Same retry semantics as @Retryable but inside a lambda
- Useful when retry wraps a specific call, not an entire method
What should a senior engineer know about retry in scheduled jobs?
A senior engineer applies @Retryable to the unit of work (a single chunk or a single external call), not to the whole multi-hour job, so one transient blip retries cheaply without re-running everything. They ensure retried operations are idempotent because an attempt may have partially succeeded. For distributed or cross-service retries they prefer a durable mechanism like a message queue with redelivery or Spring Batch's retry/skip policies over in-memory @Retryable, because in-memory retry is lost if the process dies.
package com.bank.service;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class IdempotentRateUpsertService {
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 500, multiplier = 2))
@Transactional
public void upsertRate(String currency, BigDecimal rate, java.time.LocalDate date) {
// IDEMPOTENT: keyed by currency + date, so re-running never duplicates
rateRepo.upsert(currency, date, rate);
}
}
Senior checklist:
1. Apply retry to the smallest unit of work, not the whole job
2. Ensure idempotency -- re-running a retried operation must be safe
3. Separate transient from permanent exceptions explicitly
4. Use exponential backoff with jitter for shared dependencies
5. For cross-process durability, prefer message queues over in-memory retry
Visual Overview
Method call
│
▼
┌──────────────────────────────┐
│ AOP Proxy (@EnableRetry) │
└───────────────┬──────────────┘
│
▼
┌──────────────────────────────┐
│ Attempt 1: invoke method │
│ Exception? ─── No ──► Done │
│ │ │
│ Yes │
│ │ │
│ retryFor match? ── No ──► Fail fast
│ │ │
│ Yes │
│ ▼ │
│ maxAttempts reached? │
│ │ │
│ No ──► @Backoff wait │
│ │ │
│ ▼ │
│ Attempt 2: invoke method │
│ ... │
│ │
│ maxAttempts reached? │
│ │ │
│ Yes │
│ ▼ │
│ @Recover fallback │
└──────────────────────────────┘
Backoff timeline (delay=500, multiplier=2):
──┬──────┬──────────┬────────────────┬──► Time
│ │ │ │
Att1 Att2 Att3 Att4
│ 500ms 1000ms 2000ms
│ (delay) (500*2) (1000*2)
Key Takeaway
Spring Retry adds declarative retry to a method through @Retryable, enabled by @EnableRetry. You list the transient exception types that should trigger a retry in retryFor (timeouts, connection resets) and the permanent types that should fail fast in noRetryFor (validation errors, bad input). The maxAttempts attribute counts the initial try plus the retries, so four attempts means one try and three retries. The @Backoff annotation spaces the attempts with an initial delay, an exponential multiplier, a maximum cap, and optional jitter to avoid a thundering herd.
When all attempts are exhausted, Spring calls the @Recover method whose first parameter matches the exception type and whose remaining parameters match the original method's arguments, so you can log, alert, write to a dead-letter store, and let the rest of the job continue. In the nightly FX-rate sync at National Bank of India, Rajesh made the per-currency sync retryable for transient timeouts with exponential backoff, and the recover method records the failure for operations without rethrowing so the job continues with remaining currencies.
The senior points are that Spring Retry works through an AOP proxy just like @Transactional, so the retryable method must be called from another bean; that each retry of a transactional method should get a fresh transaction; that retried operations must be idempotent because an attempt may have partially succeeded; and that for cross-process durability a message queue with redelivery is more robust than in-memory @Retryable.
Remember one sentence: @Retryable re-invokes on transient failures with exponential backoff, @Recover catches the exhaustion, and idempotency makes every retry safe.
---