Spring JdbcTemplate and Stripe Integration: How @Transactional + @Retryable on the Same Method Re-executes UUID Generation When @Retryable Is the Outer AOP Proxy, TransactionTemplate.execute() Inside @Retryable Does Not Protect UUID Computation From proceed(), and batchUpdate() Deadlock Retry Recharges All Customers Whose Keys Were Generated in the Pre-Batch Map Step
JdbcTemplate is the most widely deployed Spring database abstraction. It is synchronous, imperative, and free of the reactive lifecycle that drives most of the Stripe idempotency failure modes covered in this series. But simplicity at the API surface does not mean safety at the intersection with Spring’s AOP retry and Stripe’s idempotency key contract. Three failure modes are specific to how JdbcTemplate billing methods interact with Spring Retry and Spring’s transaction infrastructure.
This post covers three failure modes specific to JdbcTemplate + Stripe. They are structurally distinct from the jOOQ + Spring Boot post (which focuses on jOOQ’s DSLContext transaction API and optimistic locking semantics), the Spring Data R2DBC + Kotlin Coroutines post (which focuses on reactive re-subscription), and the Spring Batch post (which focuses on chunk-oriented step re-execution). The modes here are specific to how Spring’s default AOP proxy order places @Retryable outside @Transactional on a same-method annotation stack, how programmatic TransactionTemplate usage creates a false sense of UUID isolation, and how the two-phase structure of a batchUpdate() billing method puts Stripe calls in the wrong phase for retry safety.
Background: JdbcTemplate’s transaction model and Spring AOP proxy ordering
JdbcTemplate does not manage transactions itself. It borrows a Connection from the configured DataSource via DataSourceUtils.getConnection(), which participates in Spring’s transaction synchronization mechanism. If a Spring transaction is active on the current thread — initiated by @Transactional or by a TransactionTemplate.execute() call — DataSourceUtils.getConnection() returns the same connection that the active transaction is using. If no transaction is active, it borrows a connection directly and auto-commits each statement. JdbcTemplate does not open, commit, or roll back transactions on its own.
This means a JdbcTemplate.update() call inside a @Transactional method participates in the transaction managed by the @Transactional interceptor. A JdbcTemplate.update() call outside any transaction auto-commits immediately. The developer does not control commit/rollback by calling methods on JdbcTemplate — they control it by how they structure their Spring transaction boundaries.
Spring’s AOP proxy ordering determines which annotation-driven interceptor is the outermost wrapper around a method. Two interceptors are relevant here:
TransactionInterceptor(from@EnableTransactionManagement/ Spring Boot autoconfiguration): default orderOrdered.LOWEST_PRECEDENCE=Integer.MAX_VALUE.AnnotationAwareRetryOperationsInterceptor(from@EnableRetry/ Spring Boot Retry autoconfiguration): registered viaRetryConfigurationat orderOrdered.LOWEST_PRECEDENCE - 5=Integer.MAX_VALUE - 5.
Lower order value = higher priority = outer proxy. @Retryable’s interceptor (MAX - 5) is outer to @Transactional’s interceptor (MAX). The call chain when both annotations appear on the same method is:
// Call chain for a method annotated with both @Retryable and @Transactional:
//
// Caller
// → @Retryable proxy (order MAX-5, outermost)
// → @Transactional proxy (order MAX, inner)
// → method body
This ordering is the root cause of Mode 1. Understanding it precisely is important because the developer’s intuition — that @Transactional is the “outer wrapper” because it manages the bigger concept (the transaction) — is backwards relative to Spring’s actual proxy order.
Stripe’s idempotency contract: a POST to /v1/payment_intents (or any Stripe mutating endpoint) with an Idempotency-Key header returns the cached result of the first successful request with that key for 24 hours per endpoint per API key. Two requests with different keys for the same customer and amount are two distinct charges. Stripe has no cross-key deduplication. The key is the only deduplication mechanism.
Mode 1: @Transactional + @Retryable on the same JdbcTemplate method — @Retryable is the outer AOP proxy — proceed() re-invokes through @Transactional (new transaction) and then re-executes the method body including UUID generation
The most common combination in JdbcTemplate billing code: a service method annotated with both @Transactional and @Retryable. The @Transactional ensures the JDBC write is atomic. The @Retryable retries on transient database errors like CannotSerializeTransactionException (serializable isolation contention) or DeadlockLoserDataAccessException. The UUID for the Stripe idempotency key is computed at method entry — before any JDBC call — and passed to both jdbcTemplate.update() and stripeClient.charge().
The developer’s reasoning: @Transactional is the “outer” concern — it manages the transaction lifecycle. @Retryable retries the failing transaction. UUID computed at method entry is outside the transaction start point and is therefore not part of what gets retried.
The problem: Spring’s proxy order makes @Retryable the actual outer proxy. When CannotSerializeTransactionException propagates up through the @Transactional inner proxy (which rolls back the JDBC transaction and propagates the exception outward), the @Retryable outer proxy catches it and calls proceed(). proceed() re-invokes the @Transactional inner proxy — which opens a fresh JDBC transaction — and then re-invokes the method body from its first statement. The first statement is String idempotencyKey = UUID.randomUUID().toString(). UUID_B is generated on the second attempt.
// BillingService.java — unsafe mode 1: @Transactional + @Retryable on same method
@Service
public class BillingService {
private final JdbcTemplate jdbcTemplate;
private final StripeClient stripeClient;
public BillingService(JdbcTemplate jdbcTemplate, StripeClient stripeClient) {
this.jdbcTemplate = jdbcTemplate;
this.stripeClient = stripeClient;
}
// Developer's reasoning:
// "@Transactional manages the transaction — @Retryable retries the transaction
// when CannotSerializeTransactionException is thrown. UUID is computed before
// any JDBC call — it's outside the transaction boundary — @Transactional is
// the outer wrapper and @Retryable retries within it. UUID should be stable
// across transaction retries."
//
// The problem: Spring's AOP proxy order is the opposite of the developer's model.
// @Retryable (order MAX-5) is OUTER to @Transactional (order MAX).
// Call chain: @Retryable proxy → @Transactional proxy → method body.
//
// Attempt 1:
// @Retryable proxy calls @Transactional proxy.
// @Transactional opens JDBC transaction T1.
// Method body executes: UUID_A = UUID.randomUUID().
// jdbcTemplate.update(..., UUID_A) → INSERT into billing_attempt.
// stripeClient.paymentIntents().create(..., UUID_A) → pi_A committed in Stripe.
// jdbcTemplate.update() throws CannotSerializeTransactionException (serialization
// failure detected later in the transaction, perhaps on a concurrent row lock).
// @Transactional proxy catches the exception, rolls back T1 (undoes INSERT),
// propagates CannotSerializeTransactionException to @Retryable proxy.
//
// Attempt 2 (proceed() called by @Retryable's RetryTemplate):
// @Retryable proxy calls @Transactional proxy again.
// @Transactional opens a NEW JDBC transaction T2.
// Method body executes again: UUID_B = UUID.randomUUID(). ← NEW UUID
// jdbcTemplate.update(..., UUID_B) → INSERT into billing_attempt (UUID_B row).
// stripeClient.paymentIntents().create(..., UUID_B) → pi_B committed. ← DUPLICATE
// T2 commits. Both pi_A and pi_B are charged in Stripe.
// The billing_attempt table has one row (UUID_B, T2 committed).
// The pi_A charge in Stripe has no corresponding database record.
@Transactional
@Retryable(
retryFor = CannotSerializeTransactionException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 200, multiplier = 2.0)
)
public String chargeCustomer(
String customerId,
long amountCents,
String billingPeriod) {
// UUID computed at method entry. In the developer's model, this runs once.
// In practice, proceed() re-invokes the method body — this line re-executes.
String idempotencyKey = UUID.randomUUID().toString();
// DB write — may throw CannotSerializeTransactionException.
jdbcTemplate.update(
"INSERT INTO billing_attempt " +
"(customer_id, idempotency_key, billing_period, amount_cents, status) " +
"VALUES (?, ?, ?, ?, 'PENDING')",
customerId, idempotencyKey, billingPeriod, amountCents
);
// Stripe call — uses idempotencyKey from line above.
// On attempt 2, idempotencyKey is UUID_B. This creates a second charge.
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.setConfirm(true)
.build();
PaymentIntent pi = stripeClient.paymentIntents().create(
params,
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
jdbcTemplate.update(
"UPDATE billing_attempt SET stripe_pi_id = ?, status = 'CHARGED' " +
"WHERE customer_id = ? AND idempotency_key = ?",
pi.getId(), customerId, idempotencyKey
);
return pi.getId();
}
}
Why the developer’s proxy order mental model is wrong
The developer’s intuition is that @Transactional is the “outer” wrapper because the transaction is the bigger concern — it encompasses all the JDBC work. @Retryable retries the transaction on failure, so conceptually it operates on the transaction as a unit. This maps to an intuitive model where @Transactional opens the transaction, the method body runs inside it, the transaction commits or rolls back, and @Retryable decides whether to try again.
Spring’s actual proxy order is determined by the Ordered interface values assigned to each interceptor’s advisor, not by conceptual hierarchy. RetryConfiguration (the Spring Retry infrastructure class that registers the retry advisor when @EnableRetry is active) sets its order to Ordered.LOWEST_PRECEDENCE - 5. Spring’s BeanFactoryTransactionAttributeSourceAdvisor (which wraps TransactionInterceptor) uses the order configured by @EnableTransactionManagement(order = ...), defaulting to Ordered.LOWEST_PRECEDENCE. Lower numeric order = outer proxy. MAX - 5 < MAX, so @Retryable is always the outer proxy under default configuration.
The proxy boundary is what matters for understanding what proceed() re-executes. The AOP proxy intercepts the call at the bean boundary — the point where the caller invokes the method on the Spring-managed bean. The outermost proxy intercepts first. When @Retryable’s RetryTemplate calls proceed() on the MethodInvocation, it re-enters the bean’s proxy chain from the second interceptor (@Transactional) inward, not from the method body directly. @Transactional opens a new transaction. The method body starts from its first line.
// Conceptual proxy call chain on attempt 2:
//
// @Retryable proxy (outer — order MAX-5):
// catches CannotSerializeTransactionException from attempt 1
// calls proceed() on MethodInvocation
// ↓
// @Transactional proxy (inner — order MAX):
// sees no active transaction on current thread (T1 was rolled back and closed)
// opens fresh JDBC transaction T2
// calls proceed() to invoke method body
// ↓
// Method body (attempt 2):
// String idempotencyKey = UUID.randomUUID().toString(); // UUID_B ← re-executes
// jdbcTemplate.update(..., UUID_B); // uses T2's connection
// stripeClient.charge(..., UUID_B); // creates pi_B ← DUPLICATE
// jdbcTemplate.update(..., UUID_B);
// return pi_B.getId();
// ↓
// @Transactional proxy: commits T2
// @Retryable proxy: returns result
There is also a subtlety when the Stripe call succeeds on attempt 1 before the CannotSerializeTransactionException is thrown. In the code above, the serialization failure is detected when the database attempts to commit or when a locked row conflict is encountered during jdbcTemplate.update(). If the Stripe call preceded the failing JDBC call and succeeded, ch_A is already committed in Stripe when @Retryable retries. The JDBC rollback on T1 undoes the INSERT (or the partial INSERT), but Stripe’s charge is not rolled back. Attempt 2 creates ch_B. The customer is billed twice; only one billing row exists in the database (the one committed in T2).
Fix: separate the @Retryable and @Transactional into different methods on different beans
The correct structure is to keep @Retryable and @Transactional on different methods. The @Retryable method is non-transactional and receives the idempotency key as a stable parameter. It calls a separate @Transactional method (on a different Spring bean, so the call goes through the AOP proxy) that performs the JDBC write and the Stripe charge using the stable key.
// BillingFacade.java — @Retryable only, no @Transactional, key as parameter
@Service
public class BillingFacade {
private final BillingService billingService;
public BillingFacade(BillingService billingService) {
this.billingService = billingService;
}
// Key computed here, outside @Retryable scope (this method has no @Retryable).
// Caller computes key and passes it down, OR this method computes it once and
// passes the stable value to the @Retryable method below.
public String charge(String customerId, long amountCents, String billingPeriod) {
String idempotencyKey = contentHashKey(customerId, billingPeriod, amountCents);
return chargeWithRetry(customerId, amountCents, billingPeriod, idempotencyKey);
}
@Retryable(
retryFor = CannotSerializeTransactionException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 200, multiplier = 2.0)
)
public String chargeWithRetry(
String customerId,
long amountCents,
String billingPeriod,
String idempotencyKey) { // ← stable parameter, does not re-execute
// No @Transactional here. Transaction is opened by billingService.chargeTransactional().
return billingService.chargeTransactional(
customerId, amountCents, billingPeriod, idempotencyKey);
}
private static String contentHashKey(
String customerId, String billingPeriod, long amountCents) {
String input = customerId + ":" + billingPeriod + ":" + amountCents;
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
return "kb_" + HexFormat.of().formatHex(hash).substring(0, 32);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
// BillingService.java — @Transactional only, no @Retryable
@Service
public class BillingService {
private final JdbcTemplate jdbcTemplate;
private final StripeClient stripeClient;
public BillingService(JdbcTemplate jdbcTemplate, StripeClient stripeClient) {
this.jdbcTemplate = jdbcTemplate;
this.stripeClient = stripeClient;
}
@Transactional(isolation = Isolation.SERIALIZABLE)
public String chargeTransactional(
String customerId,
long amountCents,
String billingPeriod,
String idempotencyKey) { // ← same key on every @Retryable retry
jdbcTemplate.update(
"INSERT INTO billing_attempt " +
"(customer_id, idempotency_key, billing_period, amount_cents, status) " +
"VALUES (?, ?, ?, ?, 'PENDING')",
customerId, idempotencyKey, billingPeriod, amountCents
);
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.setConfirm(true)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // same key on every retry
.build()
);
jdbcTemplate.update(
"UPDATE billing_attempt SET stripe_pi_id = ?, status = 'CHARGED' " +
"WHERE customer_id = ? AND idempotency_key = ?",
pi.getId(), customerId, idempotencyKey
);
return pi.getId();
}
}
Note that BillingFacade.chargeWithRetry() must call billingService.chargeTransactional() on a separate bean (billingService), not this.chargeTransactional(). Spring AOP proxies intercept calls at the bean boundary, not within the same bean instance. A same-bean call bypasses the @Transactional proxy entirely and runs without a transaction.
Mode 2: TransactionTemplate.execute() inside @Retryable — developer moves to programmatic transaction management to fix Mode 1 — UUID computed before transactionTemplate.execute() still re-executes on proceed()
After encountering Mode 1 or reading about the @Transactional + @Retryable same-method AOP ordering problem, some developers switch to programmatic transaction management with TransactionTemplate. The intent is to gain explicit control over where the transaction boundary is and to make clear that the UUID computation is “outside” the transaction. The developer places UUID.randomUUID() before transactionTemplate.execute(), then passes the key into the TransactionCallback lambda as a captured variable.
The reasoning: transactionTemplate.execute() is the call that manages the JDBC transaction and will throw DeadlockLoserDataAccessException if a deadlock occurs inside the callback. @Retryable catches that exception and retries the transactionTemplate.execute() call. UUID was computed before that call and is therefore not re-executed on retry.
The problem is the same as in Mode 1, with a different surface: @Retryable’s proceed() re-invokes the method body from its first statement — not the transactionTemplate.execute() call within the method body. TransactionTemplate is not an AOP proxy around the method. It is a helper object that the method calls. When proceed() is called by the outer @Retryable proxy, the JVM creates a new stack frame for the method and begins executing from String idempotencyKey = UUID.randomUUID().toString() — before transactionTemplate.execute() is reached. UUID_B is generated on every retry attempt.
// BillingService.java — unsafe mode 2: UUID before TransactionTemplate, @Retryable on method
@Service
public class BillingService {
private final JdbcTemplate jdbcTemplate;
private final StripeClient stripeClient;
private final TransactionTemplate transactionTemplate;
public BillingService(
JdbcTemplate jdbcTemplate,
StripeClient stripeClient,
PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.stripeClient = stripeClient;
this.transactionTemplate = new TransactionTemplate(transactionManager);
}
// Developer's reasoning:
// "I'm using TransactionTemplate explicitly to avoid the @Transactional AOP
// ordering problem. UUID is computed BEFORE transactionTemplate.execute() —
// it is clearly outside the transaction scope. transactionTemplate.execute()
// is the call that might throw DeadlockLoserDataAccessException. @Retryable
// retries that failing call. The UUID line before it runs once and is stable."
//
// The problem: @Retryable is an AOP proxy around the METHOD, not around the
// transactionTemplate.execute() call. TransactionTemplate is a plain Java object
// inside the method body — it is not an AOP proxy or an interception boundary.
//
// When @Retryable's RetryTemplate calls proceed() on retry:
// → New stack frame for chargeCustomer() opens.
// → Statement 1: String idempotencyKey = UUID.randomUUID().toString(); // UUID_B
// → transactionTemplate.execute() runs with UUID_B captured in lambda.
// → Stripe called with UUID_B → ch_B alongside committed ch_A.
//
// proceed() does not re-enter the transactionTemplate.execute() lambda directly.
// It re-invokes the full method body, including every statement before
// transactionTemplate.execute().
@Retryable(
retryFor = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2.0)
)
public String chargeCustomer(
String customerId,
long amountCents,
String billingPeriod) {
// Developer believes this runs once. proceed() re-executes this line.
String idempotencyKey = UUID.randomUUID().toString();
// Stripe outside the transaction — developer moves Stripe before the
// transaction to avoid the "Stripe inside transaction" anti-pattern.
// But Stripe is still inside the @Retryable method boundary.
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.setConfirm(true)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // UUID_B on attempt 2
.build()
);
String piId = pi.getId();
// TransactionTemplate: explicit transaction scope. Developer thinks
// @Retryable retries this call specifically.
transactionTemplate.executeWithoutResult(status -> {
jdbcTemplate.update(
"INSERT INTO billing_attempt " +
"(customer_id, idempotency_key, stripe_pi_id, billing_period, " +
"amount_cents, status) VALUES (?, ?, ?, ?, ?, 'CHARGED')",
customerId, idempotencyKey, piId, billingPeriod, amountCents
);
});
return piId;
}
}
The distinction between the method boundary and the TransactionTemplate invocation
TransactionTemplate.execute() is a method call on a plain Spring bean. At runtime, it is indistinguishable from any other method call in the method body. It does not create an AOP proxy boundary. It does not create a scope that proceed() can re-enter at any point other than the beginning of the enclosing method.
The developer’s mental model imagines @Retryable operating like a try/catch block placed around transactionTemplate.execute() specifically — as if the annotation could identify which call in the method body threw and retry only that call. No such mechanism exists in Spring’s AOP framework. @Retryable’s proxy intercepts the method at the proxy boundary and wraps the entire method invocation in a RetryTemplate. Every retry is a new method invocation, starting from the first line of the method body.
// What the developer imagines @Retryable does:
//
// chargeCustomer():
// String idempotencyKey = UUID.randomUUID().toString(); // runs once
// stripeClient.charge(idempotencyKey); // runs once
// @Retryable catches exceptions from here only:
// ┌──────────────────────────────────────────────────────┐
// │ try { │
// │ transactionTemplate.executeWithoutResult(...); │ ← retried if throws
// │ } catch (DeadlockLoserDataAccessException e) { │
// │ // retry │
// │ } │
// └──────────────────────────────────────────────────────┘
//
// What @Retryable actually does:
//
// @Retryable proxy boundary (wraps the entire method):
// ┌──────────────────────────────────────────────────────────┐
// │ Attempt 1: │
// │ String idempotencyKey = UUID.randomUUID(); // UUID_A │
// │ stripeClient.charge(UUID_A); → pi_A committed │
// │ transactionTemplate.execute(...); // throws deadlock │
// │ ← DeadlockLoserDataAccessException propagates here │
// │ │
// │ Attempt 2 (proceed()): │
// │ String idempotencyKey = UUID.randomUUID(); // UUID_B │ ← re-executes
// │ stripeClient.charge(UUID_B); → pi_B committed ← DUP │ ← re-executes
// │ transactionTemplate.execute(...); // succeeds │
// └──────────────────────────────────────────────────────────┘
The fix for Mode 2 is the same as for Mode 1: compute the idempotency key outside the @Retryable method boundary and pass it as a stable parameter. The @Retryable-annotated method receives the key as a parameter value. Parameter values are passed by value at the time of the original method call and do not re-execute on proceed().
// BillingService.java — fixed mode 2: key as parameter
@Service
public class BillingService {
// ... fields and constructor ...
// Non-@Retryable caller computes the key once.
public String charge(String customerId, long amountCents, String billingPeriod) {
String idempotencyKey = contentHashKey(customerId, billingPeriod, amountCents);
return chargeWithRetry(customerId, amountCents, billingPeriod, idempotencyKey);
}
@Retryable(
retryFor = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2.0)
)
public String chargeWithRetry(
String customerId,
long amountCents,
String billingPeriod,
String idempotencyKey) { // ← stable: same value on every proceed()
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.setConfirm(true)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // same key on every retry
.build()
);
transactionTemplate.executeWithoutResult(status -> {
jdbcTemplate.update(
"INSERT INTO billing_attempt " +
"(customer_id, idempotency_key, stripe_pi_id, billing_period, " +
"amount_cents, status) VALUES (?, ?, ?, ?, ?, 'CHARGED')",
customerId, idempotencyKey, pi.getId(), billingPeriod, amountCents
);
});
return pi.getId();
}
}
Variant: TransactionTemplate with Stripe inside the callback
Some developers place both the JDBC write and the Stripe call inside transactionTemplate.execute(), reasoning that if the DB write fails the Stripe call also doesn’t happen. This is a different architectural choice but the same retry problem applies: if @Retryable is on the enclosing method and UUID.randomUUID() is anywhere in the method body — inside or outside the TransactionTemplate callback — it re-executes on proceed().
When Stripe is inside the transactionTemplate.execute() callback and the Stripe call succeeds but the subsequent jdbcTemplate.update() throws (and TransactionTemplate rolls back), the Stripe charge is not rolled back. The developer must still use a content-hash idempotency key so that a retry of the entire callback does not create a new Stripe charge. The key must be stable across the transaction-level retry, which means it must be stable across the method-level proceed() call, which means it must not be computed by UUID.randomUUID() inside the retried scope.
Mode 3: JdbcTemplate.batchUpdate() + @Retryable(DeadlockLoserDataAccessException.class) — UUID and Stripe calls in the map step before batchUpdate() — deadlock retry re-executes the map step for all customers
The third failure mode is common in batch billing pipelines that combine JdbcTemplate.batchUpdate() with per-customer Stripe charges. The developer structures the billing method as two sequential phases. Phase 1: iterate over the customer list, generate a UUID per customer, call Stripe per customer, and collect the results into a batch parameter array. Phase 2: call jdbcTemplate.batchUpdate() with the parameter array to atomically insert all billing records in a single JDBC batch. @Retryable(DeadlockLoserDataAccessException.class) is applied to handle deadlocks on batchUpdate() — concurrent billing runs targeting overlapping customer sets can deadlock on row locks during the bulk insert.
The developer’s reasoning: batchUpdate() is the database write step and the only step that can throw DeadlockLoserDataAccessException. The map step (Phase 1) completed successfully on attempt 1. @Retryable retries the failing operation: batchUpdate(). The map step is “already done” and will not re-execute.
The problem: @Retryable retries the method, not batchUpdate(). proceed() re-invokes the method body from its first statement. Phase 1 (the map step) re-executes for all customers. UUID.randomUUID() generates UUID_B per customer inside the map lambda. stripeClient.charge(UUID_B, ...) creates ch_B per customer. On attempt 2, every customer whose Stripe charge completed in Phase 1 of attempt 1 now has a second charge.
// BillingBatchService.java — unsafe mode 3: Stripe in map step, @Retryable on method
@Service
public class BillingBatchService {
private final JdbcTemplate jdbcTemplate;
private final StripeClient stripeClient;
private static final String INSERT_BILLING_SQL =
"INSERT INTO billing_attempt " +
"(customer_id, idempotency_key, stripe_pi_id, billing_period, " +
"amount_cents, status) VALUES (?, ?, ?, ?, ?, 'CHARGED')";
public BillingBatchService(JdbcTemplate jdbcTemplate, StripeClient stripeClient) {
this.jdbcTemplate = jdbcTemplate;
this.stripeClient = stripeClient;
}
// Developer's reasoning:
// "Phase 1 (map step) calls Stripe and builds the batch args array.
// Phase 2 (batchUpdate) is the DB write that may deadlock.
// @Retryable retries the DeadlockLoserDataAccessException from batchUpdate().
// The map step in Phase 1 already completed — those Stripe charges are done.
// @Retryable will retry batchUpdate() with the same 'batchArgs' array
// that Phase 1 already built. UUID and Stripe calls are not re-run."
//
// The problem: @Retryable retries the METHOD.
// proceed() re-invokes the method body from its first statement.
// Phase 1 re-executes for all customers:
// - UUID.randomUUID() generates UUID_B per customer.
// - stripeClient.charge(UUID_B, ...) creates ch_B per customer.
// Phase 2 calls batchUpdate() with the new UUID_B batchArgs.
// Every customer who was charged UUID_A in attempt 1's Phase 1 is now
// also charged UUID_B. Two charges per customer. One DB row per customer (UUID_B).
// No DB record for the pi_A charges.
@Retryable(
retryFor = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 300, multiplier = 2.0)
)
@Transactional
public void chargeBatch(List<Customer> customers, String billingPeriod) {
// Phase 1: map customers → charge Stripe → build batchArgs.
// This phase RE-EXECUTES on every proceed() call.
Object[][] batchArgs = customers.stream()
.map(customer -> {
// UUID generated per customer inside the map lambda.
// This re-executes on proceed() — UUID_B per customer on attempt 2.
String idempotencyKey = UUID.randomUUID().toString();
// Stripe called per customer inside the map lambda.
// On attempt 2, called with UUID_B — ch_B per customer.
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(customer.getAmountCents())
.setCurrency("usd")
.setCustomer(customer.getStripeCustomerId())
.setConfirm(true)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
return new Object[]{
customer.getId(),
idempotencyKey,
pi.getId(),
billingPeriod,
customer.getAmountCents()
};
})
.toArray(Object[][]::new);
// Phase 2: bulk DB write — may throw DeadlockLoserDataAccessException.
// This is the only call that throws. Developer believes @Retryable retries this.
jdbcTemplate.batchUpdate(INSERT_BILLING_SQL, batchArgs);
}
}
The blast radius of Phase 1 re-execution
The blast radius is proportional to how many customers completed Phase 1 (Stripe charge + UUID generation) before the deadlock. In a 500-customer batch, if Stripe charges completed for all 500 customers in Phase 1 of attempt 1 before batchUpdate() deadlocked, all 500 customers receive ch_B on attempt 2. Each customer has two Stripe charges.
The problem is compounded by Stripe’s 24-hour idempotency window. Each UUID_A charge from attempt 1’s Phase 1 is a distinct Stripe PaymentIntent with its own charge ID. UUID_B charges from attempt 2’s Phase 1 are also distinct PaymentIntents. Stripe sees two separate payment requests per customer — it has no way to know they represent the same billing event, because the developer provided different idempotency keys for each.
The failure is also difficult to detect. The database has one row per customer (from the successful batchUpdate() on attempt 2, with UUID_B). A developer auditing the database sees no anomaly — one row per customer, one PaymentIntent ID per row, all rows show status = 'CHARGED'. The duplicate charges (UUID_A charges from attempt 1) are visible only in Stripe’s dashboard or via the Stripe API. They have no corresponding database records and no idempotency key that matches any known billing record.
Fix A: compute keys before the @Retryable scope and pass as a parameter map
The immediate fix is to move UUID generation and Stripe calls out of the @Retryable scope. Pre-compute idempotency keys for all customers in a non-retried method. Pass the key map into the retried method as a parameter. The retried method only performs the batchUpdate() write using the stable pre-computed keys, and calls Stripe using the same stable keys.
// BillingBatchFacade.java — non-retried caller computes keys and charges Stripe
@Service
public class BillingBatchFacade {
private final BillingBatchService billingBatchService;
public BillingBatchFacade(BillingBatchService billingBatchService) {
this.billingBatchService = billingBatchService;
}
public void chargeBatch(List<Customer> customers, String billingPeriod) {
// Phase 1: compute keys and charge Stripe — NOT inside @Retryable scope.
// Keys are content-hash derived: same customer + billingPeriod → same key.
List<BillingResult> results = customers.stream()
.map(customer -> {
String key = contentHashKey(
customer.getId(), billingPeriod, customer.getAmountCents());
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(customer.getAmountCents())
.setCurrency("usd")
.setCustomer(customer.getStripeCustomerId())
.setConfirm(true)
.build(),
RequestOptions.builder()
.setIdempotencyKey(key) // content-hash: same on any retry
.build()
);
return new BillingResult(customer.getId(), key, pi.getId(),
customer.getAmountCents());
})
.toList();
// Phase 2: DB write — retried if deadlock occurs.
// Keys and pi IDs are stable: passed as a parameter list.
billingBatchService.persistBatch(results, billingPeriod);
}
private String contentHashKey(
String customerId, String billingPeriod, long amountCents) {
String input = customerId + ":" + billingPeriod + ":" + amountCents;
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
return "kb_" + HexFormat.of().formatHex(hash).substring(0, 32);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
// BillingBatchService.java — @Retryable only on the DB write phase
@Service
public class BillingBatchService {
private final JdbcTemplate jdbcTemplate;
private static final String INSERT_BILLING_SQL =
"INSERT INTO billing_attempt " +
"(customer_id, idempotency_key, stripe_pi_id, billing_period, " +
"amount_cents, status) VALUES (?, ?, ?, ?, ?, 'CHARGED') " +
"ON CONFLICT (idempotency_key) DO NOTHING"; // safe idempotent insert
public BillingBatchService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Retryable(
retryFor = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 300, multiplier = 2.0)
)
@Transactional
public void persistBatch(List<BillingResult> results, String billingPeriod) {
// Only the DB write is retried. results is a parameter: stable across retries.
Object[][] batchArgs = results.stream()
.map(r -> new Object[]{
r.customerId(), r.idempotencyKey(), r.piId(),
billingPeriod, r.amountCents()
})
.toArray(Object[][]::new);
jdbcTemplate.batchUpdate(INSERT_BILLING_SQL, batchArgs);
}
}
The ON CONFLICT (idempotency_key) DO NOTHING clause in the insert is an additional safety net. If persistBatch() is somehow called twice with the same results (due to a bug at the caller level), the second call is a no-op for rows whose idempotency key is already present. This is defense-in-depth, not a substitute for the structural fix (Phase 1 outside the retry scope).
Fix B: use BatchPreparedStatementSetter with pre-built, stable state
If the phase-separation refactor above is too disruptive, a lighter-weight fix is to use JdbcTemplate.batchUpdate(String sql, BatchPreparedStatementSetter pss) with a BatchPreparedStatementSetter built from pre-computed stable data. Move the UUID generation and Stripe calls to a non-retried step that constructs the BatchPreparedStatementSetter before the @Retryable method is called. The setter is passed as a parameter to the retried method.
// Caller: builds BatchPreparedStatementSetter from pre-charged, stable results
List<BillingResult> results = computeAndChargeAll(customers, billingPeriod);
BatchPreparedStatementSetter pss = new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
BillingResult r = results.get(i);
ps.setString(1, r.customerId());
ps.setString(2, r.idempotencyKey()); // pre-computed content-hash
ps.setString(3, r.piId());
ps.setString(4, billingPeriod);
ps.setLong(5, r.amountCents());
}
@Override
public int getBatchSize() { return results.size(); }
};
// Retried method receives the setter as a stable parameter.
billingBatchService.persistWithSetter(pss);
// BillingBatchService:
@Retryable(retryFor = DeadlockLoserDataAccessException.class, maxAttempts = 3)
@Transactional
public void persistWithSetter(BatchPreparedStatementSetter pss) {
jdbcTemplate.batchUpdate(INSERT_BILLING_SQL, pss);
// pss is a parameter: same object reference on every proceed() call.
// setValues() re-reads from the stable, pre-built results list.
// UUID values in the setter do not change between retries.
}
Comparison: three failure modes
| Mode | Re-execution trigger | Re-execution unit | UUID position | Developer misconception | Stripe impact |
|---|---|---|---|---|---|
1 — @Transactional + @Retryable same method |
CannotSerializeTransactionException or DeadlockLoserDataAccessException propagates through @Transactional to @Retryable outer proxy |
Entire method body (including pre-transaction UUID line) via AOP proceed() |
First line of method body, before any JDBC call | “@Transactional is outer; @Retryable retries the transaction, not UUID generation at method entry” |
UUID_B on every retry attempt → ch_B alongside ch_A |
2 — TransactionTemplate inside @Retryable, UUID before execute() |
DeadlockLoserDataAccessException from inside transactionTemplate.execute() propagates to @Retryable proxy |
Entire method body via AOP proceed() |
Before transactionTemplate.execute() call in method body |
“@Retryable retries transactionTemplate.execute() (the failing call), not my UUID line that comes before it” |
UUID_B on every retry → ch_B; Stripe called before DB write, so ch_A already committed on attempt 1 |
3 — batchUpdate() + @Retryable, UUID and Stripe in pre-batch map step |
DeadlockLoserDataAccessException from jdbcTemplate.batchUpdate() |
Entire method body via AOP proceed() |
Inside .stream().map() lambda before batchUpdate() |
“batchUpdate() is the failing step; map/Stripe phase is complete, not retried” |
UUID_B per customer → ch_B per customer in every retry; blast radius = all customers in the batch |
| Mode | DB state after retry commits | Stripe state after retry | Detection signal | Fix |
|---|---|---|---|---|
| 1 | One row (UUID_B, T2 committed); UUID_A row rolled back with T1 | pi_A (ch_A, no DB record) + pi_B (ch_B, DB row with UUID_B) | Stripe charges without matching billing_attempt rows; charge count > customer count for billing period |
Separate @Retryable and @Transactional to different beans; stable key as parameter to @Retryable method |
| 2 | One row (UUID_B) if attempt 2 succeeds; no row if attempt 1 DB write threw before insert committed | pi_A (UUID_A, no DB record if DB rolled back) + pi_B (UUID_B, DB row) | Stripe charges without matching DB rows; Stripe dashboard shows two charges per customer for the billing period | Content-hash key computed outside @Retryable method; key as stable parameter |
| 3 | N rows (UUID_B per customer) from attempt 2’s batchUpdate(); no UUID_A rows (attempt 1’s batchUpdate() failed) |
N pi_A charges (UUID_A, no DB records) + N pi_B charges (UUID_B, DB rows) = 2N total charges for N customers | Stripe charge total for billing period = 2× expected; no DB rows for UUID_A charges; Stripe shows two payment intents per customer | Move UUID generation and Stripe calls outside @Retryable scope; @Retryable on DB-write-only method receiving stable pre-built args as parameter |
JUnit 5 and WireMock test patterns
Testing Mode 1: verify same idempotency key across @Transactional + @Retryable retries
The test must confirm that the fixed implementation sends the same Idempotency-Key header on all retry attempts. WireMock records request headers. The test triggers a CannotSerializeTransactionException on the first attempt (via a mocked JdbcTemplate or an embedded database configured for serializable isolation) and verifies that the two Stripe calls (attempt 1 and attempt 2) used the same header value.
@SpringBootTest
@ExtendWith(WireMockExtension.class)
class BillingServiceMode1Test {
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@Autowired
BillingFacade billingFacade; // fixed: @Retryable on facade, key as param
@MockBean
JdbcTemplate jdbcTemplate;
@Test
void sameIdempotencyKeyOnBothRetryAttempts() {
// Arrange: first update throws serialization failure; second succeeds.
when(jdbcTemplate.update(anyString(), any()))
.thenThrow(new CannotSerializeTransactionException("serialization"))
.thenReturn(1);
wireMock.stubFor(post(urlEqualTo("/v1/payment_intents"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"id":"pi_test","status":"succeeded","amount":1000,
"currency":"usd","object":"payment_intent"}
""")));
// Act
billingFacade.charge("cus_test", 1000L, "2026-10");
// Assert: exactly two calls to Stripe (one per retry attempt).
List<LoggedRequest> stripeRequests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/payment_intents")));
assertThat(stripeRequests).hasSize(2);
// Both requests must carry the same Idempotency-Key header.
String keyAttempt1 = stripeRequests.get(0)
.getHeader("Idempotency-Key");
String keyAttempt2 = stripeRequests.get(1)
.getHeader("Idempotency-Key");
assertThat(keyAttempt1).isNotNull();
assertThat(keyAttempt1).isEqualTo(keyAttempt2);
// If they differ, UUID was regenerated on retry — the bug is present.
}
}
Testing Mode 2: verify UUID computed before TransactionTemplate does not change on retry
The unsafe version generates UUID_B on retry; the fixed version uses a stable content-hash key. The test verifies that both Stripe calls in a two-attempt run use the same key, and that the key matches the content-hash of the input parameters (not a random UUID).
@Test
void transactionTemplateRetryUsesStableContentHashKey() {
// Arrange: transactionTemplate.executeWithoutResult() throws on first call.
doThrow(new DeadlockLoserDataAccessException("deadlock", null))
.doNothing()
.when(transactionTemplateMock).executeWithoutResult(any());
wireMock.stubFor(post(urlEqualTo("/v1/payment_intents"))
.willReturn(aResponse().withStatus(200).withBody(PAYMENT_INTENT_JSON)));
// Act
billingService.chargeWithRetry(
"cus_123", 5000L, "2026-10", contentHashKey("cus_123", "2026-10", 5000L));
// Assert: two Stripe calls, same key, key is content-hash (starts with "kb_").
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/payment_intents")));
assertThat(requests).hasSize(2);
String key1 = requests.get(0).getHeader("Idempotency-Key");
String key2 = requests.get(1).getHeader("Idempotency-Key");
assertThat(key1).isEqualTo(key2);
assertThat(key1).startsWith("kb_"); // content-hash prefix, not UUID format
}
Testing Mode 3: verify Stripe is not called in the retried persistBatch() method
The correct fix places Stripe calls outside the @Retryable scope entirely. The persistBatch() method should not call Stripe at all. The test verifies that a retry of persistBatch() (triggered by a deadlock on the first batchUpdate()) does not produce additional Stripe requests beyond those made in the pre-batch phase.
@Test
void batchUpdateDeadlockRetryDoesNotCallStripeAgain() {
// Arrange: first batchUpdate throws; second succeeds.
when(jdbcTemplate.batchUpdate(anyString(), any(Object[][].class)))
.thenThrow(new DeadlockLoserDataAccessException("deadlock", null))
.thenReturn(new int[]{ 1, 1, 1 });
wireMock.stubFor(post(urlEqualTo("/v1/payment_intents"))
.willReturn(aResponse().withStatus(200).withBody(PAYMENT_INTENT_JSON)));
List<Customer> customers = List.of(
new Customer("cus_1", "cus_stripe_1", 1000L),
new Customer("cus_2", "cus_stripe_2", 2000L),
new Customer("cus_3", "cus_stripe_3", 3000L)
);
// Act: billingBatchFacade.chargeBatch() calls Stripe once per customer (3 calls)
// in the non-retried phase, then calls billingBatchService.persistBatch() which
// is @Retryable — retried once on deadlock.
billingBatchFacade.chargeBatch(customers, "2026-10");
// Assert: exactly 3 Stripe calls (one per customer in the non-retried phase).
// If the bug is present, 6 calls would appear (3 per retry attempt).
wireMock.verify(3, postRequestedFor(urlEqualTo("/v1/payment_intents")));
// Verify idempotency: all 3 keys are content-hash derived (start with "kb_"),
// not random UUIDs.
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/payment_intents")));
for (LoggedRequest req : requests) {
assertThat(req.getHeader("Idempotency-Key")).startsWith("kb_");
}
}
@Test
void batchUpdateDeadlockRetryWithSameKeysIsIdempotentInStripe() {
// Simulate: first call succeeded for cus_1 and cus_2 (Stripe committed),
// but batchUpdate() deadlocked before cus_3's DB record was written.
// On retry, all 3 customers' Stripe calls re-run with content-hash keys.
// Stripe returns cached result for cus_1 and cus_2 (same key → same pi_id).
// No duplicate charge.
wireMock.stubFor(post(urlEqualTo("/v1/payment_intents"))
.willReturn(aResponse().withStatus(200).withBody(PAYMENT_INTENT_JSON)));
// In the fixed implementation, the facade calls Stripe 3 times (once per customer,
// in the non-retried phase). Even if the facade itself were called twice (a higher-
// level retry), content-hash keys mean Stripe deduplicates: same key → same result.
String key1 = contentHashKey("cus_1", "2026-10", 1000L);
String key2 = contentHashKey("cus_1", "2026-10", 1000L);
assertThat(key1).isEqualTo(key2); // content-hash keys are deterministic
}
Integration test: verifying @Retryable proxy order with a real Spring context
The following test uses a real Spring application context to verify that the proxy order in the fixed implementation is correct: the @Retryable method on BillingFacade is the retry boundary, and BillingService.chargeTransactional() is the transaction boundary. The test verifies that two @Transactional transactions are opened (one per retry attempt) and that the idempotency key is the same in both.
@SpringBootTest
@Sql(scripts = "/schema.sql")
class BillingProxyOrderIntegrationTest {
@Autowired
BillingFacade billingFacade;
@SpyBean
BillingService billingService;
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@Test
void twoTransactionsOpenedOnRetry_sameIdempotencyKey() {
// Arrange: billingService.chargeTransactional() throws on first call.
doThrow(new CannotSerializeTransactionException("serialization"))
.doCallRealMethod()
.when(billingService).chargeTransactional(
anyString(), anyLong(), anyString(), anyString());
wireMock.stubFor(post(urlEqualTo("/v1/payment_intents"))
.willReturn(aResponse().withStatus(200).withBody(PAYMENT_INTENT_JSON)));
// Act
String piId = billingFacade.charge("cus_test", 1000L, "2026-10");
// Assert: chargeTransactional() called twice (one per attempt).
verify(billingService, times(2))
.chargeTransactional(eq("cus_test"), eq(1000L), eq("2026-10"), anyString());
// Assert: idempotency key is the same on both calls.
ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
verify(billingService, times(2))
.chargeTransactional(any(), anyLong(), any(), keyCaptor.capture());
List<String> capturedKeys = keyCaptor.getAllValues();
assertThat(capturedKeys.get(0)).isEqualTo(capturedKeys.get(1));
// Assert: Stripe called twice (once per chargeTransactional() invocation),
// same Idempotency-Key header on both calls.
List<LoggedRequest> stripeRequests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/payment_intents")));
assertThat(stripeRequests).hasSize(2);
assertThat(stripeRequests.get(0).getHeader("Idempotency-Key"))
.isEqualTo(stripeRequests.get(1).getHeader("Idempotency-Key"));
}
}
Common rules across all three modes
The three failure modes share the same underlying mechanism: @Retryable’s AOP proceed() re-invokes the entire method body on every retry attempt. The specific JdbcTemplate surface — jdbcTemplate.update() inside @Transactional, transactionTemplate.execute() as a programmatic boundary, or jdbcTemplate.batchUpdate() as a bulk write step — does not change this. The rules that follow from this are independent of JdbcTemplate:
- No
UUID.randomUUID()inside a@Retryablemethod. The method boundary is the retry boundary. Any statement inside the method body, including the first line, re-executes on everyproceed()call. - No external HTTP calls inside a
@Retryablemethod unless they use a stable key. If a Stripe call is inside the method boundary and the key is random, the call is made with a new key on every retry. If the key is a content-hash (deterministic from inputs), Stripe deduplicates and returns the cached result. - Pass idempotency keys as stable parameters. Method parameters do not re-execute on
proceed(). The parameter value is the value at the time of the original method invocation and does not change betweenproceed()calls. - Separate the
@Retryableand@Transactionalproxy boundaries. Put them on methods in different beans. The@Retryablebean calls the@Transactionalbean. Key computation happens before the@Retryablecall — not inside the@Retryablemethod body. - For batch billing, the Stripe phase and the DB write phase must be in separate methods. The DB write phase is what benefits from retry. The Stripe phase must be outside the retry scope, using content-hash keys so it is idempotent if somehow called twice.
These rules apply equally to jOOQ, Spring Data R2DBC, Spring Batch, and any other Spring-based data access layer that uses Spring Retry’s @Retryable. The JdbcTemplate modes in this post are notable because JdbcTemplate’s synchronous simplicity can create a false sense of predictability: developers often expect their @Retryable annotations to work exactly as annotated because JdbcTemplate operations are straightforward, unbuffered, and not susceptible to the reactive lifecycle complexities of R2DBC or the proxy invocation subtleties of jOOQ’s DSLContext. But Spring Retry’s AOP mechanism operates at the proxy boundary, not at the JdbcTemplate call site, and the same failure modes apply regardless of which JDBC abstraction is in use.
Keybrake: enforce idempotency at the proxy layer
JdbcTemplate, jOOQ, Spring Data JPA — every ORM has these patterns. Keybrake sits between your agent and Stripe, enforcing spend caps, logging every charge, and giving you a kill-switch that works in milliseconds. Join the waitlist and we’ll notify you at early access.