Spring Boot @Transactional + @Scheduled and Stripe Integration: How @Retryable/@Transactional Interceptor Ordering, REQUIRES_NEW Batch Retry, and OptimisticLockException @Retryable Generate New Idempotency Keys on Retry
Spring AOP’s interceptor ordering places @Retryable as the outer interceptor and @Transactional as the inner interceptor when both annotations are on the same method. Each @Retryable retry calls the method body again — opening a new @Transactional boundary — and UUID.randomUUID() at method entry re-evaluates per attempt. @Scheduled batch jobs that retry failed @Transactional(REQUIRES_NEW) customer billing calls re-invoke the billing method entirely, regenerating the UUID. @Retryable(include = OptimisticLockException.class) on a @Transactional billing method retries the Stripe call along with the DB write — even when the Stripe call already committed ch_A and only the DB write failed. Three failure modes. Three fixes.
Background: Spring AOP interceptor ordering and how @Retryable relates to @Transactional
Spring AOP wraps beans in a single JDK dynamic proxy or CGLIB proxy per bean. When multiple annotations on the same method each register an AOP advice — @Retryable, @Transactional, @Cacheable, and so on — Spring stacks the advice in a defined order around the target method. The order determines which advice is outermost (applied first on the call, last on the return) and which is innermost (applied last on the call, first on the return).
Order values are integers. Lower values are outer. Higher values are inner. Ordered.LOWEST_PRECEDENCE is Integer.MAX_VALUE (2,147,483,647) — the furthest from the outermost position. Spring Retry’s @EnableRetry registers RetryConfiguration, which implements Ordered and returns Ordered.LOWEST_PRECEDENCE - 5 (2,147,483,642). Spring’s @EnableTransactionManagement registers InfrastructureAdvisorAutoProxyCreator, and the TransactionInterceptor backed by it uses the order from @EnableTransactionManagement(order=…), which defaults to Ordered.LOWEST_PRECEDENCE (2,147,483,647).
The result is fixed: @Retryable order 2,147,483,642 is less than @Transactional order 2,147,483,647 — @Retryable is outer, @Transactional is inner. The call stack looks like this:
caller
→ @Retryable interceptor (outer, order 2,147,483,642)
→ @Transactional interceptor (inner, order 2,147,483,647)
→ method body
→ UUID.randomUUID()
→ Stripe.create(params, opts)
→ repository.save(...)
When @Retryable retries, it calls the @Transactional proxy again. A new transaction is opened. The method body executes again from the top. Every line in the method body — including UUID.randomUUID() — runs fresh. This is the mechanism behind all three failure modes in this post.
Stripe’s idempotency system identifies each billing intent by the value of the Idempotency-Key header. If two requests arrive with different key values, Stripe treats them as two independent billing intents and processes both. There is no body-hash deduplication: a request with UUID_B carrying the same customer ID and amount as a prior request with UUID_A will produce a second committed charge. The Stripe documentation explicitly states that idempotency keys must be unique per billing intent but stable across retry attempts of the same intent. If your retry mechanism generates a new UUID on each attempt, Stripe will create a separate charge for each attempt that reaches its servers.
Failure mode 1: @Retryable outer / @Transactional inner — UUID.randomUUID() at method entry regenerates per @Retryable attempt — each retry opens a fresh @Transactional boundary — UUID_B — ch_B
The most common form of this failure mode appears in billing service classes that annotate a single method with both @Retryable and @Transactional. The developer’s reasoning is sensible: the billing call should be retried on a transient StripeException, and the resulting DB record should be written atomically in the same transaction as any ledger updates. The annotations are placed together, the tests pass, and the service ships. The problem surfaces only under a specific timing: Stripe commits ch_A on attempt 1, but the HTTP response is lost before the SDK receives it — the SDK throws StripeException — @Transactional rolls back the partial DB work — @Retryable catches the exception and calls the method body again with UUID_B.
// BillingService.java — UNSAFE: UUID.randomUUID() at method entry with @Retryable outer / @Transactional inner
@Service
public class BillingService {
private final ChargeRecordRepository chargeRecordRepository;
private final CustomerRepository customerRepository;
@Retryable(
value = { StripeException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
@Transactional(rollbackFor = StripeException.class)
public ChargeRecord chargeCustomer(String customerId, long amountCents) {
// UUID at method entry — UNSAFE: re-evaluates on every @Retryable attempt.
String idempotencyKey = UUID.randomUUID().toString();
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new IllegalArgumentException("Customer not found: " + customerId));
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.setDescription("Monthly subscription - " + customer.getPlanId())
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
// Stripe committed ch_A, HTTP response was lost in transit — StripeException thrown here.
Charge charge = Charge.create(params, opts);
// This line never executes on attempt 1 due to the exception.
ChargeRecord record = new ChargeRecord(
charge.getId(), customerId, amountCents, idempotencyKey, Instant.now()
);
return chargeRecordRepository.save(record);
// @Transactional rolls back — no DB record for ch_A.
// @Retryable (outer) catches the StripeException and retries.
}
}
Execution trace on a transient network failure after Stripe committed ch_A:
- Caller invokes
billingService.chargeCustomer(customerId, amountCents)through the Spring AOP proxy. @Retryableinterceptor (outer, order 2,147,483,642) intercepts the call and begins retry tracking.@Transactionalinterceptor (inner, order 2,147,483,647) intercepts and opens a transaction (attempt 1).- Method body executes:
UUID.randomUUID()→UUID_A. Charge.create(params, opts)withUUID_A— Stripe processes the request and commitsch_A.- The HTTP response is lost in transit (network timeout, connection reset). Stripe SDK throws
StripeException(specificallyApiConnectionException). @Transactionalintercepts the propagating exception. BecauserollbackFor = StripeException.classis set, the transaction rolls back. The partial DB state (if any) is discarded. NoChargeRecordexists in the DB forch_A.@Retryable(outer) receives theStripeException. Attempt count: 1 of 3. Waits for backoff delay.- Retry attempt 2:
@Retryablecalls through the@Transactionalproxy again. @Transactionalopens a new transaction (attempt 2).- Method body executes from the top:
UUID.randomUUID()→UUID_B. A different UUID every time. Charge.create(params, opts)withUUID_B— Stripe has never seenUUID_B— treats as a brand-new billing intent — commitsch_B.- The customer is now charged twice:
ch_Aandch_Bboth committed at Stripe. The DB record (if attempt 2 succeeds) records onlych_B.ch_Ais invisible to the application’s data model.
The developer’s model — “the method runs once per billing call; the UUID is generated once per billing call” — is correct in the non-retry path. Under @Retryable, the model breaks: the method body runs once per attempt, not once per billing intent. Every UUID.randomUUID() in the method body runs once per attempt. The annotation pair makes both appear on the same method, but the AOP ordering separates their effects: @Retryable controls how many times the method is invoked, and @Transactional controls what constitutes one transaction per invocation. The UUID is inside the method body — it is inside the @Retryable boundary — it regenerates.
Fix: compute the idempotency key from stable business data before the method body
The fix requires moving the idempotency key computation outside the @Retryable re-invocation boundary. There are two practical approaches.
The first approach passes the idempotency key as a method parameter, computed by the caller before any retry logic applies:
// Caller — computes stable idempotency key BEFORE calling the @Retryable method.
public void scheduledBillingRun() {
for (Customer customer : customerRepository.findDueBillingCustomers()) {
// Key computed once per billing intent at call site — same value on every @Retryable attempt.
String idempotencyKey = "billing:" + customer.getId()
+ ":" + customer.getCurrentPlanId()
+ ":" + YearMonth.now().toString();
billingService.chargeCustomer(customer.getId(), customer.getPlanAmountCents(), idempotencyKey);
}
}
// BillingService.java — SAFE: idempotency key as parameter, stable across @Retryable retries.
@Retryable(value = { StripeException.class }, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
@Transactional(rollbackFor = StripeException.class)
public ChargeRecord chargeCustomer(String customerId, long amountCents, String idempotencyKey) {
// idempotencyKey is a stable parameter — @Retryable passes the same String on every re-invocation.
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
Charge charge = Charge.create(params, opts);
ChargeRecord record = new ChargeRecord(charge.getId(), customerId, amountCents, idempotencyKey, Instant.now());
return chargeRecordRepository.save(record);
}
The second approach computes the key from the method’s own parameters using a deterministic hash. The key is computed inside the method body, but from values that are stable across @Retryable re-invocations:
@Retryable(value = { StripeException.class }, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
@Transactional(rollbackFor = StripeException.class)
public ChargeRecord chargeCustomer(String customerId, long amountCents, String billingPeriod) {
// Key derived from immutable method parameters — same hash on every @Retryable re-invocation.
String idempotencyKey = "charge:" + customerId + ":" + amountCents + ":" + billingPeriod;
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
Charge charge = Charge.create(params, opts);
ChargeRecord record = new ChargeRecord(charge.getId(), customerId, amountCents, idempotencyKey, Instant.now());
return chargeRecordRepository.save(record);
}
When Stripe receives the retry with the same idempotencyKey, it recognizes the key as belonging to the already-committed ch_A and returns the ch_A response without creating a second charge. The retry completes successfully, the DB record is written for ch_A, and the customer is charged exactly once.
One important warning: if you customize @EnableTransactionManagement(order=…) or set a custom order attribute on your PlatformTransactionManager bean, the default ordering may change. Always verify your effective interceptor order with a quick integration test or a log of the proxy advisor chain. The safest design is to never place UUID.randomUUID() inside any method body that appears under any retry boundary — outer or inner.
Failure mode 2: @Scheduled batch job retries failed @Transactional(REQUIRES_NEW) billing calls — REQUIRES_NEW isolation means each retry is a fresh method invocation — UUID.randomUUID() inside the method regenerates — UUID_B — ch_B
Monthly billing jobs commonly use @Transactional(propagation = Propagation.REQUIRES_NEW) on per-customer billing service methods. The design goal is customer isolation: if customer A’s billing fails, the batch loop should catch the exception, log it, and continue to customer B without rolling back any of B’s committed work. REQUIRES_NEW achieves this by suspending the caller’s transaction (if any) and opening a completely independent transaction per customer call. But it also means each call to the billing method is a separate, fresh method invocation — UUID.randomUUID() inside the method re-evaluates on each call.
// MonthlyBillingRunner.java — @Scheduled batch job with manual retry for failed customers.
@Component
public class MonthlyBillingRunner {
private final BillingService billingService;
private final CustomerRepository customerRepository;
@Scheduled(cron = "0 0 2 1 * ?") // 2 AM on the 1st of each month
public void runMonthlyBilling() {
List<Customer> customers = customerRepository.findActivePaidCustomers();
List<String> failed = new ArrayList<>();
// First pass — charge all customers.
for (Customer customer : customers) {
try {
billingService.chargeCustomerIsolated(customer.getId(), customer.getPlanAmountCents());
} catch (Exception e) {
log.warn("Initial charge failed for customer {}: {}", customer.getId(), e.getMessage());
failed.add(customer.getId());
}
}
// Retry pass — re-try failed customers once after a short pause.
if (!failed.isEmpty()) {
log.info("Retrying {} failed customers...", failed.size());
try { Thread.sleep(5_000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
for (String customerId : failed) {
try {
// UNSAFE: billingService.chargeCustomerIsolated() generates a new UUID on every call.
// The retry call creates UUID_B — ch_B alongside committed ch_A.
billingService.chargeCustomerIsolated(customerId,
customerRepository.findById(customerId).get().getPlanAmountCents());
} catch (Exception e) {
log.error("Retry charge failed for customer {}: {}", customerId, e.getMessage());
}
}
}
}
}
// BillingService.java — REQUIRES_NEW per-customer billing — UNSAFE: UUID inside method body.
@Service
public class BillingService {
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void chargeCustomerIsolated(String customerId, long amountCents) {
// UUID inside the REQUIRES_NEW method — regenerates on every method invocation.
String idempotencyKey = UUID.randomUUID().toString();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
Charge charge = Charge.create(params, opts);
// Audit log insert throws DataIntegrityViolationException on attempt 1 (duplicate entry).
// REQUIRES_NEW transaction rolls back — charge record not persisted.
// But ch_A is committed at Stripe — it is outside the transaction boundary.
billingAuditRepository.insertChargeAudit(charge.getId(), customerId, amountCents);
}
}
Execution trace when the audit log write fails after Stripe commits ch_A:
runMonthlyBilling()callsbillingService.chargeCustomerIsolated(customerId, amountCents)through the Spring AOP proxy (first pass).@Transactional(REQUIRES_NEW)opens a new independent transaction.- Method body executes:
UUID.randomUUID()→UUID_A. Charge.create(params, opts)withUUID_A— Stripe processes and commitsch_A.billingAuditRepository.insertChargeAudit()throwsDataIntegrityViolationException(a prior run’s audit record for this customer still exists, violating a unique constraint on(customerId, billingMonth)).@Transactional(REQUIRES_NEW)rolls back. The billing record is not persisted.ch_Ais not rolled back — it is a committed Stripe charge, entirely outside the DB transaction.- Exception propagates to
runMonthlyBilling(). The first-passtry/catchcatches it. Customer is added tofailedlist. - After the first-pass loop completes, the retry loop calls
billingService.chargeCustomerIsolated(customerId, amountCents)again. @Transactional(REQUIRES_NEW)opens another new independent transaction.- Method body executes from the top:
UUID.randomUUID()→UUID_B. Charge.create(params, opts)withUUID_B— Stripe has never seenUUID_B— treats as a new billing intent — commitsch_B.- Customer is charged twice:
ch_A(Stripe committed, not in DB) andch_B(Stripe committed, may or may not persist to DB depending on whether the root cause is fixed).
This failure mode is distinct from failure mode 1 in a crucial way: @Retryable is not involved. The retry is a manual loop in the @Scheduled runner. REQUIRES_NEW isolation — the feature designed to make the batch loop fault-tolerant — is the direct enabler of the duplicate charge: it guarantees that each call to chargeCustomerIsolated() is treated as an independent, fresh method invocation. That is exactly correct for transaction isolation. It is fatal for idempotency if the UUID is inside the method.
A subtler variant of this failure mode uses a named ScheduledExecutorService with Future<Void> tasks submitted per customer. The tasks call billingService.chargeCustomerIsolated(). When a task’s Future completes exceptionally, the orchestrator submits a new Future for the retry. Each new Future is a new method invocation — UUID_B — ch_B. The mechanism is identical even though the retry infrastructure is different from @Retryable.
Fix: pass a pre-computed idempotency key to the REQUIRES_NEW billing method
The fix is to compute the idempotency key before the first call and pass the same value on every retry invocation:
// MonthlyBillingRunner.java — SAFE: idempotency key computed once per customer before first call.
@Scheduled(cron = "0 0 2 1 * ?")
public void runMonthlyBilling() {
String billingPeriod = YearMonth.now().toString(); // "2026-10" — stable for all retry attempts this month
List<Customer> customers = customerRepository.findActivePaidCustomers();
List<FailedCharge> failed = new ArrayList<>();
for (Customer customer : customers) {
// Key computed once per customer per billing period — outside the REQUIRES_NEW method boundary.
String idempotencyKey = "billing:" + customer.getId() + ":" + billingPeriod;
try {
billingService.chargeCustomerIsolated(customer.getId(), customer.getPlanAmountCents(), idempotencyKey);
} catch (Exception e) {
log.warn("Initial charge failed for {}, will retry", customer.getId(), e);
failed.add(new FailedCharge(customer.getId(), customer.getPlanAmountCents(), idempotencyKey));
}
}
if (!failed.isEmpty()) {
try { Thread.sleep(5_000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
for (FailedCharge fc : failed) {
try {
// SAFE: same idempotencyKey as the first attempt — Stripe deduplicates against ch_A.
billingService.chargeCustomerIsolated(fc.customerId(), fc.amountCents(), fc.idempotencyKey());
} catch (Exception e) {
log.error("Retry charge failed for {}: {}", fc.customerId(), e.getMessage());
}
}
}
}
// BillingService.java — SAFE: idempotency key as parameter.
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void chargeCustomerIsolated(String customerId, long amountCents, String idempotencyKey) {
// idempotencyKey is a stable parameter — same value on initial call and all retries.
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
Charge charge = Charge.create(params, opts);
billingAuditRepository.insertChargeAudit(charge.getId(), customerId, amountCents);
}
When the retry call arrives at Stripe with the same idempotencyKey, Stripe looks up its key store, finds ch_A committed under that key, and returns the ch_A response without creating ch_B. The retry is idempotent. The REQUIRES_NEW transaction isolation is preserved — the retry still opens a new transaction, but the Stripe call is deduplicated at Stripe’s side. The audit log write in the retry transaction has the same opportunity to succeed (or fail) independently of the Stripe call.
One structural note: the billingPeriod component in the key ensures that a billing run in November generates different keys than the October run for the same customer. If the key were "billing:" + customerId only, a November retry would be deduplicated against October’s charge — the November customer would not be billed. The billing period component ensures distinctness across calendar periods while guaranteeing stability within a single period.
Failure mode 3: @Retryable(include = OptimisticLockException.class) on @Transactional billing method — Stripe call precedes @Version-checked DB update — ch_A committed before OptimisticLockException — @Retryable retries the Stripe call — UUID_B — ch_B
Optimistic locking is a standard technique for handling concurrent modification of shared entities in a billing system. A Subscription entity carries a @Version field. When two transactions attempt to update the same subscription row concurrently — the billing job updating lastChargedAt while a customer self-service request updates their plan — one of the transactions will fail at flush time with OptimisticLockException. The billing job is a legitimate retry target for this exception: the customer’s data changed, but the billing intent is unchanged. Re-reading the entity and retrying the write is the correct strategy.
The failure mode arises when @Retryable(include = OptimisticLockException.class) is added to the billing method and the method body places the Stripe API call before the @Version-checked DB write. The developer was thinking about retrying the DB write. The @Retryable interceptor retries the entire method body, including the Stripe call.
// SubscriptionBillingService.java — UNSAFE: Stripe call before @Version-checked DB update.
@Service
public class SubscriptionBillingService {
private final SubscriptionRepository subscriptionRepository;
@Retryable(
include = { OptimisticLockException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 500)
)
@Transactional
public void billSubscription(String subscriptionId) {
Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new IllegalStateException("Subscription not found: " + subscriptionId));
// UUID at method entry — regenerates on every @Retryable attempt.
// @Retryable (outer) re-invokes this entire method body, including this line.
String idempotencyKey = UUID.randomUUID().toString();
long amountCents = subscription.getPlanAmountCents();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(subscription.getStripeCustomerId())
.setAmount(amountCents)
.setCurrency("usd")
.setDescription("Subscription renewal: " + subscription.getPlanId())
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
// Stripe call occurs BEFORE the @Version-checked DB write.
// Stripe commits ch_A here. Success — returns ch_A response.
Charge charge = Charge.create(params, opts);
// @Version-checked update — concurrent modification throws OptimisticLockException
// if another transaction updated the subscription row between the read above and this flush.
subscription.setLastChargedAt(Instant.now());
subscription.setLastChargeId(charge.getId());
subscription.setLastBillingStatus(BillingStatus.CHARGED);
subscriptionRepository.save(subscription);
// OptimisticLockException thrown here on flush — propagates out of @Transactional.
// @Transactional rolls back — the subscription update is discarded.
// ch_A is committed at Stripe — it is NOT rolled back.
// @Retryable (outer) catches the OptimisticLockException and retries.
}
}
Execution trace when concurrent modification causes OptimisticLockException after Stripe commits ch_A:
- The
@Scheduledbilling job callssubscriptionBillingService.billSubscription(subscriptionId)through the proxy. @Retryableinterceptor (outer) intercepts.@Transactionalinterceptor (inner) opens a transaction (attempt 1).- Method body:
UUID.randomUUID()→UUID_A. Subscription entity read. Stripe called withUUID_A. Stripe commitsch_Aand returns theChargeobject. subscriptionRepository.save(subscription)flushes to the DB. TheUPDATESQL has aWHERE version = :expected_versionclause. Zero rows updated (concurrent transaction already updated the row and incremented the version). JPA throwsOptimisticLockException.@TransactionalseesOptimisticLockException(aRuntimeException). Rolls back the transaction. The subscription update is discarded.ch_Aremains committed at Stripe.@Retryable(outer) catchesOptimisticLockException. Attempt count: 1 of 3. Waits 500 ms.- Retry attempt 2:
@Transactionalopens a new transaction (attempt 2). - Method body executes from the top:
subscriptionRepository.findById()reads the current subscription state (after the concurrent update).UUID.randomUUID()→UUID_B. Charge.create(params, opts)withUUID_B— Stripe has never seenUUID_B— treats as a new billing intent — commitsch_B.- The subscription entity read in step 8 has the current version. If no further concurrent modification occurs, the DB write succeeds. The subscription’s
lastChargeIdis set toch_B.ch_Ais invisible to the data model. - Customer charged twice:
ch_Aandch_B.
The key asymmetry in this failure mode: the developer added @Retryable(include = OptimisticLockException.class) to make the DB write retry-safe under concurrent modification. That intent is correct and important. The mistake is that retrying the DB write also means retrying everything above it in the method body, including the Stripe call. The Stripe call is not a candidate for OptimisticLockException retry — it already succeeded. Retrying it with a new UUID constitutes a duplicate billing request.
This failure mode is particularly insidious because:
- It only triggers under concurrent modification of the subscription entity — which is rare in development and staging but real in production when plan changes and billing jobs run simultaneously.
- The retry succeeds at both Stripe and DB (attempt 2 gets the updated entity version), so the system appears to function correctly. There is no error log. The duplicate charge is silent.
- The
lastChargeIdon the subscription points toch_B. Reconciliation againstch_Awill show a charge with no matching DB record — typically dismissed as a “ghost charge” from a prior buggy deploy, not correctly identified as a concurrent-retry duplicate.
Fix option A: split Stripe call and DB write into separate method boundaries
The cleanest fix separates the Stripe call (which must not be retried with a new UUID) from the DB write (which is safe to retry under OptimisticLockException):
// SubscriptionBillingService.java — SAFE: split Stripe call and DB write.
@Service
public class SubscriptionBillingService {
private final SubscriptionRepository subscriptionRepository;
private final SubscriptionWriteService subscriptionWriteService;
// No @Retryable here — the Stripe call is not a retry target for OptimisticLockException.
@Transactional(readOnly = true)
public void billSubscription(String subscriptionId) {
Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new IllegalStateException("Subscription not found: " + subscriptionId));
// Key derived from stable business data — same value regardless of how many times this runs.
String idempotencyKey = "billing:" + subscriptionId + ":" + YearMonth.now().toString();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(subscription.getStripeCustomerId())
.setAmount(subscription.getPlanAmountCents())
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
Charge charge = Charge.create(params, opts);
// Stripe call complete. ch_A committed. If this method is somehow retried,
// the same idempotencyKey returns ch_A without creating a new charge.
// DB write delegated to a separate @Retryable @Transactional service method.
subscriptionWriteService.recordCharge(subscriptionId, charge.getId());
}
}
// SubscriptionWriteService.java — SAFE: @Retryable targets only the @Version-checked DB write.
@Service
public class SubscriptionWriteService {
private final SubscriptionRepository subscriptionRepository;
@Retryable(include = { OptimisticLockException.class }, maxAttempts = 5, backoff = @Backoff(delay = 100))
@Transactional
public void recordCharge(String subscriptionId, String chargeId) {
Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new IllegalStateException("Subscription not found: " + subscriptionId));
subscription.setLastChargedAt(Instant.now());
subscription.setLastChargeId(chargeId);
subscription.setLastBillingStatus(BillingStatus.CHARGED);
subscriptionRepository.save(subscription);
// @Retryable retries only this DB write on OptimisticLockException.
// No Stripe call in this method — UUID regeneration is irrelevant.
}
}
The Stripe call and the DB write are now in separate methods. @Retryable is only on the DB-write method. If OptimisticLockException fires during the DB write, only the DB write is retried — the Stripe call already completed and is not re-issued. The chargeId parameter is the concrete ch_A charge ID from the completed Stripe call, passed as a stable value into the retry-safe DB method.
Fix option B: content-hash idempotency key stable across @Retryable re-invocations
If splitting the methods is not feasible, use a content-hash idempotency key that produces the same value on every @Retryable invocation:
@Retryable(include = { OptimisticLockException.class }, maxAttempts = 3, backoff = @Backoff(delay = 500))
@Transactional
public void billSubscription(String subscriptionId) {
Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new IllegalStateException("Subscription not found: " + subscriptionId));
// Content-hash key — derived from stable inputs — same value on every @Retryable attempt.
// subscriptionId and billingPeriod are immutable method-parameter-equivalent values.
String billingPeriod = YearMonth.now().toString();
String idempotencyKey = "billing:" + subscriptionId + ":" + billingPeriod;
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(subscription.getStripeCustomerId())
.setAmount(subscription.getPlanAmountCents())
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
Charge charge = Charge.create(params, opts);
// If @Retryable retries after OptimisticLockException, the Stripe call arrives with the same
// idempotencyKey — Stripe returns ch_A response without creating a new charge.
subscription.setLastChargedAt(Instant.now());
subscription.setLastChargeId(charge.getId());
subscriptionRepository.save(subscription);
}
Fix option B is simpler to implement, but fix option A is structurally superior: it eliminates the coupling between Stripe retry semantics and DB write retry semantics entirely. If you later change the retry policy for OptimisticLockException (more attempts, different backoff, additional exception types), you do not need to re-verify the Stripe idempotency behavior. The two concerns are cleanly separated.
Cross-mode analysis: how these three modes relate to each other and to prior posts in this series
All three failure modes in this post share a single root cause: UUID.randomUUID() placed inside a method body that is re-invoked by a retry mechanism. The retry mechanisms differ — @Retryable interceptor, manual retry loop, @Retryable(OptimisticLockException) — but the UUID regeneration mechanism is identical in all cases.
Mode 1 vs. Spring @Scheduled + @Retryable (session 281): Session 281 covered UUID.randomUUID() inside a @Retryable-annotated service method called by a @Scheduled runner. Mode 1 in this post adds @Transactional to the same method. The additional dimension is the ordering of the two interceptors: developers who add @Transactional to a @Retryable method sometimes expect the transaction to be the outermost boundary, reasoning that “the whole retry sequence should be one transaction.” It is not. @Retryable is outermost. Each retry opens a fresh @Transactional.
Mode 2 vs. Spring @Async + @Scheduled (session 283): Session 283’s mode 3 covered manual retry loops in a @Scheduled job using CompletableFuture.exceptionally(). Mode 2 in this post uses a simpler sequential retry loop calling @Transactional(REQUIRES_NEW) methods. The shared pattern: the retry mechanism is external to the billing service method, so no interceptor can see the full retry boundary, and UUID inside the service method regenerates freely per invocation. The fix in both cases is to compute the idempotency key before any method invocation and pass it as a stable parameter.
Mode 3 vs. Micronaut Data @Transactional + OptimisticLockException: The Micronaut Data post covered @Retryable(includes = OptimisticLockException.class) on a Micronaut Data @Transactional method. Mode 3 here is the Spring equivalent. The mechanism is structurally identical: an AOP-based retry annotation whose stated target is a DB write exception retries the entire method body including the Stripe call that precedes the DB write. The fix options are also structurally identical: split the Stripe call from the DB write, or use a content-hash key.
The ordering asymmetry unique to Spring: Micronaut and Quarkus have their own interceptor ordering defaults for @Retryable / @Transactional. Spring’s specific defaults (LOWEST_PRECEDENCE - 5 for @Retryable, LOWEST_PRECEDENCE for @Transactional) are a Spring-specific detail. Developers migrating from Quarkus to Spring who assumed a different ordering are particularly exposed to mode 1 because the code appears structurally identical to the Quarkus version but has a different interceptor stack.
Testing: verifying idempotency key stability across all three modes
Unit tests cannot verify the Spring AOP interceptor stack behavior reliably. Only integration tests that instantiate the full Spring application context and trigger actual AOP proxying can confirm the effective interceptor ordering. Each test captures the Idempotency-Key headers from all Stripe API calls (via WireMock) and asserts that all attempts for a single billing intent carry the same key value.
Test for mode 1 and mode 3
@SpringBootTest
@ExtendWith(MockitoExtension.class)
class BillingServiceIdempotencyTest {
@Autowired
private BillingService billingService;
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@Test
void retryable_transactional_carries_same_idempotency_key_on_all_attempts() {
// Stripe returns 503 twice, then 200 on attempt 3.
List<String> capturedKeys = Collections.synchronizedList(new ArrayList<>());
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("first-retry"));
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retry")
.whenScenarioStateIs("first-retry")
.willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("second-retry"));
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retry")
.whenScenarioStateIs("second-retry")
.willReturn(aResponse().withStatus(200).withBody(successChargeBody("ch_test123"))));
wireMock.addMockServiceRequestListener((request, response) -> {
if (request.getUrl().startsWith("/v1/charges")) {
String key = request.getHeader("Idempotency-Key");
if (key != null) capturedKeys.add(key);
}
});
billingService.chargeCustomer("cus_test", 1999L, "billing:cus_test:1999:2026-10");
assertThat(capturedKeys).hasSize(3);
// All three attempts must carry the same key — if UUID regenerated, this fails.
assertThat(new HashSet<>(capturedKeys)).hasSize(1);
}
}
Test for mode 2: REQUIRES_NEW batch retry
@SpringBootTest
class MonthlyBillingRunnerIdempotencyTest {
@Autowired
private MonthlyBillingRunner billingRunner;
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@Test
void requires_new_retry_carries_same_idempotency_key() {
// Stripe returns 200 but the first DB audit write will fail — forcing a retry.
// Test verifies that both the initial call and the retry carry the same key.
List<String> capturedKeys = Collections.synchronizedList(new ArrayList<>());
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.willReturn(aResponse().withStatus(200).withBody(successChargeBody("ch_test456"))));
wireMock.addMockServiceRequestListener((request, response) -> {
if (request.getUrl().startsWith("/v1/charges")) {
capturedKeys.add(request.getHeader("Idempotency-Key"));
}
});
// Trigger the billing run — the first pass will fail the DB audit write;
// the retry pass will re-call chargeCustomerIsolated for the same customer.
billingRunner.runMonthlyBilling();
// If two Stripe calls were made (initial + retry), they must carry the same key.
if (capturedKeys.size() == 2) {
assertThat(capturedKeys.get(0)).isEqualTo(capturedKeys.get(1));
}
// If only one Stripe call was made (no retry needed), test passes trivially.
}
}
The assertions are intentionally strict: new HashSet<>(capturedKeys).size() == 1 fails immediately for all three unsafe patterns (UUID regenerated on each attempt), and passes for all three fixes (UUID stable). Run these tests against the unsafe implementation first to confirm they detect the failure, then against the fix to confirm the fix closes the gap. A test that cannot be seen to fail is not a test for this property.
Interceptor order customization: what changes if you override the defaults
If your application customizes the interceptor ordering — for example, by setting @EnableTransactionManagement(order = 10) to make @Transactional outermost, or by setting a custom order on RetryConfiguration via @EnableRetry’s order attribute — the failure mode behavior changes:
| Configuration | @Retryable | @Transactional | What changes |
|---|---|---|---|
| Default | outer (2,147,483,642) | inner (2,147,483,647) | Retry opens fresh transaction per attempt. UUID in method body regenerates. |
@EnableTransactionManagement(order = 0) |
inner (2,147,483,642) | outer (0) | @Transactional wraps the entire retry sequence — one transaction for all retry attempts. Rollback on final failure rolls back all DB work. UUID in method body still regenerates per @Retryable attempt, but all attempts are within the same outer transaction boundary. |
Neither ordering eliminates the UUID regeneration problem when UUID.randomUUID() is inside the method body. In the default ordering, each retry opens a fresh @Transactional boundary and UUID regenerates. In the reversed ordering (Transactional outer), all retries are inside one transaction and UUID still regenerates per @Retryable attempt (the @Retryable interceptor is still inside and re-invokes the method body). The only fix in either case is to compute the key from stable values that do not change between @Retryable re-invocations.
The one meaningful difference between orderings: with @Transactional outer and @Retryable inner, the @Retryable re-invocations all happen within a single open transaction. This means any DB writes in the method body during failed attempts are visible to the open transaction and will be rolled back together if the transaction ultimately fails. With the default ordering (@Retryable outer, @Transactional inner), each attempt’s DB writes are in a separate transaction: the first attempt’s writes may be committed or rolled back independently of the second attempt. For billing, the default ordering is usually safer for DB consistency per attempt, even though it introduces the UUID regeneration risk if the developer is not aware of the interceptor order.
Summary: three modes, one principle
Spring’s @Transactional annotation adds a second axis of complexity to billing retry behavior — transaction boundaries. But the Stripe idempotency failure in all three modes reduces to the same principle: UUID.randomUUID() inside a method body that is re-invoked by any retry mechanism will generate a fresh UUID on each invocation.
Mode 1 (“@Retryable outer, @Transactional inner”): the Spring AOP ordering is not visible to the developer reading the method signature. Both annotations appear identical — method-level annotations — but one of them controls how many times the method body runs. The fix is to remove UUID generation from the method body and derive the key from stable parameters.
Mode 2 (“REQUIRES_NEW batch retry”): the retry mechanism is external to the billing service method. The @Scheduled runner calls the billing method twice for the same customer. REQUIRES_NEW isolation — a feature, not a bug — guarantees that each call is fresh. UUID inside the billing method regenerates. The fix is to pre-compute the key before the first call and pass it as a parameter to both the initial call and the retry.
Mode 3 (“@Retryable(OptimisticLockException)”): the developer’s mental model was “retry the DB write.” The AOP interceptor retried the entire method body. The Stripe call — which already succeeded — was re-issued with a new UUID. The fix is either to split the Stripe call from the DB write into separate method boundaries, or to use a content-hash key that produces the same value when the method is re-invoked with the same business data.
The content-hash key pattern — "billing:" + customerId + ":" + planId + ":" + billingPeriod — closes all three modes simultaneously. It is computed from values that are identical across all retry attempts of the same billing intent: the same customer, the same plan, the same billing period. It also survives JVM restarts, pod evictions, and crash-recovery scenarios where a UUID stored in a local variable would be lost. It is the one change that makes UUID.randomUUID()-based failures impossible for any retry mechanism — not just the three modes documented here.
Keybrake enforces this at the proxy layer: every Stripe API call through Keybrake includes a scoped key computed from the request’s business parameters, independent of what idempotency key the application provides. Duplicate charge attempts — regardless of which retry mechanism generated them — are blocked at the proxy before they reach Stripe’s billing engine. See how Keybrake works for the mechanism.
Catch duplicate charges before they reach Stripe
Keybrake proxies your Stripe calls and enforces idempotency at the network layer — independent of your retry mechanism, framework, or interceptor ordering. No code changes required in your billing service.