Back to Scheduling: @Scheduled, ShedLock, Retry
Q2Question 2 of 4

How do you add retry logic to Spring schedulers?

Key Concepts
  • @Retryable re-invokes a method on transient failures, enabled by @EnableRetry on a configuration class.
  • retryFor lists transient exceptions worth retrying; noRetryFor lists permanent exceptions that should fail fast.
  • maxAttempts counts the initial try plus retries, so maxAttempts=4 means one try and three retries.
  • @Backoff controls spacing with delay, multiplier, maxDelay, and optional jitter to avoid thundering herd.
  • @Recover is the fallback method called when all retries are exhausted, matching by exception type and method signature.
  • Spring Retry works through an AOP proxy, so the retryable method must be called from another bean, not through self-invocation.
  • Retried operations must be idempotent because an attempt may have partially succeeded before failing.

Definition

`@Retryable` re-invokes a method on transient failures with configurable backoff, `@Recover` is the fallback when retries are exhausted, and `@EnableRetry` activates the feature through AOP proxies.
  • @Retryable -- wraps a method so that if it throws a configured exception, Spring re-invokes it up to maxAttempts times with spacing controlled by @Backoff.
  • retryFor -- lists which exception types trigger a retry, such as TransientDataAccessException or ResourceAccessException.
  • noRetryFor -- lists exception types that should fail fast without retrying, such as IllegalArgumentException.
  • maxAttempts -- total attempts including the first, so maxAttempts = 4 means one initial try plus three retries.
  • @Backoff -- controls the delay between attempts: delay is the initial wait, multiplier makes it exponential, maxDelay caps the growth, and random adds 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.

JAVA
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);
    }
}
TEXT
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.

JAVA
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
}
XML
<!-- 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">>
TEXT
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 |

JAVA
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
    }
}
TEXT
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.

JAVA
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) { }
}
TEXT
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.

JAVA
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
    }
}
TEXT
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.

JAVA
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");
    }
}
JAVA
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");
    }
}
TEXT
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.

JAVA
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);
    }
}
TEXT
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.

JAVA
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);
        });
    }
}
TEXT
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.

JAVA
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);
    }
}
TEXT
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

DIAGRAM
  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           │
  └──────────────────────────────┘
DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate