Spring Boot @Async + @Scheduled and Stripe Integration: How StripeException Invisible to @Scheduled Orchestrator, @Retryable Wrapping @Async Seeing Only CompletableFuture, and CompletableFuture Retry Chain Generate Duplicate Charges or Idempotency Conflicts
Spring’s @Async and @Scheduled annotations interact with Stripe’s idempotency system in ways that produce three distinct billing failure modes: a StripeException thrown inside the @Async thread pool is invisible to the @Scheduled orchestrator — the orchestrator already returned after dispatching the async task — the developer adds a manual for-loop inside the @Async method body and generates a new UUID.randomUUID() per loop iteration — UUID_B — ch_B alongside already-committed ch_A; @Retryable placed on the same method as @Async becomes the outer interceptor in the proxy chain — @Async’s inner interceptor dispatches the billing work to a thread pool and immediately returns a CompletableFuture to @Retryable — @Retryable sees no exception from the synchronous proxy method call and considers the call successful — StripeException in the async thread is never seen by @Retryable — the developer adds a .exceptionally() callback generating a new UUID.randomUUID() for the retry — UUID_B — ch_B; and a CompletableFuture retry chain where the retry lambda calls the @Async billing method again with a new UUID.randomUUID() generated inside the lambda — UUID_B — ch_B alongside committed ch_A.
Background: how @Async, @Scheduled, and Spring AOP interceptors compose
Spring’s @Async annotation is processed by AsyncAnnotationBeanPostProcessor, which wraps annotated beans in a CGLIB proxy and registers an AsyncAnnotationAdvisor as an interceptor. When a method annotated with @Async is called through the proxy, the AsyncExecutionInterceptor intercepts the call, submits the method invocation as a Callable to the configured AsyncTaskExecutor (defaulting to SimpleAsyncTaskExecutor if no other executor is configured), and immediately returns a CompletableFuture (for CompletableFuture<T> return types) or null (for void return types) to the caller. The actual method body runs asynchronously in the thread pool.
For void-returning @Async methods, exceptions thrown in the async thread are forwarded to an AsyncUncaughtExceptionHandler registered with AsyncConfigurer. If no handler is configured, the default handler logs the exception at ERROR level and discards it. For CompletableFuture<T>-returning methods, exceptions are stored in the CompletableFuture’s exceptional state. In both cases, the exception is decoupled from the calling thread — the @Scheduled orchestrator that dispatched the @Async task has already returned before the exception occurs, and no mechanism automatically propagates the exception back to the scheduler.
Spring’s @Scheduled annotation is processed by ScheduledAnnotationBeanPostProcessor. It registers tasks with a TaskScheduler (defaulting to ThreadPoolTaskScheduler with a pool size of one thread). The scheduler calls the annotated method on that scheduler thread. When a @Scheduled method calls an @Async method via a cross-bean reference (injected dependency), the cross-bean call traverses the @Async proxy, the AsyncExecutionInterceptor fires, and the billing work is submitted to the async thread pool. Control returns to the scheduler thread immediately after the dispatch, regardless of whether the billing work succeeds or fails.
@Retryable’s RetryOperationsInterceptor is registered by RetryConfiguration, which implements Ordered and returns Ordered.LOWEST_PRECEDENCE - 5 (2,147,483,642) as its order. AsyncAnnotationAdvisor defaults to Ordered.LOWEST_PRECEDENCE (2,147,483,647) unless a custom @Order is set on @EnableAsync. In Spring AOP, a lower order number means higher precedence — that advisor runs as the outermost wrapper in the interceptor chain. When @Retryable and @Async are placed on the same method, @Retryable (order 2,147,483,642) is the outer interceptor and @Async (order 2,147,483,647) is the inner interceptor. This ordering has critical consequences for how exceptions propagate and whether @Retryable can observe them.
Failure mode 1: @Async billing task without @Retryable — StripeException in async thread invisible to @Scheduled orchestrator — developer adds manual retry loop with UUID.randomUUID() per iteration — UUID_B — ch_B
The developer builds a subscription billing job. A @Scheduled method iterates over all active subscribers and dispatches each customer’s billing charge as an @Async task so that Stripe’s network latency does not block the scheduler thread for the entire subscriber list. The @Async billing method carries no @Retryable annotation. A transient StripeException inside the async thread is invisible to the scheduler.
// SubscriberBillingService.java — @Async billing task. No @Retryable.
// When StripeException is thrown in the thread pool thread, it propagates to
// the CompletableFuture's exceptional state. The @Scheduled orchestrator
// already returned after calling chargeCustomerAsync(). It never sees the exception.
@Service
public class SubscriberBillingService {
@Autowired
private StripeClient stripeClient;
@Async("billingTaskExecutor")
public CompletableFuture<String> chargeCustomerAsync(
String customerId, String planId,
long amountCents, String billingPeriod) throws StripeException {
// BUG (in the eventual workaround): UUID generated per attempt in the retry loop.
// This code runs in the thread pool; StripeException here is stored in the Future.
String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":" + UUID.randomUUID();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
Charge charge = stripeClient.charges().create(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return CompletableFuture.completedFuture(charge.getId());
}
}
// MonthlyBillingJob.java — @Scheduled orchestrator dispatches @Async tasks.
// The scheduler thread dispatches all tasks and returns immediately.
// StripeExceptions in the async tasks are captured in the returned Futures.
// The scheduler never observes those exceptions directly.
@Component
public class MonthlyBillingJob {
@Autowired
private SubscriberBillingService billingService;
@Autowired
private SubscriberRepository subscriberRepository;
@Scheduled(cron = "0 0 2 1 * *") // 02:00 on the 1st of each month
public void runMonthlyBilling() {
String billingPeriod = YearMonth.now().toString(); // "2026-10"
List<Subscriber> active = subscriberRepository.findAllActive();
// dispatch each billing task asynchronously
List<CompletableFuture<String>> futures = active.stream()
.map(s -> billingService.chargeCustomerAsync(
s.getCustomerId(), s.getPlanId(), s.getAmountCents(), billingPeriod))
.collect(Collectors.toList());
// Optional join to wait for all tasks. Even here, an exceptionally-completed
// future only surfaces if the developer calls future.get() or checks isCompletedExceptionally().
// The scheduler thread sees no unhandled exception from failed @Async tasks.
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
}
When a transient Stripe error occurs on one subscriber’s billing task, the StripeException is captured in that subscriber’s CompletableFuture. If the developer calls CompletableFuture.allOf(...).join(), the join() call rethrows the exception as an unchecked CompletionException in the scheduler thread after all tasks complete. But the exception arrives after all tasks have already attempted their Stripe calls; it is not a signal that can trigger a retry of the failed tasks before join() returns. The developer observes missing charges and adds a retry loop inside the @Async method body.
// SubscriberBillingService.java — UNSAFE manual retry loop inside @Async method.
// Developer intent: "Add retry logic to handle transient Stripe errors."
// BUG: UUID.randomUUID() called per loop iteration.
// Attempt 1: UUID_A → Stripe commits ch_A → SocketTimeoutException before response arrives.
// Attempt 2: UUID_B (new call to UUID.randomUUID()) → Stripe sees new key → commits ch_B.
// Customer billed twice.
@Async("billingTaskExecutor")
public CompletableFuture<String> chargeCustomerAsync(
String customerId, String planId,
long amountCents, String billingPeriod) {
Exception lastException = null;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
// BUG: UUID generated per loop iteration.
// The developer's intent: "Each attempt is a new HTTP request and needs a unique key."
// This is the wrong model. An idempotency key must be STABLE across retries of the
// same intent. A new UUID per attempt means each attempt is a new charge from Stripe's
// perspective. If ch_A was committed on attempt 1 but a SocketTimeoutException prevented
// the 200 OK from arriving, attempt 2 with UUID_B produces ch_B — a duplicate.
String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":" + UUID.randomUUID();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
Charge charge = stripeClient.charges().create(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return CompletableFuture.completedFuture(charge.getId());
} catch (StripeException e) {
lastException = e;
log.warn("Billing attempt {} failed for customer {}: {}",
attempt, customerId, e.getMessage());
try {
Thread.sleep(1500L * attempt);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return CompletableFuture.failedFuture(ie);
}
}
}
log.error("All billing attempts failed for customer {}", customerId);
return CompletableFuture.failedFuture(lastException);
}
The failure sequence for a subscriber charged $119.88:
- The monthly billing job dispatches
chargeCustomerAsync("cust_abc", "pro-annual", 11988L, "2026-10"). The@Asyncinterceptor schedules execution inbillingTaskExecutor. The scheduler thread returns immediately. - In the thread pool thread, loop attempt 1:
idempotencyKey = "cust_abc:billing:pro-annual:2026-10:" + UUID_A. The Stripe charge request is sent. Stripe processes the request and commitsch_A = "ch_3P...". ASocketTimeoutExceptionfires before the 200 OK arrives at the client. - The
catch (StripeException e)block catches the exception. The loop increments to attempt 2. - Loop attempt 2:
UUID.randomUUID()is called again.idempotencyKey = "cust_abc:billing:pro-annual:2026-10:" + UUID_B.UUID_Bis a different UUID value. - Stripe receives a new charge request with
Idempotency-Key: cust_abc:billing:pro-annual:2026-10:UUID_B. Stripe has no record ofUUID_B. It treats this as a new charge request and commitsch_B = "ch_4Q...". Customercust_abcis billed $119.88 twice in October.
The developer’s model — “each attempt is a new request and needs a new key” — is the inverse of how Stripe’s idempotency system works. An idempotency key is not a request identifier; it is an intent identifier. The key must be the same for all retry attempts of the same billing intent so that Stripe can recognize them as retries of a known operation and return the prior committed result rather than processing a new charge. Generating a new UUID per attempt tells Stripe: “this is a brand-new charge I have not attempted before,” regardless of what actually committed on the prior attempt.
The fix for failure mode 1
Generate the idempotency key from immutable business data that is stable across all retry attempts, and compute it once before the retry loop. The key must be the same string on every attempt for the same billing intent.
// SubscriberBillingService.java — SAFE: content-hash key computed once before retry loop.
// Same key on every attempt for the same (customerId, planId, billingPeriod) triple.
// Stripe deduplicates a retry that arrives after a committed charge.
@Async("billingTaskExecutor")
public CompletableFuture<String> chargeCustomerAsync(
String customerId, String planId,
long amountCents, String billingPeriod) {
// Content-hash key derived from immutable business data.
// Computed once, used on all retry attempts.
// Also crash-safe: if the JVM restarts and this billing task is replayed for the
// same inputs, the key is identical — Stripe deduplicates against already-committed charge.
String idempotencyKey = "billing:" + customerId + ":" + planId + ":" + billingPeriod;
Exception lastException = null;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
// Same idempotencyKey on every attempt. Stripe deduplicates the retry.
Charge charge = stripeClient.charges().create(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return CompletableFuture.completedFuture(charge.getId());
} catch (StripeException e) {
lastException = e;
log.warn("Billing attempt {} failed for customer {}: {}",
attempt, customerId, e.getMessage());
try {
Thread.sleep(1500L * attempt);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return CompletableFuture.failedFuture(ie);
}
}
}
log.error("All billing attempts failed for customer {}", customerId);
return CompletableFuture.failedFuture(lastException);
}
A cleaner alternative is to use @Retryable on a separate @Service bean and call it from within the @Async method body via a cross-bean injection. The cross-bean call traverses the @Retryable service’s proxy, so the RetryOperationsInterceptor fires correctly. The idempotency key must still be computed before passing to the @Retryable service and must not be computed inside the @Retryable method body (see the pattern described in the Spring Retry post and the Spring @Scheduled + @Retryable post).
// BillingChargeService.java — @Retryable on a separate synchronous service bean.
// Called from the @Async method body via cross-bean injection.
// @Retryable proxy is traversed; RetryOperationsInterceptor fires on StripeException.
@Service
public class BillingChargeService {
@Autowired
private StripeClient stripeClient;
@Retryable(
retryFor = { StripeException.class, SocketTimeoutException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1500, multiplier = 2.0)
)
public String charge(String customerId, String planId,
long amountCents, String billingPeriod,
String idempotencyKey) throws StripeException {
// Key received as a parameter — stable across all @Retryable attempts.
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
Charge charge = stripeClient.charges().create(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return charge.getId();
}
@Recover
public String recoverCharge(StripeException e, String customerId, String planId,
long amountCents, String billingPeriod,
String idempotencyKey) {
log.error("Billing failed permanently for customer {} after all retries: {}",
customerId, e.getMessage());
return null;
}
}
// SubscriberBillingService.java — @Async wrapper calls @Retryable service.
// Content-hash key computed here, before the cross-bean call.
@Service
public class SubscriberBillingService {
@Autowired
private BillingChargeService billingChargeService;
@Async("billingTaskExecutor")
public CompletableFuture<String> chargeCustomerAsync(
String customerId, String planId,
long amountCents, String billingPeriod) {
// Key computed once in the @Async method, before calling @Retryable service.
// Passed as a parameter — not regenerated inside the @Retryable method body.
String idempotencyKey = "billing:" + customerId + ":" + planId + ":" + billingPeriod;
String chargeId = billingChargeService.charge(
customerId, planId, amountCents, billingPeriod, idempotencyKey);
return CompletableFuture.completedFuture(chargeId);
}
}
Failure mode 2: @Retryable placed on @Async annotated method — @Retryable is outer interceptor — @Async returns CompletableFuture before billing executes — @Retryable sees no exception — StripeException in async thread unreachable — developer adds .exceptionally() with new UUID.randomUUID() — UUID_B — ch_B
The developer wants @Async for thread isolation and @Retryable for Stripe retry logic, and places both annotations on the same billing service method. The intent is for each @Retryable attempt to run the Stripe charge asynchronously. The Spring AOP proxy composition produces a different outcome.
// SubscriberBillingService.java — UNSAFE: @Retryable + @Async on the same method.
// Developer expectation: "@Retryable retries the @Async billing task on StripeException."
// Actual behavior:
// @Retryable (outer interceptor, order 2,147,483,642) calls through to
// @Async (inner interceptor, order 2,147,483,647).
// @Async submits the billing work to billingTaskExecutor, immediately returns
// CompletableFuture to @Retryable — no exception thrown from the proxy method.
// @Retryable sees CompletableFuture as a successful return, exits.
// StripeException happens asynchronously in the thread pool, stored in the Future.
// @Retryable never fires. Zero retries.
@Service
public class SubscriberBillingService {
@Autowired
private StripeClient stripeClient;
@Async("billingTaskExecutor")
@Retryable(
retryFor = { StripeException.class, SocketTimeoutException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2.0)
)
public CompletableFuture<String> chargeCustomerAsync(
String customerId, String planId,
long amountCents, String billingPeriod) throws StripeException {
// This code runs in billingTaskExecutor thread pool, not in the @Retryable interceptor's
// call stack. @Retryable's RetryOperationsInterceptor is on the CALLING thread.
// When StripeException is thrown here, it propagates into the CompletableFuture's
// exceptional completion state — not into @Retryable's exception catch block.
String idempotencyKey = "billing:" + customerId + ":" + planId + ":" + billingPeriod;
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
Charge charge = stripeClient.charges().create(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return CompletableFuture.completedFuture(charge.getId());
}
}
The proxy invocation sequence when the @Scheduled orchestrator calls chargeCustomerAsync():
- The proxy is called.
@Retryable’sRetryOperationsInterceptorfires as the outermost interceptor (order 2,147,483,642). This is attempt 1 of the retry loop from@Retryable’s perspective. @Retryablecalls through to the next interceptor in the chain:@Async’sAsyncExecutionInterceptor(order 2,147,483,647).AsyncExecutionInterceptorserializes the method invocation (including its arguments) into aCallable, submits it tobillingTaskExecutor, and immediately returns aCompletableFutureto the calling thread — the scheduler thread. TheCompletableFutureis in a pending state; no billing work has executed yet.@Retryable’sRetryOperationsInterceptorreceives theCompletableFutureas the return value of the proxy method call. No exception was thrown from the proxy method.@Retryableconsiders the call successful. It exits — no retry is registered.- The
@Scheduledorchestrator receives theCompletableFuture. - Asynchronously in
billingTaskExecutor, the actual billing method body executes.stripeClient.charges().create()throwsStripeException. The exception propagates out of theCallableand is stored in theCompletableFuture’s exceptional state.@Retryable’s interceptor is not in this call stack — it ran and exited on the calling thread in step 4 above.
The developer discovers that @Retryable never fires and adds a .exceptionally() callback to the returned CompletableFuture as a workaround:
// MonthlyBillingJob.java — UNSAFE .exceptionally() workaround.
// Developer adds .exceptionally() to retry on failure.
// BUG: new UUID.randomUUID() inside .exceptionally() lambda generates UUID_B.
// If ch_A was committed on the original @Async attempt, the .exceptionally() retry
// sends UUID_B — Stripe sees a new charge intent — ch_B alongside ch_A.
@Scheduled(cron = "0 0 2 1 * *")
public void runMonthlyBilling() {
String billingPeriod = YearMonth.now().toString();
List<Subscriber> active = subscriberRepository.findAllActive();
active.forEach(subscriber -> {
CompletableFuture<String> future = billingService.chargeCustomerAsync(
subscriber.getCustomerId(), subscriber.getPlanId(),
subscriber.getAmountCents(), billingPeriod);
// BUG: UUID generated inside .exceptionally() lambda — a different UUID than
// the one used in the original @Async invocation. If the original @Async attempt
// committed ch_A before throwing StripeException, this retry with UUID_B triggers ch_B.
future.exceptionally(ex -> {
if (ex.getCause() instanceof StripeException) {
log.warn("Billing failed for {}, retrying with new key", subscriber.getCustomerId());
try {
// New UUID computed in the retry path — UUID_B — not correlated with UUID_A
// from the original @Async attempt that may have already committed ch_A.
String retryKey = subscriber.getCustomerId() + ":billing:"
+ subscriber.getPlanId() + ":" + billingPeriod
+ ":retry:" + UUID.randomUUID();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(subscriber.getAmountCents())
.setCurrency("usd")
.setCustomer(subscriber.getCustomerId())
.build();
stripeClient.charges().create(params,
RequestOptions.builder().setIdempotencyKey(retryKey).build());
} catch (StripeException retryEx) {
log.error("Retry billing also failed for {}", subscriber.getCustomerId());
}
}
return null;
});
});
}
The .exceptionally() callback’s retry path generates a new UUID.randomUUID() via the retryKey construction. The original @Async invocation used a content-hash key "billing:cust_abc:pro-annual:2026-10". The retry path uses "cust_abc:billing:pro-annual:2026-10:retry:UUID_B" — a different key entirely. If the original @Async attempt committed ch_A before throwing StripeException, the retry key UUID_B is unknown to Stripe, and Stripe commits ch_B.
Even if the developer uses the same base key in the retry but appends ":retry:" + UUID.randomUUID(), the UUID suffix makes each retry attempt have a unique key. The suffix exists so that the developer can “distinguish a retry attempt from the original attempt,” but this goal is incompatible with Stripe’s idempotency model: the idempotency key must match the original key for Stripe to recognize the request as a retry of a known operation rather than a new charge.
The fix for failure mode 2
@Retryable and @Async should not be placed on the same method. The correct pattern separates the two concerns: @Async on the dispatch method handles thread isolation; @Retryable on a separate synchronous service method handles retry logic. The @Async method body calls the @Retryable service via a cross-bean injection, traversing the retry proxy inside the async thread.
// BillingChargeService.java — synchronous @Retryable service.
// Called from within the @Async task body — cross-bean call traverses the proxy.
// @Retryable fires on StripeException inside the thread pool thread.
@Service
public class BillingChargeService {
@Autowired
private StripeClient stripeClient;
@Retryable(
retryFor = { StripeException.class, SocketTimeoutException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2.0)
)
public String charge(String customerId, String planId,
long amountCents, String billingPeriod,
String idempotencyKey) throws StripeException {
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
return stripeClient.charges().create(params,
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()).getId();
}
}
// SubscriberBillingService.java — @Async dispatch; @Retryable logic in injected service.
// @Async provides thread isolation. @Retryable is on BillingChargeService, not here.
// The content-hash idempotency key is computed before calling @Retryable service.
@Service
public class SubscriberBillingService {
@Autowired
private BillingChargeService billingChargeService;
@Async("billingTaskExecutor")
public CompletableFuture<String> chargeCustomerAsync(
String customerId, String planId,
long amountCents, String billingPeriod) {
// Idempotency key computed once in the @Async dispatch method.
// Passed to @Retryable service as a parameter — stable across all retries.
String idempotencyKey = "billing:" + customerId + ":" + planId + ":" + billingPeriod;
try {
String chargeId = billingChargeService.charge(
customerId, planId, amountCents, billingPeriod, idempotencyKey);
return CompletableFuture.completedFuture(chargeId);
} catch (StripeException e) {
// @Retryable exhausted all attempts. Log and propagate the final failure.
log.error("Billing permanently failed for customer {} after retries: {}",
customerId, e.getMessage());
return CompletableFuture.failedFuture(e);
}
}
}
With this pattern, @Retryable’s proxy is traversed by the cross-bean call from chargeCustomerAsync() to billingChargeService.charge() inside the thread pool thread. @Retryable’s RetryOperationsInterceptor wraps the synchronous charge() call. When StripeException is thrown from charge(), the interceptor catches it, waits for the backoff delay, and re-invokes charge() with the same arguments — including the same idempotencyKey value. @Async’s dispatch and @Retryable’s retry now operate on different method boundaries as intended.
Failure mode 3: CompletableFuture retry chain — retry lambda calls @Async billing method with UUID.randomUUID() generated inside the lambda — UUID_B — ch_B alongside committed ch_A
The developer chooses not to use @Retryable at all and instead builds retry logic directly into the CompletableFuture chain using .exceptionally(), .handle(), or .thenCompose(). The motivation is to keep retry logic co-located with the async dispatch in the @Scheduled orchestrator, or to use reactive-style composition rather than Spring’s annotation-driven retry. The bug arises from generating a new UUID.randomUUID() inside the retry lambda, which runs in a CompletableFuture completion thread when the original @Async task fails.
// MonthlyBillingJob.java — UNSAFE CompletableFuture retry chain.
// Developer builds retry using .exceptionally() + re-invocation of @Async billing method.
// BUG: billingPeriodKey built with UUID.randomUUID() inside the @Async method.
// The retry lambda calls chargeCustomerAsync() for the same customer again.
// The new @Async call generates a new UUID inside chargeCustomerAsync().
// UUID_B != UUID_A — if ch_A was committed on the original attempt, ch_B is a duplicate.
@Scheduled(cron = "0 0 2 1 * *")
public void runMonthlyBilling() {
String billingPeriod = YearMonth.now().toString();
List<Subscriber> active = subscriberRepository.findAllActive();
List<CompletableFuture<String>> futures = active.stream()
.map(subscriber -> {
CompletableFuture<String> initial = billingService.chargeCustomerAsync(
subscriber.getCustomerId(), subscriber.getPlanId(),
subscriber.getAmountCents(), billingPeriod);
// Retry via .exceptionally() callback.
// The callback fires when the initial @Async task completes exceptionally.
// The callback calls chargeCustomerAsync() again — a new @Async invocation.
// BUG: chargeCustomerAsync() internally generates UUID.randomUUID() per call.
// Each call to chargeCustomerAsync() produces a different UUID.
// The retry call produces UUID_B — a different key than UUID_A in the original call.
return initial.exceptionally(ex -> {
if (ex instanceof StripeException
|| (ex.getCause() instanceof StripeException)) {
log.warn("Initial billing failed for {}, retrying once",
subscriber.getCustomerId());
try {
// NEW invocation of chargeCustomerAsync — NEW UUID inside the method.
// UUID_B generated by this call's internal UUID.randomUUID().
return billingService.chargeCustomerAsync(
subscriber.getCustomerId(), subscriber.getPlanId(),
subscriber.getAmountCents(), billingPeriod).get();
} catch (Exception retryEx) {
log.error("Retry also failed for {}", subscriber.getCustomerId());
return null;
}
}
return null;
});
})
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
// chargeCustomerAsync() — @Async method with UUID.randomUUID() inside the method body.
// Each call to this method generates a new UUID — a new Stripe idempotency key.
// Two calls to this method for the same (customerId, planId, billingPeriod) generate
// two distinct keys. The original call uses UUID_A; the retry call uses UUID_B.
@Async("billingTaskExecutor")
public CompletableFuture<String> chargeCustomerAsync(
String customerId, String planId,
long amountCents, String billingPeriod) throws StripeException {
// BUG: UUID generated inside the method body.
// Every call to chargeCustomerAsync() — original or retry — generates a new UUID.
String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":" + UUID.randomUUID();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
Charge charge = stripeClient.charges().create(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return CompletableFuture.completedFuture(charge.getId());
}
The failure sequence:
- The scheduler dispatches
chargeCustomerAsync("cust_def", "team", 9900L, "2026-10"). Inside the@Asyncthread:idempotencyKey = "cust_def:billing:team:2026-10:" + UUID_A. Stripe commitsch_A. ASocketTimeoutExceptionprevents the 200 OK from arriving. TheCompletableFuturecompletes exceptionally. - The
.exceptionally()callback fires in aCompletableFuturecompletion thread. The callback callschargeCustomerAsync()again for the same customer. - The new
@Asyncinvocation runs in the thread pool:idempotencyKey = "cust_def:billing:team:2026-10:" + UUID_B.UUID_Bis a fresh call toUUID.randomUUID()— it is not the same value asUUID_Afrom step 1. - Stripe receives the retry charge request with
Idempotency-Key: cust_def:billing:team:2026-10:UUID_B. It has no record ofUUID_B. Stripe commitsch_B. Customercust_defis billed twice.
A subtler variant of this failure arises when the developer explicitly generates the UUID outside the @Async method and passes it in, but places the UUID generation inside the retry lambda rather than before the lambda is defined:
// UNSAFE variant: UUID generated inside the .exceptionally() lambda itself.
// Equivalent bug: each lambda invocation generates a new UUID, not correlated with UUID_A.
return initial.exceptionally(ex -> {
// BUG: UUID.randomUUID() called here — inside the lambda, not before the chain.
// This generates UUID_B at lambda execution time, which is after UUID_A was used in 'initial'.
String retryKey = customerId + ":billing:" + planId + ":" + billingPeriod
+ ":" + UUID.randomUUID();
try {
// ... direct Stripe call with retryKey ...
} catch (Exception e2) { return null; }
return null;
});
In this variant the developer bypassed the @Async method and calls Stripe directly inside the lambda, but still regenerates the UUID at lambda execution time. The outcome is identical: UUID_B at lambda execution time, ch_B if ch_A was committed on the original attempt.
The fix for failure mode 3
Generate the idempotency key once, before the CompletableFuture chain is assembled, and pass the same pre-computed key to all calls in the chain. The key is captured by the lambda closure; all retry invocations in the chain use the same key value.
// MonthlyBillingJob.java — SAFE: idempotency key computed before the CompletableFuture chain.
// Both the initial call and all retry calls use the same pre-computed key.
// Stripe deduplicates the retry against already-committed ch_A.
@Scheduled(cron = "0 0 2 1 * *")
public void runMonthlyBilling() {
String billingPeriod = YearMonth.now().toString();
List<Subscriber> active = subscriberRepository.findAllActive();
List<CompletableFuture<String>> futures = active.stream()
.map(subscriber -> {
// Content-hash key computed BEFORE the chain. Captured by all lambdas.
// All calls in the chain — initial and retry — use this same value.
final String idempotencyKey = "billing:"
+ subscriber.getCustomerId() + ":"
+ subscriber.getPlanId() + ":"
+ billingPeriod;
CompletableFuture<String> initial = billingService.chargeCustomerAsync(
subscriber.getCustomerId(), subscriber.getPlanId(),
subscriber.getAmountCents(), billingPeriod, idempotencyKey);
return initial.exceptionally(ex -> {
if (ex instanceof StripeException
|| (ex.getCause() instanceof StripeException)) {
log.warn("Initial billing failed for {}, retrying",
subscriber.getCustomerId());
try {
// Same idempotencyKey captured from outer scope.
// Stripe recognizes this as a retry of UUID_A and returns ch_A's result.
return billingService.chargeCustomerAsync(
subscriber.getCustomerId(), subscriber.getPlanId(),
subscriber.getAmountCents(), billingPeriod, idempotencyKey).get();
} catch (Exception retryEx) {
log.error("Retry failed for {}", subscriber.getCustomerId());
return null;
}
}
return null;
});
})
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
// chargeCustomerAsync() — SAFE: idempotency key received as a parameter.
// Callers supply the key; this method never generates it independently.
// The same key is used whether this is the initial call or a retry call.
@Async("billingTaskExecutor")
public CompletableFuture<String> chargeCustomerAsync(
String customerId, String planId,
long amountCents, String billingPeriod,
String idempotencyKey) throws StripeException {
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
Charge charge = stripeClient.charges().create(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return CompletableFuture.completedFuture(charge.getId());
}
The key insight is that a CompletableFuture retry chain is equivalent to a retry loop from Stripe’s idempotency perspective: each invocation of a Stripe charge endpoint is a separate HTTP request, and Stripe’s idempotency key must be the same on all of them for deduplication to work. Whether the retry is structured as a for-loop, an @Retryable interceptor, or a .exceptionally() chain makes no difference to Stripe — all three must carry the same idempotency key on all attempts.
Cross-mode structural analysis
The three failure modes in this post form a progression in how retry logic interacts with asynchronous execution, not merely three instances of the same root cause.
Mode 1 arises from the absence of a retry mechanism: the developer adds ad-hoc retry inside the @Async method body after discovering that StripeException is invisible to the @Scheduled orchestrator. The UUID generation error enters during the retrofit, when the developer adds UUID.randomUUID() inside the loop without recognizing that a retry must use the same key as the original attempt.
Mode 2 arises from incorrect annotation composition: the developer expects @Retryable and @Async to stack such that each @Retryable attempt re-executes the async billing logic. This expectation is plausible — other annotation combinations in Spring do compose as expected — but fails because @Async’s interceptor decouples the method return from the method execution. The CompletableFuture returned to @Retryable represents a pending asynchronous computation, not a completed result. @Retryable’s exception-catching logic operates on the synchronous return path only; it has no mechanism to observe the CompletableFuture’s eventual exceptional completion. The UUID error enters in the developer’s workaround, which adds a new UUID in the .exceptionally() callback after discovering that @Retryable is not working as expected.
Mode 3 arises from correct use of CompletableFuture composition but incorrect key scoping: the developer correctly uses .exceptionally() for retry, but generates the idempotency key in a location (inside the @Async method body, or inside the lambda) where it is regenerated on each invocation rather than fixed at the start of the billing intent.
The unifying fix across all three modes is the same: compute the idempotency key from immutable business data that identifies the billing intent, not from a random value that identifies a specific method call. The key must be computable from the same inputs that define the billing transaction — customerId + planId + billingPeriod — so that any retry, regardless of which thread or which lambda invocation generates it, produces the same key string.
| Mode | Root cause | UUID generation site | Why retry carries UUID_B |
|---|---|---|---|
1 — Manual loop in @Async body |
No retry mechanism; ad-hoc loop added | Inside for-loop body |
UUID.randomUUID() called at top of each loop iteration |
2 — @Retryable + @Async same method |
@Retryable never fires; CompletableFuture workaround added |
Inside .exceptionally() lambda |
New UUID.randomUUID() in retry lambda, not correlated with original key |
3 — CompletableFuture retry chain |
Correct pattern, wrong key scoping | Inside @Async method body (per-call) |
Second call to chargeCustomerAsync() generates a new UUID |
Testing patterns for these failure modes
Each failure mode requires a different test setup because they fail through different mechanisms.
Mode 1 test — verify that the manual retry loop sends the same idempotency key on all attempts. Use WireMock to stub the Stripe charge endpoint: return 503 Service Unavailable twice, then 200 OK on the third attempt. Assert that all three WireMock-recorded requests carry the same Idempotency-Key header value. This test fails before the fix (because the loop regenerates UUID per iteration, so each request carries a different key) and passes after the fix (all three requests carry the content-hash key).
// Mode 1 test — same idempotency key on all manual retry loop attempts.
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class SubscriberBillingServiceRetryTest {
@Autowired
private SubscriberBillingService billingService;
@Test
void manualRetryLoopSendsStableIdempotencyKey() throws Exception {
// Stub: fail twice, succeed on third attempt
stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("TransientStripe")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("failed-once"));
stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("TransientStripe")
.whenScenarioStateIs("failed-once")
.willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("failed-twice"));
stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("TransientStripe")
.whenScenarioStateIs("failed-twice")
.willReturn(aResponse().withStatus(200)
.withBody("{\"id\":\"ch_test\",\"object\":\"charge\"}")));
billingService.chargeCustomerAsync("cust_abc", "pro-annual", 11988L, "2026-10").get();
List<ServeEvent> events = getAllServeEvents();
assertThat(events).hasSize(3);
// All three requests must carry the same Idempotency-Key header.
List<String> keys = events.stream()
.map(e -> e.getRequest().header("Idempotency-Key").firstValue())
.collect(Collectors.toList());
assertThat(keys).allMatch(k -> k.equals(keys.get(0)));
}
}
Mode 2 test — verify that @Retryable placed on an @Async method does not retry and that the .exceptionally() workaround sends the same key. Two assertions: (a) stub all 3 Stripe calls to return 503 — assert WireMock received exactly 1 request (not 3), proving @Retryable never fired; (b) for the .exceptionally() workaround: stub the first request as 503 and the second as 200, assert both requests carry the same idempotency key.
Mode 3 test — verify that all invocations in the CompletableFuture chain carry the same idempotency key. Stub the first charge call as 503 and the second (triggered by .exceptionally()) as 200. Assert that both WireMock-recorded requests carry the same Idempotency-Key header, which is the content-hash key pre-computed before the chain. After the fix, the second call passes the same captured idempotencyKey variable and both keys match. Before the fix, the second call generates a new UUID inside chargeCustomerAsync() and the keys differ.
Comparison with related Spring posts on this site
This post’s failure modes are structurally distinct from all prior posts in this series. The Spring @Scheduled + @Retryable post covers the case where @Retryable is correctly wired on a separate service bean called from @Scheduled, and the bugs are UUID placement in the @Retryable method body, self-invocation proxy bypass, and singleton state across executions. The present post’s Mode 2 is a failure in @Retryable/@Async annotation composition — @Retryable is never effectively in the retry-eligible call chain at all — not a proxy bypass or key placement issue.
The Spring @Transactional + @Async post covers TransactionSynchronizationManager thread-local propagation failure when @Async creates a new thread, and how thenApply() lambdas regenerate UUIDs. The present post’s Mode 3 is a similar CompletableFuture chain UUID issue, but the mechanism is different: in the @Transactional + @Async post, the problem is UUID placement inside a thenApply() lambda on a CompletableFuture returned by the async method; in the present post’s Mode 3, the problem is UUID placement inside the @Async method itself, so every invocation of the method — original or retry — generates a new UUID.
The Spring @EventListener + @Retryable post covers @TransactionalEventListener’s proxy bypass via afterCommit(). The present post’s Mode 2 is also a case where @Retryable never fires due to annotation composition, but the mechanism differs: in the event listener post, the bypass is caused by Spring’s synchronization infrastructure calling the method via Method.invoke(targetBean, ...) directly; in the present post, the cause is @Async’s interceptor returning a CompletableFuture to @Retryable before the billing work executes. The event listener bypass is a proxy invocation issue; the @Async + @Retryable issue is an asynchrony-of-exceptions issue.
Mode 1’s manual retry loop with UUID.randomUUID() per iteration is the direct @Async equivalent of the manual loop in the @TransactionalEventListener post’s Mode 2, and of the manual loop in the @Scheduled + @Retryable post’s Mode 2 (self-invocation workaround). All three are the same root cause — developer adds a for-loop after discovering a retry mechanism doesn’t fire, generates new UUID per iteration — but in each post the reason the retry mechanism doesn’t fire is different: AOP proxy not traversed in self-invocation (@Scheduled post), afterCommit() bypassing proxy (@EventListener post), and exception captured in CompletableFuture (this post).
Keybrake — idempotency enforcement for Stripe API calls
Keybrake intercepts your outbound Stripe requests, validates that every charge and payment-intent carries a stable idempotency key, and blocks requests that would generate a duplicate charge. Catches UUID-in-retry-loop bugs, UUID-in-CompletableFuture-chain bugs, and “@Retryable wraps @Async” misconfigurations before they reach Stripe.