jOOQ, Spring Boot, and Stripe Integration: How @Retryable on DSLContext.transactionResult() Re-invokes UUID Generation on Each proceed() Call, Optimistic Locking DataChangedException Misleads Developers into Thinking Stripe Charges Rolled Back with the JDBC Transaction, and batchInsert() Deadlock Retry Recharges Already-Billed Customers via UUID_B
jOOQ’s fluent DSL and explicit SQL control make it a popular choice for teams that want more than JPA’s object mapping but less than raw JDBC boilerplate. It also introduces three idempotency traps for Stripe integration that are specific to how jOOQ’s transaction API interacts with Spring’s AOP retry, how jOOQ’s optimistic locking signals look to developers working with Stripe, and how jOOQ’s batch API position in the method body affects what gets re-executed on deadlock retry.
This post covers three failure modes specific to jOOQ + Spring Boot + Stripe. They are structurally distinct from the Spring Data R2DBC + Kotlin Coroutines post (which focuses on reactive context re-subscription semantics), the Spring WebClient + @Transactional post (which focuses on reactive retry operators), and the Spring Batch post (which focuses on chunk-oriented step re-execution). The modes here are specific to how jOOQ’s DSLContext transaction API interacts with Spring Retry’s AOP interceptors, how jOOQ’s JDBC transaction rollback behavior is misread relative to external HTTP calls, and how jOOQ’s batchInsert() position in a batch billing pipeline determines the blast radius of a deadlock retry.
Background: jOOQ’s transaction model and its interaction with Spring
jOOQ manages JDBC transactions through the TransactionProvider SPI. Two providers are relevant in Spring Boot:
DefaultTransactionProvider(jOOQ default): jOOQ manages the JDBCConnectiondirectly from aConnectionProvider.DSLContext.transaction()opens a new connection (or borrows from a pool), setsautoCommit = false, runs the lambda, and commits or rolls back. Spring’s transaction manager is not involved.@Transactionalon the calling method creates a Spring transaction, but the jOOQdsl.transaction {}call opens a separate JDBC connection and transaction.SpringTransactionProvider(recommended for Spring Boot): jOOQ’sdsl.transaction {}participates in the current Spring transaction viaDataSourceUtils.getConnection(). If a Spring transaction is active (from@Transactional), the jOOQ lambda runs within it. If no Spring transaction is active, jOOQ borrows a connection directly. This is the configuration produced by Spring Boot’sJooqAutoConfigurationwhen both jOOQ and a SpringDataSourceare present.
The failure modes in this post apply to both providers, but the interaction with @Transactional differs. Modes 1 and 3 use SpringTransactionProvider; Mode 2 demonstrates the critical issue with both configurations.
Stripe’s idempotency key contract: a POST to /v1/payment_intents with an Idempotency-Key header returns the cached result of the first request with that key for 24 hours per endpoint per API key. Two requests for the same customer at the same amount with different keys are two charges, not one. Stripe has no cross-key deduplication. The key is the only contract.
jOOQ’s transaction lambdas are JDBC transaction boundaries. HTTP calls to external services inside a jOOQ transaction lambda are not part of the JDBC transaction. This distinction is the source of Mode 2’s failure.
Mode 1: @Retryable on a method containing dsl.transactionResult() — UUID at method entry level — AOP proceed() re-invokes the method, regenerating UUID_B
The most common jOOQ + Spring Retry combination in production code: a @Service method annotated with @Retryable delegates to dsl.transactionResult() to perform the billing write inside a jOOQ-managed JDBC transaction. The UUID is computed at method entry, before the transactionResult() call, and passed into the lambda. The developer’s reasoning: the UUID is computed once per method call, and @Retryable retries the transaction inside the method, not the method itself.
The problem: @Retryable’s AOP interceptor (AnnotationAwareRetryOperationsInterceptor) wraps the method at the proxy boundary. It calls proceed() on the AOP interceptor chain on each retry attempt. proceed() 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: UUID at method entry inside @Retryable scope
@Service
public class BillingService {
private final DSLContext dsl;
private final StripeClient stripeClient;
public BillingService(DSLContext dsl, StripeClient stripeClient) {
this.dsl = dsl;
this.stripeClient = stripeClient;
}
// Developer's reasoning:
// "UUID.randomUUID() runs before dsl.transactionResult() is called.
// It's not inside the jOOQ transaction lambda. @Retryable retries
// the transaction when StripeConnectException is thrown — that means
// jOOQ's transaction lambda re-runs on retry, not the method itself.
// The UUID is stable because it's computed once when the method is called."
//
// The problem: @Retryable's AOP interceptor calls proceed() on retry.
// proceed() re-invokes the method body from its first statement.
// Statement 1: String idempotencyKey = UUID.randomUUID().toString();
// — UUID_B is generated on attempt 2.
// Attempt 1: UUID_A → Stripe commits pi_A → ch_A → StripeConnectException thrown.
// Attempt 2: proceed() → UUID_B generated → dsl.transactionResult() opens new
// JDBC transaction → Stripe commits pi_B → ch_B alongside ch_A.
// Two PaymentIntents, one customer, one billing period.
@Retryable(
retryFor = StripeConnectException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 500, multiplier = 2.0)
)
public String chargeCustomer(
String customerId,
long amountCents,
String billingPeriod) {
// UUID at method entry — inside the @Retryable proxy boundary.
// proceed() re-runs this line on every retry attempt.
String idempotencyKey = UUID.randomUUID().toString();
return dsl.transactionResult(config -> {
DSLContext ctx = DSL.using(config);
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.setConfirm(true)
.build();
// Stripe HTTP call — uses idempotencyKey from the enclosing method.
// On retry (proceed() call 2), idempotencyKey is UUID_B — new charge.
PaymentIntent pi = stripeClient.paymentIntents().create(
params,
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
ctx.insertInto(BILLING_ATTEMPT)
.set(BILLING_ATTEMPT.CUSTOMER_ID, customerId)
.set(BILLING_ATTEMPT.IDEMPOTENCY_KEY, idempotencyKey)
.set(BILLING_ATTEMPT.STRIPE_PI_ID, pi.getId())
.set(BILLING_ATTEMPT.BILLING_PERIOD, billingPeriod)
.set(BILLING_ATTEMPT.AMOUNT_CENTS, amountCents)
.execute();
return pi.getId();
});
}
}
Why the misconception is plausible with jOOQ
The developer’s mental model is grounded in a reasonable reading of the code structure. The UUID is syntactically outside the transactionResult() lambda. In Java’s execution model, the UUID assignment runs before the lambda is entered. At the time of the first method invocation, this is exactly right: UUID_A is computed, then the lambda receives it as a captured final variable.
The error is in the model of what @Retryable retries. The developer imagines @Retryable as a wrapper around the jOOQ transaction lambda — as if the annotation could somehow instrument the transactionResult() call and retry only the lambda. This would be analogous to how @Transactional with REQUIRES_NEW creates a new JDBC transaction on each call: a transaction-level retry boundary.
But @Retryable is an AOP annotation. It instruments the method via a Spring AOP proxy. The proxy intercepts the call to chargeCustomer() and wraps the interceptor chain in a RetryTemplate. The RetryTemplate’s retry callback calls proceed() on the MethodInvocation. proceed() is a re-entry into the method body at the proxy boundary — not at the transactionResult() boundary. Every statement in the method body, including the UUID assignment on line 1, re-executes on every proceed() call.
There is no mechanism in Spring’s AOP framework for an annotation to skip the first N lines of a method body on retry. The retry boundary is the method boundary, and the method boundary is the proxy boundary. Everything inside the proxy boundary re-executes.
The distinction between @Retryable’s retry boundary and jOOQ’s transaction boundary
It helps to visualize the two boundaries explicitly:
// Conceptual execution model:
//
// @Retryable proxy boundary (outermost — wraps the entire method):
// ┌─────────────────────────────────────────────────────────────────┐
// │ Attempt 1: │
// │ String idempotencyKey = UUID.randomUUID().toString(); // UUID_A│
// │ dsl.transactionResult(config -> { │
// │ ┌──jOOQ transaction boundary──────────────────────────────┐│
// │ │ stripeClient.charge(UUID_A) → pi_A committed ✓ ││
// │ │ INSERT billing_attempt (UUID_A) ✓ ││
// │ └─────────────────────────────────────────────────────────┘│
// │ }); // throws StripeConnectException ← @Retryable catches │
// │ │
// │ Attempt 2 (proceed() called by RetryTemplate): │
// │ String idempotencyKey = UUID.randomUUID().toString(); // UUID_B│ ← re-runs
// │ dsl.transactionResult(config -> { │
// │ ┌──NEW jOOQ transaction boundary──────────────────────────┐│
// │ │ stripeClient.charge(UUID_B) → pi_B committed ✗ (dup) ││
// │ │ INSERT billing_attempt (UUID_B) ✗ (second row) ││
// │ └─────────────────────────────────────────────────────────┘│
// │ }); │
// └─────────────────────────────────────────────────────────────────┘
//
// UUID_A was captured in attempt 1's lambda by the jOOQ TransactionalCallable.
// The lambda is a new object on attempt 2's proceed() call — a new method invocation
// with a new stack frame. UUID_B is captured in attempt 2's lambda.
The fix is identical to the fix for all AOP-based retry failure modes: compute the idempotency key outside the @Retryable proxy boundary and pass it as a method parameter. The @Retryable-annotated method receives the key as a parameter value — a parameter value does not change between proceed() calls.
// BillingService.java — fixed mode 1: key passed as parameter, computed by caller
@Service
public class BillingService {
private final DSLContext dsl;
private final StripeClient stripeClient;
public BillingService(DSLContext dsl, StripeClient stripeClient) {
this.dsl = dsl;
this.stripeClient = stripeClient;
}
@Retryable(
retryFor = StripeConnectException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 500, multiplier = 2.0)
)
public String chargeCustomer(
String customerId,
long amountCents,
String billingPeriod,
String idempotencyKey) { // ← stable parameter from caller
// idempotencyKey is a parameter — it does not re-execute on proceed().
// Attempt 2: proceed() passes the same idempotencyKey value through
// the same parameter slot. Stripe deduplicates: pi_A result
// returned for UUID_A on attempt 2. No pi_B.
return dsl.transactionResult(config -> {
DSLContext ctx = DSL.using(config);
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.setConfirm(true)
.build();
PaymentIntent pi = stripeClient.paymentIntents().create(
params,
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
ctx.insertInto(BILLING_ATTEMPT)
.set(BILLING_ATTEMPT.CUSTOMER_ID, customerId)
.set(BILLING_ATTEMPT.IDEMPOTENCY_KEY, idempotencyKey)
.set(BILLING_ATTEMPT.STRIPE_PI_ID, pi.getId())
.set(BILLING_ATTEMPT.BILLING_PERIOD, billingPeriod)
.set(BILLING_ATTEMPT.AMOUNT_CENTS, amountCents)
.execute();
return pi.getId();
});
}
}
// BillingOrchestrator.java — content-hash key computed outside the @Retryable proxy
@Service
public class BillingOrchestrator {
private final BillingService billingService;
public BillingOrchestrator(BillingService billingService) {
this.billingService = billingService;
}
public void runBillingCycle(List<Customer> customers, String billingPeriod) {
for (Customer customer : customers) {
// Content-hash key: deterministic from customer + period + amount.
// Same input → same key → Stripe deduplicates on attempt 2.
// Computed before the @Retryable proxy boundary.
String key = contentHashKey(customer.getId(), billingPeriod, customer.getAmountCents());
billingService.chargeCustomer(
customer.getId(),
customer.getAmountCents(),
billingPeriod,
key
);
}
}
private String contentHashKey(String customerId, String period, long amountCents) {
String input = customerId + "|" + period + "|" + amountCents;
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
Why @Recover does not help
Some developers add a @Recover method to handle the case where all retry attempts fail. @Recover receives the last exception and the original method arguments. This is useful for alerting or fallback logic. It does not prevent UUID_B from being sent to Stripe on attempts 2 and 3. The duplicate charges are created before @Recover is ever called — they happen during the proceed() calls. @Recover is a post-exhaustion handler, not a retry strategy.
The correct tool for controlling retry-time behavior is to move the idempotency key out of the retried method, as shown above. @Recover can safely log the UUID_A key value (passed as an argument from the non-retried caller) for idempotency audit purposes after all retries fail.
Mode 2: jOOQ optimistic locking retry — DataChangedException rolls back the JDBC transaction but not the Stripe HTTP call — UUID_B on next loop iteration — ch_B
jOOQ generates code for tables with version columns (marked with @Version in jOOQ’s code generation configuration, corresponding to a version column in the database schema). The generated update() call for a versioned record automatically appends WHERE id = ? AND version = ? and returns the number of rows updated. If the number is 0 — because another transaction incremented the version between our SELECT and our UPDATE — jOOQ throws DataChangedException, or developers manually check the row count and throw it themselves.
The failure mode: the developer charges Stripe inside the dsl.transaction {} lambda before the version-checking update, then retries the entire lambda on DataChangedException. The developer’s inference: since DataChangedException caused the transaction to roll back, and the Stripe charge happened inside the transaction, the charge was also rolled back. A new UUID is safe to use on the next iteration because the original charge was undone.
This inference is wrong. Stripe HTTP calls are not JDBC operations. They do not participate in JDBC transactions. dsl.transaction {} manages the JDBC connection and issues COMMIT or ROLLBACK to the database. A Stripe POST /v1/payment_intents that returned a 200 OK with a PaymentIntent ID is a committed Stripe charge, regardless of what happens to the JDBC transaction afterward.
// BillingService.java — unsafe mode 2: Stripe inside dsl.transaction{} before version check
// UUID inside the retry loop — DataChangedException triggers retry — UUID_B → ch_B
@Service
public class BillingService {
private static final int MAX_RETRIES = 3;
private final DSLContext dsl;
private final StripeClient stripeClient;
public BillingService(DSLContext dsl, StripeClient stripeClient) {
this.dsl = dsl;
this.stripeClient = stripeClient;
}
public String chargeWithOptimisticLocking(
String customerId,
long amountCents,
String billingPeriod) {
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
// UUID inside the loop — new UUID on every iteration.
// Developer's reasoning:
// "If DataChangedException is thrown, the transaction rolled back.
// The Stripe charge inside the transaction was also rolled back —
// it's as if it never happened. I need a new UUID because the old
// one was part of the rolled-back operation."
String idempotencyKey = UUID.randomUUID().toString();
try {
String piId = dsl.transactionResult(config -> {
DSLContext ctx = DSL.using(config);
// Read current customer state and version
CustomersRecord customer = ctx
.selectFrom(CUSTOMERS)
.where(CUSTOMERS.ID.eq(customerId))
.fetchOne();
if (customer == null) throw new NoSuchElementException(customerId);
// Stripe call INSIDE the jOOQ transaction — BEFORE the version check.
// If StripeConnectException is thrown here, the transaction rolls back,
// no BILLING_ATTEMPT row is inserted — this path is safe.
//
// If the Stripe call SUCCEEDS and returns pi.getId(), then:
// the version check UPDATE may return 0 rows (concurrent modification)
// → DataChangedException is thrown → transaction rolls back.
// But the Stripe charge (pi.getId()) ALREADY COMMITTED at Stripe.
// Rolling back the JDBC transaction does not undo the Stripe HTTP call.
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.setConfirm(true)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
// Optimistic locking: UPDATE ... WHERE version = current version
int updated = ctx.update(BILLING_ATTEMPT)
.set(BILLING_ATTEMPT.STRIPE_PI_ID, pi.getId())
.set(BILLING_ATTEMPT.STATUS, "CHARGED")
.set(BILLING_ATTEMPT.VERSION, customer.getVersion() + 1)
.where(BILLING_ATTEMPT.CUSTOMER_ID.eq(customerId))
.and(BILLING_ATTEMPT.VERSION.eq(customer.getVersion()))
.execute();
if (updated == 0) {
// Version conflict — another transaction modified this customer.
// DataChangedException rolls back the JDBC transaction.
// The INSERT above is rolled back. The Stripe charge is NOT rolled back.
throw new DataChangedException("Version conflict for " + customerId);
}
return pi.getId();
});
return piId;
} catch (DataChangedException e) {
if (attempt == MAX_RETRIES - 1) throw e;
// Sleep with backoff, then loop: UUID_B generated at top of next iteration.
// Stripe sees UUID_B — creates pi_B — ch_B alongside committed ch_A.
try { Thread.sleep(50L * (attempt + 1)); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
throw new IllegalStateException("Max retries exceeded");
}
}
The JDBC transaction / external HTTP boundary in detail
To understand why the developer’s reasoning is wrong, it helps to trace what JDBC ROLLBACK actually does and does not undo.
When dsl.transaction() calls ROLLBACK on the JDBC connection, the database engine undoes all DML statements (INSERT, UPDATE, DELETE) that were executed on that connection since the last BEGIN. The database’s write-ahead log discards the uncommitted changes. From the database’s perspective, those rows were never inserted or updated. This is the standard ACID A (Atomicity): the transaction either commits entirely or rolls back entirely.
Stripe’s POST /v1/payment_intents is not a DML statement on a JDBC connection. It is an HTTPS request to a remote server operated by Stripe. The TCP handshake, TLS negotiation, HTTP request, Stripe’s backend processing (including communication with card networks), and HTTP response all happen entirely outside the JDBC connection. When stripeClient.paymentIntents().create() returns a PaymentIntent object, Stripe’s backend has committed the PaymentIntent to Stripe’s own database and, if confirm: true, has submitted the charge to the card network. Stripe cannot undo this because a JDBC ROLLBACK occurs on your local database milliseconds later.
The developer’s confusion is understandable: the Stripe call is textually inside the dsl.transaction(config -> { ... }) lambda, which looks like an ACID boundary. But the ACID guarantee applies only to operations on the JDBC connection associated with config. HTTP calls to external services are not ACID operations, regardless of where they appear in the code.
The sequence of events for Mode 2
- Iteration 1:
idempotencyKey = UUID_A dsl.transaction {}begins JDBC transaction T₁SELECT * FROM customers WHERE id = ?readsversion = 5stripeClient.charge(UUID_A)completes → Stripe commits ch_A →pi.getId() = pi_AUPDATE billing_attempt SET ... WHERE customer_id = ? AND version = 5returns 0 rows (another transaction set version to 6)DataChangedExceptionthrown inside the lambda- jOOQ catches the exception → issues
ROLLBACKon T₁ → theUPDATE(step 5) is undone — but ch_A (step 4) is not undone DataChangedExceptionpropagates to thecatchblock- Iteration 2:
idempotencyKey = UUID_B← new UUID generated here dsl.transaction {}begins JDBC transaction T₂SELECTreadsversion = 6(updated by the other transaction)stripeClient.charge(UUID_B)completes → Stripe commits ch_B →pi.getId() = pi_BUPDATE ... WHERE version = 6returns 1 row → T₂ commits- Method returns
pi_B - Customer has been charged twice: ch_A (UUID_A, pi_A) and ch_B (UUID_B, pi_B)
ch_A is invisible in the local database because T₁ rolled back before inserting the BILLING_ATTEMPT row. ch_A exists only in Stripe’s PaymentIntent history and in the cardholder’s statement. Discovery requires querying Stripe’s API for the customer’s PaymentIntent history and correlating timestamps — not a query on the local billing_attempt table.
The fix: move Stripe after the optimistic lock check, or compute UUID outside the loop
Two valid fixes exist for Mode 2, with different tradeoffs:
Fix A: Move the Stripe call after the version check — The SELECT reads the current version. The UPDATE ... WHERE version = ? is performed first. Only if the update commits successfully (returns 1) is the Stripe charge initiated. On a version conflict, the transaction rolls back before any Stripe call. UUID is still inside the loop but Stripe is only called after the DB lock is acquired, so no Stripe call occurs on a conflicting iteration.
// Fix A: Stripe call after version-check UPDATE commits
String piId = dsl.transactionResult(config -> {
DSLContext ctx = DSL.using(config);
CustomersRecord customer = ctx
.selectFrom(CUSTOMERS)
.where(CUSTOMERS.ID.eq(customerId))
.fetchOne();
if (customer == null) throw new NoSuchElementException(customerId);
// Version check BEFORE Stripe call.
// If this UPDATE returns 0 rows (version conflict), DataChangedException is thrown
// BEFORE stripeClient.charge() is called. No Stripe charge on conflicting iteration.
int updated = ctx.update(BILLING_ATTEMPT)
.set(BILLING_ATTEMPT.STATUS, "PENDING")
.set(BILLING_ATTEMPT.VERSION, customer.getVersion() + 1)
.where(BILLING_ATTEMPT.CUSTOMER_ID.eq(customerId))
.and(BILLING_ATTEMPT.VERSION.eq(customer.getVersion()))
.execute();
if (updated == 0) throw new DataChangedException("Version conflict for " + customerId);
// Version check passed — UPDATE committed (pending commit of this transaction).
// Now call Stripe. UUID is still inside the loop but no Stripe call on conflict iterations.
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.setConfirm(true)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // idempotencyKey is UUID per iteration
.build()
);
ctx.update(BILLING_ATTEMPT)
.set(BILLING_ATTEMPT.STRIPE_PI_ID, pi.getId())
.set(BILLING_ATTEMPT.STATUS, "CHARGED")
.where(BILLING_ATTEMPT.CUSTOMER_ID.eq(customerId))
.execute();
return pi.getId();
});
Fix B: Compute UUID outside the loop, use content-hash key — The UUID is computed once before the loop begins, derived deterministically from customer ID and billing period. All loop iterations use the same key. If a Stripe charge succeeds on iteration 1 (before the version conflict), iteration 2 sends the same key and Stripe returns the cached ch_A result. The UUID is stable across the entire loop.
// Fix B: content-hash key computed outside the loop — all iterations use same key
public String chargeWithOptimisticLocking(
String customerId, long amountCents, String billingPeriod) {
// Key computed once before the retry loop.
// All iterations use the same key. Stripe deduplicates.
String idempotencyKey = contentHashKey(customerId, billingPeriod, amountCents);
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return dsl.transactionResult(config -> {
// ... same body, idempotencyKey is captured from outer scope,
// does not change between loop iterations.
});
} catch (DataChangedException e) {
if (attempt == MAX_RETRIES - 1) throw e;
Thread.sleep(50L * (attempt + 1));
}
}
throw new IllegalStateException("Max retries exceeded");
}
Fix B is generally preferable because it is correct even if the Stripe call is placed before the version check (it tolerates structural mistakes in the transaction body). Fix A is safe for the current structure but is fragile: if the Stripe call is accidentally moved before the version check in a future refactor, duplicate charges silently reappear. The content-hash key in Fix B provides defense-in-depth.
jOOQ’s UpdatableRecord.store() and optimistic locking via generated records
jOOQ’s code generator produces UpdatableRecord subclasses for tables with primary keys. If the table also has a @Version-mapped column, record.store() (or record.update()) automatically applies the optimistic locking check in the generated SQL. The same failure mode applies: if store() throws DataChangedException after a Stripe HTTP call that already completed, the Stripe charge is not rolled back.
// Unsafe: using UpdatableRecord.store() with Stripe call before version check
BillingAttemptRecord record = ctx.newRecord(BILLING_ATTEMPT);
record.setCustomerId(customerId);
record.setIdempotencyKey(idempotencyKey); // ← UUID_B on retry loop iteration 2
// Stripe call before record.store() — same problem as above
PaymentIntent pi = stripeClient.paymentIntents().create(params, requestOptions);
record.setStripePiId(pi.getId());
record.store(); // May throw DataChangedException — Stripe charge not rolled back if it does
Mode 3: @Retryable(DeadlockLoserDataAccessException.class) + batchInsert() — Stripe charges in the map step re-execute on deadlock retry — UUID_B per customer — ch_B for already-billed customers
The third failure mode combines jOOQ’s batchInsert() with Spring’s @Retryable on a batch billing method. The developer structures the method as two steps: (1) map over customers, call Stripe per customer, and build a list of BillingAttemptRecord objects with the returned PaymentIntent IDs and the per-customer UUIDs; (2) pass the list to dsl.batchInsert(records) to bulk-insert all records in a single JDBC batch. @Retryable is applied for DeadlockLoserDataAccessException, which batchInsert() can throw when two concurrent batch inserts deadlock on the same rows.
The developer’s reasoning: batchInsert() is the database write step — the step that threw the exception. The Stripe charges in the preceding map step are completed work that is not retried. @Retryable retries the batchInsert() failure.
The problem: @Retryable retries the entire method. proceed() re-invokes the method from its first statement — including the customers.stream().map(customer -> { ... })... step. The map step re-runs for all customers. UUID.randomUUID() is called per customer inside the map lambda — UUID_B per customer on attempt 2. stripeClient.charge(UUID_B, ...) is called for every customer in the map step — ch_B per customer who was already charged with UUID_A in attempt 1’s map step.
// BillingService.java — unsafe mode 3: Stripe in map step, batchInsert() retried
@Service
public class BillingBatchService {
private final DSLContext dsl;
private final StripeClient stripeClient;
public BillingBatchService(DSLContext dsl, StripeClient stripeClient) {
this.dsl = dsl;
this.stripeClient = stripeClient;
}
// Developer's reasoning:
// "batchInsert() is the database write step. DeadlockLoserDataAccessException
// is thrown by batchInsert() when two concurrent batch inserts deadlock.
// @Retryable retries the failing operation — the batchInsert() call.
// The Stripe charges in the preceding .map() step are already done
// and are not part of the retry. @Retryable will just re-run batchInsert()
// with the same 'records' list."
//
// The problem: @Retryable retries the METHOD, not the batchInsert() call.
// proceed() re-invokes the method body from its first statement.
// The .stream().map() re-executes for every customer.
// UUID.randomUUID() generates UUID_B per customer.
// stripeClient.charge(UUID_B, ...) creates ch_B per customer.
// batchInsert() inserts the new UUID_B records.
// Customers who were charged UUID_A in attempt 1's map step now have ch_B too.
@Retryable(
retryFor = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
@Transactional
public void processMonthlyBilling(
List<Customer> customers,
String billingPeriod) {
// Map step: per-customer UUID + Stripe charge + record construction.
// On retry (proceed() call 2), this entire map re-executes for all customers.
// UUID_B generated per customer — stripeClient.charge(UUID_B) called per customer.
List<BillingAttemptRecord> records = customers.stream()
.map(customer -> {
// UUID inside the map lambda — re-executes per customer on proceed() retry.
String key = UUID.randomUUID().toString();
// Stripe HTTP call inside the map — not inside the JDBC transaction.
// On retry: UUID_B → ch_B for customers already charged with UUID_A.
PaymentIntent pi;
try {
pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(customer.getAmountCents())
.setCurrency("usd")
.setCustomer(customer.getId())
.setConfirm(true)
.build(),
RequestOptions.builder()
.setIdempotencyKey(key)
.build()
);
} catch (StripeException e) {
throw new RuntimeException("Stripe charge failed for " + customer.getId(), e);
}
BillingAttemptRecord record = dsl.newRecord(BILLING_ATTEMPT);
record.setCustomerId(customer.getId());
record.setIdempotencyKey(key);
record.setStripePiId(pi.getId());
record.setBillingPeriod(billingPeriod);
record.setAmountCents(customer.getAmountCents());
record.setStatus("CHARGED");
return record;
})
.collect(Collectors.toList());
// batchInsert() may throw DeadlockLoserDataAccessException.
// @Retryable catches it and calls proceed() — which re-runs the ENTIRE method.
// The map step above re-executes — all Stripe charges re-run with new UUIDs.
dsl.batchInsert(records).execute();
}
}
Why the misconception is especially plausible in this pattern
The Mode 3 misconception is more intuitive than Mode 1’s because it correctly identifies which line of code threw the exception: dsl.batchInsert(records).execute(). In many retry patterns, “retry the failing operation” means re-running the line that failed. If the developer were manually writing a retry loop — for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { dsl.batchInsert(records).execute(); } — the map step would indeed not re-run. The records list is computed once before the loop, and the loop re-runs only dsl.batchInsert(records).execute().
@Retryable looks similar to that manual loop at the annotation level but behaves differently: it retries the method, not the statement. The records variable is a local variable in the method body. It does not persist across proceed() calls. On attempt 2, records is a new List computed fresh by re-running the .stream().map() expression. The Stripe calls in the map lambda are part of that computation.
The position of @Retryable in the method signature (before @Transactional in the annotations list, or after it) also affects behavior. By default, @Retryable has @Order(Ordered.LOWEST_PRECEDENCE - 1) and @Transactional has @Order(Ordered.LOWEST_PRECEDENCE - 10). The retry interceptor is outer; the transaction interceptor is inner. When batchInsert() throws DeadlockLoserDataAccessException, the transaction interceptor marks the transaction as rollback-only and begins the rollback. The retry interceptor catches the exception after the transaction has been marked rollback-only. On proceed() for attempt 2, the transaction interceptor tries to begin a new transaction (because the previous one was rolled back). Attempt 2’s map step runs in the new transaction’s context — but the Stripe calls in the map step still execute outside the JDBC transaction scope, and UUID_B is generated per customer.
The fix: two-step separation with content-hash keys pre-computed
Separate the Stripe charging step from the jOOQ batch insert step. Compute content-hash keys outside the map lambda. The @Retryable method retries only the DB write; the Stripe charges use stable keys.
// BillingBatchService.java — fixed mode 3: keys pre-computed, Stripe separate from batchInsert
@Service
public class BillingBatchService {
private final DSLContext dsl;
private final StripeClient stripeClient;
public BillingBatchService(DSLContext dsl, StripeClient stripeClient) {
this.dsl = dsl;
this.stripeClient = stripeClient;
}
public void processMonthlyBilling(
List<Customer> customers,
String billingPeriod) {
// Step 1: Compute stable content-hash keys — no UUID.randomUUID().
// Step 2: Call Stripe per customer with stable keys.
// Step 3: Build records from Stripe results.
// Step 4: Insert with retry-safe batchInsert.
// Keys computed before any DB or Stripe call — deterministic and stable.
Map<String, String> customerKeys = customers.stream()
.collect(Collectors.toMap(
Customer::getId,
c -> contentHashKey(c.getId(), billingPeriod, c.getAmountCents())
));
// Stripe charges run once — outside the @Retryable method.
// A key maps uniquely to one customer+period+amount — Stripe deduplicates
// if this step is accidentally re-run (e.g., process restart).
Map<String, String> customerPiIds = new LinkedHashMap<>();
for (Customer customer : customers) {
String key = customerKeys.get(customer.getId());
try {
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(customer.getAmountCents())
.setCurrency("usd")
.setCustomer(customer.getId())
.setConfirm(true)
.build(),
RequestOptions.builder()
.setIdempotencyKey(key)
.build()
);
customerPiIds.put(customer.getId(), pi.getId());
} catch (StripeException e) {
throw new RuntimeException("Stripe charge failed for " + customer.getId(), e);
}
}
// Build records from the already-completed Stripe results.
List<BillingAttemptRecord> records = customers.stream()
.map(customer -> {
BillingAttemptRecord record = dsl.newRecord(BILLING_ATTEMPT);
record.setCustomerId(customer.getId());
record.setIdempotencyKey(customerKeys.get(customer.getId()));
record.setStripePiId(customerPiIds.get(customer.getId()));
record.setBillingPeriod(billingPeriod);
record.setAmountCents(customer.getAmountCents());
record.setStatus("CHARGED");
return record;
})
.collect(Collectors.toList());
// batchInsert step — retried on deadlock.
// The records list is passed as a parameter — proceed() re-runs the method
// but the records list is now computed from pre-charged Stripe data.
// Actually: records is still a local variable — it will be re-computed on retry.
// See below for the correct structure.
insertRecordsWithRetry(records);
}
// Correct structure: @Retryable only on the DB-write method.
// The records list is passed in — it does not change between proceed() retries.
@Retryable(
retryFor = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
@Transactional
public void insertRecordsWithRetry(List<BillingAttemptRecord> records) {
// records is a parameter — does not re-execute on proceed().
// The Stripe charge step in processMonthlyBilling() is not inside
// this @Retryable method and does not re-run on deadlock retry.
dsl.batchInsert(records).execute();
}
private String contentHashKey(String customerId, String period, long amountCents) {
String input = customerId + "|" + period + "|" + amountCents;
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
The key architectural change: the @Retryable annotation is moved to a dedicated insertRecordsWithRetry() method that receives the pre-built records list as a parameter. When proceed() is called on insertRecordsWithRetry(), it re-runs only the dsl.batchInsert(records).execute() call with the same records parameter. The Stripe charging loop in the caller method is not annotated with @Retryable and does not re-execute on deadlock retry.
The @Transactional + @Retryable interaction and UnexpectedRollbackException
When both @Retryable (outer) and @Transactional (inner) are on the same method, a deadlock exception causes a specific interaction that can mask the duplicate charge problem. The sequence:
- Attempt 1:
@Transactionalopens transaction T₁. Map step runs.batchInsert()throwsDeadlockLoserDataAccessException. - Spring’s transaction interceptor catches the exception, marks T₁ as rollback-only, and begins rollback.
DeadlockLoserDataAccessExceptionpropagates through the transaction interceptor to the retry interceptor.@Retryablecatches the exception and callsproceed()for attempt 2.proceed()passes through the@Transactionalinterceptor again. T₁ has been rolled back. A new transaction T₂ is opened.- Map step re-runs inside T₂. UUID_B generated. Stripe charges with UUID_B.
batchInsert()succeeds. - T₂ commits. Method returns successfully.
In some Spring configurations, step 5 does not open a new transaction — instead, it throws UnexpectedRollbackException because T₁’s transaction synchronization was not fully cleared before proceed(). The developer sees UnexpectedRollbackException on the second attempt and diagnoses it as a Spring transaction configuration bug, not a duplicate charge bug. The duplicate charges from attempt 1’s map step (ch_A values) are present in Stripe but have no corresponding BILLING_ATTEMPT rows in the DB (T₁ rolled back). The symptom that surfaces is the UnexpectedRollbackException; the symptom that does not surface until customer dispute is the orphaned ch_A charges.
Separating @Retryable to the DB-write-only method (as in the fix above) avoids this entire interaction by design: @Transactional and @Retryable are on the same insertRecordsWithRetry() method but there are no Stripe calls inside it, so UnexpectedRollbackException on attempt 2 is the only failure mode and there are no Stripe duplicates to clean up.
Comparison table: the three failure modes
| Mode | Re-execution trigger | Re-execution unit | UUID position | Developer misconception | Stripe impact |
|---|---|---|---|---|---|
1. @Retryable + dsl.transactionResult() |
AOP proceed() on retry |
Entire method body including UUID at method entry | Local variable at method entry, before transactionResult() |
“UUID is outside the jOOQ transaction lambda, so it’s stable across retries” | UUID_B → ch_B per retry attempt |
2. Optimistic locking loop + DataChangedException |
Manual loop iteration on DataChangedException |
Stripe call inside dsl.transaction {} before version check |
Local variable inside the retry loop, before dsl.transaction {} |
“JDBC transaction rollback undoes the Stripe charge inside dsl.transaction {}” |
ch_A committed in iteration 1 (not rolled back); UUID_B → ch_B in iteration 2 |
3. @Retryable + batchInsert() with Stripe in map step |
AOP proceed() on DeadlockLoserDataAccessException |
Entire method body including the .stream().map() with Stripe calls |
Inside the .map() lambda, per customer |
“batchInsert() is the failing step — the map/Stripe step is not retried” | UUID_B per customer → ch_B for all customers charged in attempt 1’s map step |
| Mode | DB impact | Detection signal | Fix |
|---|---|---|---|
1. @Retryable + dsl.transactionResult() |
Second BILLING_ATTEMPT row with UUID_B inserted by second transactionResult() call |
Two rows per customer per billing period; two Stripe PI IDs for same customer | Pass key as parameter, compute outside @Retryable proxy with content-hash |
2. Optimistic locking loop + DataChangedException |
ch_A orphaned (no DB row, T₁ rolled back); second row with UUID_B from T₂ commit | ch_A in Stripe history with no matching BILLING_ATTEMPT row; Stripe duplicate charge alert |
Move Stripe after version check (Fix A); or content-hash key outside loop (Fix B) |
3. @Retryable + batchInsert() |
T₁ rolled back (no DB rows for attempt 1 Stripe charges); T₂ inserts UUID_B rows for all customers | UUID_A charges in Stripe with no matching DB rows; UUID_B rows in DB per customer | Separate @Retryable to a DB-write-only method receiving records as a parameter |
Testing jOOQ + Spring Boot retry patterns for idempotency
Each failure mode requires a test that either asserts the Idempotency-Key header is identical across all Stripe calls for a given customer-period, or asserts that Stripe is called exactly once per customer regardless of retry behavior. WireMock with @SpringBootTest provides the assertion surface for all three modes.
// BillingServiceMode1Test.java — @Retryable proceeds() must use same Idempotency-Key
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BillingServiceMode1Test {
@Autowired BillingService billingService;
@Autowired WireMockServer wireMockServer;
@Test
void retryable_should_use_same_idempotency_key_across_all_proceed_calls() {
// First attempt: Stripe returns 500 (connection error)
wireMockServer.stubFor(
post(urlPathEqualTo("/v1/payment_intents"))
.inScenario("stripe-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willSetStateTo("second-attempt")
.willReturn(aResponse().withStatus(500)
.withBody("{\"error\":{\"type\":\"api_connection_error\"}}")));
// Second attempt: Stripe returns success
wireMockServer.stubFor(
post(urlPathEqualTo("/v1/payment_intents"))
.inScenario("stripe-retry")
.whenScenarioStateIs("second-attempt")
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_test_001\",\"status\":\"succeeded\"}")));
// In the fixed version, key is passed as parameter.
String stableKey = contentHashKey("cus_001", "2026-10", 9900L);
billingService.chargeCustomer("cus_001", 9900L, "2026-10", stableKey);
// Both Stripe requests must use the same Idempotency-Key header
List<LoggedRequest> requests = wireMockServer.findAll(
postRequestedFor(urlPathEqualTo("/v1/payment_intents")));
assertThat(requests).hasSize(2);
Set<String> usedKeys = requests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(usedKeys).hasSize(1);
assertThat(usedKeys.iterator().next()).isEqualTo(stableKey);
}
}
// BillingServiceMode2Test.java — DataChangedException must not trigger new Stripe charge
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BillingServiceMode2Test {
@Autowired BillingService billingService;
@Autowired WireMockServer wireMockServer;
@Autowired DSLContext dsl;
@Test
void optimistic_lock_retry_must_not_create_second_stripe_charge() {
// Simulate concurrent version modification:
// First SELECT reads version 5. Another transaction sets version to 6
// before the UPDATE. DataChangedException on iteration 1's UPDATE.
// Iteration 2 reads version 6 and UPDATE succeeds.
// Both attempts use the same content-hash key (Fix B).
// Stripe is called on both iterations (Fix A avoids iteration 1 Stripe call).
// If Fix B: both calls use same key → Stripe deduplicates → 1 charge.
// If neither fix: UUID_B on iteration 2 → 2 charges.
String stableKey = contentHashKey("cus_001", "2026-10", 9900L);
wireMockServer.stubFor(
post(urlPathEqualTo("/v1/payment_intents"))
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_001\",\"status\":\"succeeded\"}")));
// Setup: insert a customer record with version = 5
dsl.insertInto(CUSTOMERS)
.set(CUSTOMERS.ID, "cus_001")
.set(CUSTOMERS.VERSION, 5L)
.execute();
// Simulate concurrent modification: update version to 6 before the test runs
// (would be done by a concurrent transaction in production)
dsl.update(CUSTOMERS)
.set(CUSTOMERS.VERSION, 6L)
.where(CUSTOMERS.ID.eq("cus_001"))
.execute();
billingService.chargeWithOptimisticLocking("cus_001", 9900L, "2026-10");
// Assert: only 1 unique Idempotency-Key used across all Stripe calls
List<LoggedRequest> stripeRequests = wireMockServer.findAll(
postRequestedFor(urlPathEqualTo("/v1/payment_intents")));
Set<String> keys = stripeRequests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(keys).hasSize(1)
.withFailMessage("Optimistic lock retry must not generate UUID_B for Stripe");
}
}
// BillingBatchServiceMode3Test.java — batchInsert() deadlock must not re-run Stripe charges
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BillingBatchServiceMode3Test {
@Autowired BillingBatchService batchService;
@Autowired WireMockServer wireMockServer;
@Test
void batchInsert_deadlock_retry_must_not_recharge_already_billed_customers() {
List<Customer> customers = List.of(
new Customer("cus_001", 9900L, "2026-10"),
new Customer("cus_002", 4900L, "2026-10"),
new Customer("cus_003", 14900L, "2026-10")
);
// All Stripe requests succeed
wireMockServer.stubFor(
post(urlPathEqualTo("/v1/payment_intents"))
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBodyFile("payment-intent-success.json")));
batchService.processMonthlyBilling(customers, "2026-10");
// Each customer must appear in exactly 1 Stripe request (3 total, not 6)
List<LoggedRequest> all = wireMockServer.findAll(
postRequestedFor(urlPathEqualTo("/v1/payment_intents")));
// In the fixed version: 3 Stripe calls (one per customer, in the non-retried step).
// In the unfixed version with deadlock retry: 6 Stripe calls (3 attempt-1 + 3 attempt-2).
assertThat(all).hasSize(3);
// Each customer-specific key must appear exactly once
Map<String, Long> keyUsageCount = all.stream()
.collect(Collectors.groupingBy(
r -> r.getHeader("Idempotency-Key"),
Collectors.counting()));
assertThat(keyUsageCount.values())
.allSatisfy(count -> assertThat(count).isEqualTo(1L))
.withFailMessage("Deadlock retry must not re-send Stripe charges with new UUIDs");
}
}
jOOQ-specific patterns that affect key stability
dsl.connection() vs. dsl.transaction() for explicit connection management
Some developers use dsl.connection(connection -> { ... }) (as opposed to dsl.transaction()) to access the raw JDBC Connection and manually manage autoCommit, setIsolation, or SAVEPOINT semantics. The same UUID placement rules apply. If UUID is computed inside a connection() callback that is itself inside a @Retryable method, proceed() re-invokes the method and the connection() callback re-runs with a fresh stack frame, re-generating UUID. The callback is not a persistence boundary that survives proceed().
dsl.batchStore() and dsl.batchUpdate()
jOOQ’s batchStore() (store a list of UpdatableRecord) and batchUpdate() (update a list of records) have the same position-in-method semantics as batchInsert() for Mode 3. If Stripe charges are called before these batch calls inside a @Retryable-annotated method, and the batch call throws a deadlock or constraint violation that triggers @Retryable, the Stripe charges re-execute on proceed(). The fix is the same: separate the Stripe charging step from the DB write step, and apply @Retryable only to the DB write method.
jOOQ with Kotlin: DSL lambda and coroutine extension interactions
When using jOOQ in a Kotlin codebase, dsl.transaction { config -> ... } becomes dsl.transaction { config: Configuration -> ... } or, with jOOQ-kotlin extensions, potentially ctx.transaction { ... }. The UUID placement rules are identical: a val key = UUID.randomUUID().toString() inside the method body of a @Retryable-annotated function will regenerate on each proceed() call. If the Kotlin function is a suspend fun, the interaction with @Retryable is the same as described in the Kotlin Coroutines + @Transactional + Stripe post: @Retryable is not coroutine-aware, and proceed() creates a new coroutine dispatch, re-running the function body from the first statement.
jOOQ’s generated UpdatableRecord and @IdempotencyKey annotations
Some teams add a custom jOOQ code generation strategy that annotates the idempotency_key column with a custom @IdempotencyKey marker annotation. This is useful for documentation and linting but does not change the runtime behavior: the value of the idempotency_key field on a freshly constructed record is set by the application code that called record.setIdempotencyKey(UUID.randomUUID().toString()). If that call is inside a @Retryable method body, the value is re-set to UUID_B on each proceed() call. The annotation is a marker, not a constraint that prevents re-assignment.
Keybrake’s role in jOOQ + Stripe idempotency failures
The three failure modes above share a structural property with the idempotency failures described in every post in this series: the duplicate charge is created silently. Stripe processes ch_B as a legitimate, independent charge. The application database, depending on the failure mode, either has no row for ch_A (Mode 2, T₁ rolled back) or has two rows (Modes 1 and 3, one per attempt). Neither Stripe’s Dashboard nor a query on the billing_attempt table is sufficient to surface the duplicate — the correlation requires joining Stripe’s PaymentIntent history, the billing_attempt table, and the Idempotency-Key header values sent on each request, within a narrow time window.
Keybrake sits in the request path between the Spring application and the Stripe API. Every proxied Stripe request is logged with its Idempotency-Key header value, the calling agent key, the customer ID (parsed from the request body), the amount, and the timestamp. The Keybrake audit log surfaces the Mode 2 signal directly: two Stripe requests for the same customer within seconds, with different Idempotency-Key values. In Mode 3, the signal is the same but multiplied across all customers in the batch: N requests sent with UUID_A values followed by N requests sent with UUID_B values for the same N customers, all within the batchInsert retry window.
The per-agent daily spend cap provides an operational backstop for Mode 3 at batch scale. A 500-customer monthly billing run where batchInsert() deadlocks and triggers @Retryable would create 500 duplicate charges. The per-agent daily cap closes the Keybrake vault key once the daily spend threshold is reached, blocking further Stripe proxying for that key until the cap is reset or raised. This limits the blast radius of a batch deadlock retry from potentially hundreds of duplicate charges to a much smaller number before the anomaly is surfaced via the cap violation alert.
Summary: the AOP boundary is the retry boundary, and JDBC rollback does not undo HTTP
The three jOOQ failure modes reduce to two root causes.
The first is shared with every @Retryable-based failure mode in this series:
The
@Retryableannotation’s retry boundary is the method boundary, not any operation or call within the method.proceed()re-invokes the method from its first statement. No statement inside the method body is exempt from re-execution based on its position relative to the line that threw the retryable exception. UUID generation at method entry, in a lambda, or inside a.map()expression all re-execute on eachproceed()call.
The second is specific to jOOQ and applies to any pattern that places external HTTP calls inside a JDBC transaction boundary:
jOOQ’s
dsl.transaction {}(anddsl.transactionResult {}) is a JDBC transaction boundary. JDBC transactions provide ACID guarantees for operations on the JDBC connection. External HTTP calls to Stripe inside adsl.transaction {}lambda are not JDBC operations and are not subject to ACID guarantees. A JDBCROLLBACKundoes database writes. It does not undo Stripe HTTP calls that completed before the rollback. A Stripe charge that returned a PaymentIntent ID is committed at Stripe, regardless of what happens to the surrounding JDBC transaction.
The content-hash key — derived deterministically from customer ID, billing period, and amount — satisfies both rules simultaneously. It is stable across proceed() calls because it is computed from input data, not from UUID.randomUUID(). It is idempotent across JDBC rollback retries because the same input data always produces the same key. Stripe deduplicates correctly on it for 24 hours per endpoint.
See also: Spring Data R2DBC + Kotlin Coroutines + Stripe, Spring Boot 3 Virtual Threads + Stripe, Spring WebClient @Transactional + Stripe, and Resilience4j + Stripe.
Put a spend cap on your agent’s Stripe key
Keybrake proxies your Stripe calls with per-agent spend caps, endpoint allowlists, and a full audit log. Duplicate idempotency keys — from jOOQ optimistic locking retries, batch deadlocks, or @Retryable misconfiguration — are visible in the audit log before they become customer support problems.