Spring Boot @Scheduled + @Retryable and Stripe Integration: How UUID Inside @Retryable Service Method Body, @Scheduled Self-Invocation @Retryable Proxy Bypass, and Stale Singleton Idempotency Key Generate Duplicate Charges or Idempotency Conflicts

Spring’s @Scheduled and @Retryable annotations interact in ways that produce three distinct Stripe billing failure modes: UUID.randomUUID() placed inside the body of a @Retryable billing service method re-evaluates when @Retryable re-invokes the method after a transient Stripe error — each re-invocation builds a new UUID_B that drives a second charge ch_B alongside already-committed ch_A; a @Scheduled method that calls a @Retryable method on the same bean via direct self-invocation bypasses the Spring AOP proxy entirely — @Retryable’s interceptor never fires — the billing job fails unretried on transient errors, and the developer’s subsequent manual-retry workaround that generates a new UUID.randomUUID() per attempt introduces the duplicate-charge bug that a stable key would have prevented; and an idempotency key stored as a lazily-initialized singleton instance field — correct within one @Scheduled run because it is stable across @Retryable retries within that run — stales to the prior run’s key on the next scheduled execution, causing Stripe to return a 422 idempotency conflict on every attempt because the prior run’s payload no longer matches the current run’s payload.

Background: how @Scheduled, @Retryable, and Spring AOP proxies compose

Spring’s @Retryable annotation, from the spring-retry library, works through Spring AOP. When a bean class contains @Retryable-annotated methods, Spring wraps the bean in a CGLIB or JDK dynamic proxy. The proxy adds a RetryOperationsInterceptor to the call chain for each @Retryable method. When a caller invokes the method through the proxy, the interceptor catches any exception matching the retryFor list and re-invokes the underlying method from the beginning of its body.

The critical constraint for all AOP proxies in Spring: the proxy boundary is only traversed for inter-bean calls. When Bean A holds a reference to Bean B’s proxy and calls proxyB.chargeCustomer(), the call enters the proxy and the interceptors fire. When Bean B calls one of its own @Retryable methods as this.chargeCustomer() from within another of its methods, this is the raw target object — not the proxy — and the call bypasses the proxy entirely. @Retryable’s interceptor never fires. This is Spring’s well-documented self-invocation limitation, which applies equally to @Transactional, @Async, and any other AOP-based annotation.

@Scheduled is processed by ScheduledAnnotationBeanPostProcessor, which runs as a BeanPostProcessor during the application context startup. After all other post-processors (including AbstractAutoProxyCreator, which builds the AOP proxy for @Retryable) have run, ScheduledAnnotationBeanPostProcessor inspects the final bean object — which is the proxy — and registers the annotated method with the TaskScheduler (backed by a ThreadPoolTaskScheduler or an injected ScheduledExecutorService). The TaskScheduler holds a ScheduledMethodRunnable wrapping the proxy and the method reference.

This means: when @Scheduled is on method runBillingJob(), the TaskScheduler calls proxy.runBillingJob(). If runBillingJob() itself is annotated with @Retryable, the interceptor fires for calls entering from outside. But when runBillingJob()’s body calls this.chargeAllUsers(), where chargeAllUsers() is also in the same bean and also annotated with @Retryable, that second call bypasses the proxy. The distinction: proxy method on the outer boundary, self-invocation on the inner call.

The combination of these two behaviors creates three failure modes in scheduled billing jobs that also make Stripe API calls with idempotency keys.

Failure mode 1: UUID.randomUUID() inside @Retryable service method body — @Retryable re-invokes the method — UUID_B — ch_B alongside committed ch_A

The developer correctly splits the billing job into two beans: a @Scheduled orchestrator that drives the billing run and a @Retryable billing service that makes the Stripe API call. The cross-bean structure is correct — no self-invocation, proxy traversal works. The bug is that UUID.randomUUID() is placed inside the @Retryable service method body, not in the orchestrator.

// SubscriptionBillingService.java — UNSAFE: UUID inside @Retryable service method body.
// Developer intent: each call to chargeSubscription() generates a unique idempotency key.
// Developer assumption: chargeSubscription() is called once per user per billing run.
// Actual behavior: chargeSubscription() is called once per user per @Retryable ATTEMPT.
// On attempt 2, the method body re-executes from the top: UUID.randomUUID() fires again.
// UUID_B drives a new charge request. If ch_A was committed on attempt 1, ch_B is a duplicate.
@Service
public class SubscriptionBillingService {

    @Autowired
    private StripeClient stripeClient;

    @Retryable(
        retryFor = { StripeException.class, SocketTimeoutException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 2000, multiplier = 2.0)
    )
    public Charge chargeSubscription(String customerId, long amountCents, String planId) throws StripeException {
        // BUG: UUID generated here, at the top of the @Retryable method body.
        // This statement executes on every @Retryable attempt, not just the first.
        // Developer reasoning: "This method is called once per subscriber per billing cycle,
        // so UUID.randomUUID() evaluates once per subscriber per cycle."
        // Actual: @Retryable calls chargeSubscription() again from this line on retry.
        String idempotencyKey = customerId + ":sub:" + planId + ":" + UUID.randomUUID();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(amountCents)
            .setCurrency("usd")
            .setCustomer(customerId)
            .putMetadata("plan_id", planId)
            .putMetadata("billing_cycle", LocalDate.now().toString())
            .build();

        return stripeClient.charges().create(params,
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build());
    }
}

// MonthlyBillingJob.java — the @Scheduled orchestrator. Cross-bean call: correct.
// chargeSubscription() is called through the proxy on subscriptionBillingService.
@Component
public class MonthlyBillingJob {

    @Autowired
    private SubscriptionBillingService subscriptionBillingService;

    @Autowired
    private SubscriptionRepository subscriptionRepository;

    @Scheduled(cron = "0 0 3 1 * *")  // 3am on the 1st of every month
    public void runMonthlyBilling() {
        List<Subscription> active = subscriptionRepository.findAllActive();
        for (Subscription sub : active) {
            try {
                // Cross-bean call: goes through the @Retryable proxy on subscriptionBillingService.
                // @Retryable interceptor will fire if chargeSubscription() throws StripeException.
                Charge charge = subscriptionBillingService.chargeSubscription(
                    sub.getCustomerId(), sub.getAmountCents(), sub.getPlanId());
                sub.markCharged(charge.getId());
                subscriptionRepository.save(sub);
            } catch (Exception e) {
                log.error("Billing failed for customer {} after retries: {}",
                    sub.getCustomerId(), e.getMessage());
            }
        }
    }
}

The failure sequence when Stripe processes the charge on attempt 1 but a transient network error prevents the response from reaching the client:

  1. The TaskScheduler fires runMonthlyBilling() at 3am. The loop reaches customer cust_abc.
  2. subscriptionBillingService.chargeSubscription("cust_abc", 2999L, "pro-monthly") is called through the @Retryable proxy. The interceptor wraps the call (attempt 1).
  3. Inside the method body: idempotencyKey = "cust_abc:sub:pro-monthly:" + UUID_A is computed. The charge request is sent to Stripe with Idempotency-Key: cust_abc:sub:pro-monthly:UUID_A.
  4. Stripe processes the request, deducts $29.99, and commits ch_A = "ch_3R...". A network timeout occurs before the 200 OK arrives at the client. Stripe’s side: the charge was committed.
  5. SocketTimeoutException propagates from the Stripe SDK call to the @Retryable interceptor.
  6. The interceptor matches SocketTimeoutException.class in retryFor. It waits 2 seconds (the initial backoff). It re-invokes chargeSubscription("cust_abc", 2999L, "pro-monthly") from the very first line of the method body (attempt 2).
  7. idempotencyKey = "cust_abc:sub:pro-monthly:" + UUID_B is computed. UUID_B is a different value: UUID.randomUUID() does not know about UUID_A.
  8. Stripe receives the charge request with Idempotency-Key: cust_abc:sub:pro-monthly:UUID_B. This is a new key Stripe has never seen. Stripe treats it as a distinct charge request and processes it: ch_B = "ch_4S..." is committed. Customer cust_abc is billed $29.99 twice.

The developer’s error: conflating “called once per billing cycle” (true when no exception) with “executes once per billing cycle” (false when @Retryable retries). @Retryable does not checkpoint the method at the line that threw the exception and resume there. It re-invokes the entire method from the beginning. Every statement before the Stripe API call, including UUID.randomUUID(), runs again on each retry attempt.

The fix for failure mode 1

Generate the idempotency key in the @Scheduled orchestrator, outside the @Retryable boundary, and pass it as a stable parameter into the service method.

// SubscriptionBillingService.java — SAFE: key received as parameter, not generated inside.
@Service
public class SubscriptionBillingService {

    @Autowired
    private StripeClient stripeClient;

    @Retryable(
        retryFor = { StripeException.class, SocketTimeoutException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 2000, multiplier = 2.0)
    )
    public Charge chargeSubscription(String customerId, long amountCents, String planId,
                                     String idempotencyKey) throws StripeException {
        // Key received as a parameter: same value on every @Retryable attempt.
        // @Retryable re-invokes with the same arguments — idempotencyKey is stable.
        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(amountCents)
            .setCurrency("usd")
            .setCustomer(customerId)
            .putMetadata("plan_id", planId)
            .putMetadata("billing_cycle", LocalDate.now().toString())
            .build();

        return stripeClient.charges().create(params,
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build());
    }
}

// MonthlyBillingJob.java — SAFE: key generated once per customer before @Retryable call.
@Component
public class MonthlyBillingJob {

    @Scheduled(cron = "0 0 3 1 * *")
    public void runMonthlyBilling() {
        List<Subscription> active = subscriptionRepository.findAllActive();
        for (Subscription sub : active) {
            // Key generated here: in the orchestrator, outside @Retryable.
            // All @Retryable attempts for this customer use the same key.
            // Content-hash key: stable across JVM restarts; survives scheduler crashes.
            String billingPeriod = YearMonth.now().toString();  // e.g. "2026-10"
            String key = "sub:" + sub.getCustomerId() + ":" + sub.getPlanId()
                + ":" + billingPeriod;

            try {
                Charge charge = subscriptionBillingService.chargeSubscription(
                    sub.getCustomerId(), sub.getAmountCents(), sub.getPlanId(), key);
                sub.markCharged(charge.getId());
                subscriptionRepository.save(sub);
            } catch (Exception e) {
                log.error("Billing failed for customer {} after retries: {}",
                    sub.getCustomerId(), e.getMessage());
            }
        }
    }
}

Content-hash keys ("sub:" + customerId + ":" + planId + ":" + billingPeriod) are preferable to UUID.randomUUID() keys even when generated in the orchestrator. A UUID key generated in the orchestrator is stable within one JVM run: it does not change across retries, so it protects against the duplicate-charge bug. But if the JVM crashes after Stripe commits ch_A and before sub.markCharged(ch_A.getId()) persists the charge record, the next scheduler run (after JVM restart) will generate a new UUID for the same billing period — UUID_C — and ch_C alongside committed ch_A. A content-hash key does not change across JVM restarts: the same inputs produce the same key, so Stripe’s idempotency store returns the prior committed charge rather than processing a new one.

Failure mode 2: @Scheduled method self-invokes @Retryable method on the same bean — Spring AOP proxy bypassed — @Retryable silently does nothing — developer workaround introduces UUID_B

The developer wants to avoid the overhead of a separate service bean and keeps both the scheduler entry point and the Stripe billing logic in a single class. The @Scheduled method calls the @Retryable billing method as a direct method call on this:

// BillingJob.java — UNSAFE: @Scheduled calls @Retryable on same bean (self-invocation).
// Developer intent: runBilling() drives the job; chargeCustomer() retries Stripe errors.
// Actual behavior: this.chargeCustomer() bypasses the @Retryable proxy — no retries.
@Component
public class BillingJob {

    @Autowired
    private StripeClient stripeClient;

    @Autowired
    private CustomerRepository customerRepository;

    @Scheduled(fixedRate = 86_400_000L)  // every 24 hours
    public void runBilling() {
        List<Customer> customers = customerRepository.findDue();
        for (Customer customer : customers) {
            String key = customer.getId() + ":" + Instant.now().toEpochMilli();
            try {
                // Self-invocation: this.chargeCustomer() is called on the raw bean target,
                // not on the Spring AOP proxy. @Retryable's interceptor never fires.
                // The developer wrote this expecting retry behavior; it provides none.
                Charge charge = chargeCustomer(customer, key);
                customer.recordPayment(charge.getId());
                customerRepository.save(customer);
            } catch (StripeException e) {
                log.error("Charge failed for customer {}: {}", customer.getId(), e.getMessage());
                // StripeException propagates unretried. Developer expected @Retryable to handle this.
            }
        }
    }

    @Retryable(
        retryFor = StripeException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    public Charge chargeCustomer(Customer customer, String idempotencyKey) throws StripeException {
        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(customer.getAmountCents())
            .setCurrency("usd")
            .setCustomer(customer.getStripeCustomerId())
            .build();

        return stripeClient.charges().create(params,
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build());
    }
}

The self-invocation problem is not immediately obvious because the code compiles, the application starts, and billing works correctly when Stripe returns a 200 OK on the first attempt. The bug is invisible until a transient Stripe 503 or a network timeout causes chargeCustomer() to throw StripeException. At that point:

The developer can confirm this by checking the Spring context configuration: adding a @EnableRetry annotation is necessary for @Retryable to work at all. More importantly, they can add a log line in the @Recover method (if they have one) and observe it is never called. Or they can add a breakpoint at the @Retryable proxy interceptor class RetryOperationsInterceptor.invoke() and observe it is never entered for chargeCustomer() calls originating from within BillingJob.

The workaround that introduces the duplicate-charge bug

A developer who discovers that @Retryable is not retrying will often add a manual retry loop. If they do not understand the idempotency requirements, the loop will generate a new UUID.randomUUID() on each iteration:

// BillingJob.java — STILL UNSAFE: manual retry loop with new UUID per attempt.
// Developer found that @Retryable was not working (self-invocation bypass).
// Added manual retry loop. Bug: new UUID on each attempt = potential duplicate charge.
@Scheduled(fixedRate = 86_400_000L)
public void runBilling() {
    List<Customer> customers = customerRepository.findDue();
    for (Customer customer : customers) {
        int attempts = 0;
        boolean charged = false;
        while (attempts < 3 && !charged) {
            attempts++;
            // BUG: new UUID on each loop iteration.
            // Attempt 1: key_1 = customer.getId() + ":1720000000000"
            // Attempt 2: key_2 = customer.getId() + ":1720000001000"  ← different timestamp
            // Stripe: key_1 and key_2 are unrelated idempotency keys.
            // If attempt 1 committed ch_A, attempt 2 creates ch_B.
            String key = customer.getId() + ":" + Instant.now().toEpochMilli();
            try {
                Charge charge = chargeCustomer(customer, key);
                customer.recordPayment(charge.getId());
                customerRepository.save(customer);
                charged = true;
            } catch (StripeException e) {
                if (attempts < 3) {
                    try { Thread.sleep(1000L * attempts); } catch (InterruptedException ie) { break; }
                } else {
                    log.error("Charge failed after {} attempts: {}", attempts, e.getMessage());
                }
            }
        }
    }
}

The timestamp-based key (customer.getId() + ":" + Instant.now().toEpochMilli()) produces a different value on every loop iteration because milliseconds advance between iterations. Attempt 1 commits ch_A with key cust_123:1720000000000. A network timeout prevents the response from arriving. Attempt 2 sends key cust_123:1720000001000 (a different timestamp, a new key). Stripe commits ch_B. Customer cust_123 is billed twice.

The correct fix is structural: use two beans.

The fix for failure mode 2

Move the @Retryable method to a separate @Service bean. The @Scheduled orchestrator holds an @Autowired reference to the service bean. All calls from the orchestrator to the service pass through the service bean’s proxy. @Retryable’s interceptor fires on every call.

// ChargingService.java — dedicated @Service for the Stripe call + @Retryable.
// Separate bean: calls from BillingJob traverse the proxy. @Retryable fires.
@Service
public class ChargingService {

    @Autowired
    private StripeClient stripeClient;

    @Retryable(
        retryFor = StripeException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    public Charge chargeCustomer(Customer customer, String idempotencyKey) throws StripeException {
        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(customer.getAmountCents())
            .setCurrency("usd")
            .setCustomer(customer.getStripeCustomerId())
            .build();

        return stripeClient.charges().create(params,
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build());
    }

    @Recover
    public Charge recoverCharge(StripeException e, Customer customer, String idempotencyKey) {
        log.error("All 3 charge attempts failed for customer {} key {}: {}",
            customer.getId(), idempotencyKey, e.getMessage());
        throw new BillingException("Charge failed after retries", e);
    }
}

// BillingJob.java — SAFE: @Scheduled orchestrator calls ChargingService through its proxy.
@Component
public class BillingJob {

    @Autowired
    private ChargingService chargingService;  // separate bean; proxy traversal works

    @Autowired
    private CustomerRepository customerRepository;

    @Scheduled(fixedRate = 86_400_000L)
    public void runBilling() {
        String billingDate = LocalDate.now().toString();  // stable within one day
        List<Customer> customers = customerRepository.findDue();
        for (Customer customer : customers) {
            // Content-hash key: deterministic, stable across retries and JVM restarts.
            String key = "daily:" + customer.getId() + ":" + billingDate;
            try {
                Charge charge = chargingService.chargeCustomer(customer, key);
                // @Retryable proxy intercepts StripeException, retries with same key.
                // All 3 attempts use the same idempotencyKey — no duplicate charges.
                customer.recordPayment(charge.getId());
                customerRepository.save(customer);
            } catch (BillingException e) {
                // After all 3 @Retryable attempts exhausted and @Recover rethrew.
                log.error("Final billing failure for {}", customer.getId());
            }
        }
    }
}

Two additional points. First, the @Recover method signature must match the retried method’s parameters (plus an exception as the first parameter) for Spring Retry to dispatch to it. If the @Recover method signature does not match, Spring Retry will not invoke it and the original exception will propagate after all retries are exhausted. Second, @EnableRetry must be on a configuration class for any @Retryable annotation to work. If @EnableRetry is missing, all @Retryable annotations are silently ignored at runtime — a common startup omission that is difficult to diagnose because it produces no error and the application appears to work correctly until a transient failure occurs.

Failure mode 3: lazily initialized singleton idempotency key instance field — correct within one @Scheduled run, stales to prior run’s key on next execution — Stripe 422 idempotency conflict

The developer correctly identifies that the idempotency key should be stable across @Retryable retries within a single billing run. Rather than passing the key as a parameter, they cache it in an instance field of the singleton billing bean and initialize it lazily on first use within each scheduled execution:

// WeeklyBillingJob.java — UNSAFE: lazily initialized singleton instance field for idempotency key.
// Developer intent: generate one UUID per billing run, reuse it for all @Retryable retries.
// Bug: singleton bean persists the field value across @Scheduled executions.
// Second run: null check is false (field is still set from first run); stale UUID_A is used.
// Stripe sees UUID_A with a new payload: different billing period, different amount.
// Stripe returns 422 with error.code = "idempotency_key_in_use".
@Component
public class WeeklyBillingJob {

    @Autowired
    private SubscriptionBillingService billingService;

    @Autowired
    private SubscriptionRepository subscriptionRepository;

    // BUG: instance field on a singleton bean.
    // Initialized to null at application startup.
    // Set to UUID_A on the first @Scheduled execution.
    // Still UUID_A when the second @Scheduled execution begins one week later.
    private String currentRunIdempotencyKey;

    @Scheduled(cron = "0 0 4 * * MON")  // every Monday at 4am
    public void runWeeklyBilling() {
        // BUG: null check only guards against the very first execution.
        // On the second and subsequent executions, the field is not null (it holds UUID_A).
        // The null check does not detect "start of a new run" — it detects "field was never set."
        if (currentRunIdempotencyKey == null) {
            currentRunIdempotencyKey = UUID.randomUUID().toString();
        }

        List<Subscription> subscriptions = subscriptionRepository.findAllActive();
        for (Subscription sub : subscriptions) {
            // Passes the (allegedly current-run) key into @Retryable service.
            // On the first run: correct — UUID_A is fresh, all retries use it.
            // On the second run: UUID_A is the key from the first run (one week ago).
            try {
                billingService.chargeSubscription(sub, currentRunIdempotencyKey);
            } catch (Exception e) {
                log.error("Billing failed for sub {}: {}", sub.getId(), e.getMessage());
            }
        }
    }
}

// SubscriptionBillingService.java — uses the key passed in (correct structure).
@Service
public class SubscriptionBillingService {

    @Autowired
    private StripeClient stripeClient;

    @Retryable(
        retryFor = { StripeException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1500, multiplier = 2.0)
    )
    public Charge chargeSubscription(Subscription sub, String idempotencyKey) throws StripeException {
        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(sub.getAmountCents())
            .setCurrency("usd")
            .setCustomer(sub.getCustomerId())
            .putMetadata("subscription_id", sub.getId())
            .putMetadata("billing_week", ISOWeek.format(LocalDate.now()))
            .build();

        return stripeClient.charges().create(params,
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build());
    }
}

On the first Monday execution, everything works correctly: currentRunIdempotencyKey is null, the null check fires, UUID_A is generated, all subscriptions are charged with UUID_A (with @Retryable retries if needed), and the run completes. The field retains UUID_A.

On the second Monday execution (one week later), the same WeeklyBillingJob bean instance is reused — it is a Spring singleton, created once at startup and never destroyed or replaced during normal operation. The null check evaluates to false: currentRunIdempotencyKey is not null, it is UUID_A. The null check was intended to mean “start of a new billing run”; it actually means “field was never set, not even once.”

The second run’s charge requests carry UUID_A as the idempotency key. Each request has a different payload from the first run: the billing_week metadata is different (last week versus this week), the amount may differ for subscribers on different billing cycles, and the subscription_id metadata on each sub is the same entity but the request-level amount or metadata combination produces a new payload fingerprint. Stripe’s idempotency store has a record for UUID_A from last week’s charge. The payload fingerprint of the new request does not match. Stripe returns HTTP 422 with:

{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_in_use",
    "message": "Keys can only be used once to successfully create an object.
                 Try again with a different key, or reuse the key with identical
                 parameters to retrieve the existing object."
  }
}

@Retryable retries the call with the same UUID_A key. The same 422 is returned on every attempt. After three attempts, the exception propagates from billingService.chargeSubscription(). The catch block in runWeeklyBilling() logs the error and moves on to the next subscription. The entire weekly billing run fails without charging any subscriber. No money is collected. No exception is loud enough to page the on-call engineer immediately (it looks like a loop of log.error calls rather than an application crash).

Why this is particularly hard to detect

The first run passes all manual testing and any automated tests that run the application context in a fresh JVM. In a fresh JVM, currentRunIdempotencyKey starts as null and the null check correctly initializes it. The bug only manifests on the second invocation of runWeeklyBilling() within the same JVM lifetime. In development, developers typically restart the application between test runs. In production, the application runs continuously, and the scheduler fires runWeeklyBilling() a second time one week after the first.

A test that exercises two consecutive runWeeklyBilling()` invocations on the same bean instance without resetting the field will catch the bug. Most test setups create a fresh application context for each test class or test method, which resets the singleton and misses the bug.

The fix for failure mode 3

Two approaches:

Option A: reset the instance field to null at the start of every @Scheduled execution. The null check then correctly means “key has not been generated for this run yet.”

// WeeklyBillingJob.java — SAFER with reset: instance field reset at start of each run.
// Still has a problem: if the job runs concurrently (shouldn't for @Scheduled, but worth noting),
// two concurrent runs could race on the instance field. Option B avoids this entirely.
@Scheduled(cron = "0 0 4 * * MON")
public void runWeeklyBilling() {
    // Reset at the start of each execution: now the null check correctly detects
    // "this run has not yet generated its key" vs "the field was never set in this JVM."
    currentRunIdempotencyKey = null;

    if (currentRunIdempotencyKey == null) {
        currentRunIdempotencyKey = UUID.randomUUID().toString();
    }
    // ... rest of billing logic
}

But Option A is fragile: the reset must precede all calls that read the field, and the code becomes order-sensitive in a way that is easy to break during maintenance. A simpler and more idiomatic fix:

Option B: use a local variable, not an instance field. A local variable is naturally scoped to one method invocation. It is initialized fresh on every call to runWeeklyBilling() and disappears at method return. No state persists between scheduled executions.

// WeeklyBillingJob.java — SAFE: local variable scoped to one @Scheduled execution.
// Naturally reset on every invocation. No cross-run state leaks.
@Scheduled(cron = "0 0 4 * * MON")
public void runWeeklyBilling() {
    // Local variable: re-initialized on every call to runWeeklyBilling().
    // Generated once per run, outside the inner loop — stable for all @Retryable retries.
    // Prefer content-hash keys for crash-safety (see below).
    String runKey = UUID.randomUUID().toString();

    List<Subscription> subscriptions = subscriptionRepository.findAllActive();
    for (Subscription sub : subscriptions) {
        // Each subscription gets its own content-hash key, not the single runKey.
        // This is preferable: if the scheduler restarts mid-run, per-subscriber
        // content-hash keys allow already-committed charges to be deduplicated by Stripe.
        String subKey = "weekly:" + sub.getId() + ":" + ISOWeek.format(LocalDate.now());
        try {
            billingService.chargeSubscription(sub, subKey);
        } catch (Exception e) {
            log.error("Billing failed for sub {}: {}", sub.getId(), e.getMessage());
        }
    }
}

The production-grade fix replaces both the runKey UUID variable and the per-run UUID with per-subscription content-hash keys. The content-hash key is deterministic from inputs that are stable across JVM restarts: the subscription ID and the billing period (ISO week). If the scheduler crashes mid-run and restarts, the per-subscription keys are identical to those used before the crash. Stripe’s idempotency store returns the already-committed charge for subscriptions that were processed before the crash, and the new charges are processed for subscriptions that were not.

Cross-mode structural distinctions

Mode Root cause Symptom Detection difficulty
1: UUID in @Retryable body UUID.randomUUID() placed before the Stripe call inside the @Retryable method Duplicate charges (ch_B on retry) Easy to miss during testing — only manifests on transient Stripe errors
2: Self-invocation proxy bypass @Scheduled calls @Retryable method on this (same bean) No retry at all; missing charges on transient errors Invisible in tests — only visible in production under transient failures
3: Stale singleton UUID field Lazy-init null check does not reset between @Scheduled executions Stripe 422 idempotency conflict on second+ run; all charges fail Only manifests on the second JVM-continuous execution; not caught by unit tests

Mode 1 and Mode 3 are mirror failures. Mode 1 generates too many distinct keys (one per @Retryable attempt within one run). Mode 3 generates too few distinct keys (the same key across multiple runs, which should use different keys). Both result in incorrect Stripe behavior: Mode 1 causes double charges; Mode 3 causes 422 idempotency conflicts that block all billing. The correct operating point is exactly one key per (customer, billing period) pair: same key across retries within a run, different key across runs for different billing periods.

Mode 2 is structurally different: it is a proxy composition bug, not directly an idempotency-key placement bug. But it leads to an idempotency-key bug as a downstream consequence when developers add manual retry loops to compensate for the missing @Retryable behavior.

Comparison with related Spring posts on this site

Previous posts have covered the intersection of Spring AOP and Stripe idempotency from different angles. The Spring WebMVC + @Async post covers the “silent @Retryable no-op” on the same method when @Async and @Retryable are both on the same method — the @Async interceptor returns a Future immediately, so @Retryable’s outer interceptor never sees the exception thrown inside the async task. The Spring @Transactional + @Async post covers how @Async’s thread-pool boundary creates a new transaction context, breaking TSM-based UUID caching patterns. The Spring @RequestScope + @Async post covers how @RequestScope proxy throws ScopeNotActiveException on async threads, leading to UUID.randomUUID() fallbacks inside async lambdas.

The present post’s failure modes are distinct from all three:

Test patterns with WireMock

The following patterns verify all three failure modes in a @SpringBootTest environment using WireMock to control Stripe’s responses.

Mode 1: confirm that all @Retryable attempts use the same idempotency key

// SubscriptionBillingServiceRetryTest.java
@SpringBootTest
@EnableRetry
class SubscriptionBillingServiceRetryTest {

    @Autowired
    private SubscriptionBillingService billingService;

    private WireMockServer wireMock;
    private List<String> capturedIdempotencyKeys;

    @BeforeEach
    void setUp() {
        wireMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        wireMock.start();
        capturedIdempotencyKeys = Collections.synchronizedList(new ArrayList<>());

        // Stub: first request returns 503 (transient failure); second returns 200 (success).
        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .inScenario("retry-scenario")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
            .willSetStateTo("after-first-attempt"));

        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .inScenario("retry-scenario")
            .whenScenarioStateIs("after-first-attempt")
            .willReturn(aResponse().withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"id\":\"ch_test\",\"object\":\"charge\",\"amount\":2999}")));

        // Wire up request journal listener to capture Idempotency-Key headers.
        wireMock.addMockServiceRequestListener((request, response) -> {
            String key = request.getHeader("Idempotency-Key");
            if (key != null) capturedIdempotencyKeys.add(key);
        });
    }

    @AfterEach
    void tearDown() { wireMock.stop(); }

    @Test
    void retryableAttemptsMustUseTheSameIdempotencyKey() throws Exception {
        String stableKey = "sub:cust_test:pro-monthly:2026-10";
        Subscription sub = new Subscription("cust_test", 2999L, "pro-monthly");

        billingService.chargeSubscription(sub, stableKey);

        // Both WireMock stubs were hit: 2 HTTP requests reached the mock Stripe.
        assertThat(capturedIdempotencyKeys).hasSize(2);

        // CRITICAL ASSERTION: both requests used the same idempotency key.
        // If UUID is inside chargeSubscription(), this fails: keys will differ.
        assertThat(new HashSet<>(capturedIdempotencyKeys)).hasSize(1);
        assertThat(capturedIdempotencyKeys.get(0)).isEqualTo(stableKey);
        assertThat(capturedIdempotencyKeys.get(1)).isEqualTo(stableKey);
    }
}

Mode 2: confirm that @Retryable fires when called from a separate bean

// ChargingServiceProxyTest.java — verifies cross-bean @Retryable works; would catch self-invocation.
@SpringBootTest
@EnableRetry
class ChargingServiceProxyTest {

    @Autowired
    private ChargingService chargingService;  // the separate @Service bean

    private WireMockServer wireMock;
    private AtomicInteger requestCount;

    @BeforeEach
    void setUp() {
        wireMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        wireMock.start();
        requestCount = new AtomicInteger(0);

        wireMock.addMockServiceRequestListener((req, resp) -> requestCount.incrementAndGet());

        // Always return 503 so all 3 @Retryable attempts fire.
        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}")));
    }

    @Test
    void retryableShouldAttemptThreeTimesOnStripeError() {
        Customer customer = new Customer("cust_proxy_test", "tok_visa", 999L);
        String key = "daily:cust_proxy_test:2026-10-01";

        assertThatThrownBy(() -> chargingService.chargeCustomer(customer, key))
            .isInstanceOf(BillingException.class);

        // If @Retryable proxy was NOT traversed (self-invocation), requestCount == 1.
        // If @Retryable proxy WAS traversed (cross-bean), requestCount == 3 (maxAttempts=3).
        assertThat(requestCount.get()).isEqualTo(3);
    }
}

Mode 3: confirm that the idempotency key changes between @Scheduled executions

// WeeklyBillingJobMultiRunTest.java — simulates two consecutive @Scheduled executions.
// Tests the stale-singleton-field bug by running runWeeklyBilling() twice in the same test.
@SpringBootTest
class WeeklyBillingJobMultiRunTest {

    @Autowired
    private WeeklyBillingJob billingJob;

    private WireMockServer wireMock;
    private List<String> allCapturedKeys;

    @BeforeEach
    void setUp() {
        wireMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        wireMock.start();
        allCapturedKeys = Collections.synchronizedList(new ArrayList<>());

        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"id\":\"ch_test\",\"object\":\"charge\",\"amount\":1000}")));

        wireMock.addMockServiceRequestListener((request, response) -> {
            String key = request.getHeader("Idempotency-Key");
            if (key != null) allCapturedKeys.add(key);
        });
    }

    @Test
    void consecutiveRunsMustUseDistinctIdempotencyKeys() throws Exception {
        // Simulate two consecutive weekly billing runs on the same bean instance.
        billingJob.runWeeklyBilling();  // first run
        billingJob.runWeeklyBilling();  // second run (same JVM, same singleton bean)

        // Assuming one active subscription in the test database.
        // Two runs = two HTTP requests = two captured keys.
        assertThat(allCapturedKeys).hasSize(2);

        // Keys used in run 1 and run 2 must be distinct.
        // Content-hash keys: "weekly:sub_123:2026-W40" same for both runs in the same week —
        // that is intentional and correct (Stripe deduplicates). But if the billing week is
        // different between runs in the test (or the test explicitly advances time), they differ.
        // For the singleton-UUID pattern under test: if the bug is present, both runs
        // use the same UUID (UUID_A) — the Set has size 1. Bug confirmed.
        Set<String> distinctKeys = new HashSet<>(allCapturedKeys);
        assertThat(distinctKeys).as("Runs in different billing periods must use different keys")
            .hasSize(allCapturedKeys.size());  // one distinct key per request when using content-hash
    }
}

The third test is most effective when the billing period changes between the two runWeeklyBilling() calls — for example, by using a test-scoped Clock that advances time between the calls. With content-hash keys tied to the ISO week, keys from different weeks will naturally differ. With UUID keys from a lazily initialized singleton field, the keys will be identical for both runs, and the assertThat(distinctKeys).hasSize(allCapturedKeys.size()) assertion will fail when there is only one key in the set but two entries in the full list.

Addressing common objections

“Can’t I just make @Scheduled call a self-injected proxy?”

Yes, self-injection is sometimes used as a workaround for the self-invocation problem. A bean can inject itself (@Autowired private BillingJob self), and calling self.chargeCustomer() instead of this.chargeCustomer() routes the call through the proxy. This works but introduces circular dependency handling requirements (you need @Lazy on the self-injection or a BeanFactory.getBean() lookup). The separate-bean approach in Option 2 is cleaner, more explicit, and matches the single-responsibility principle: the orchestrator orchestrates, the service calls the API.

“What if @Scheduled has @Retryable on the scheduler method itself?”

Placing @Retryable directly on the @Scheduled method and calling a non-@Retryable service works — the TaskScheduler calls runBillingJob() through the proxy, so @Retryable’s interceptor fires for exceptions thrown by the entire scheduled method body, including from nested service calls. But this approach retries the entire scheduled run (all users) rather than retrying individual user charges, which means a transient failure on user N causes all users from user 1 to N-1 (already successfully charged) to be re-attempted. If idempotency keys are content-hash keys, those re-attempts are safe: Stripe returns the prior committed charges. If they are UUID keys, they are not safe: new charges are created for already-charged users. The per-user @Retryable service approach is preferable because retries are scoped to the failing user, not the entire billing run.

“How do I know if my @Retryable annotations are actually working?”

Add a @Recover method and log when it is invoked. Add a counter metric (Micrometer or a simple AtomicLong) that increments on each attempt (you can do this in a custom RetryListener registered in RetryConfiguration). Write a test that mocks the Stripe client to throw StripeException on the first N-1 calls and succeed on call N; assert that the mock was called exactly N times. If your test shows 1 call, @Retryable’s interceptor never fired — you have a self-invocation bug or a missing @EnableRetry.

Runtime visibility with Keybrake

The three failure modes above share a common gap: neither your application logs nor your Stripe dashboard alone give you a complete picture of what happened. Your application logs show the exception and the retry attempts (if @Retryable is firing). Your Stripe dashboard shows every committed charge. But connecting “these two Stripe charges were created from the same billing run for the same customer” requires correlating the idempotency key in the Stripe event with the key your application actually used — which requires reading both the Stripe dashboard and your application logs side-by-side.

Keybrake sits between your application and the Stripe API as a scoped proxy. Every request your agent or scheduled job makes to Stripe passes through Keybrake, which logs the full request including the Idempotency-Key header, the response status, and the charge ID if Stripe committed one. When a @Scheduled billing run produces two charges for the same customer — as in Mode 1 — Keybrake’s audit log shows both requests with their distinct idempotency keys and both committed charge IDs in the same query window. You do not need to join application logs and Stripe dashboard data manually. The audit log is the single source of truth.

Keybrake also enforces per-vendor daily spend caps. A @Scheduled billing job that has Mode 3’s stale-singleton bug and receives 422 conflicts on all its attempts will produce zero revenue but zero charges. A billing job that has Mode 1’s duplicate-charge bug will produce revenue but also produce duplicate charges. Both are observable in Keybrake’s dashboard: the 422 run shows a wall of 422s with no successful charges; the duplicate-charge run shows two committed charges for the same customer within the same billing window. A daily spend cap configured to the expected billing-run revenue will alert on the overage from duplicates before the billing window closes.

See every Stripe call your scheduled jobs make

Keybrake proxies your agent’s and scheduler’s Stripe calls — logging every idempotency key, every response code, and every committed charge ID. Spot duplicate charges and 422 idempotency conflicts in the audit log before your customers notice on their statement.