Spring Boot @Async + @Transactional and Stripe Integration: How CompletableFuture Retry Loops, @Retryable on @Transactional Service Calling @Async Sub-service, and exceptionallyCompose() Chain Generate New Idempotency Keys
When a Spring Boot application uses @Async to offload Stripe calls to a thread pool and adds retry logic for transient errors, three structurally distinct mechanisms each silently generate a new idempotency key on every retry attempt — and Stripe creates a second charge. In all three cases the root cause is the same: UUID.randomUUID() is placed at a scope that re-executes per retry rather than once per billing intent, and the combination of CompletableFuture task submission, @Async proxy behavior, and @Transactional propagation rules make it non-obvious when that boundary is crossed.
This post covers failure modes that are distinct from the earlier post on @Async + @Transactional AOP ordering, which examined which proxy wraps which and how the resulting interceptor stack affects transaction visibility. The three modes here focus on a different set of problems: how CompletableFuture task submission semantics interact with retry loops, how @Retryable propagates retries across an @Async service boundary, and how exceptionallyCompose()-based recovery interacts with both the async thread model and outer @Transactional state. All three produce duplicate charges in production while passing unit tests that mock the Stripe SDK.
Background: CompletableFuture.supplyAsync() is eager and executes its supplier once per supplyAsync() call
A CompletableFuture in Java is a hot, eager future. When you call CompletableFuture.supplyAsync(supplier, executor), the executor immediately schedules the supplier for execution. The supplier runs exactly once per supplyAsync() call — not once per subscription (there is no subscription concept in CompletableFuture). If you call supplyAsync() three times, three separate tasks are submitted and three separate supplier invocations occur.
This is a fundamental difference from cold reactive publishers like Mono.fromCallable() (Project Reactor) or Single.fromCallable() (RxJava3). Those are lazy: the callable runs only when a subscriber subscribes. But even with those lazy publishers, retry operators re-subscribe to the upstream publisher on each retry, causing the callable to run again. The underlying issue is the same: any code in the “per-attempt” boundary — whether that is a supplier passed to supplyAsync(), a callable passed to fromCallable(), or a method body invoked via proceed() — executes per attempt. UUID.randomUUID() placed in any such boundary regenerates per attempt.
With @Async specifically, there is an additional layer: each call to an @Async-annotated method through the Spring AOP proxy causes the method body to be submitted to the configured TaskExecutor as a new Callable. The method body is the equivalent of the supplier in supplyAsync(). Each call to the @Async method = one new task submission = one new method body execution = one new evaluation of every statement in the method body including UUID.randomUUID().
Mode 1: UUID inside CompletableFuture.supplyAsync() lambda in @Async service — caller retry loop calls @Async method again per attempt — each call creates a new supplyAsync() task — lambda re-evaluates — UUID_B — ch_B
The first failure mode arises when a developer places UUID.randomUUID() inside the CompletableFuture.supplyAsync() lambda in an @Async service method and the caller implements retry by calling the @Async method again. This is the most direct form of the per-invocation UUID problem, and it is especially common in codebases where the @Async service method was written to be “self-contained” — generating its own idempotency key so that callers do not need to think about it.
// StripeAsyncService.java
@Service
public class StripeAsyncService {
// @Async proxy intercepts this method call and submits the body to taskExecutor.
// Each call to chargeCustomerAsync() = one new task submitted = one new supplyAsync() = one new lambda execution.
@Async("stripeExecutor")
public CompletableFuture<String> chargeCustomerAsync(String customerId,
int amountCents,
String billingPeriod) {
// UUID inside the lambda — runs per supplyAsync() call.
// Each retry from the caller calls this method again.
// Each method call submits a new supplyAsync() task.
// Lambda re-evaluates — UUID.randomUUID() generates UUID_B on retry — UUID_B — ch_B.
return CompletableFuture.supplyAsync(() -> {
String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
try {
Charge charge = Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
} catch (StripeException e) {
throw new RuntimeException(e);
}
}, stripeBlockingExecutor); // blocking executor for Stripe SDK
}
}
The caller implements retry by calling the method again in a loop:
// BillingOrchestrator.java
@Service
public class BillingOrchestrator {
private final StripeAsyncService stripeService;
private final BillingAuditRepository auditRepository;
@Transactional
public String processCharge(String customerId, int amountCents, String billingPeriod) {
int maxAttempts = 3;
Exception lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
// Each iteration calls stripeService.chargeCustomerAsync() again.
// Each call = new @Async task submission = new supplyAsync() = new lambda execution.
// UUID.randomUUID() inside the lambda generates a fresh UUID per attempt.
// On attempt 1: UUID_A → Stripe commits ch_A (network timeout before response).
// On attempt 2: UUID_B → Stripe creates ch_B (ch_A already committed).
CompletableFuture<String> future = stripeService.chargeCustomerAsync(
customerId, amountCents, billingPeriod);
String chargeId = future.get(10, TimeUnit.SECONDS);
auditRepository.recordCharge(customerId, billingPeriod, chargeId);
return chargeId;
} catch (ExecutionException ex) {
lastException = (Exception) ex.getCause();
if (!isRetryable(ex.getCause()) || attempt == maxAttempts) break;
try { Thread.sleep(200L * attempt); } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); break;
}
} catch (TimeoutException | InterruptedException ex) {
lastException = ex;
// timeout — Stripe may have committed ch_A, response never arrived
if (attempt == maxAttempts) break;
}
}
throw new BillingException("Stripe charge failed after " + maxAttempts + " attempts", lastException);
}
}
The failure sequence. Attempt 1 calls chargeCustomerAsync(). Spring’s @Async proxy intercepts the call, creates a Callable wrapping the method body, and submits it to stripeExecutor. The method body runs in an executor thread. CompletableFuture.supplyAsync() submits the lambda to a secondary stripeBlockingExecutor. The lambda evaluates: UUID.randomUUID() generates UUID_A. The Stripe SDK sends a POST to /v1/charges with Idempotency-Key: UUID_A. Stripe validates the request, creates ch_A, and begins sending the HTTP response. A network disturbance terminates the connection before the SDK receives the response body. The SDK throws a StripeException wrapping an IOException. The lambda wraps it in RuntimeException and propagates. The CompletableFuture completes exceptionally. future.get() in the caller throws ExecutionException.
isRetryable() returns true for network errors. The loop increments attempt to 2 and calls chargeCustomerAsync() again. A new @Async task is submitted. A new CompletableFuture.supplyAsync() call is made. The lambda runs again. UUID.randomUUID() generates UUID_B. The Stripe SDK sends a POST with Idempotency-Key: UUID_B. Stripe has no record of UUID_B — UUID_A was committed as ch_A — and creates ch_B. The customer is charged twice.
Why “self-contained key generation” is incompatible with caller-side retry
The design intent of placing UUID.randomUUID() inside the async service method is to keep the service self-contained: callers do not need to generate or manage idempotency keys. This is a reasonable encapsulation goal but it is architecturally incompatible with caller-side retry. Caller-side retry means the caller controls how many times the operation runs. If the service generates a new key each time it runs, the service and the caller have conflicting contracts: the caller says “this is the same billing operation, retry it”; the service says “this is a new billing operation, here is a new key.” Stripe follows the service’s signal (a new key = a new charge request) and ignores the caller’s intent.
The resolution is to assign responsibility for idempotency key generation to the scope that defines “this billing operation.” That scope is the caller: the caller knows what constitutes a single billing intent, how many times it retries, and what parameters define uniqueness. The service receives the key as a parameter and passes it through unchanged.
Fix for mode 1
Compute the idempotency key in the caller before the retry loop, as a content-hash of the stable billing parameters. Pass the precomputed key to the @Async service as a method parameter. The service uses the passed key without generating a new one. Every retry iteration calls the service with the same key. Stripe deduplicates the second and subsequent requests against the first committed charge.
// StripeAsyncService.java — key as method parameter
@Service
public class StripeAsyncService {
@Async("stripeExecutor")
public CompletableFuture<String> chargeCustomerAsync(String customerId,
int amountCents,
String billingPeriod,
String idempotencyKey) { // passed in
return CompletableFuture.supplyAsync(() -> {
// idempotencyKey is a method parameter — the same value on every call
// regardless of how many times the caller retries.
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
try {
Charge charge = Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
} catch (StripeException e) {
throw new RuntimeException(e);
}
}, stripeBlockingExecutor);
}
}
// BillingOrchestrator.java — key computed once, before the retry loop
@Transactional
public String processCharge(String customerId, int amountCents, String billingPeriod) {
// Content-hash key — stable across all retry iterations.
// Also stable if this method is called again with the same arguments (e.g., job re-run).
final String idempotencyKey = "charge:" + customerId + ":" + amountCents + ":" + billingPeriod;
int maxAttempts = 3;
Exception lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
CompletableFuture<String> future = stripeService.chargeCustomerAsync(
customerId, amountCents, billingPeriod, idempotencyKey);
String chargeId = future.get(10, TimeUnit.SECONDS);
auditRepository.recordCharge(customerId, billingPeriod, chargeId);
return chargeId;
} catch (ExecutionException ex) {
lastException = (Exception) ex.getCause();
if (!isRetryable(ex.getCause()) || attempt == maxAttempts) break;
try { Thread.sleep(200L * attempt); } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); break;
}
} catch (TimeoutException | InterruptedException ex) {
lastException = ex;
if (attempt == maxAttempts) break;
}
}
throw new BillingException("Stripe charge failed after " + maxAttempts + " attempts", lastException);
}
The idempotencyKey is computed once before the loop. The loop variable attempt changes on each iteration; idempotencyKey does not. Each call to chargeCustomerAsync() receives the same string. Each supplyAsync() lambda receives the same effectively-final captured value. Stripe receives the same Idempotency-Key on every attempt and deduplicates: if ch_A was committed on attempt 1, Stripe returns ch_A on attempt 2 without creating ch_B.
Mode 2: @Retryable on outer @Transactional service calls @Async inner service per retry attempt — each @Async call is a fresh method body execution — UUID at @Async method scope regenerates — UUID_B — ch_B
The second failure mode arises in a layered service design where an outer service has both @Retryable and @Transactional, and it delegates the Stripe API call to an inner @Async service. The outer service blocks on the CompletableFuture with .get(). When the Stripe call fails and the exception propagates through .get(), @Retryable retries the outer service method, which calls the inner @Async service again. The inner service generates a new UUID on every invocation — UUID_B — ch_B.
This mode is structurally different from mode 1 in a critical way: the retry mechanism is @Retryable via AOP proxy interception (proceed() semantics), not a manual retry loop. The distinction matters because @Retryable’s RetryOperationsInterceptor calls context.proceed() to re-invoke the method. Each proceed() executes the outer service method body from scratch, which in turn calls the inner @Async service method. Each call to the inner @Async service method dispatches a new task to the async executor. Each task execution runs the @Async method body. UUID.randomUUID() at the top of the @Async method body generates a new UUID per dispatch.
// StripeAsyncService.java — inner @Async service
@Service
public class StripeAsyncService {
// UUID at method scope — executes once per @Async method invocation.
// Each call to this method from the outer @Retryable service = one new invocation.
// Each invocation dispatches a new task to the async executor.
// The method body runs in the async thread: UUID.randomUUID() generates UUID_B on retry.
@Async("stripeExecutor")
public CompletableFuture<String> charge(String customerId, int amountCents,
String billingPeriod) {
// UUID at method scope — UNSAFE when outer @Retryable calls this method again.
// Each @Retryable proceed() call = new outer method body = new call to this method
// = new @Async dispatch = new method body execution = new UUID.
final String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
return CompletableFuture.supplyAsync(() -> {
try {
Charge c = Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return c.getId();
} catch (StripeException e) {
throw new RuntimeException(e);
}
}, stripeBlockingExecutor);
}
}
// BillingService.java — outer @Retryable + @Transactional service
@Service
public class BillingService {
private final StripeAsyncService stripeService;
private final BillingAuditRepository auditRepository;
@Retryable(
retryFor = { BillingRetryableException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 200, multiplier = 2)
)
@Transactional
public String chargeAndAudit(String customerId, int amountCents, String billingPeriod) {
try {
// Calls stripeService.charge() — @Async proxy submits to executor.
// @Retryable retries this entire method body on BillingRetryableException.
// Each retry = new proceed() call = new chargeAndAudit() body = new stripeService.charge() call
// = new @Async task = new method body in async thread = new UUID.randomUUID() = UUID_B.
CompletableFuture<String> future = stripeService.charge(customerId, amountCents, billingPeriod);
String chargeId = future.get(10, TimeUnit.SECONDS); // blocks in @Transactional thread
auditRepository.recordCharge(customerId, billingPeriod, chargeId);
return chargeId;
} catch (ExecutionException ex) {
Throwable cause = ex.getCause();
if (isRetryable(cause)) {
throw new BillingRetryableException("Stripe call failed, retrying", cause);
}
throw new BillingException("Stripe call failed (non-retryable)", cause);
} catch (TimeoutException ex) {
// Stripe may have committed ch_A — response lost to timeout.
// @Retryable will retry with a new UUID_B — ch_B.
throw new BillingRetryableException("Stripe call timed out", ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new BillingException("Interrupted waiting for Stripe", ex);
}
}
}
The failure sequence. The first @Retryable attempt calls proceed() on the outer method, which runs chargeAndAudit(). Inside, stripeService.charge() is called. Spring’s @Async proxy submits the StripeAsyncService.charge() method body to stripeExecutor. The body executes: UUID.randomUUID() generates UUID_A. supplyAsync() submits the Stripe SDK call to stripeBlockingExecutor. The SDK sends a POST with Idempotency-Key: UUID_A. Stripe creates ch_A and begins sending the response. A network timeout fires before the SDK receives the response. The CompletableFuture completes exceptionally. future.get() throws ExecutionException. isRetryable(cause) returns true. BillingRetryableException is thrown from chargeAndAudit().
@Retryable’s RetryOperationsInterceptor catches BillingRetryableException. The retry policy allows a second attempt. The interceptor calls context.proceed() again. chargeAndAudit() runs from the beginning. stripeService.charge() is called again. Spring’s @Async proxy submits a new task to stripeExecutor. The StripeAsyncService.charge() method body executes again in the async thread: UUID.randomUUID() generates UUID_B. The Stripe SDK sends a POST with Idempotency-Key: UUID_B. Stripe has no record of UUID_B and creates ch_B. The customer is charged twice.
The @Transactional propagation break across @Async boundary
Mode 2 has an additional subtlety that developers often discover simultaneously with the duplicate charge: the @Async method’s @Transactional annotation (if present) does not participate in the outer transaction. Spring’s transaction management is based on ThreadLocal storage. The outer @Transactional service method runs in thread T-1. Spring binds the transaction to T-1’s ThreadLocal. When the @Async proxy submits the inner service method to the executor, it runs in thread T-2. T-2’s ThreadLocal has no transaction bound to it. The inner @Async method’s @Transactional annotation starts a new, independent transaction in T-2 — or, if the inner method has no @Transactional, the inner method runs without any transaction context.
This means a rollback of the outer @Transactional in T-1 does not roll back any database writes made by the inner @Async method in T-2. The inner method’s transaction commits independently when the inner method finishes (if the inner method has @Transactional and succeeds). Developers who believe that “if the outer transaction rolls back, the Stripe charge is safe because the database record is also rolled back” are only correct about the database — the Stripe charge on the external API is not rolled back by any Spring transaction rollback, and the database records from the @Async method are governed by the inner method’s independent transaction, not the outer one.
This is a separate correctness concern from the duplicate charge, but it surfaces in the same failure scenario: when a Stripe call in an @Async method succeeds and the outer @Transactional method subsequently throws an unrelated exception and rolls back, the Stripe charge is committed and the audit record (written in the inner @Async method) is also committed — but the outer transaction rollback may have silently skipped writing to a different table that the developer expected to be covered by the outer transaction.
Fix for mode 2
The fix follows the same principle as mode 1: compute the idempotency key in the scope that defines the billing intent — the outermost method boundary that controls retry. In mode 2, that is the @Retryable outer service method. The key is computed once per chargeAndAudit() invocation by a human caller (before @Retryable retries). But @Retryable calls chargeAndAudit()’s method body via proceed(), and each proceed() is a fresh method body execution — a UUID at the top of chargeAndAudit() would also regenerate per proceed() call.
The correct anchor is the caller of chargeAndAudit(): the controller, batch job, or orchestrator that decides what constitutes one billing attempt. That scope computes the key and passes it as a parameter. Both chargeAndAudit() and stripeService.charge() receive the key as a parameter and pass it through. @Retryable’s proceed() retries chargeAndAudit() with the same parameter values — the same key — and chargeAndAudit() passes the same key to stripeService.charge() on every retry.
// StripeAsyncService.java — key as parameter
@Service
public class StripeAsyncService {
@Async("stripeExecutor")
public CompletableFuture<String> charge(String customerId, int amountCents,
String billingPeriod,
String idempotencyKey) { // passed in
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
return CompletableFuture.supplyAsync(() -> {
try {
Charge c = Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return c.getId();
} catch (StripeException e) {
throw new RuntimeException(e);
}
}, stripeBlockingExecutor);
}
}
// BillingService.java — key as parameter, passed through
@Retryable(retryFor = { BillingRetryableException.class }, maxAttempts = 3,
backoff = @Backoff(delay = 200, multiplier = 2))
@Transactional
public String chargeAndAudit(String customerId, int amountCents,
String billingPeriod, String idempotencyKey) { // passed in
try {
CompletableFuture<String> future = stripeService.charge(
customerId, amountCents, billingPeriod, idempotencyKey);
String chargeId = future.get(10, TimeUnit.SECONDS);
auditRepository.recordCharge(customerId, billingPeriod, chargeId);
return chargeId;
} catch (ExecutionException ex) {
Throwable cause = ex.getCause();
if (isRetryable(cause)) throw new BillingRetryableException("Stripe failed, retrying", cause);
throw new BillingException("Stripe failed (non-retryable)", cause);
} catch (TimeoutException ex) {
throw new BillingRetryableException("Stripe timed out", ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new BillingException("Interrupted", ex);
}
}
// Caller — computes key once, passes to service
@Service
public class BillingController {
public String processSubscription(String customerId, int amountCents, String period) {
// Content-hash key — stable for this billing intent across all @Retryable retries.
String key = "charge:" + customerId + ":" + amountCents + ":" + period;
return billingService.chargeAndAudit(customerId, amountCents, period, key);
}
}
Why moving UUID to chargeAndAudit() method scope does not fix mode 2
A developer who reads about the mode 1 fix might try moving UUID.randomUUID() from inside the @Async method body to the top of the outer chargeAndAudit() method. This does not work for mode 2. @Retryable’s RetryOperationsInterceptor retries by calling context.proceed(), which re-invokes the method body. Every statement in the method body — including a UUID.randomUUID() call at the top of chargeAndAudit(), before the stripeService.charge() call — re-executes per proceed() call. The UUID is inside the @Retryable-intercepted method body; the interceptor’s retry mechanism re-invokes that body. Moving the UUID generation to method scope fixes the per-supplyAsync-lambda problem (mode 1) but does nothing about the per-proceed-invocation problem (mode 2). The fix must be at the caller scope, outside the @Retryable-intercepted method boundary.
Mode 3: exceptionallyCompose() recovery calls @Async service again — each recovery invocation is a new method body execution — UUID at @Async method scope regenerates — UUID_B — ch_B — plus outer @Transactional rollback-only complication on TimeoutException
The third failure mode uses CompletableFuture.exceptionallyCompose() (Java 12+) or handle()/thenCompose() (Java 8+) to implement retry inline within the CompletableFuture chain, rather than in a loop or via @Retryable. The recovery function calls the @Async service method again. Because each call to the @Async service is a new method invocation, UUID generated at the @Async method scope regenerates — UUID_B — ch_B. An additional Stripe-specific complication arises when this pattern is used inside a @Transactional outer method that blocks with .get().
// StripeAsyncService.java — UUID at @Async method scope
@Service
public class StripeAsyncService {
@Async("stripeExecutor")
public CompletableFuture<String> charge(String customerId, int amountCents,
String billingPeriod) {
// UUID at method scope — correct for a single call.
// UNSAFE: exceptionallyCompose() in the caller calls this method again per recovery.
// Each recovery call = new @Async dispatch = new method body execution = new UUID.randomUUID().
final String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
return CompletableFuture.supplyAsync(() -> {
try {
return Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
).getId();
} catch (StripeException e) {
throw new RuntimeException(e);
}
}, stripeBlockingExecutor);
}
}
// BillingService.java — exceptionallyCompose() retry chain
@Service
public class BillingService {
@Transactional
public String chargeWithComposedRetry(String customerId, int amountCents, String billingPeriod) {
// First attempt.
CompletableFuture<String> chargeIdFuture = stripeService.charge(customerId, amountCents, billingPeriod)
.exceptionallyCompose(ex -> {
// Recovery: called when the first attempt's future completes exceptionally.
// Calls stripeService.charge() again — new @Async dispatch — new method body — new UUID.
if (isRetryable(ex)) {
return stripeService.charge(customerId, amountCents, billingPeriod); // UUID_B
}
throw new CompletionException(ex);
});
try {
// Blocking inside @Transactional: the @Transactional transaction is held open
// for the duration of the CompletableFuture computation.
// If .get() throws TimeoutException, Spring marks the @Transactional rollback-only.
// Subsequent operations inside this @Transactional boundary throw UnexpectedRollbackException.
String chargeId = chargeIdFuture.get(10, TimeUnit.SECONDS);
auditRepository.recordCharge(customerId, billingPeriod, chargeId);
return chargeId;
} catch (ExecutionException ex) {
throw new BillingException("Charge failed", ex.getCause());
} catch (TimeoutException ex) {
// @Transactional may mark rollback-only here depending on exception handling config.
// Stripe may have committed ch_A before the timeout — the retry in exceptionallyCompose()
// will have already run (before .get() returned) with UUID_B — ch_B.
throw new BillingException("Charge timed out", ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new BillingException("Interrupted", ex);
}
}
}
The failure sequence has two parts. First, the duplicate charge. The first call to stripeService.charge() dispatches an @Async task. The task generates UUID_A and calls Stripe. Stripe commits ch_A and begins sending the response. A network failure terminates the connection. The first CompletableFuture completes exceptionally. exceptionallyCompose()’s recovery function fires. It calls stripeService.charge() again. A new @Async task is dispatched. The new task generates UUID_B. The Stripe SDK sends a POST with Idempotency-Key: UUID_B. Stripe has no record of UUID_B and creates ch_B. The customer is charged twice. This second call completes successfully. The exceptionallyCompose() chain returns a completed future with ch_B’s ID.
Now the @Transactional complication. chargeIdFuture.get(10, TimeUnit.SECONDS) blocks in the @Transactional thread (T-1). Both async tasks — the first failing attempt and the exceptionallyCompose() retry — execute in async executor threads. The @Transactional on T-1 holds an open database connection for the entire duration of both async calls. If the total time for attempt 1 + network failure detection + retry attempt 2 exceeds 10 seconds, get() throws TimeoutException in T-1. Spring’s @Transactional propagation for unchecked exceptions and RuntimeException-derived types marks the transaction rollback-only. The auditRepository.recordCharge() call never executes. When the outer @Transactional transaction commits, Spring finds it rollback-only and commits a rollback instead.
The result: Stripe committed ch_A (before timeout) and possibly ch_B (in the exceptionallyCompose() recovery, which may have completed before or after the timeout), but the @Transactional rollback left no audit record in the database. The application loses track of which charges were committed. The customer may be charged twice with no database evidence of either charge.
Why exceptionallyCompose() fires before get() returns
Developers sometimes believe that exceptionallyCompose() is deferred until after the whole chain is observed — that is, that it fires after .get(). It does not. exceptionallyCompose() is a non-blocking pipeline stage. When the upstream future completes exceptionally, the downstream exceptionallyCompose() stage fires immediately in the thread that completed the upstream future (or in the default async executor if the stage was attached asynchronously). This is independent of whether anyone has called .get() on the final future. In the failure scenario above, exceptionallyCompose() fires in the async executor thread the moment the first Stripe call’s CompletableFuture completes exceptionally — which happens before the 10-second timeout fires in T-1. By the time get() throws TimeoutException in T-1, the exceptionallyCompose() retry has already been submitted and may already be running. The duplicate charge is in flight or already committed before the @Transactional thread detects the timeout.
Fix for mode 3
The fix has two parts: eliminate the UUID regeneration, and eliminate the pattern of blocking with get() inside a @Transactional method on a future that may retry internally.
For the UUID regeneration: compute the key in the scope that defines the billing intent and pass it as a parameter through the entire chain. The scope that defines the intent for an exceptionallyCompose() chain is the method that creates the initial future — chargeWithComposedRetry(). But chargeWithComposedRetry() is itself a candidate for retry (e.g., via @Retryable at a higher level), so the safest placement is the external caller, as in modes 1 and 2.
For the @Transactional + blocking interaction: decouple the async computation from the database write. Do not block inside a @Transactional method on a future that may take arbitrarily long. Instead, use CompletableFuture.thenApply() or thenCompose() to attach the database write as a pipeline stage that executes after the Stripe call completes, outside the @Transactional boundary in the main thread. Use a separate @Transactional method for the audit write, called from the thenApply() stage in the async thread.
// StripeAsyncService.java — key as parameter
@Async("stripeExecutor")
public CompletableFuture<String> charge(String customerId, int amountCents,
String billingPeriod,
String idempotencyKey) { // passed in
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
return CompletableFuture.supplyAsync(() -> {
try {
return Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
).getId();
} catch (StripeException e) { throw new RuntimeException(e); }
}, stripeBlockingExecutor);
}
// AuditService.java — separate @Transactional for DB write
@Service
public class AuditService {
@Transactional
public void recordCharge(String customerId, String billingPeriod, String chargeId) {
auditRepository.save(new BillingAudit(customerId, billingPeriod, chargeId));
}
}
// BillingService.java — decoupled async chain, no @Transactional blocking
@Service
public class BillingService {
public CompletableFuture<String> chargeWithComposedRetry(String customerId,
int amountCents,
String billingPeriod) {
// Content-hash key — stable across all exceptionallyCompose() retries.
final String idempotencyKey = "charge:" + customerId + ":" + amountCents + ":" + billingPeriod;
return stripeService.charge(customerId, amountCents, billingPeriod, idempotencyKey)
.exceptionallyCompose(ex -> {
if (isRetryable(ex)) {
// Same idempotencyKey — Stripe deduplicates against ch_A if ch_A was committed.
return stripeService.charge(customerId, amountCents, billingPeriod, idempotencyKey);
}
throw new CompletionException(ex);
})
.thenApply(chargeId -> {
// DB write in async thread — separate @Transactional, not blocking the caller.
auditService.recordCharge(customerId, billingPeriod, chargeId);
return chargeId;
});
}
}
With this structure, idempotencyKey is computed once before the chain. Both the initial stripeService.charge() call and the exceptionallyCompose() recovery call receive the same key. If Stripe committed ch_A on the first attempt, the recovery call with the same key returns ch_A without creating ch_B. The database audit write happens after the Stripe call completes, in the async thread, under a fresh @Transactional boundary that is not held open across the Stripe HTTP roundtrip.
Cross-mode comparison: what re-executes per retry, and where the key must live
| Mode | Retry mechanism | What re-executes | UUID must live at |
|---|---|---|---|
| 1: retry loop | Manual loop calling @Async method again |
Entire @Async method body + supplyAsync() lambda per call |
Caller scope, before retry loop |
2: @Retryable |
@Retryable AOP proceed() on outer @Transactional method |
Outer method body (which calls @Async service again) |
Caller of @Retryable method, passed as parameter |
3: exceptionallyCompose() |
exceptionallyCompose() recovery calling @Async service again |
@Async method body per recovery call |
Method building the chain, before first .charge() call |
All three modes share the same structural root cause: the boundary that re-executes per retry and the boundary where the UUID is generated are the same boundary. The fix in all three cases is to move UUID generation one scope outward — to a scope that executes once per billing intent, not once per retry attempt.
The cross-mode contrast that matters most is mode 2 versus modes 1 and 3. In modes 1 and 3, moving UUID generation from inside the lambda to the @Async method scope fixes the per-lambda regeneration. In mode 2, even if UUID is at the @Async method scope, @Retryable’s proceed() re-invokes the outer @Transactional method, which calls the @Async service again, triggering a new @Async method body execution. Moving UUID to the outer method scope (inside the @Transactional method) does not help either — proceed() re-invokes the outer method body too. The only scope outside all three of these boundaries is the caller of the @Retryable method.
The content-hash key and why it outperforms UUID even at the correct scope
All three fixes above use a content-hash key of the form "charge:" + customerId + ":" + amountCents + ":" + billingPeriod rather than moving a UUID.randomUUID() call to the correct scope. A UUID at the correct scope eliminates the retry-regeneration problem: one UUID is generated per billing intent, and all retry attempts use the same value. But a UUID at the correct scope does not eliminate the job-re-run problem.
Consider a monthly billing job that fails after processing 3,000 customers and is re-run from scratch. For each customer, the job creates a new UUID (because the job is a fresh JVM invocation, and UUID generation at the caller scope means “once per this job run” for this customer). If the billing job generated and committed ch_A for customer X in the first run before failing, the re-run generates a new UUID for customer X and calls Stripe, producing ch_B. Stripe has no way to know this is a re-run of the same billing intent from the prior job execution because the key changed.
A content-hash key derived from (customerId, amountCents, billingPeriod) is deterministic and produces the same string regardless of JVM invocation, thread, or run count. A billing job re-run with the same parameters for customer X produces the same key, and Stripe deduplicates the second attempt against ch_A — returning ch_A without creating ch_B. This is the full idempotency guarantee: not just retry safety within one job run, but re-run safety across job executions.
One constraint on content-hash keys: the hash input must uniquely identify the billing intent. Including the billing period (billingPeriod, e.g., "2026-10") ensures that a legitimate charge for the same customer in a different billing period uses a different key and is not incorrectly deduplicated against the prior month’s charge. The hash input should include every dimension that distinguishes one billing intent from another: customer ID, amount, currency if variable, billing period, and any other semantic differentiator relevant to the business logic.
Test patterns: catching mode 1, mode 2, and mode 3 before they reach production
These three failure modes require integration tests that capture all Stripe API calls and assert on the idempotency key values sent across all attempts. Unit tests that mock the Stripe SDK at the method level hide the retry behavior — the mock returns a success or failure value without executing the retry path. The key assertion in all three patterns is: the set of distinct idempotency key values seen across all captured requests should have cardinality 1.
// Integration test setup — WireMock captures all Stripe requests
@SpringBootTest
@AutoConfigureMockMvc
class BillingIdempotencyTest {
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig().port(8089))
.build();
// Mode 1 test: retry loop — assert one unique idempotency key across all requests
@Test
void retryLoop_sameIdempotencyKeyOnAllAttempts() {
// First attempt: Stripe 503 (simulates transient error after possible charge commit)
wm.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry").whenScenarioStateIs(STARTED)
.willReturn(serverError().withStatus(503))
.willSetStateTo("attempt-2"));
// Second attempt: Stripe 200 with charge ID
wm.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry").whenScenarioStateIs("attempt-2")
.willReturn(okJson("{\"id\":\"ch_test\",\"object\":\"charge\",\"status\":\"succeeded\"}")));
billingOrchestrator.processCharge("cus_test", 2000, "2026-10");
List<LoggedRequest> captured = wm.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(captured).hasSize(2);
// Extract all Idempotency-Key header values
Set<String> keys = captured.stream()
.map(req -> req.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
// ASSERT: exactly one unique key across all retry attempts
assertThat(keys).hasSize(1);
assertThat(keys.iterator().next()).isNotEmpty();
}
// Mode 2 test: @Retryable — same key across @Retryable proceed() calls
@Test
void retryable_sameIdempotencyKeyOnAllProceedCalls() {
wm.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retryable").whenScenarioStateIs(STARTED)
.willReturn(serverError().withStatus(503))
.willSetStateTo("retry-1"));
wm.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retryable").whenScenarioStateIs("retry-1")
.willReturn(okJson("{\"id\":\"ch_test\",\"object\":\"charge\",\"status\":\"succeeded\"}")));
// Key computed in caller and passed as parameter
String key = "charge:cus_test:2000:2026-10";
billingService.chargeAndAudit("cus_test", 2000, "2026-10", key);
List<LoggedRequest> captured = wm.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(captured).hasSize(2);
Set<String> keys = captured.stream()
.map(req -> req.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(keys).hasSize(1);
assertThat(keys.iterator().next()).isEqualTo(key); // content-hash — deterministic
}
// Mode 3 test: exceptionallyCompose() — same key in initial call and recovery call
@Test
void exceptionallyCompose_sameIdempotencyKeyInRecovery() throws Exception {
wm.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("compose").whenScenarioStateIs(STARTED)
.willReturn(serverError().withStatus(503))
.willSetStateTo("recovery"));
wm.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("compose").whenScenarioStateIs("recovery")
.willReturn(okJson("{\"id\":\"ch_test\",\"object\":\"charge\",\"status\":\"succeeded\"}")));
CompletableFuture<String> result = billingService.chargeWithComposedRetry(
"cus_test", 2000, "2026-10");
String chargeId = result.get(15, TimeUnit.SECONDS);
assertThat(chargeId).isEqualTo("ch_test");
List<LoggedRequest> captured = wm.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(captured).hasSize(2);
Set<String> keys = captured.stream()
.map(req -> req.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
// Both the initial call and the exceptionallyCompose() recovery must use the same key.
assertThat(keys).hasSize(1);
}
}
These tests use WireMock to capture real HTTP requests to Stripe’s API, including the actual Idempotency-Key header values sent by the Stripe Java SDK. The keys.size() == 1 assertion fails immediately for all three unsafe patterns — two distinct UUIDs appear in the captured requests. It passes for all three fixed patterns — the same content-hash key appears in every captured request.
A note on async test timing: modes 1 and 3 require the test to wait for the retry path to complete before inspecting wm.findAll(). The CompletableFuture.get(15, TimeUnit.SECONDS) call in the mode 3 test serves this purpose. For mode 1, billingOrchestrator.processCharge() blocks until the retry loop completes. For mode 2, billingService.chargeAndAudit() is synchronous at the @Retryable level and blocks until all retries complete (WireMock scenario handles the 503→200 transition between proceed() calls).
Summary
All three Spring Boot @Async + @Transactional failure modes share a common structure: UUID.randomUUID() is placed at a scope that re-executes per retry. In mode 1, the scope is the CompletableFuture.supplyAsync() lambda or the @Async method body itself, re-executed per retry loop iteration. In mode 2, the scope is the @Async method body, re-executed because the outer @Retryable’s proceed() re-invokes the outer method, which calls the @Async service again. In mode 3, the scope is the @Async method body, re-executed because exceptionallyCompose() recovery calls the @Async service method again.
The fix in all three cases is to compute the idempotency key as a content-hash of the stable billing parameters at a scope that is outside all retry boundaries and to pass the precomputed key as a method parameter to every service that needs to use it. This decouples idempotency key identity (one per billing intent) from retry execution (one per attempt). Combined with WireMock integration tests that capture real idempotency key headers, the keys.size() == 1 assertion catches any regression where UUID generation has drifted back into a per-attempt scope.
Keybrake catches duplicate Stripe keys before they charge your customers twice
Keybrake is a scoped API-key proxy that sits between your application and Stripe. It enforces idempotency key uniqueness per billing intent — rejecting retries that arrive with a new key for an already-committed charge, and logging every key collision for audit. Join the waitlist.