Spring Boot @Transactional + @Async and Stripe Integration: How UUID in @Async Method Body, thenApply() Lambda UUID in CompletableFuture Chain, and TransactionSynchronizationManager UUID Binding Generate New Idempotency Keys on @Retryable Retry
Spring’s @Transactional and @Async annotations each alter method execution in ways that interact non-trivially with @Retryable when Stripe API calls require a stable idempotency key. Three distinct failure modes arise from their combination: UUID.randomUUID() placed inside an @Async service method body re-evaluates when the caller’s @Retryable retries and calls the @Async method again — a new task submission to the AsyncTaskExecutor executes the method body from scratch; UUID.randomUUID() placed in a thenApply() lambda inside a CompletableFuture chain built inside the @Async method re-evaluates when @Retryable calls the @Async method again and a new CompletableFuture chain is constructed from the top; and UUID.randomUUID() generated via a TransactionSynchronizationManager.bindResource() caching pattern inside an @Async @Transactional method re-evaluates because @Async always runs in a thread-pool thread where @Transactional starts a fresh transaction with empty TransactionSynchronizationManager state on every @Retryable attempt.
Background: how @Async, @Transactional, and @Retryable compose in Spring AOP proxies
All three annotations work through Spring’s AOP proxy mechanism. When a Spring bean is annotated with any combination of @Transactional, @Async, and @Retryable, Spring wraps the bean in one or more CGLIB or JDK proxy objects, each adding an interceptor to the method call chain. The order in which interceptors fire is determined by each advisor’s getOrder() value: lower numbers are outermost (first to intercept the incoming call, last to return). The default priority order from outermost to innermost is @Retryable (Ordered.LOWEST_PRECEDENCE - 5 = 2147483642), @Transactional (default PlatformTransactionManager advisor order, typically 0 or configured via @EnableTransactionManagement(order = ...)), and @Async (Ordered.LOWEST_PRECEDENCE = 2147483647).
In practice this means: when a caller invokes a method annotated with all three, @Transactional’s interceptor begins a transaction, then @Retryable’s interceptor wraps the transactional call, then @Async’s interceptor submits the method body to an executor and returns a CompletableFuture or Future<T> immediately. However, this ordering is rarely used correctly: @Async + @Retryable on the same method produces the well-known silent non-retry bug, where @Retryable never sees the exception thrown inside the async task because @Async’s proceed() returns the future without blocking. The idempotency-key failure modes in this post arise from the cross-bean pattern: a @Retryable-annotated facade calls an @Async @Transactional service method, then blocks on the returned future with .get().
The cross-bean pattern is necessary because Spring AOP proxies require inter-bean calls to traverse the proxy. Self-invocation (a bean calling its own annotated methods directly) bypasses the proxy entirely, so @Async would not submit the work asynchronously on a self-call. The cross-bean pattern — a facade bean calling a service bean — is the standard solution. The idempotency-key failures arise because this standard solution creates call boundaries that developers do not always reason about carefully when placing UUID.randomUUID().
A second source of confusion is @Transactional and @Async’s transaction propagation interaction. The Spring documentation notes that @Async “will always apply to be executed in a separate thread.” This separate thread has no existing transaction context: TransactionSynchronizationManager stores its state in ThreadLocal variables, and those ThreadLocals are not inherited by thread-pool threads. When @Transactional (with the default REQUIRED propagation) is applied to the @Async method, it finds no existing transaction on the new thread and starts a fresh one. This fresh transaction is completely independent of any transaction active on the caller’s thread. The result: the caller’s DB writes and the @Async method’s DB writes are in separate transactions, and rolling back the caller’s transaction does not roll back the @Async method’s writes, and vice versa. Developers who add @Transactional to both methods expecting atomicity are surprised to discover this is a silent correctness failure — Spring does not warn about it at startup.
Failure mode 1: UUID.randomUUID() in @Async method body — caller’s @Retryable retries by re-calling the @Async method — new AsyncTaskExecutor submission — new method body execution — UUID_B — ch_B
The developer splits billing into a facade and a service. The facade handles retry orchestration; the service handles the Stripe API call asynchronously. The UUID is generated inside the service method body:
// AsyncBillingService.java — UNSAFE: UUID.randomUUID() inside the @Async method body.
// Developer intent: each charge() call generates a unique idempotency key.
// Developer assumption: charge() is called once per billing operation, so one UUID per operation.
// Actual behavior: charge() is called once per @Retryable ATTEMPT.
// Each call submits a new task to the AsyncTaskExecutor.
// Each task execution evaluates UUID.randomUUID() from scratch.
// UUID_B on attempt 2 — ch_B alongside committed ch_A.
@Service
public class AsyncBillingService {
@Autowired
private StripeClient stripeClient;
@Async("billingExecutor")
@Transactional
public CompletableFuture<Charge> charge(String userId, long amountCents) {
// UUID generated here: inside the @Async method body, on the async thread.
// This executes on a billingExecutor thread-pool thread, not the caller's thread.
// @Transactional starts a fresh transaction on this thread (no existing tx to join).
// The UUID is local to this task execution — not shared with previous or future tasks.
String idempotencyKey = userId + ":" + amountCents + ":" + UUID.randomUUID();
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return CompletableFuture.completedFuture(charge);
} catch (StripeException e) {
// Wrap checked exception for CompletableFuture propagation.
CompletableFuture<Charge> failed = new CompletableFuture<>();
failed.completeExceptionally(e);
return failed;
}
}
}
// BillingFacade.java — the @Retryable caller.
// Blocks on .get() so that StripeException propagates to @Retryable.
@Service
public class BillingFacade {
@Autowired
private AsyncBillingService asyncBillingService;
@Retryable(
retryFor = { StripeException.class, IOException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
@Transactional // BUG: @Async breaks propagation — this tx does not wrap asyncBillingService.charge()
public Charge chargeCustomer(String userId, long amountCents) throws Exception {
CompletableFuture<Charge> future = asyncBillingService.charge(userId, amountCents);
try {
return future.get(); // blocks; propagates StripeException to @Retryable
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof StripeException se) throw se;
if (cause instanceof IOException ioe) throw ioe;
throw e;
}
}
}
The failure sequence when Stripe commits ch_A on attempt 1 but a transient 503 prevents the client from receiving the success response:
- Caller invokes
chargeCustomer("user-42", 9900L).@Retryableintercepts and calls the method body (attempt 1).@Transactionalbeginstx_facade_1on the calling thread. asyncBillingService.charge("user-42", 9900L)is called. The@Asyncproxy submits a new task tobillingExecutorand returns aCompletableFuture<Charge>immediately. The calling thread blocks atfuture.get().- On the executor thread,
@Transactionalbeginstx_async_1(fresh, independent oftx_facade_1).idempotencyKey = "user-42:9900:" + UUID_Ais computed. - Stripe receives the charge request with
Idempotency-Key: user-42:9900:UUID_A. Stripe processes and commitsch_A = "ch_111". Before the 200 OK response is fully delivered, a network 503 is returned. StripeExceptionis caught inside the executor task. TheCompletableFutureis completed exceptionally.tx_async_1is rolled back.- The calling thread’s
future.get()throwsExecutionException. The catch block unwraps and rethrowsStripeException.tx_facade_1rolls back. @RetryablecatchesStripeException. It waits 1 second. It re-invokeschargeCustomer("user-42", 9900L)from the beginning of the method body (attempt 2).@Transactionalbeginstx_facade_2.asyncBillingService.charge("user-42", 9900L)is called again.@Asyncsubmits a new task tobillingExecutor.- On the executor thread (possibly the same thread as attempt 1, possibly a different one — the executor pool may reuse threads, but task state is never reused),
@Transactionalbeginstx_async_2.UUID.randomUUID()is called again in the new task execution.idempotencyKey = "user-42:9900:" + UUID_B.UUID_B ≠ UUID_A. - Stripe receives the charge request with
Idempotency-Key: user-42:9900:UUID_B. Stripe has never seen this key. Stripe createsch_B = "ch_222". - Both
ch_Aandch_Bare live Stripe charges foruser-42. The application records onlych_B.
The two wrong mental models in this pattern
The first wrong mental model is about method call boundaries vs. task execution boundaries. The developer thinks of charge() as “a function called once per billing operation.” This is true for synchronous methods: one call site = one execution. For @Async methods, one call site does trigger one task submission, but @Retryable is the call site that fires repeatedly. Each @Retryable attempt is a distinct call site invocation, which triggers a distinct @Async task submission, which triggers a distinct method body execution.
The second wrong mental model is about @Transactional and @Async atomicity. The developer adds @Transactional to chargeCustomer() in the facade, expecting that if the Stripe call fails, the DB writes made by both the facade and the service are rolled back together in one transaction. They are not: the service’s @Async execution runs in a separate thread where @Transactional starts an independent transaction. The facade’s rollback does not affect the service’s transaction, which has already committed or rolled back independently.
The @Transactional illusion is a silent failure at the data-consistency level. The idempotency-key failure is a separate, financial-consequence failure. They share a root cause: the developer assumed @Async is transparent — that code running in the executor behaves as if it ran synchronously on the caller’s thread. @Async is not transparent: it creates an execution boundary that breaks both transaction context and local variable scope from one call to the next.
The fix: generate the idempotency key in the @Retryable caller, before crossing the @Async boundary
// SAFE: idempotency key computed BEFORE calling the @Async service.
// The key is computed once on the facade's thread (attempt 1 of @Retryable).
// It is passed as a stable parameter through the @Async boundary.
// All @Retryable attempts use the same key.
@Service
public class BillingFacade {
@Autowired
private AsyncBillingService asyncBillingService;
@Retryable(
retryFor = { StripeException.class, IOException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
public Charge chargeCustomer(String userId, long amountCents, String idempotencyKey)
throws Exception {
// idempotencyKey is passed by the caller from OUTSIDE @Retryable.
// Same key on all three attempts.
CompletableFuture<Charge> future = asyncBillingService.charge(userId, amountCents, idempotencyKey);
try {
return future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof StripeException se) throw se;
if (cause instanceof IOException ioe) throw ioe;
throw e;
}
}
}
// Caller (controller or scheduler):
String key = userId + ":" + amountCents + ":" + billingPeriod; // content-hash key
billingFacade.chargeCustomer(userId, amountCents, key);
// AsyncBillingService.java — accepts idempotencyKey as a parameter.
@Async("billingExecutor")
public CompletableFuture<Charge> charge(String userId, long amountCents, String idempotencyKey) {
// idempotencyKey is a captured parameter — the same value on all calls.
// No UUID.randomUUID() inside this method body.
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return CompletableFuture.completedFuture(charge);
} catch (StripeException e) {
CompletableFuture<Charge> failed = new CompletableFuture<>();
failed.completeExceptionally(e);
return failed;
}
}
Note that a content-hash key — constructed from stable business attributes like userId + ":" + amountCents + ":" + billingPeriod — is preferable to a UUID key even when passed from the outside. A UUID key generated outside @Retryable is stable across all retries within one JVM run, but is lost if the process crashes between Stripe’s commit of ch_A and the application’s receipt of the success response. A content-hash key survives process restart because the same business inputs produce the same key on the next scheduler run.
Failure mode 2: UUID inside thenApply() lambda in a CompletableFuture chain — @Retryable retry calls the @Async method again — new chain built from scratch — thenApply() re-executes — UUID_B — ch_B
A more sophisticated version of the same failure arises when the @Async method builds a multi-stage CompletableFuture pipeline. The developer moves key generation into a thenApply() stage, reasoning that it belongs “after validation” rather than before the chain starts:
// AsyncBillingService.java — UNSAFE: UUID.randomUUID() inside a thenApply() lambda.
// Developer intent: generate the idempotency key after validating the billing inputs,
// in the stage where billing context is assembled.
// Developer reasoning: "thenApply() runs once when the chain reaches that stage."
// Actual behavior: the entire CompletableFuture chain is rebuilt each time charge() is called.
// @Retryable (in the facade) calls charge() again on retry.
// @Async submits a new task that executes the method body from the first statement.
// A new CompletableFuture chain is created: new supplyAsync(), new thenApply() instance.
// thenApply()'s body evaluates UUID.randomUUID() — UUID_B — ch_B.
@Service
public class AsyncBillingService {
@Autowired
private StripeClient stripeClient;
@Autowired
private BillingValidator billingValidator;
@Async("billingExecutor")
public CompletableFuture<Charge> charge(String userId, long amountCents) {
return CompletableFuture
.supplyAsync(() -> billingValidator.validate(userId, amountCents)) // stage 1: validate
.thenApply(validationResult -> {
// Stage 2: assemble billing context, including idempotency key.
// Developer mental model: "this stage runs once per chain execution."
// Correct per-chain: each thenApply() lambda runs exactly once per chain.
// Incorrect across retries: the chain is rebuilt on every call to charge(),
// and @Retryable calls charge() once per retry attempt.
String idempotencyKey = userId + ":" + amountCents + ":" + UUID.randomUUID(); // UUID_B on retry
return new BillingRequest(userId, amountCents, idempotencyKey, validationResult);
})
.thenCompose(req -> CompletableFuture.supplyAsync(() -> {
// Stage 3: call Stripe with the assembled request.
try {
return stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(req.amountCents())
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(req.idempotencyKey())
.build());
} catch (StripeException e) {
throw new CompletionException(e);
}
}));
}
}
The facade is unchanged from Mode 1: it calls charge() and blocks on .get(), which propagates the StripeException to the @Retryable interceptor. The failure sequence:
@RetryableinvokeschargeCustomer()(attempt 1). The facade callscharge("user-42", 9900L).@Asyncsubmits the task body tobillingExecutor. The method body constructs a three-stageCompletableFuturechain:supplyAsync(validate) → thenApply(assembleKey) → thenCompose(callStripe). This chain is returned as the task’s future.- Stage 2 (
thenApply) executes:idempotencyKey = "user-42:9900:" + UUID_A. Stage 3 calls Stripe withUUID_A. Stripe commitsch_A. Network 503 is returned. StripeExceptionis wrapped in aCompletionExceptioninside stage 3, propagated through the chain, and stored in the top-level future.future.get()in the facade throwsExecutionException.StripeExceptionis unwrapped and rethrown.@RetryablecatchesStripeExceptionand re-invokeschargeCustomer()(attempt 2). The facade callscharge("user-42", 9900L)again.@Asyncsubmits a new task body tobillingExecutor. The method body runs from the first statement:CompletableFuture.supplyAsync(validate)creates a brand new chain. A newthenApply()lambda instance is created.- Stage 2 of the new chain executes:
idempotencyKey = "user-42:9900:" + UUID_B. Stage 3 calls Stripe withUUID_B. Stripe has never seen this key. Stripe createsch_B = "ch_222". ch_Aandch_Bare both live charges foruser-42.
Why “thenApply() runs once per chain” is true but insufficient
The developer’s reasoning “thenApply() runs once per chain” is factually correct within the scope of a single chain execution. Given a chain A.thenApply(f).thenCompose(g), the function f is invoked exactly once when stage A completes. There is no internal retry of f within a single chain execution.
The reasoning breaks down at the next level: “once per chain” is only one-per-attempt, not one-per-billing-operation, because the caller’s @Retryable calls charge() — the chain’s constructor — multiple times. The chain object produced by the first call is discarded after attempt 1’s failure; attempt 2 produces a new, independent chain object. “The same chain” does not persist across @Retryable attempts.
The developer may be drawing a mental analogy to a singleton: “the chain is constructed once and reused.” CompletableFuture chains are not singletons. They are imperative constructions: every call to the method that builds them produces a distinct set of CompletableFuture objects with distinct lambda instances. No Spring mechanism caches or reuses a CompletableFuture across method invocations.
The fix: move UUID generation above the chain construction
// SAFE: idempotency key computed before the CompletableFuture chain is built.
// The key is a captured final variable available to all stages.
// Recomputing the chain on @Retryable retry uses the same key (passed as parameter).
@Async("billingExecutor")
public CompletableFuture<Charge> charge(String userId, long amountCents, String idempotencyKey) {
// idempotencyKey is a parameter — the same value on all calls from @Retryable.
// No UUID.randomUUID() anywhere in the chain.
return CompletableFuture
.supplyAsync(() -> billingValidator.validate(userId, amountCents))
.thenApply(validationResult -> new BillingRequest(userId, amountCents, idempotencyKey, validationResult))
.thenCompose(req -> CompletableFuture.supplyAsync(() -> {
try {
return stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(req.amountCents())
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(req.idempotencyKey())
.build());
} catch (StripeException e) {
throw new CompletionException(e);
}
}));
}
The thenApply() stage still runs once per chain, and the chain is still rebuilt on each @Retryable attempt. That is acceptable because the rebuilt chain always carries the same idempotency key. Rebuilding a CompletableFuture chain is cheap; the important invariant is that the key is stable, not that the chain is a singleton.
A structural distinction from the Spring @RequestScope + @Retryable post: in that post, the UUID was placed inside a supplyAsync() lambda as a fallback for a caught ScopeNotActiveException. Here, the UUID is placed in a thenApply() stage at the second step of a pipeline. Both are inside lambdas that re-execute per @Retryable attempt; the pipeline structure is different but the root cause is the same.
Failure mode 3: UUID generated via TransactionSynchronizationManager.bindResource() inside @Async @Transactional method — new thread = new transaction = empty TSM state — UUID.randomUUID() re-generates — UUID_B — ch_B
A more unusual failure mode arises when the developer attempts to use TransactionSynchronizationManager (TSM) as a transaction-scoped UUID cache. The intent is to generate the idempotency key once per transaction and return the cached value on subsequent calls within the same transaction — a pattern that works for synchronous transactional contexts but breaks under @Async.
// AsyncBillingService.java — UNSAFE: UUID bound to TSM as a transaction-scoped resource.
// Developer intent: generate UUID once per transaction; if this method is called multiple
// times within the same @Transactional context, return the same UUID.
// This pattern works for synchronous @Transactional methods called from the same thread.
// It does NOT work for @Async methods because:
// (a) @Async always runs in a thread-pool thread with no inherited transaction context.
// (b) @Transactional on the @Async method starts a NEW transaction on that thread.
// (c) Every @Retryable attempt calls this @Async method, submits a new task,
// which runs in a (new or reused) thread with a NEW @Transactional transaction.
// (d) Each new transaction starts with empty TSM state — TSM.getResource(KEY) returns null.
// (e) UUID.randomUUID() fires again — UUID_B — ch_B.
private static final String IDEMPOTENCY_KEY_RESOURCE = "stripe-idempotency-key";
@Service
public class AsyncBillingService {
@Autowired
private StripeClient stripeClient;
@Async("billingExecutor")
@Transactional // Starts a new transaction on the executor thread.
public CompletableFuture<Charge> charge(String userId, long amountCents) {
// Attempt to retrieve a cached idempotency key from TSM.
// Developer reasoning: "if this method is called within an existing @Transactional
// context, the key was already bound in a prior call and we retrieve it here."
// Actual behavior on executor thread: no existing transaction — @Transactional creates a
// fresh one — TSM state is empty — getResource() always returns null.
String idempotencyKey = (String) TransactionSynchronizationManager
.getResource(IDEMPOTENCY_KEY_RESOURCE);
if (idempotencyKey == null) {
// First call within this transaction: generate and cache.
// But there is never a "second call within this transaction" from @Retryable —
// each @Retryable attempt creates a new @Async task with a new @Transactional context.
// This branch fires on EVERY @Retryable attempt.
idempotencyKey = userId + ":" + amountCents + ":" + UUID.randomUUID(); // UUID_B on retry
TransactionSynchronizationManager.bindResource(IDEMPOTENCY_KEY_RESOURCE, idempotencyKey);
}
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return CompletableFuture.completedFuture(charge);
} catch (StripeException e) {
CompletableFuture<Charge> failed = new CompletableFuture<>();
failed.completeExceptionally(e);
return failed;
}
}
}
The failure sequence when Stripe commits ch_A on attempt 1 but the response is lost:
@Retryableinvokes the facade’schargeCustomer()(attempt 1). The facade callsasyncBillingService.charge("user-42", 9900L).@Asyncsubmits the task tobillingExecutor. On the executor thread,@Transactionalbeginstx_1(fresh).TransactionSynchronizationManagerstate is initialized as empty fortx_1.TSM.getResource(IDEMPOTENCY_KEY_RESOURCE)returnsnull(no key bound yet intx_1). The null branch fires:idempotencyKey = "user-42:9900:" + UUID_A. The key is bound totx_1’s TSM state.- Stripe receives
UUID_A. Stripe commitsch_A. Network 503 is returned. StripeExceptionis stored in the future.tx_1is rolled back (TSM state fortx_1, including the bound idempotency key, is cleaned up by Spring’s transaction infrastructure). The keyUUID_Ais gone from TSM.- The facade’s
future.get()throwsExecutionException.StripeExceptionis unwrapped and rethrown to@Retryable. @Retryableretries. The facade callsasyncBillingService.charge("user-42", 9900L)again (attempt 2).@Asyncsubmits a new task. On the executor thread (new or reused),@Transactionalbeginstx_2. TSM state fortx_2is empty.TSM.getResource(IDEMPOTENCY_KEY_RESOURCE)returnsnullagain. The null branch fires again:idempotencyKey = "user-42:9900:" + UUID_B.UUID_B ≠ UUID_A.- Stripe receives
UUID_B. Stripe has never seen this key. Stripe createsch_B = "ch_222".
Why TransactionSynchronizationManager looks like a stable key store
TSM is used correctly in many Spring patterns: storing a JDBC Connection bound to the current transaction, caching a computed value within a transaction boundary, or registering a synchronization callback that fires on commit. In synchronous code, a call to TSM.getResource(key) from within an active @Transactional method returns the value bound during the same transaction, because the transaction is active on the current thread and TSM uses ThreadLocal storage.
The developer’s intent — “bind the UUID once per transaction, reuse within the transaction” — is sound for synchronous code where the caller and callee share a thread and a transaction. It breaks under @Async for two reasons:
- Thread boundary.
@Asyncsubmits work to a thread-pool thread.ThreadLocalvariables are not inherited by thread-pool threads. TSM’sThreadLocalmaps start empty on the new thread.@Transactionalon the@Asyncmethod creates a new transaction bound to this new thread’sThreadLocalstate. The caller’s@Transactionalcontext is invisible on the executor thread. - Transaction lifetime vs. retry lifetime. Even if TSM were somehow shared across threads (it is not), the transaction
tx_1is rolled back when theStripeExceptionpropagates. Transaction rollback causes Spring’s transaction infrastructure to callTSM.unbindResource()for all resources bound during the transaction. The key bound intx_1is explicitly removed on rollback.tx_2starts with a clean slate regardless of whattx_1bound.
A third subtlety: if the executor reuses the same thread for attempt 2 as it used for attempt 1, a developer might incorrectly reason that the ThreadLocal state from attempt 1 persists. It does not. Spring’s TransactionSynchronizationManager calls clear() on its ThreadLocal maps at transaction completion (both commit and rollback), specifically to prevent state leakage across transactions that share a thread. The thread from attempt 1 has clean TSM state when it is returned to the pool and reused for attempt 2.
The fix: use a TSM-independent key computed outside the @Async boundary
// SAFE: idempotency key provided by the caller from outside the @Async and @Transactional boundaries.
// TSM is not used for key storage at all.
@Async("billingExecutor")
public CompletableFuture<Charge> charge(String userId, long amountCents, String idempotencyKey) {
// idempotencyKey is a parameter — stable regardless of transaction boundaries.
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return CompletableFuture.completedFuture(charge);
} catch (StripeException e) {
CompletableFuture<Charge> failed = new CompletableFuture<>();
failed.completeExceptionally(e);
return failed;
}
}
If the requirement is specifically “one UUID per billing operation, persisted to the DB for durability,” the correct approach is to generate and persist the key in a separate @Transactional(propagation = REQUIRES_NEW) step that commits independently of the outer transaction:
// Pattern: persist idempotency key in REQUIRES_NEW before calling @Async.
// The REQUIRES_NEW transaction commits the key row to DB even if the outer tx rolls back.
// On @Retryable retry: the REQUIRES_NEW step finds the existing key row and returns it.
// The @Async method always receives the same persisted key.
@Service
public class BillingFacade {
@Autowired
private IdempotencyKeyStore keyStore; // handles DB persistence
@Autowired
private AsyncBillingService asyncBillingService;
@Retryable(retryFor = StripeException.class, maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0))
public Charge chargeCustomer(String userId, long amountCents, String billingPeriod)
throws Exception {
// getOrCreate() is REQUIRES_NEW: commits independently.
// Attempt 1: no key found → INSERT key row → commit → return UUID_A.
// Attempt 2: key row exists (committed by attempt 1's REQUIRES_NEW) → return UUID_A.
String idempotencyKey = keyStore.getOrCreate(userId, amountCents, billingPeriod);
CompletableFuture<Charge> future = asyncBillingService.charge(userId, amountCents, idempotencyKey);
try {
return future.get();
} catch (ExecutionException e) {
if (e.getCause() instanceof StripeException se) throw se;
throw e;
}
}
}
@Service
public class IdempotencyKeyStore {
@Autowired
private IdempotencyKeyRepository repository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public String getOrCreate(String userId, long amountCents, String billingPeriod) {
String keyName = userId + ":" + amountCents + ":" + billingPeriod;
return repository.findByKeyName(keyName)
.map(IdempotencyKey::getValue)
.orElseGet(() -> {
// First call: INSERT new key row, committed by this REQUIRES_NEW tx.
String value = UUID.randomUUID().toString();
repository.save(new IdempotencyKey(keyName, value));
return value;
});
}
}
The REQUIRES_NEW propagation ensures that the key row is committed to the database before the @Async method is called and before the outer @Transactional has a chance to roll back. On @Retryable attempt 2, the getOrCreate() call finds the committed row and returns the same UUID_A. Stripe sees the same idempotency key on attempt 2 and returns the cached ch_A result.
Cross-mode comparison: where the UUID is placed and which execution boundary re-evaluates it
| Mode | Where UUID.randomUUID() is placed | Execution boundary that re-evaluates it | Stripe outcome |
|---|---|---|---|
| 1 | @Async method body (direct assignment) |
@Retryable re-calls @Async method → new AsyncTaskExecutor task submission → new method body execution |
UUID_B → ch_B |
| 2 | thenApply() lambda inside CompletableFuture chain in @Async method |
@Retryable re-calls @Async method → new task → new CF chain built from scratch → new thenApply() lambda instance → lambda body re-evaluates |
UUID_B → ch_B |
| 3 | Null-check branch of TSM.getResource() in @Async @Transactional method body |
@Retryable re-calls @Async method → new AsyncTaskExecutor task → new thread → @Transactional begins new tx → TSM state empty → null check fires → UUID.randomUUID() re-evaluates |
UUID_B → ch_B |
Mode 1 is structurally the same root cause as Mode 2 — UUID inside the @Async method’s execution unit — but Mode 2 adds a layer of indirection: the UUID is one step deeper, inside a CompletableFuture stage, making it visually separate from the retry-controlled method call. Mode 3 is distinct in mechanism: the UUID re-generation is hidden behind a caching pattern (TSM.getResource() == null) that appears to guard against regeneration but fails because each @Retryable attempt brings a fresh @Transactional context.
How @Transactional and @Async’s propagation gap compounds the billing risk
The three idempotency-key failures each produce a direct financial consequence: a duplicate Stripe charge. @Transactional’s broken propagation under @Async adds a second failure layer that can mask or compound the billing error.
In the standard cross-bean pattern, the facade’s @Transactional governs the facade’s DB writes (e.g., writing a BillingAttempt row with status PENDING). The service’s @Transactional (on the @Async method) governs the service’s DB writes (e.g., writing the Stripe charge response). These two transactions are independent:
- The facade’s transaction is active on the caller’s thread during
future.get(). When the future completes exceptionally, the facade’s transaction rolls back theBillingAttemptrow. - The service’s transaction is active on the executor thread during the Stripe call. If the Stripe call fails, the service’s transaction rolls back the charge-response row (if any was written). If the Stripe call succeeds but the response is lost, the service may or may not write a response row depending on exactly where in the code the failure occurs.
The practical consequence is that after ch_A is committed by Stripe but the response is lost, neither transaction has committed a record of ch_A. The application has no DB record of the charge that Stripe knows about. On @Retryable attempt 2 (with the UUID failure), ch_B is created and its response is successfully received. The application records ch_B. From the application’s perspective, the billing succeeded once. From Stripe’s perspective, the customer was charged twice.
This is the “success masking” failure pattern: the application sees one successful charge because only ch_B was written to its DB. The duplicate ch_A is invisible in the application’s data layer but visible in Stripe’s audit log and on the customer’s bank statement.
Structural distinctions from prior Spring posts in this series
This post covers three failure modes that are structurally distinct from those in the Spring WebMVC @Async post, the Spring @RequestScope post, and the Spring @Cacheable + @Retryable post:
- The Spring WebMVC
@Asyncpost covers three modes centered onDeferredResultandMODE_INHERITABLETHREADLOCALfor HTTP async dispatch, and the silent non-retry bug when@Async+@Retryableare on the same method (where@Retryableis outermost but never sees an exception because@Async’sproceed()returns a future immediately). This post uses the cross-bean pattern where@Retryableis on the caller, not the@Asyncmethod. The retry is active and functional; the UUID failure comes from the retry causing a re-call of the@Asyncmethod. - The Spring
@RequestScopepost covers UUID re-generation caused byScopeNotActiveExceptionfallback logic and by non-memoized@RequestScopebean methods. Both are about the mechanics of the Spring request scope proxy. This post covers@TransactionalandTSMmechanics. Neither post’s failure modes overlap with the other’s. - The Spring
@Cacheable+@Retryablepost covers AOP advisor ordering between@Cacheableand@Retryable, SpEL key expression re-evaluation, and cache-miss-on-exception behavior. The cache is a return-value cache; TSM in Mode 3 of this post is a within-transaction resource store. The failure mechanisms are different:@Cacheable’s failure is about which proxy intercepts first and whether the cache is populated; TSM’s failure is about thread-locality and transaction lifecycle cleanup.
Test patterns for verifying idempotency key stability under @Transactional + @Async + @Retryable
The most reliable test captures the Stripe idempotency key on every attempt and asserts that exactly one distinct key was used across all attempts:
// Test: idempotency key is stable across @Retryable attempts.
// Uses WireMock to stub the Stripe API: first call returns 503, second returns 200.
// Captures the Idempotency-Key header from both requests.
// Asserts: both requests used the same idempotency key.
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class AsyncBillingIdempotencyTest {
@Autowired
BillingFacade billingFacade;
@Test
void retryUsesStableIdempotencyKey() throws Exception {
// Stub: first charge request → 503; second charge request → 200 with charge response.
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("retried"));
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry")
.whenScenarioStateIs("retried")
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_test_001\",\"amount\":9900,\"status\":\"succeeded\"}")));
billingFacade.chargeCustomer("user-42", 9900L, "2026-10");
List<LoggedRequest> requests = findAll(postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests).hasSize(2);
// The critical assertion: both attempts must use exactly the same idempotency key.
Set<String> keys = requests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(toSet());
assertThat(keys).hasSize(1); // fails if UUID_B was generated on attempt 2
}
}
For Mode 3 (TSM-based UUID), add a test that verifies the @Transactional context on the executor thread does not carry over from one task to the next:
@Test
void tsmStateDoesNotPersistAcrossAsyncTaskExecutions() throws Exception {
// Verify that TransactionSynchronizationManager state is empty at the start of
// each @Async task — i.e., that the TSM caching pattern cannot cache across retries.
// This is a unit-level test of the executor thread behavior, separate from the
// Stripe integration test above.
// Arrange: configure a @Async method that reads TSM state and logs whether
// the resource was present at task start.
List<Boolean> tsmStatePresent = new CopyOnWriteArrayList<>();
// Use a test double that records TSM state on each invocation:
AsyncBillingService service = context.getBean(AsyncBillingService.class);
service.charge("user-42", 9900L, "probe"); // first call; TSM should be empty at start
// The point: on any @Async executor thread, TSM starts clean.
// A TSM.getResource() call that guards UUID generation will always return null
// on an executor thread, because executor threads never inherit the caller's TSM state.
assertThat(tsmStatePresent).allMatch(present -> !present);
}
Both tests should run with @SpringBootTest rather than a mocked executor so that the real @Async, @Transactional, and @Retryable proxy chains are exercised. Mocking the AsyncTaskExecutor to run synchronously would make both @Async’s thread-pool behavior and @Transactional’s fresh-transaction behavior invisible to the test, allowing the tests to pass even with buggy idempotency-key generation code.
Diagnosis checklist for existing Spring @Transactional + @Async + @Retryable billing code
When reviewing a codebase that uses this combination for Stripe billing, check these locations specifically for UUID generation that re-evaluates on retry:
- Inside any
@Async-annotated method body that is called from a@Retryable-annotated method in a different bean. IfUUID.randomUUID()appears in the method body, it re-evaluates on every@Retryableattempt. - Inside any lambda in a
CompletableFuturechain built inside an@Asyncmethod that is called from a@Retryablemethod.thenApply(),thenCompose(),thenCombine(), andthenAccept()lambdas all re-evaluate per chain construction, and the chain is rebuilt on every@Retryablecall. - Inside a null-check branch on any thread-local or transaction-local state inside an
@Async@Transactionalmethod.TransactionSynchronizationManager.getResource(key) == null,RequestContextHolder.getRequestAttributes() == null,SecurityContextHolder.getContext().getAuthentication() == null, and similar guards all fire on every@Retryableattempt because the thread-local state is always absent on a fresh executor thread with a fresh transaction. - Any UUID stored in a field on a Spring
@Prototypeor@RequestScopebean that is instantiated inside the@Asyncmethod. If the bean is created anew inside the@Asyncmethod body (rather than injected and reused), the constructor-level UUID initialization re-evaluates per@Retryableattempt.
The uniform fix in all four cases: move UUID generation above both the @Retryable boundary and the @Async boundary. Compute the idempotency key in the outermost caller, before entering the first annotated proxy in the chain. Pass the key as a stable parameter to every method below it in the call hierarchy. No method inside a @Retryable or @Async execution boundary should generate a new UUID from scratch.
Keybrake enforces stable idempotency keys at the proxy layer
Keybrake sits between your agent and the Stripe API. It captures the idempotency key on the first request and enforces it on every retry, regardless of what your application code generates. A stuck retry loop that regenerates UUID on every attempt sends a new key each time — Keybrake replaces it with the original key, preventing the duplicate charge. It also hard-caps spend per agent per day so that a retry storm has a bounded worst case.