Spring Boot CompletableFuture Batch Billing and Stripe Integration: How RetryTemplate Re-executes the RetryCallback Body, CompletableFuture.allOf() Batch Outer Retry Recreates All Per-Customer Futures, and @Transactional + @Retryable Method-Scope UUID Regeneration Defeats a Partial Fix

When Spring Boot applications use CompletableFuture for batch Stripe billing — dispatching N charges in parallel, fanning out with allOf(), wrapping blocking SDK calls in supplyAsync() — and add retry logic via Spring Retry’s RetryTemplate or @Retryable, three structurally distinct failure modes each silently regenerate a new idempotency key on every retry attempt. The surface presentation differs in each mode: UUID inside the RetryCallback lambda body but outside supplyAsync(); UUID inside each per-customer supplyAsync() supplier in an allOf() batch; UUID at the very top of the @Transactional @Retryable method body outside all lambdas. But the root cause is the same in all three: UUID.randomUUID() sits inside a scope that re-executes per retry attempt, and the developer’s mental model of what “stable” means does not account for which boundaries Spring Retry’s retry mechanism recrosses.

These failure modes are structurally distinct from those covered in the Spring Boot @Async + @Transactional post (which addressed UUID inside CompletableFuture.supplyAsync() when a retry loop calls the @Async method again, @Retryable on an outer @Transactional service calling an @Async inner service, and exceptionallyCompose() recovery chains that reinvoke @Async methods) and the Spring Boot @Transactional + WebClient reactive retry post (Reactor retryWhen() re-subscription semantics in a transaction boundary). The three modes here focus specifically on the Java-concurrent CompletableFuture API surface: RetryTemplate’s callback re-invocation model, the eager fan-out semantics of CompletableFuture.allOf() under @Retryable, and the critical insight that moving UUID from inside a lambda to “method scope” is insufficient when the method itself is the retry unit under @Retryable’s AOP proceed() model.

Background: CompletableFuture batch billing and how Spring Retry’s two mechanisms differ in their re-execution granularity

Spring Boot applications use CompletableFuture for Stripe batch billing in two primary forms. The first is wrapping a blocking Stripe SDK call in CompletableFuture.supplyAsync() to offload it to a thread pool, returning the CompletableFuture<String> to the caller. The second is fanning out N per-customer charges in parallel using a stream map to supplyAsync() per customer and CompletableFuture.allOf() to collect all results. Both patterns are correct for concurrency; the interaction with retry introduces the idempotency problem.

Spring Retry provides two mechanisms with different re-execution granularities:

RetryTemplate.execute(RetryCallback) is programmatic and synchronous. The developer creates a RetryCallback<T, E> lambda and passes it to retryTemplate.execute(). The RetryTemplate calls the callback, catches any matching exception, waits for the backoff period, and calls the callback again. The re-execution unit is the callback lambda body. Every statement inside the lambda body re-executes on every retry attempt: variable declarations, object constructions, and UUID.randomUUID() calls alike.

@Retryable is declarative and AOP-based. Spring Retry wraps the annotated method in a proxy interceptor. When the interceptor catches a matching exception thrown from the method, it waits for the backoff period and calls proceed() — a fresh method invocation via the AOP join point. The re-execution unit is the method body. Every statement inside the method body re-executes on every @Retryable retry: local variable declarations, collection initializations, and UUID.randomUUID() calls alike. Local variable state is not preserved between proceed() calls; each call starts with a fresh activation frame.

Both mechanisms re-execute their respective units entirely. The distinction matters for where developers place UUID.randomUUID(): inside the RetryCallback body is inside the retry unit; at the top of the @Retryable method body is also inside the retry unit. The only scope that is outside both retry units is the caller of retryTemplate.execute() or the caller of the @Retryable-annotated method — but by that point, the developer has inverted the architecture they wanted. The practical solution is to replace UUID.randomUUID() with a content-hash function whose output is a deterministic function of the billing parameters, producing the same key regardless of how many times the retry unit re-executes.

Mode 1: UUID inside the RetryCallback body — outside supplyAsync(), but the callback itself is the retry unit — RetryTemplate re-invokes the callback — UUID_B — ch_B

The first failure mode involves RetryTemplate.execute(RetryCallback) where UUID.randomUUID() is placed inside the RetryCallback lambda body but outside the CompletableFuture.supplyAsync() supplier lambda. The developer’s reasoning: “I placed the UUID before supplyAsync(), not inside the async supplier. The UUID is computed before the async call starts and is passed via closure to the supplier. It is stable.” This reasoning correctly identifies that the UUID is not inside the supplyAsync() supplier — but it misidentifies the retry boundary. The RetryCallback lambda body is the retry boundary, not the supplyAsync() supplier. The supplier is contained within the callback; the callback is what RetryTemplate re-invokes.

// BillingService.java — unsafe mode 1: UUID inside RetryCallback body, outside supplyAsync() supplier
@Service
public class BillingService {

    private final RetryTemplate retryTemplate;

    public BillingService() {
        this.retryTemplate = RetryTemplate.builder()
            .maxAttempts(3)
            .fixedBackoff(300)
            .retryOn(StripeException.class)
            .build();
    }

    public String chargeCustomer(String customerId, int amountCents) throws StripeException {
        return retryTemplate.execute((RetryContext context) -> {
            // RetryTemplate re-executes this entire lambda body on each retry attempt.
            // UUID.randomUUID() is NOT inside supplyAsync() — but it IS inside the
            // RetryCallback body, which RetryTemplate calls again on each retry.
            // Developer's reasoning: "it's outside the async supplier, so it's computed
            // before the async call and is stable." Wrong: the callback itself re-executes.
            String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE

            ChargeCreateParams params = ChargeCreateParams.builder()
                .setAmount((long) amountCents)
                .setCurrency("usd")
                .setCustomer(customerId)
                .build();

            RequestOptions options = RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build();

            // supplyAsync() submits Charge.create() (blocking) to ForkJoinPool.commonPool().
            // params and options are captured from the closure — they carry UUID_A on attempt 1.
            // On retry, the callback re-executes: new params and options built with UUID_B.
            try {
                return CompletableFuture.supplyAsync(() -> {
                    try {
                        return Charge.create(params, options).getId();
                    } catch (StripeException e) {
                        throw new CompletionException(e);
                    }
                }).get();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException(e);
            } catch (ExecutionException e) {
                if (e.getCause() instanceof StripeException stripeEx) {
                    throw stripeEx; // RetryTemplate catches StripeException and retries
                }
                throw new RuntimeException(e);
            }
        });
    }
}

The call sequence on a transient Stripe network failure:

  1. retryTemplate.execute(callback) invokes the RetryCallback lambda.
  2. Callback body starts executing. UUID.randomUUID() generates UUID_A. params and options are built with UUID_A as the idempotency key.
  3. CompletableFuture.supplyAsync() submits the supplier to ForkJoinPool.commonPool(). The supplier captures params and options from the callback-scope closure — both carry UUID_A. .get() blocks the callback thread waiting for the supplier to complete.
  4. The supplier runs on a pool thread: Charge.create(params, options) makes the HTTP call to Stripe with UUID_A. Stripe receives the request and begins processing. Stripe commits charge ch_A.
  5. Before the HTTP response arrives, a transient network reset occurs. Charge.create() throws ApiConnectionException. The supplier wraps it as CompletionException and the CompletableFuture completes exceptionally.
  6. .get() throws ExecutionException wrapping the CompletionException wrapping the ApiConnectionException. The catch block unwraps and rethrows the StripeException.
  7. RetryTemplate catches the StripeException. The retry policy allows up to 3 attempts. RetryTemplate waits 300ms and calls the RetryCallback lambda again — a fresh invocation of the entire callback body.
  8. Retry attempt 2: Callback body starts executing. UUID.randomUUID() generates UUID_B — a new random value distinct from UUID_A. New params and options are built with UUID_B. CompletableFuture.supplyAsync() submits a new supplier carrying UUID_B. The supplier calls Charge.create() with UUID_B.
  9. Stripe receives the request with UUID_B. Stripe has UUID_A in its idempotency store (ch_A committed), but has never seen UUID_B. Stripe processes UUID_B as a new billing intent and commits charge ch_B.
  10. Attempt 2 succeeds. retryTemplate.execute() returns the ch_B charge ID. The customer has been charged twice: ch_A (committed on attempt 1, unknown to the application) and ch_B (returned to the caller).

The key misconception in this mode: the developer conflates “not inside the async supplier” with “stable across retries.” These are different properties. The supplyAsync() supplier is one scope. The RetryCallback lambda body is a broader scope that contains the supplier construction. The RetryCallback lambda body is what RetryTemplate re-invokes. Any UUID.randomUUID() call anywhere inside the callback body — before supplyAsync(), inside supplyAsync(), or after — re-evaluates on every retry attempt. The supplier’s closure captures the UUID from the callback scope at the time the supplyAsync() call is made, but since the callback scope is the retry unit, both the UUID.randomUUID() call and the CompletableFuture.supplyAsync() call re-execute together on each retry. The supplier captures a fresh UUID_B each time.

A secondary nuance: CompletableFuture.supplyAsync() is eager. The supplier starts executing on the thread pool immediately when supplyAsync() is called, not when .get() is called. .get() merely blocks until the already-running supplier completes. This means that if the developer called supplyAsync() and then computed UUID after it (which would be incorrect design), the UUID computation and the Stripe call would race. The correct sequence is always: compute idempotency key → build RequestOptions → call supplyAsync() capturing both via closure. But even with this correct construction order, UUID inside the callback body remains unsafe under RetryTemplate.

The fix is to compute the idempotency key outside the RetryCallback lambda, at the chargeCustomer() method scope, where it executes exactly once per billing intent regardless of how many RetryTemplate retries occur:

// BillingService.java — safe mode 1: content-hash key outside RetryCallback
@Service
public class BillingService {

    private final RetryTemplate retryTemplate;

    public BillingService() {
        this.retryTemplate = RetryTemplate.builder()
            .maxAttempts(3)
            .fixedBackoff(300)
            .retryOn(StripeException.class)
            .build();
    }

    public String chargeCustomer(String customerId, int amountCents) throws StripeException {
        // Content-hash key: computed at chargeCustomer() scope, outside the RetryCallback.
        // Stable across all RetryTemplate retry attempts — same output on attempt 1, 2, and 3.
        // Also stable if the billing job restarts — same inputs produce the same key.
        final String idempotencyKey = "charge:" + customerId + ":" + amountCents;

        return retryTemplate.execute((RetryContext context) -> {
            // idempotencyKey captured from chargeCustomer() scope via closure.
            // RetryTemplate re-invokes this callback on each retry — but idempotencyKey
            // is captured from outside, not recomputed here. Same value on every attempt.
            ChargeCreateParams params = ChargeCreateParams.builder()
                .setAmount((long) amountCents)
                .setCurrency("usd")
                .setCustomer(customerId)
                .build();

            RequestOptions options = RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey) // stable key from method scope
                .build();

            try {
                return CompletableFuture.supplyAsync(() -> {
                    try {
                        return Charge.create(params, options).getId();
                    } catch (StripeException e) {
                        throw new CompletionException(e);
                    }
                }).get();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException(e);
            } catch (ExecutionException e) {
                if (e.getCause() instanceof StripeException stripeEx) {
                    throw stripeEx;
                }
                throw new RuntimeException(e);
            }
        });
    }
}

With this fix, attempt 1 sends the content-hash key "charge:cus_001:9900". If Stripe committed ch_A before the network error, the retry sends the same key. Stripe recognizes the key in its idempotency store and returns ch_A without creating ch_B. The billing result is correct: one charge, correctly deduplicated.

A practical note on the content-hash key format: "charge:" + customerId + ":" + amountCents is suitable for per-customer single-shot charges where the billing intent is uniquely identified by the customer ID and the amount. For recurring billing (monthly jobs), the billing period should be part of the key: "billing:2026-10:" + customerId + ":" + amountCents. This prevents accidental deduplication across different billing cycles — October’s charge and November’s charge for the same customer and amount should produce different charges, not be deduplicated. Including the billing period ensures they carry different keys.

Mode 2: CompletableFuture.allOf() batch billing — UUID inside per-customer supplyAsync() supplier — @Retryable batch method recreates all futures — blast radius: N−1 duplicate charges for a batch of N

The second failure mode scales the problem to batch billing. A monthly billing job charges N customers in parallel: a stream maps each customer to a CompletableFuture.supplyAsync() call, and CompletableFuture.allOf() fans out all N parallel Stripe calls. @Retryable annotates the batch method to handle transient Stripe failures, and @Transactional ensures DB billing records are saved atomically. UUID.randomUUID() is inside each per-customer supplyAsync() supplier.

// MonthlyBillingService.java — unsafe mode 2: UUID inside supplyAsync() supplier in @Retryable @Transactional batch
@Service
public class MonthlyBillingService {

    private final BillingRecordRepository billingRecordRepository;
    private final ExecutorService stripeExecutor = Executors.newFixedThreadPool(10);

    @Retryable(retryFor = StripeException.class, maxAttempts = 3, backoff = @Backoff(delay = 500))
    @Transactional
    public void chargeAllCustomers(List<CustomerBillingInfo> customers) {
        // Stream map creates CompletableFutures. All futures start immediately (eager).
        // If @Retryable re-invokes this method body, the stream map re-runs:
        // new CompletableFutures for ALL customers, each with a new UUID inside the supplier.
        List<CompletableFuture<ChargeResult>> futures = customers.stream()
            .map(customer -> CompletableFuture.supplyAsync(() -> {
                String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE: inside supplier

                ChargeCreateParams params = ChargeCreateParams.builder()
                    .setAmount((long) customer.amountCents())
                    .setCurrency("usd")
                    .setCustomer(customer.stripeCustomerId())
                    .build();

                try {
                    Charge charge = Charge.create(params, RequestOptions.builder()
                        .setIdempotencyKey(idempotencyKey)
                        .build());
                    return new ChargeResult(customer.id(), charge.getId(), idempotencyKey);
                } catch (StripeException e) {
                    throw new CompletionException(e); // ExecutionException wraps this in allOf().get()
                }
            }, stripeExecutor))
            .collect(Collectors.toList());

        // allOf() is EAGER: all N futures are already running when allOf() is called.
        // allOf().get() blocks until all complete or any one fails.
        try {
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        } catch (ExecutionException e) {
            // allOf() propagates the first failure. Unwrap and rethrow so @Retryable catches it.
            Throwable cause = e.getCause();
            if (cause instanceof CompletionException ce && ce.getCause() instanceof StripeException stripeEx) {
                throw stripeEx;
            }
            throw new RuntimeException(e);
        }

        // Save billing records in @Transactional context (runs after allOf() completes).
        List<BillingRecord> records = futures.stream()
            .map(f -> {
                try { return f.get(); }
                catch (Exception e) { throw new RuntimeException(e); }
            })
            .map(r -> new BillingRecord(r.customerId(), r.chargeId(), r.idempotencyKey()))
            .collect(Collectors.toList());
        billingRecordRepository.saveAll(records);
    }
}

The call sequence on a transient failure for one customer in a batch of 100:

  1. @Retryable’s AOP interceptor calls proceed() — the chargeAllCustomers() method body begins.
  2. Stream map executes. For each of the 100 customers, CompletableFuture.supplyAsync() is called and a new future is submitted to stripeExecutor. The stream completes synchronously; all 100 futures are already running before collect() returns. Each future captures its customer reference from the stream map lambda scope.
  3. The 100 suppliers execute on stripeExecutor threads. Each supplier calls UUID.randomUUID() and generates its own UUID_A1 through UUID_A100. Each calls Charge.create() with its respective UUID.
  4. Futures 1–99 complete successfully. Customers 1–99 are charged as ch_A1 through ch_A99. Their idempotency keys UUID_A1 through UUID_A99 are recorded in Stripe’s idempotency store.
  5. Future 100 (customer 100) fails. Stripe commits ch_A100 but the network response is lost. The supplier throws CompletionException wrapping ApiConnectionException. Future 100 completes exceptionally.
  6. allOf().get() throws ExecutionException. The catch block unwraps and rethrows the StripeException.
  7. @Retryable’s AOP interceptor catches the StripeException. Retry policy: up to 3 attempts. Wait 500ms. Call proceed() again — a fresh invocation of chargeAllCustomers().
  8. Retry attempt 2: The chargeAllCustomers() method body begins again with the same customers list (same reference, passed through proceed()). Stream map re-executes. For each of the 100 customers, a new CompletableFuture.supplyAsync() is called. Each supplier calls UUID.randomUUID() and generates UUID_B1 through UUID_B100 — entirely new random values, all distinct from UUID_A1 through UUID_A100.
  9. All 100 futures start running. Customers 1–99 (already charged as ch_A1–ch_A99) receive new requests with UUID_B1–UUID_B99. Stripe has never seen UUID_B1–UUID_B99. Stripe processes each as a new billing intent and commits ch_B1 through ch_B99. Customer 100 is charged with UUID_B100; depending on whether ch_A100 was committed before the ApiConnectionException, customer 100 may also be charged twice.

Blast radius: In the worst case (failure on the last customer after all others succeeded), N−1 customers receive duplicate charges. For a billing batch of 1,000 customers, a single transient failure at customer 1,000 can produce up to 999 duplicate charges on the retry. Each duplicate is a real Stripe charge on the customer’s card, billed for the full subscription amount.

There are two independent problems compounding in Mode 2:

A partial fix addresses Problem A only — moving UUID outside the supplier but keeping it inside the stream map lambda (which is itself inside the @Retryable method body):

// Partial fix — Problem A only (still has Problem B):
// UUID outside supplier but inside stream map lambda, which is inside @Retryable method body.
List<CompletableFuture<ChargeResult>> futures = customers.stream()
    .map(customer -> {
        // UUID is outside the supplyAsync() supplier.
        // But the entire stream map re-runs on each @Retryable proceed() call.
        // The stream map lambda body re-executes per proceed() re-invocation.
        // UUID.randomUUID() here still generates UUID_B1..UUID_BN on retry attempt 2.
        String idempotencyKey = UUID.randomUUID().toString(); // STILL UNSAFE
        return CompletableFuture.supplyAsync(() -> {
            // idempotencyKey captured from stream map scope — stable within this invocation
            // but this invocation restarts entirely on each @Retryable proceed()
            ...
        }, stripeExecutor);
    })
    .collect(Collectors.toList());

Moving UUID outside the supplyAsync() supplier into the stream map lambda does not fix the problem. The stream map lambda is still inside the @Retryable method body. @Retryable’s proceed() re-invokes the entire method body, which re-executes the stream map, which re-executes all map lambdas, each of which calls UUID.randomUUID() again. The supplier captures the lambda’s UUID by closure, and the lambda captures a fresh UUID per proceed() re-invocation. UUID_B.

The complete fix addresses both problems:

// MonthlyBillingService.java — safe mode 2: content-hash keys + per-item retry
@Service
public class MonthlyBillingService {

    private final BillingRecordRepository billingRecordRepository;
    private final ExecutorService stripeExecutor = Executors.newFixedThreadPool(10);
    private final RetryTemplate perItemRetry = RetryTemplate.builder()
        .maxAttempts(3)
        .fixedBackoff(300)
        .retryOn(StripeException.class)
        .build();

    // No @Retryable on the outer batch method — retry is per-item inside the stream map.
    @Transactional
    public void chargeAllCustomers(List<CustomerBillingInfo> customers, String billingPeriod) {
        List<CompletableFuture<ChargeResult>> futures = customers.stream()
            .map(customer -> {
                // Content-hash key: deterministic, stable across job restarts and per-item retries.
                // billingPeriod (e.g., "2026-10") prevents cross-month deduplication.
                String idempotencyKey = "billing:" + billingPeriod + ":"
                    + customer.id() + ":" + customer.amountCents();

                return CompletableFuture.supplyAsync(
                    () -> chargeWithPerItemRetry(customer, idempotencyKey),
                    stripeExecutor
                );
            })
            .collect(Collectors.toList());

        try {
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        } catch (ExecutionException e) {
            throw new RuntimeException(e.getCause());
        }

        // Save billing records
        List<BillingRecord> records = futures.stream()
            .map(f -> { try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); } })
            .map(r -> new BillingRecord(r.customerId(), r.chargeId(), r.idempotencyKey()))
            .collect(Collectors.toList());
        billingRecordRepository.saveAll(records);
    }

    private ChargeResult chargeWithPerItemRetry(CustomerBillingInfo customer, String idempotencyKey) {
        try {
            return perItemRetry.execute(context -> {
                // idempotencyKey is a parameter — computed outside this RetryTemplate and outside
                // this method. Stable across all RetryTemplate retry attempts.
                ChargeCreateParams params = ChargeCreateParams.builder()
                    .setAmount((long) customer.amountCents())
                    .setCurrency("usd")
                    .setCustomer(customer.stripeCustomerId())
                    .build();
                try {
                    Charge charge = Charge.create(params, RequestOptions.builder()
                        .setIdempotencyKey(idempotencyKey)
                        .build());
                    return new ChargeResult(customer.id(), charge.getId(), idempotencyKey);
                } catch (StripeException e) {
                    throw e;
                }
            });
        } catch (StripeException e) {
            throw new CompletionException(e);
        } catch (Throwable t) {
            throw new CompletionException(t);
        }
    }
}

With per-item retry and content-hash keys: if customer 47’s first Charge.create() call fails transiently, only customer 47’s ChargeResult future retries — customers 1–46 and 48–N complete normally on their first attempt and are never re-contacted. Customer 47’s retry sends the same content-hash key; Stripe deduplicates if ch_A47 was committed. The batch as a whole succeeds after the per-item retry resolves customer 47. No duplicate charges.

A note on the @Transactional and per-item retry interaction: the chargeWithPerItemRetry() method runs on stripeExecutor threads, outside the @Transactional context of chargeAllCustomers(). This is intentional: Stripe API calls should not participate in the database transaction, and the billingRecordRepository.saveAll() at the end runs after all futures complete, within the transaction. The design keeps Stripe API calls (inherently non-transactional, per-item retryable) separate from the DB record save (transactional, single-shot).

Mode 3: @Transactional + @Retryable batch method — developer moves UUID to method body scope outside all lambdas — the partial fix that still fails — @Retryable proceed() reinitializes all local variables

The third failure mode is the “I already fixed this” trap. The developer has encountered advice about not placing UUID.randomUUID() inside lambdas that re-execute. They have revised their code to move UUID generation to the method body, outside all lambdas — not inside the stream map lambda, not inside the supplyAsync() supplier. They compute one UUID per customer in a plain for loop or stream at the top of the method and store the results in a Map. The lambdas then look up stable values from the Map via closure. The developer believes the problem is solved.

// BatchBillingService.java — unsafe mode 3: UUID at method body scope (outside all lambdas)
// Developer's intent: "UUID is computed at method scope, outside all lambdas.
// It's stable — lambdas capture it from the Map, not regenerate it."
// This is correct reasoning for a non-retryable method.
// It is wrong under @Retryable: proceed() is a fresh method invocation.
@Service
public class BatchBillingService {

    private final BillingRecordRepository billingRecordRepository;

    @Retryable(retryFor = StripeException.class, maxAttempts = 3, backoff = @Backoff(delay = 300))
    @Transactional
    public void processMonthlyBatch(List<CustomerBillingInfo> customers) {
        // Step 1: Compute one UUID per customer, at method scope, outside all lambdas.
        // Developer thinks: "This Map is computed once, before any lambda. The lambdas
        // capture values from it. UUID.randomUUID() is not inside any lambda. Fixed."
        Map<String, String> idempotencyKeys = new HashMap<>();
        for (CustomerBillingInfo customer : customers) {
            idempotencyKeys.put(customer.id(), UUID.randomUUID().toString()); // UNSAFE: method scope != stable under @Retryable
        }

        // Step 2: Stream map — each lambda retrieves its UUID from the Map (not regenerating it).
        List<CompletableFuture<ChargeResult>> futures = customers.stream()
            .map(customer -> {
                // Retrieval, not regeneration — this part is correctly scoped.
                // But the Map itself was populated with new random UUIDs at the top of this
                // method body, which @Retryable re-invoked with a fresh activation frame.
                String idempotencyKey = idempotencyKeys.get(customer.id());
                return CompletableFuture.supplyAsync(() -> {
                    try {
                        Charge charge = Charge.create(
                            ChargeCreateParams.builder()
                                .setAmount((long) customer.amountCents())
                                .setCurrency("usd")
                                .setCustomer(customer.stripeCustomerId())
                                .build(),
                            RequestOptions.builder()
                                .setIdempotencyKey(idempotencyKey)
                                .build()
                        );
                        return new ChargeResult(customer.id(), charge.getId(), idempotencyKey);
                    } catch (StripeException e) {
                        throw new CompletionException(e);
                    }
                });
            })
            .collect(Collectors.toList());

        try {
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        } catch (ExecutionException e) {
            Throwable cause = e.getCause();
            if (cause instanceof CompletionException ce && ce.getCause() instanceof StripeException stripeEx) {
                throw stripeEx;
            }
            throw new RuntimeException(e);
        }

        // Save billing records
        ...
    }
}

The developer has correctly identified that UUID.randomUUID() should not be inside the supplier lambda and has moved it outside all lambdas. But @Retryable’s AOP intercept model makes the method body itself the retry unit — not any specific lambda inside it. The entire method body, including the for loop that populates idempotencyKeys, re-executes on each @Retryable retry.

How @Retryable AOP interception works, in detail: Spring Boot creates a CGLIB proxy subclass of BatchBillingService that intercepts calls to processMonthlyBatch(). The proxy method body does not directly call the target method — it delegates to the Spring Retry infrastructure. On the first call to processMonthlyBatch() on the proxy:

  1. The AnnotationAwareRetryOperationsInterceptor is invoked via the AOP chain.
  2. The interceptor calls methodInvocation.proceed() — this invokes the real processMonthlyBatch() method on the BatchBillingService target object.
  3. The real method body executes: new HashMap, for loop populating UUID_A1–UUID_AN, stream map, allOf().
  4. If a StripeException is thrown, it propagates up to the interceptor.
  5. The interceptor checks the retry policy. If retries remain, it waits for the backoff and calls methodInvocation.proceed() again.
  6. proceed() invokes the real processMonthlyBatch() method again. A new activation frame is created. All local variables are uninitialized at entry. idempotencyKeys is a new empty HashMap. The for loop executes: UUID.randomUUID() generates UUID_B1 through UUID_BN — all new values. Stream map runs with the new idempotencyKeys Map. All N CompletableFuture suppliers capture UUID_B values. All N Stripe calls carry UUID_B keys. Already-charged customers get UUID_B requests and Stripe creates ch_B charges.

The developer’s mental model failure: “The UUID is computed at method scope, not inside any lambda, so it is computed once.” This is true for a non-retryable method. A non-retryable method is called once per billing intent, executes the body once, and returns. For a non-retryable method, any local variable at method scope is computed once. But @Retryable breaks this guarantee: the method body is called multiple times for a single billing intent (the same customers list, the same billing job run). “Method scope” is not a stable scope under @Retryable — it is the retry unit itself.

The only scope that is stable under @Retryable is the caller of the @Retryable-annotated method. But the fix should not require moving UUID computation to the caller — that would distribute a billing-internal concern to callers and complicate the API. The correct fix is to replace UUID.randomUUID() with a deterministic content-hash function that computes the same output given the same billing parameters, independent of whether it is called 1, 2, or 3 times during @Retryable retries:

// BatchBillingService.java — safe mode 3: content-hash keys stable across @Retryable re-invocations
@Service
public class BatchBillingService {

    private final BillingRecordRepository billingRecordRepository;

    @Retryable(retryFor = StripeException.class, maxAttempts = 3, backoff = @Backoff(delay = 300))
    @Transactional
    public void processMonthlyBatch(List<CustomerBillingInfo> customers) {
        // billingPeriod is computed from system time — same value within the same billing month
        // regardless of which @Retryable attempt this is (1st, 2nd, or 3rd in the same run).
        // If the batch job restarts (e.g., pod crash), YearMonth.now() returns the same month
        // value and Stripe's 24h idempotency window deduplicates correctly.
        String billingPeriod = YearMonth.now().toString(); // "2026-10"

        List<CompletableFuture<ChargeResult>> futures = customers.stream()
            .map(customer -> {
                // Content-hash key: deterministic function of billing period + customer ID + amount.
                // @Retryable re-invokes this method body — this line re-executes — but produces
                // the SAME value because it depends only on stable inputs, not on randomness.
                // UUID.randomUUID() would produce UUID_B here; this produces "billing:2026-10:cus_047:9900" again.
                String idempotencyKey = "billing:" + billingPeriod + ":"
                    + customer.id() + ":" + customer.amountCents();

                return CompletableFuture.supplyAsync(() -> {
                    try {
                        Charge charge = Charge.create(
                            ChargeCreateParams.builder()
                                .setAmount((long) customer.amountCents())
                                .setCurrency("usd")
                                .setCustomer(customer.stripeCustomerId())
                                .build(),
                            RequestOptions.builder()
                                .setIdempotencyKey(idempotencyKey) // same value on every @Retryable attempt
                                .build()
                        );
                        return new ChargeResult(customer.id(), charge.getId(), idempotencyKey);
                    } catch (StripeException e) {
                        throw new CompletionException(e);
                    }
                });
            })
            .collect(Collectors.toList());

        try {
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        } catch (ExecutionException e) {
            Throwable cause = e.getCause();
            if (cause instanceof CompletionException ce && ce.getCause() instanceof StripeException stripeEx) {
                throw stripeEx;
            }
            throw new RuntimeException(e);
        }

        // Save billing records
        List<BillingRecord> records = futures.stream()
            .map(f -> { try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); } })
            .map(r -> new BillingRecord(r.customerId(), r.chargeId(), r.idempotencyKey()))
            .collect(Collectors.toList());
        billingRecordRepository.saveAll(records);
    }
}

With content-hash keys, @Retryable’s proceed() re-invocations compute the same key for customer cus_047 with amount 9900 in billing period 2026-10 on every attempt. The first attempt sends "billing:2026-10:cus_047:9900". If Stripe committed ch_A47 before the network error, the retry sends the same key. Stripe deduplicates and returns ch_A47 without creating ch_B47. The property “same key on every retry” does not depend on the method being called once — it depends on the key being derived from values that are the same on every call.

A secondary note on the @Transactional + @Retryable interceptor ordering. Spring Retry’s AnnotationAwareRetryOperationsInterceptor implements Ordered. By default in Spring Retry 2.x, it uses Ordered.LOWEST_PRECEDENCE - 2 (Integer.MAX_VALUE - 2 = 2147483645). Spring’s TransactionInterceptor uses Ordered.LOWEST_PRECEDENCE (2147483647). In Spring’s ordering model, a lower order value means higher priority (outer interceptor). @Retryable at 2147483645 is outer; @Transactional at 2147483647 is inner. Each @Retryable proceed() call starts a new @Transactional context. This is generally the correct behavior for business logic — each retry should start a clean transaction, not inherit a transaction that may be marked rollback-only from a previous failed attempt. However, it means the DB billing records from a successful @Transactional commit on the first attempt are committed when @Retryable later fires a second attempt — the second attempt’s billingRecordRepository.saveAll() will attempt to re-insert the same records, potentially hitting unique constraints. Idempotent DB operations (upserts, INSERT ... ON CONFLICT DO NOTHING, or checking existence before insert) are required to handle this correctly alongside Stripe idempotency key stability.

The progression of failed partial fixes and the general principle

Across the three modes, there is a progression in where developers place UUID.randomUUID() as they attempt to fix the problem:

Stage UUID placement Why developer thinks it’s safe Why it fails
Original bug Inside supplyAsync() supplier lambda Each async call should have its own UUID Supplier re-executes when the outer retry recreates the future
Partial fix 1 Inside RetryCallback body, outside supplyAsync() UUID is not inside the async supplier, so it is computed before the async call The RetryCallback body is the retry unit; UUID regenerates per RetryTemplate attempt
Partial fix 2 Inside stream map lambda, outside supplyAsync() UUID is not inside the async supplier; lambda scope is above supplier scope Stream map re-executes per @Retryable proceed(); UUID regenerates per retry
Partial fix 3 (Mode 3) At method body top, outside all lambdas, stored in a Map UUID is not inside any lambda; it is at method scope, computed once before lambdas Method body is the @Retryable retry unit; all local variables reinitialize per proceed()
Correct fix N/A — replaced with content-hash function Output depends only on billing parameters, not on randomness N/A — same output regardless of how many times the retry unit re-executes

The general principle: any call to UUID.randomUUID() inside a scope that re-executes per retry attempt is unsafe, regardless of whether that scope is a supplier lambda, a callback body, a stream map lambda, or the top of a method body. The fix does not require finding the right scope inside the retry boundary — there is no such scope, because every scope inside the retry boundary re-executes. The fix requires replacing UUID.randomUUID() with a function whose output is determined by stable inputs that are the same on every retry attempt.

Stable inputs for a billing job: the billing period (month, not random), the customer ID (same reference passed through proceed()), and the amount (same in the billing info). A content-hash key from these three inputs is identical on every @Retryable retry attempt and on every job restart within Stripe’s 24-hour idempotency window. UUID.randomUUID() fails this property by design: it generates a different value every time it is called.

Testing patterns: capturing Idempotency-Key headers across retry attempts with WireMock and verifying equality

The definitive test for all three failure modes is to configure WireMock to return a transient failure on the first Stripe request and success on the second, capture all Idempotency-Key headers sent across both attempts, and assert that the key sent on attempt 1 equals the key sent on attempt 2. A passing assertion proves that the retry mechanism does not regenerate the idempotency key. A failing assertion proves that UUID is being regenerated — in production, this becomes a duplicate charge.

// BillingServiceRetryTest.java — tests for mode 1 (RetryTemplate)
@SpringBootTest
class BillingServiceRetryTest {

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();

    @Autowired
    private BillingService billingService;

    @BeforeEach
    void configureStripeClient() {
        // Point Stripe SDK at WireMock
        Stripe.overrideApiBase("http://localhost:" + wireMock.getPort());
        Stripe.apiKey = "sk_test_wiremock";
    }

    @Test
    void retryTemplateShouldSendSameIdempotencyKeyOnBothAttempts() throws Exception {
        // First Stripe call returns 500 (transient server error)
        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .inScenario("stripe-transient")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse()
                .withStatus(500)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"error\":{\"type\":\"api_error\",\"message\":\"server error\"}}"))
            .willSetStateTo("first-failed"));

        // Second Stripe call returns 200 (success)
        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .inScenario("stripe-transient")
            .whenScenarioStateIs("first-failed")
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"id\":\"ch_test001\",\"object\":\"charge\",\"amount\":9900," +
                          "\"currency\":\"usd\",\"customer\":\"cus_001\"}")));

        billingService.chargeCustomer("cus_001", 9900);

        List<LoggedRequest> requests = wireMock.findAll(
            postRequestedFor(urlPathEqualTo("/v1/charges"))
        );
        assertThat(requests).hasSize(2); // one failure, one success

        String keyOnAttempt1 = requests.get(0).getHeader("Idempotency-Key");
        String keyOnAttempt2 = requests.get(1).getHeader("Idempotency-Key");

        // THE assertion: same key on both attempts
        // With UUID.randomUUID() inside RetryCallback: UUID_B != UUID_A — test fails
        // With content-hash key: "charge:cus_001:9900" == "charge:cus_001:9900" — test passes
        assertThat(keyOnAttempt1)
            .as("Idempotency-Key must be identical on retry attempt to prevent duplicate Stripe charge")
            .isEqualTo(keyOnAttempt2);

        // Verify the key format (content-hash, not UUID)
        assertThat(keyOnAttempt1)
            .as("Idempotency-Key should be content-hash, not random UUID")
            .isEqualTo("charge:cus_001:9900");
    }

    @Test
    void retryTemplateShouldNotChargeCustomerTwiceOnTransientFailure() throws Exception {
        // Same WireMock setup — first 500, then 200
        // ... (same stub setup as above) ...

        String chargeId = billingService.chargeCustomer("cus_001", 9900);

        // Verify exactly 2 requests (1 failure + 1 success retry)
        List<LoggedRequest> requests = wireMock.findAll(
            postRequestedFor(urlPathEqualTo("/v1/charges"))
        );
        assertThat(requests).hasSize(2);

        // chargeId should be ch_test001 (from the successful second attempt)
        assertThat(chargeId).isEqualTo("ch_test001");

        // Key equality: no UUID regeneration
        assertThat(requests.get(0).getHeader("Idempotency-Key"))
            .isEqualTo(requests.get(1).getHeader("Idempotency-Key"));
    }
}

For the batch allOf() test (Mode 2 and Mode 3), the assertion structure differs slightly: instead of one customer sending 2 requests, the test verifies that when the batch retries (due to one customer failing), already-succeeded customers either are not re-contacted at all (per-item retry architecture) or, if they are re-contacted (outer batch retry architecture with content-hash keys), send the same key on the re-contact as on the first contact:

// MonthlyBillingServiceTest.java — test for batch retry key stability
@SpringBootTest
class MonthlyBillingServiceTest {

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();

    @Autowired
    private MonthlyBillingService monthlyBillingService;

    @Test
    void batchRetryMustNotRegenerateIdempotencyKeyForAlreadyChargedCustomers() throws Exception {
        // Customer cus_001 and cus_002 succeed on first attempt.
        // Customer cus_003 fails on first attempt, succeeds on second.
        // Batch @Retryable outer retry re-contacts all three customers on attempt 2.
        // With content-hash keys, cus_001 and cus_002's second-attempt keys must equal first-attempt keys.

        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .withRequestBody(matchingJsonPath("$.customer", equalTo("cus_001")))
            .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
                .withBody("{\"id\":\"ch_001\",\"object\":\"charge\",\"amount\":9900,\"currency\":\"usd\",\"customer\":\"cus_001\"}")));

        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .withRequestBody(matchingJsonPath("$.customer", equalTo("cus_002")))
            .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
                .withBody("{\"id\":\"ch_002\",\"object\":\"charge\",\"amount\":9900,\"currency\":\"usd\",\"customer\":\"cus_002\"}")));

        // cus_003: fail on first, succeed on second (WireMock scenario)
        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .withRequestBody(matchingJsonPath("$.customer", equalTo("cus_003")))
            .inScenario("cus003-transient")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse().withStatus(500).withHeader("Content-Type", "application/json")
                .withBody("{\"error\":{\"type\":\"api_error\"}}"))
            .willSetStateTo("cus003-failed"));

        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .withRequestBody(matchingJsonPath("$.customer", equalTo("cus_003")))
            .inScenario("cus003-transient")
            .whenScenarioStateIs("cus003-failed")
            .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "application/json")
                .withBody("{\"id\":\"ch_003\",\"object\":\"charge\",\"amount\":9900,\"currency\":\"usd\",\"customer\":\"cus_003\"}")));

        List<CustomerBillingInfo> customers = List.of(
            new CustomerBillingInfo("cus_001", "cus_001", 9900),
            new CustomerBillingInfo("cus_002", "cus_002", 9900),
            new CustomerBillingInfo("cus_003", "cus_003", 9900)
        );

        monthlyBillingService.chargeAllCustomers(customers, "2026-10");

        // Check all requests to WireMock
        List<LoggedRequest> cus001Requests = wireMock.findAll(
            postRequestedFor(urlPathEqualTo("/v1/charges"))
                .withRequestBody(matchingJsonPath("$.customer", equalTo("cus_001")))
        );

        // If using per-item retry (recommended): cus_001 should only receive 1 request
        // If using outer batch @Retryable: cus_001 receives 2 requests — keys must be equal
        if (cus001Requests.size() > 1) {
            String cus001Key1 = cus001Requests.get(0).getHeader("Idempotency-Key");
            String cus001Key2 = cus001Requests.get(1).getHeader("Idempotency-Key");
            assertThat(cus001Key1)
                .as("cus_001 was re-contacted on batch retry — keys must be equal to prevent duplicate charge")
                .isEqualTo(cus001Key2);
            // With content-hash: "billing:2026-10:cus_001:9900" == "billing:2026-10:cus_001:9900" — passes
            // With UUID.randomUUID(): UUID_B != UUID_A — fails — duplicate charge in production
        }
    }
}

The test structure for Mode 3 (UUID at method body scope) is identical to Mode 2 — the observable behavior from WireMock’s perspective is the same: two Stripe requests for cus_001 with either equal or unequal Idempotency-Key headers. The difference is internal to the service implementation: Mode 2’s UUID is inside a supplier lambda; Mode 3’s UUID is inside a for loop at the top of the method. Both produce UUID_B on retry because both are inside the @Retryable method body. The same WireMock test catches both bugs.

Running this test against the buggy Mode 3 code (UUID at method body top in a Map) produces an assertion failure with output similar to:

AssertionError: cus_001 was re-contacted on batch retry — keys must be equal to prevent duplicate charge
expected: "a7f3d214-8b9c-4e5a-b123-000000000001"
but was:  "9c8b2031-f4a7-4d3b-e456-000000000002"

The failure message is concrete: two different UUID strings, confirming that UUID.randomUUID() was called on both the first and second @Retryable attempts, and that in production cus_001 would have been charged twice. The test passes after replacing UUID.randomUUID() with the content-hash function, where both keys resolve to "billing:2026-10:cus_001:9900".

Summary comparison across all three modes

Mode Retry mechanism UUID placement Re-execution scope Blast radius
Mode 1 RetryTemplate.execute(RetryCallback) Inside RetryCallback body, outside supplyAsync() supplier Per RetryTemplate attempt (callback re-invocation) 1 duplicate charge per single customer
Mode 2 @Retryable on @Transactional batch method Inside CompletableFuture.supplyAsync() supplier, per customer in stream map Per @Retryable proceed() (method body re-invocation) Up to N−1 duplicate charges for a batch of N customers
Mode 3 @Retryable on @Transactional batch method At method body top, outside all lambdas, in a Map built from a for loop Per @Retryable proceed() (method body re-invocation) Up to N duplicate charges for a batch of N customers

The fix in all three modes: replace UUID.randomUUID() with a deterministic content-hash function. For Mode 1, compute the key at chargeCustomer() method scope, outside the RetryCallback. For Modes 2 and 3, compute the key inside the stream map lambda using the customer’s ID and amount as inputs — the same call re-executes on each @Retryable retry but produces the same output because the inputs are stable. The content-hash key is immune to the re-execution property of the retry boundary; UUID.randomUUID() is not.

Put policy guardrails on every agent Stripe call

Keybrake proxies your agent’s Stripe, Twilio, and Resend API calls — enforcing per-vendor spend caps, endpoint allowlists, and one-click revoke. Idempotency bugs that duplicate charges are caught at the proxy layer before they hit production.

See also: Spring Boot @Async + @Transactional and Stripe Integration — UUID inside CompletableFuture.supplyAsync() when a retry loop calls the @Async method again, @Retryable on an outer @Transactional service calling an @Async sub-service, and exceptionallyCompose() recovery chains. And Spring Boot @Transactional + WebClient reactive retry — Reactor retryWhen() re-subscription semantics under @Transactional and the WebClient reactive pipeline.