Spring Data JPA and Stripe Integration: How @Retryable on a @Transactional JPA Service Method Re-opens the EntityManager and Regenerates UUID on Each proceed() Call, Optimistic Locking Retry Loops Leave Committed Stripe Charges Orphaned After ObjectOptimisticLockingFailureException, and @TransactionalEventListener + @Retryable Fires Stripe Twice After One Transaction Commit
Spring Data JPA is the most widely used Spring persistence abstraction. Its combination of repository interfaces, entity lifecycle management, and tight integration with Spring’s transaction infrastructure creates three Stripe idempotency failure modes that do not appear in JdbcTemplate or jOOQ billing code — each driven by a different JPA-specific mechanism.
This post covers three failure modes that are specific to Spring Data JPA + Stripe. They are structurally distinct from the Spring JdbcTemplate post (which covers @Transactional + @Retryable proxy order, TransactionTemplate inside @Retryable, and batchUpdate() phase separation), the jOOQ post (which covers jOOQ’s DSLContext transaction API and optimistic locking semantics), and the Spring Data R2DBC post (which covers reactive re-subscription). The modes here are specific to how JPA’s EntityManager lifecycle is tied to the @Transactional AOP proxy, how ObjectOptimisticLockingFailureException interacts with Stripe’s HTTP timeline, and how @TransactionalEventListener(phase = AFTER_COMMIT) creates a deceptive safety boundary that does not protect UUID computation inside the listener from @Retryable’s proceed().
Background: Spring Data JPA’s transaction model and what @Transactional actually manages
Spring Data JPA’s @Transactional annotation does more than begin and commit a SQL transaction. When applied to a JPA service method, it controls two distinct lifecycle objects: the JDBC transaction on the underlying DataSource connection, and the JPA EntityManager bound to the current thread for the duration of the method. Spring’s JpaTransactionManager implements both concerns together inside the TransactionInterceptor AOP proxy.
When TransactionInterceptor begins a transaction on behalf of a @Transactional method, it either joins an existing transaction on the current thread or opens a new one. Opening a new transaction means: acquire a JDBC connection from the pool, set auto-commit false, create a new EntityManager via the EntityManagerFactory, bind the EntityManager as an EntityManagerHolder in Spring’s TransactionSynchronizationManager, and bind the JDBC connection as a ConnectionHolder. This binding is how all JpaRepository calls inside the @Transactional method share the same EntityManager and connection — Spring’s SharedEntityManagerCreator looks up the thread-bound EntityManagerHolder and returns the same instance to every repository call made within the method.
When the @Transactional proxy commits or rolls back, it flushes the EntityManager (on commit), commits or rolls back the JDBC transaction, closes the EntityManager, and removes both holders from TransactionSynchronizationManager. The EntityManager is closed and released. Any entity references held by the method body become detached — they are no longer associated with any persistence context.
This lifecycle has a direct consequence for @Retryable: when the @Transactional proxy rolls back and propagates an exception, the EntityManager that was open for that attempt is closed and gone. When @Retryable’s proceed() re-invokes the @Transactional proxy, a brand-new EntityManager is created for attempt 2. The method body starts fresh — no first-party persistence context survives between retry attempts.
Spring Retry’s AOP ordering: AnnotationAwareRetryOperationsInterceptor is registered at order Ordered.LOWEST_PRECEDENCE - 5 = Integer.MAX_VALUE - 5. Spring’s TransactionInterceptor uses Ordered.LOWEST_PRECEDENCE = Integer.MAX_VALUE. Lower order value = outer proxy. @Retryable is always the outer proxy relative to @Transactional on the same method. This is the same ordering covered in the JdbcTemplate and jOOQ posts, but with JPA it has an additional layer: the thing that proceed() re-invokes is the @Transactional proxy, which manages both the SQL transaction and the EntityManager lifecycle.
Stripe’s idempotency contract: a POST to any Stripe mutating endpoint with an Idempotency-Key header deduplicates against that key for 24 hours per endpoint per API key. Two requests to the same endpoint with different keys for the same customer and amount are two distinct charges. Stripe provides no cross-key deduplication. The key is the only mechanism.
Mode 1: @Retryable + @Transactional on a JPA service method — @Retryable is the outer AOP proxy — proceed() re-invokes through @Transactional which opens a fresh EntityManager AND re-executes UUID generation at method entry
The most common pattern in Spring Data JPA billing code: a service method annotated with both @Transactional and @Retryable. The @Transactional ensures the JPA write is atomic. The @Retryable retries on serialization failures or deadlocks. The UUID for the Stripe idempotency key is computed at the start of the method body, before any repository call. The developer reasons that the UUID is outside the JPA persistence context, so it is stable across transaction retries.
The problem: Spring’s proxy order makes @Retryable the outer proxy. On a database exception such as CannotSerializeTransactionException, the @Transactional inner proxy rolls back the transaction and closes the EntityManager, then propagates the exception outward. The @Retryable outer proxy catches it and calls proceed(). proceed() re-invokes the @Transactional proxy, which opens a fresh EntityManager and new transaction, then re-invokes the method body from its first statement. That first statement is String idempotencyKey = UUID.randomUUID().toString(). UUID_B is generated on attempt 2.
The JPA-specific developer misconception takes a particular form: “@Transactional is the outer wrapper that owns the JPA session lifecycle — it manages the EntityManager that the repositories use — @Retryable retries within the existing EntityManager context, not UUID generation at method entry.” The developer is thinking of @Transactional as the parent lifecycle manager, which it is for the EntityManager, but they conflate this with AOP proxy ordering. The Spring AOP proxy that wraps the method is ordered numerically; the thing with the higher conceptual responsibility is not necessarily the outer proxy. @Retryable’s proxy wraps the entire @Transactional proxy, including the EntityManager lifecycle.
// BillingService.java — unsafe mode 1: @Transactional + @Retryable on same JPA method
@Service
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final CustomerRepository customerRepository;
private final StripeClient stripeClient;
public BillingService(
BillingAttemptRepository billingAttemptRepository,
CustomerRepository customerRepository,
StripeClient stripeClient) {
this.billingAttemptRepository = billingAttemptRepository;
this.customerRepository = customerRepository;
this.stripeClient = stripeClient;
}
// Developer's reasoning:
// "@Transactional manages the EntityManager and transaction lifecycle.
// @Retryable retries the transaction when CannotSerializeTransactionException
// is thrown. UUID.randomUUID() is computed before any repository call —
// it's outside the JPA persistence context — @Transactional is the outer
// wrapper and @Retryable retries within it. UUID is stable across retries."
//
// The problem: Spring's AOP proxy order is @Retryable (MAX-5, outermost)
// → @Transactional (MAX, inner) → method body.
//
// Attempt 1:
// @Retryable proxy calls @Transactional proxy.
// @Transactional opens EntityManager EM1 and JDBC transaction T1.
// Method body: UUID_A = UUID.randomUUID().
// billingAttemptRepository.save(attempt with UUID_A) — INSERT via EM1.
// stripeClient.paymentIntents().create(..., UUID_A) → pi_A committed in Stripe.
// Some later jdbcTemplate or repository call throws CannotSerializeTransactionException.
// @Transactional proxy catches, rolls back T1, closes EM1, propagates exception.
// billing_attempt row (UUID_A) rolled back. pi_A is committed in Stripe.
//
// Attempt 2 (proceed() called by @Retryable's RetryTemplate):
// @Retryable proxy calls @Transactional proxy again.
// @Transactional opens FRESH EntityManager EM2 and new transaction T2.
// Method body executes again: UUID_B = UUID.randomUUID(). ← NEW UUID
// billingAttemptRepository.save(attempt with UUID_B) — INSERT via EM2.
// stripeClient.paymentIntents().create(..., UUID_B) → pi_B committed. ← DUPLICATE
// T2 commits. EM2 flushed and closed.
// billing_attempt table has one row (UUID_B, T2 committed).
// pi_A in Stripe has NO corresponding database record. ch_B is a duplicate.
@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. Developer believes this runs once.
// In practice, proceed() re-invokes the method body including this line.
String idempotencyKey = UUID.randomUUID().toString();
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new IllegalArgumentException("Customer not found: " + customerId));
BillingAttempt attempt = new BillingAttempt();
attempt.setCustomerId(customerId);
attempt.setIdempotencyKey(idempotencyKey);
attempt.setAmountCents(amountCents);
attempt.setBillingPeriod(billingPeriod);
attempt.setStatus("PENDING");
billingAttemptRepository.save(attempt);
// Stripe charge committed before any serialization failure is detected.
// On retry, UUID_B generates a new charge ch_B in Stripe.
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customer.getStripeCustomerId())
.putMetadata("billing_period", billingPeriod)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
attempt.setStripePaymentIntentId(pi.getId());
attempt.setStatus("SUCCEEDED");
billingAttemptRepository.save(attempt);
return pi.getId();
}
}
Why the EntityManager lifecycle amplifies the problem
In JdbcTemplate billing code, a @Transactional rollback undoes the JDBC writes. The JDBC connection is returned to the pool, and on retry a new connection is acquired. The method body is a plain Java method; it does not hold references to any stateful database object that survives between attempts. In Spring Data JPA, the situation is the same structurally — the EntityManager is closed on rollback — but the developer’s mental model often differs. JPA entities can be referenced by the method body. After a rollback, those entity references are detached: calling entity.getField() returns stale data; calling repository.save(entity) with a detached entity may throw DetachedObjectException or silently create a new row, depending on the entity state and the JPA provider. On retry, the @Transactional proxy opens EM2, and any entity variables from attempt 1 are still detached (they were managed by EM1, which is closed). If the method body re-uses those entity references without re-loading them, behavior is undefined.
This is a secondary concern relative to UUID regeneration, but it amplifies the risk: the developer may detect the DetachedObjectException and diagnose the retry as broken due to JPA state management, missing the Stripe duplicate charge entirely.
The fix: separate the UUID computation from the retried scope
The root cause is that @Retryable’s proceed() re-invokes the method body from its first statement. The UUID must be computed before @Retryable’s scope begins — meaning: before the method @Retryable is applied to runs. The standard fix is to separate the retry scope into two beans: a non-@Retryable facade that computes the UUID and passes it as a stable parameter, and a @Transactional + @Retryable inner service that receives the UUID as a parameter. proceed() on the inner service’s method re-invokes the method with the same stable UUID parameter.
// Fix: separate UUID computation from the @Retryable scope
@Service
public class BillingFacade {
private final BillingService billingService;
public BillingFacade(BillingService billingService) {
this.billingService = billingService;
}
// UUID computed here — outside @Retryable scope.
// Stripe idempotency content-hash: deterministic from inputs,
// stable across the facade's lifetime for this billing period.
public String chargeCustomer(String customerId, long amountCents, String billingPeriod) {
String idempotencyKey = UUID.nameUUIDFromBytes(
(customerId + ":" + billingPeriod).getBytes(StandardCharsets.UTF_8)
).toString();
return billingService.chargeCustomerWithKey(customerId, amountCents, billingPeriod, idempotencyKey);
}
}
@Service
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final CustomerRepository customerRepository;
private final StripeClient stripeClient;
// UUID is a parameter — @Retryable's proceed() re-invokes this method
// with the same idempotencyKey argument that BillingFacade passed.
// UUID does not regenerate on retry.
@Transactional
@Retryable(
retryFor = CannotSerializeTransactionException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 200, multiplier = 2.0)
)
public String chargeCustomerWithKey(
String customerId,
long amountCents,
String billingPeriod,
String idempotencyKey) { // stable parameter — same on every retry
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new IllegalArgumentException("Customer not found: " + customerId));
BillingAttempt attempt = billingAttemptRepository
.findByIdempotencyKey(idempotencyKey)
.orElseGet(() -> {
BillingAttempt a = new BillingAttempt();
a.setCustomerId(customerId);
a.setIdempotencyKey(idempotencyKey);
a.setAmountCents(amountCents);
a.setBillingPeriod(billingPeriod);
a.setStatus("PENDING");
return a;
});
if ("SUCCEEDED".equals(attempt.getStatus())) {
return attempt.getStripePaymentIntentId();
}
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customer.getStripeCustomerId())
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // same key — Stripe deduplicates
.build()
);
attempt.setStripePaymentIntentId(pi.getId());
attempt.setStatus("SUCCEEDED");
billingAttemptRepository.save(attempt);
return pi.getId();
}
}
The content-hash key pattern (UUID.nameUUIDFromBytes(customerId + ":" + billingPeriod)) makes the key deterministic from stable inputs. Even if the facade is called more than once for the same customer and billing period — by a scheduler restart, a double-submit, or an upstream retry — it always passes the same key to Stripe. Stripe returns the cached result of the first successful charge for that key within 24 hours. The billing_attempt table uses the idempotency key as a unique index, so concurrent inserts produce a unique constraint violation that the caller can handle instead of a silent duplicate.
Mode 2: @Version optimistic locking retry loop — UUID inside the while loop — ObjectOptimisticLockingFailureException does not roll back the Stripe charge — UUID_B on the next iteration creates ch_B alongside committed ch_A
The second failure mode involves Spring Data JPA’s optimistic locking mechanism. A JPA entity is annotated with @Version. When repository.save(entity) is called, Hibernate generates an UPDATE ... WHERE version = :currentVersion statement. If another transaction committed a version increment between this transaction’s read and this transaction’s write, the WHERE clause matches zero rows. Hibernate detects the zero-row update and throws StaleObjectStateException, which Spring’s JPA exception translation wraps into ObjectOptimisticLockingFailureException.
The typical developer pattern for handling this in a billing context: a while (true) retry loop inside a @Transactional service method. Inside the loop: compute a UUID for the Stripe idempotency key, call Stripe, load the entity, update it, save it. If ObjectOptimisticLockingFailureException is thrown by save(), catch it and continue the loop to retry the conflicting write with the latest entity version. The developer reasons that the optimistic lock failure means the DB write conflicted — the Stripe charge succeeded before the DB exception — but a new UUID on the next iteration is acceptable because the retry is starting fresh.
The problem: Stripe’s idempotency contract is per-key per-endpoint. If the Stripe charge in iteration 1 completed (ch_A committed), iteration 2’s UUID_B creates ch_B — a second charge for the same customer. The developer’s reasoning that “a new UUID on retry is fine because we’re starting from scratch” is wrong: starting from scratch with a new UUID means charging the customer again.
There is a secondary JPA-specific complication: when ObjectOptimisticLockingFailureException is thrown by save() inside a @Transactional method, Spring’s transaction infrastructure marks the current transaction as rollback-only. The method is inside a @Transactional scope whose transaction is now rollback-only. If the developer catches the exception and tries to continue the same method, calling any repository method that participates in the existing transaction will get the rollback-only connection — and on transaction end (either an explicit commit attempt or the @Transactional proxy’s cleanup), Spring throws UnexpectedRollbackException because the transaction was marked rollback-only. The developer sees UnexpectedRollbackException and misdiagnoses the problem as a transaction management issue, missing the Stripe duplicate entirely.
// BillingService.java — unsafe mode 2: @Version optimistic locking retry loop
// with UUID inside the loop and Stripe called before save()
@Service
@Transactional
public class BillingService {
private final AccountBalanceRepository accountBalanceRepository;
private final BillingAttemptRepository billingAttemptRepository;
private final StripeClient stripeClient;
public BillingService(
AccountBalanceRepository accountBalanceRepository,
BillingAttemptRepository billingAttemptRepository,
StripeClient stripeClient) {
this.accountBalanceRepository = accountBalanceRepository;
this.billingAttemptRepository = billingAttemptRepository;
this.stripeClient = stripeClient;
}
// Developer's reasoning:
// "ObjectOptimisticLockingFailureException means the version column conflict
// caused the DB write to fail. The Stripe charge happened before save() threw,
// so ch_A did succeed — but generating a new UUID on the next iteration is
// fine because we're retrying the entire operation from scratch.
// Stripe returns the same result for a committed charge with the same key,
// but we don't reuse the key intentionally because 'retry = fresh start.'"
//
// The problem:
// Iteration 1: UUID_A generated. Stripe charged — ch_A committed in Stripe.
// accountBalanceRepository.save(balance) throws ObjectOptimisticLockingFailureException
// (another thread incremented the version between our findById and our save).
// catch block: caught, loop continues.
//
// BUT: Spring has already marked the current @Transactional transaction
// as rollback-only. Any subsequent repository call in the same transaction
// will operate on a rollback-only connection. When the @Transactional proxy
// exits, it sees rollback-only and throws UnexpectedRollbackException.
// The BillingAttempt row for UUID_A is rolled back (never committed).
// ch_A remains committed in Stripe.
//
// Iteration 2: UUID_B generated — new UUID, not UUID_A.
// Stripe charged — ch_B committed in Stripe. ← DUPLICATE CHARGE
// accountBalanceRepository.save() succeeds (no concurrent modification this time).
// BillingAttempt for UUID_B inserted.
// @Transactional commits. DB has UUID_B row only.
// Stripe has ch_A (no DB record) and ch_B. Customer charged twice.
public String chargeCustomer(String customerId, long amountCents) {
int attempts = 0;
while (true) {
try {
attempts++;
// UUID generated inside the loop — new UUID on every iteration.
String idempotencyKey = UUID.randomUUID().toString();
// Stripe charge happens BEFORE the @Version-guarded save().
// ch_A committed in Stripe before any DB conflict is detected.
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
BillingAttempt attempt = new BillingAttempt();
attempt.setCustomerId(customerId);
attempt.setIdempotencyKey(idempotencyKey);
attempt.setAmountCents(amountCents);
attempt.setStripePaymentIntentId(pi.getId());
attempt.setStatus("SUCCEEDED");
billingAttemptRepository.save(attempt);
// @Version-guarded entity update — this is what can throw
// ObjectOptimisticLockingFailureException.
AccountBalance balance = accountBalanceRepository
.findByCustomerId(customerId)
.orElseThrow();
balance.setLastBilledAt(Instant.now());
balance.setLastPaymentIntentId(pi.getId());
accountBalanceRepository.save(balance); // throws if version conflict
return pi.getId();
} catch (ObjectOptimisticLockingFailureException e) {
if (attempts >= 3) throw e;
// Loop continues with UUID_B on next iteration.
// Developer thinks: "the DB write failed — retry from scratch is safe."
// Reality: ch_A already committed in Stripe; UUID_B charges again.
}
}
}
}
The rollback-only complication
When save(balance) throws ObjectOptimisticLockingFailureException inside the @Transactional method, the Spring JpaTransactionManager registers the exception as a rollback-triggering condition. The transaction synchronization is marked rollback-only via TransactionStatus.setRollbackOnly(). Subsequent repository calls in the same transaction will execute against the rollback-only connection — Hibernate will process the SQL but the transaction will roll back on close regardless. When the @Transactional proxy attempts to commit on method return, it detects rollback-only and throws UnexpectedRollbackException. The developer sees:
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
The billing_attempt row for UUID_A is rolled back. The billing_attempt row for UUID_B (if iteration 2 succeeded at the DB level) is also rolled back — because it ran inside the same rollback-only transaction. Stripe has ch_A and possibly ch_B. The database has no billing_attempt rows. The developer debugs the UnexpectedRollbackException and does not immediately look at Stripe charges.
The fix: compute UUID outside the retry scope; call Stripe after the @Version save
Two fixes, both necessary for full correctness. First, the UUID must be computed outside the retry scope and remain stable across iterations. Second, the Stripe charge should happen after the @Version-guarded save, not before it. Charging after the save means: if the DB write fails, Stripe is never called for that iteration — no orphaned charge. If the DB write succeeds, Stripe is called once with a stable UUID. If Stripe is called and succeeds but the transaction rolls back afterwards (a different failure mode), Stripe’s idempotency key plus an ON CONFLICT DO NOTHING pattern at the DB layer handles re-entry.
// Fix: UUID outside the loop; Stripe called after DB write succeeds
@Service
public class BillingService {
private final AccountBalanceRepository accountBalanceRepository;
private final BillingAttemptRepository billingAttemptRepository;
private final StripeClient stripeClient;
// Content-hash UUID: deterministic from stable inputs.
// Same customerId + billingPeriod always produces the same key.
public String chargeCustomer(String customerId, long amountCents, String billingPeriod) {
String idempotencyKey = UUID.nameUUIDFromBytes(
(customerId + ":" + billingPeriod).getBytes(StandardCharsets.UTF_8)
).toString();
return chargeWithKey(customerId, amountCents, billingPeriod, idempotencyKey);
}
@Transactional
public String chargeWithKey(
String customerId,
long amountCents,
String billingPeriod,
String idempotencyKey) {
int attempts = 0;
while (true) {
try {
attempts++;
// Load balance for @Version check.
AccountBalance balance = accountBalanceRepository
.findByCustomerId(customerId)
.orElseThrow();
balance.setLastBilledAt(Instant.now());
balance.setLastBillingPeriod(billingPeriod);
// @Version save — if this throws, Stripe has NOT been called yet.
// No orphaned charge.
accountBalanceRepository.save(balance);
// DB write succeeded — now call Stripe with stable idempotencyKey.
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // same key across all callers
.build()
);
BillingAttempt attempt = new BillingAttempt();
attempt.setCustomerId(customerId);
attempt.setIdempotencyKey(idempotencyKey);
attempt.setAmountCents(amountCents);
attempt.setBillingPeriod(billingPeriod);
attempt.setStripePaymentIntentId(pi.getId());
attempt.setStatus("SUCCEEDED");
billingAttemptRepository.save(attempt);
return pi.getId();
} catch (ObjectOptimisticLockingFailureException e) {
if (attempts >= 3) throw e;
// Loop retries the entire operation from the findById load.
// UUID is stable — if Stripe was called in a prior iteration
// (shouldn't be, since save() throws before Stripe is called),
// Stripe's idempotency key deduplicates.
}
}
}
}
The reordering — DB write before Stripe call — eliminates the window where Stripe commits a charge that the DB transaction subsequently rolls back. The content-hash UUID eliminates the duplicate-charge risk if the order is accidentally reversed in the future or in a code review that moves the Stripe call earlier. Both fixes together provide defense in depth.
Note on the rollback-only problem: the fix above places the entire retry loop inside a single @Transactional method. ObjectOptimisticLockingFailureException is caught inside the method before it propagates to the @Transactional proxy, so the proxy never sees it and the transaction is not marked rollback-only. This works when the exception is caught and handled entirely within the method scope. If the exception is allowed to propagate to the @Transactional proxy (by not catching it inside the method), the transaction is marked rollback-only and subsequent repository calls in the same method will fail. The fix keeps the exception local to the retry loop.
Mode 3: @TransactionalEventListener(phase = AFTER_COMMIT) + @Retryable — UUID computed inside the event handler — proceed() re-invokes the handler including UUID generation — ch_B after a single transaction commit
The third failure mode is specific to the event-driven JPA pattern. A developer uses Spring’s application event system to decouple the Stripe charge from the JPA transaction. The publishing service method is annotated @Transactional. At the end of the method, it publishes a BillingEvent via ApplicationEventPublisher.publishEvent(). A separate @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) method in a listener bean handles the event after the publishing transaction commits. The listener charges Stripe.
The developer’s motivation for this pattern is well-founded: charging Stripe inside the publishing transaction means the charge is committed before the JPA entities are safe. If the JPA transaction rolls back after the Stripe charge, the charge is orphaned. Moving the Stripe call to AFTER_COMMIT ensures the DB record is committed before Stripe is charged. This is a correct reasoning about the AFTER_COMMIT semantics.
The mistake is adding @Retryable to the listener method and computing the UUID inside it. The developer reasons that @Retryable on the listener retries the Stripe HTTP call that failed inside the handler. But @Retryable’s proceed() re-invokes the listener method from its first statement. If the UUID is computed inside the handler, UUID_B is generated on the first retry. Stripe receives a new key and creates ch_B. The BillingAttempt entity that was saved and committed in the outer transaction has idempotency_key = UUID_A. ch_B is charged in Stripe but no database record will ever reference it.
// BillingEventListener.java — unsafe mode 3:
// UUID computed inside @TransactionalEventListener handler + @Retryable
@Component
public class BillingEventListener {
private final StripeClient stripeClient;
private final BillingAttemptRepository billingAttemptRepository;
public BillingEventListener(StripeClient stripeClient,
BillingAttemptRepository billingAttemptRepository) {
this.stripeClient = stripeClient;
this.billingAttemptRepository = billingAttemptRepository;
}
// Developer's reasoning:
// "@TransactionalEventListener(AFTER_COMMIT) fires once after the outer
// transaction commits — the BillingAttempt entity is safely persisted.
// @Retryable on this method retries if Stripe returns a 429 or network
// timeout. UUID is computed at handler entry for this charge invocation —
// @Retryable retries only the Stripe HTTP call, not UUID generation."
//
// The problem:
// @TransactionalEventListener fires the listener method once after the
// outer @Transactional commits.
// @Retryable wraps the listener method at the AOP proxy level.
// When stripeClient.paymentIntents().create() throws StripeException
// (429, network timeout), @Retryable catches it.
// @Retryable calls proceed() — which re-invokes the listener method
// from its first statement.
// UUID_B = UUID.randomUUID().toString() ← NEW UUID on retry.
// ch_B committed in Stripe.
// BillingAttempt entity (committed with UUID_A) now has no matching
// Stripe PaymentIntent — ch_A may never have been committed (it threw),
// but ch_B is committed and the DB has UUID_A. Mismatch.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Retryable(
retryFor = StripeException.class,
maxAttempts = 4,
backoff = @Backoff(delay = 500, multiplier = 2.0)
)
public void onBillingEvent(BillingEvent event) {
// UUID computed inside the handler — not the event payload UUID.
// Developer thinks @Retryable retries the HTTP call, not this line.
String idempotencyKey = UUID.randomUUID().toString(); // ← regenerates on retry
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("usd")
.setCustomer(event.getStripeCustomerId())
.putMetadata("billing_attempt_id", event.getBillingAttemptId())
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
// Attempt to update the BillingAttempt row after charge.
// @TransactionalEventListener(AFTER_COMMIT) runs outside the committed
// outer transaction. A @Transactional annotation on this method would
// start a new transaction (REQUIRES_NEW). Without @Transactional here,
// this save() runs in no-transaction mode (auto-commit, if JPA allows it).
billingAttemptRepository.findById(event.getBillingAttemptId()).ifPresent(attempt -> {
attempt.setStripePaymentIntentId(pi.getId());
attempt.setStatus("STRIPE_CHARGED");
billingAttemptRepository.save(attempt);
});
}
}
The event class and publishing service:
// BillingEvent.java
public class BillingEvent {
private final String billingAttemptId;
private final String stripeCustomerId;
private final long amountCents;
private final String billingPeriod;
public BillingEvent(String billingAttemptId, String stripeCustomerId,
long amountCents, String billingPeriod) {
this.billingAttemptId = billingAttemptId;
this.stripeCustomerId = stripeCustomerId;
this.amountCents = amountCents;
this.billingPeriod = billingPeriod;
}
// getters...
}
// BillingService.java — event publisher
@Service
@Transactional
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final ApplicationEventPublisher eventPublisher;
public BillingService(BillingAttemptRepository billingAttemptRepository,
ApplicationEventPublisher eventPublisher) {
this.billingAttemptRepository = billingAttemptRepository;
this.eventPublisher = eventPublisher;
}
public String initiateCharge(String customerId, String stripeCustomerId,
long amountCents, String billingPeriod) {
BillingAttempt attempt = new BillingAttempt();
attempt.setCustomerId(customerId);
attempt.setAmountCents(amountCents);
attempt.setBillingPeriod(billingPeriod);
attempt.setStatus("PENDING");
BillingAttempt saved = billingAttemptRepository.save(attempt);
// Event published inside @Transactional — listener fires after commit.
// Developer thinks: DB is safe before Stripe is charged.
eventPublisher.publishEvent(
new BillingEvent(saved.getId(), stripeCustomerId, amountCents, billingPeriod)
);
return saved.getId();
}
}
Why @TransactionalEventListener does not create a UUID isolation boundary
@TransactionalEventListener(phase = AFTER_COMMIT) controls when the listener method fires relative to the publishing transaction: it fires after the publishing transaction has committed successfully. It does not control anything about the listener method’s internal execution semantics. From @Retryable’s perspective, the listener method is just a method. @Retryable’s AOP proxy wraps the listener method at the proxy layer; when proceed() is called, it re-invokes the method body from its first statement, regardless of whether the method is a @TransactionalEventListener handler, a @Scheduled task, a controller endpoint, or a plain service method.
The developer’s intuition that “AFTER_COMMIT fires the listener after the transaction commits and @Retryable retries only what failed inside the handler” conflates the event delivery semantics (when the listener runs relative to the outer transaction) with the retry execution semantics (what code proceed() re-invokes). They are orthogonal: the first is about the event lifecycle relative to the outer transaction; the second is about the AOP proxy boundary around the listener method itself.
A secondary complication: transaction context inside the listener
By default, @TransactionalEventListener(phase = AFTER_COMMIT) runs outside the publishing transaction (because that transaction has already committed). If the listener method has no @Transactional annotation, it runs in no-transaction context. Repository calls inside it auto-commit. If the listener method has @Transactional, it starts a new transaction (the default propagation is REQUIRED, which joins an existing transaction or starts one; since there is no publishing transaction active at this point, it always starts a new transaction). Adding @Transactional(propagation = REQUIRES_NEW) to the listener is the standard way to give it a separate transaction for its DB updates.
When @Retryable is also on the listener, the proxy order matters again: @Retryable (MAX-5) is outer to @Transactional (MAX). On retry, proceed() re-invokes through @Transactional, which opens a new transaction for attempt 2. This is the same Mode 1 pattern applied to the listener. Both UUID and the new transaction are fresh on every retry. The developer now has two independent problems on the same method: UUID_B from @Retryable outer proxy order, and a new EntityManager from @Transactional inner proxy. Both must be fixed.
The fix: put the UUID in the event payload; never compute it inside the retried handler
The UUID must be computed before the event is published — in the publisher — and carried as part of the event payload. The listener receives the UUID from the event and passes it to Stripe. @Retryable’s proceed() re-invokes the listener with the same event object (the event is passed as a parameter; proceed() re-uses the same argument). The UUID in the event object is the same UUID on every retry.
// Fix: UUID in the event payload — stable across retries
// BillingEvent.java — carries the UUID that was persisted with the BillingAttempt
public class BillingEvent {
private final String billingAttemptId;
private final String stripeCustomerId;
private final long amountCents;
private final String billingPeriod;
private final String idempotencyKey; // ← UUID from the publisher
public BillingEvent(String billingAttemptId, String stripeCustomerId,
long amountCents, String billingPeriod, String idempotencyKey) {
this.billingAttemptId = billingAttemptId;
this.stripeCustomerId = stripeCustomerId;
this.amountCents = amountCents;
this.billingPeriod = billingPeriod;
this.idempotencyKey = idempotencyKey;
}
// getters...
}
// BillingService.java — publisher now computes the UUID as a content-hash
@Service
@Transactional
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final ApplicationEventPublisher eventPublisher;
public String initiateCharge(String customerId, String stripeCustomerId,
long amountCents, String billingPeriod) {
// Content-hash UUID: deterministic from stable inputs.
String idempotencyKey = UUID.nameUUIDFromBytes(
(customerId + ":" + billingPeriod).getBytes(StandardCharsets.UTF_8)
).toString();
BillingAttempt attempt = new BillingAttempt();
attempt.setCustomerId(customerId);
attempt.setIdempotencyKey(idempotencyKey); // persisted in DB before event fires
attempt.setAmountCents(amountCents);
attempt.setBillingPeriod(billingPeriod);
attempt.setStatus("PENDING");
BillingAttempt saved = billingAttemptRepository.save(attempt);
// Event carries the persisted UUID — listener will pass this to Stripe.
eventPublisher.publishEvent(
new BillingEvent(saved.getId(), stripeCustomerId, amountCents,
billingPeriod, idempotencyKey)
);
return saved.getId();
}
}
// BillingEventListener.java — fixed
@Component
public class BillingEventListener {
private final StripeClient stripeClient;
private final BillingAttemptRepository billingAttemptRepository;
public BillingEventListener(StripeClient stripeClient,
BillingAttemptRepository billingAttemptRepository) {
this.stripeClient = stripeClient;
this.billingAttemptRepository = billingAttemptRepository;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Retryable(
retryFor = StripeException.class,
maxAttempts = 4,
backoff = @Backoff(delay = 500, multiplier = 2.0)
)
public void onBillingEvent(BillingEvent event) {
// UUID comes from the event payload — same object on every retry.
// @Retryable's proceed() re-invokes this method with the same event argument.
// event.getIdempotencyKey() returns the same UUID_A every time.
String idempotencyKey = event.getIdempotencyKey(); // ← stable, from publisher
// Check if already charged (defensive idempotency).
billingAttemptRepository.findById(event.getBillingAttemptId()).ifPresent(attempt -> {
if ("STRIPE_CHARGED".equals(attempt.getStatus())) {
return; // Already charged — Stripe would return cached result anyway,
// but skip the HTTP call for efficiency.
}
});
PaymentIntent pi = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("usd")
.setCustomer(event.getStripeCustomerId())
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // same on every retry — Stripe deduplicates
.build()
);
billingAttemptRepository.findById(event.getBillingAttemptId()).ifPresent(attempt -> {
attempt.setStripePaymentIntentId(pi.getId());
attempt.setStatus("STRIPE_CHARGED");
billingAttemptRepository.save(attempt);
});
}
}
With this fix, the listener receives the UUID from the event payload. @Retryable’s proceed() re-invokes the listener with the same event argument (Spring passes the event object as the method parameter; proceed() re-uses the AOP method invocation’s argument array, which holds the same event reference). event.getIdempotencyKey() returns UUID_A on every retry. Stripe deduplicates requests with UUID_A and returns the cached PaymentIntent from the first successful call.
The @Transactional(propagation = REQUIRES_NEW) ensures the listener’s DB update runs in its own transaction. With @Retryable outer to @Transactional, each retry opens a fresh EntityManager and transaction. This is correct for a listener: the first attempt’s transaction may not have committed (it may have rolled back due to a DB error unrelated to Stripe), so each retry should attempt the DB update in a fresh transaction rather than join the rollback-only first transaction.
Comparison: failure modes, re-execution triggers, and fixes
| Mode | Re-execution trigger | Re-execution unit | UUID position | Developer misconception | Stripe impact |
|---|---|---|---|---|---|
1: @Retryable + @Transactional on JPA method |
CannotSerializeTransactionException propagates through @Transactional inner proxy to @Retryable outer proxy |
Entire method body via proceed() — fresh EntityManager + new transaction each time |
Method entry, before repository.save() |
@Transactional is the outer wrapper that owns the JPA session — @Retryable retries within the existing EntityManager context |
ch_B alongside committed ch_A; ch_A has no DB record |
2: @Version OLE retry loop |
ObjectOptimisticLockingFailureException caught inside method — loop iterates |
Loop body re-executed from top of iteration including UUID and Stripe | Inside while loop, before Stripe call |
OLE is a DB conflict — Stripe succeeded before the DB exception — new UUID on retry is fine because "starting from scratch" | ch_B on iteration 2; ch_A orphaned in Stripe (no DB record) |
3: @TransactionalEventListener + @Retryable |
Stripe HTTP failure propagates to @Retryable proxy on listener method |
Entire listener method body via proceed() |
Inside listener, before Stripe call | AFTER_COMMIT fires the listener after outer transaction commits — @Retryable retries only the HTTP call, not UUID generation |
ch_B alongside committed/failed ch_A; BillingAttempt DB row has UUID_A — mismatch |
| Mode | DB state after failure | Stripe state | Detection signal | Fix |
|---|---|---|---|---|
| 1 | UUID_B row committed; UUID_A row rolled back — one DB record | ch_A and ch_B both committed — two charges | Stripe customer has two PaymentIntents for same billing period; one has no billing_attempt counterpart | Separate UUID computation into non-@Retryable facade; pass as parameter to @Retryable + @Transactional inner service |
| 2 | BillingAttempt rows rolled back (transaction rollback-only); AccountBalance may or may not be updated | ch_A committed; ch_B possibly committed on iteration 2 | UnexpectedRollbackException logged; Stripe has charges with no DB billing_attempt record |
Content-hash UUID outside loop; move Stripe call after @Version-guarded save; keep ObjectOptimisticLockingFailureException local to loop (don’t let it propagate to @Transactional proxy) |
| 3 | BillingAttempt row with UUID_A committed (by publisher); listener’s status update may have partial commits | ch_A may or may not have committed; ch_B committed on retry | BillingAttempt.idempotency_key = UUID_A; Stripe PaymentIntent pi_B has no billing_attempt row; charges page shows two PIs for same customer + amount | UUID in event payload, computed by publisher as content-hash; listener reads UUID from event argument (stable across proceed() retries) |
JUnit 5 + WireMock test patterns
Each mode has a test that verifies idempotency: after an engineered failure that triggers retry, exactly one Stripe PaymentIntent creation request is made (Stripe deduplicates on the key), and the database has exactly one billing record.
Mode 1 test: @Retryable + @Transactional proxy order — same key across retries
// BillingServiceRetryTest.java — Mode 1
@SpringBootTest
@AutoConfigureWireMock(port = 0)
@Transactional
class BillingServiceRetryTest {
@Autowired
BillingFacade billingFacade;
@Autowired
BillingAttemptRepository billingAttemptRepository;
@Autowired
WireMockServer wireMockServer;
@Test
void chargeCustomer_sameIdempotencyKeyOnRetry_stripeSeesOneUniqueKey() {
// Stripe returns 409 (idempotency conflict) on the first call,
// succeeds on the second — WireMock verifies the same key is sent both times.
String successBody = """
{"id":"pi_test","object":"payment_intent","status":"succeeded","amount":1000}
""";
wireMockServer.stubFor(post(urlEqualTo("/v1/payment_intents"))
.withHeader("Idempotency-Key", matching(".*"))
.inScenario("retry-scenario")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse()
.withStatus(500)
.withBody("{\"error\":{\"type\":\"api_error\",\"message\":\"Internal server error\"}}"))
.willSetStateTo("retried"));
wireMockServer.stubFor(post(urlEqualTo("/v1/payment_intents"))
.withHeader("Idempotency-Key", matching(".*"))
.inScenario("retry-scenario")
.whenScenarioStateIs("retried")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody(successBody)));
billingFacade.chargeCustomer("cust_123", 1000L, "2026-Q4");
// Collect all Idempotency-Key header values sent to Stripe.
List requests = wireMockServer.findAll(
postRequestedFor(urlEqualTo("/v1/payment_intents")));
assertThat(requests).hasSize(2);
Set keys = requests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
// Both requests used the SAME idempotency key — safe retry behavior.
assertThat(keys).hasSize(1);
// Exactly one billing_attempt row in the DB.
assertThat(billingAttemptRepository.count()).isEqualTo(1);
}
}
Mode 2 test: optimistic locking loop — Stripe not called on OLE iteration
// BillingServiceOleTest.java — Mode 2
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BillingServiceOleTest {
@Autowired
BillingService billingService;
@Autowired
AccountBalanceRepository accountBalanceRepository;
@Autowired
BillingAttemptRepository billingAttemptRepository;
@Autowired
WireMockServer wireMockServer;
@Test
void chargeWithKey_oleOnFirstSave_stripeCalledOnlyAfterSuccessfulSave() {
// Arrange: AccountBalance with version=0.
AccountBalance balance = new AccountBalance();
balance.setCustomerId("cust_456");
balance.setVersion(0L);
accountBalanceRepository.save(balance);
// Arrange: Stripe succeeds.
wireMockServer.stubFor(post(urlEqualTo("/v1/payment_intents"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_ok\",\"status\":\"succeeded\",\"amount\":2000}")));
// Act: call chargeWithKey directly (already has the content-hash UUID).
String key = UUID.nameUUIDFromBytes("cust_456:2026-Q4".getBytes()).toString();
billingService.chargeWithKey("cust_456", "stripe_cus_456", 2000L, "2026-Q4", key);
// With the fixed implementation, Stripe is only called AFTER accountBalanceRepository.save()
// succeeds. If OLE is thrown before Stripe is called, Stripe request count = 0 for that
// attempt. Across all successful attempts, exactly 1 Stripe call is made.
verify(1, postRequestedFor(urlEqualTo("/v1/payment_intents")));
verify(1, postRequestedFor(urlEqualTo("/v1/payment_intents"))
.withHeader("Idempotency-Key", equalTo(key)));
assertThat(billingAttemptRepository.countByCustomerId("cust_456")).isEqualTo(1);
}
}
Mode 3 test: @TransactionalEventListener + @Retryable — UUID from event payload stable across retries
// BillingEventListenerRetryTest.java — Mode 3
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BillingEventListenerRetryTest {
@Autowired
BillingService billingService;
@Autowired
BillingAttemptRepository billingAttemptRepository;
@Autowired
WireMockServer wireMockServer;
@Test
void onBillingEvent_stripeReturns429ThenSucceeds_sameIdempotencyKeyBothAttempts() {
String customerId = "cust_789";
String stripeCustomerId = "stripe_cus_789";
// First Stripe call returns 429 (rate limit); second succeeds.
wireMockServer.stubFor(post(urlEqualTo("/v1/payment_intents"))
.inScenario("event-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse()
.withStatus(429)
.withBody("{\"error\":{\"type\":\"rate_limit_error\",\"message\":\"Too many requests\"}}"))
.willSetStateTo("rate-limited"));
wireMockServer.stubFor(post(urlEqualTo("/v1/payment_intents"))
.inScenario("event-retry")
.whenScenarioStateIs("rate-limited")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_event_ok\",\"status\":\"succeeded\",\"amount\":3000}")));
// Act: trigger the billing flow; the AFTER_COMMIT listener fires after
// billingService.initiateCharge() commits.
billingService.initiateCharge(customerId, stripeCustomerId, 3000L, "2026-Q4");
// Assert: Stripe was called twice (429 then 200), same key both times.
List requests = wireMockServer.findAll(
postRequestedFor(urlEqualTo("/v1/payment_intents")));
assertThat(requests).hasSize(2);
Set keys = requests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(keys).hasSize(1); // same key on both attempts
// Assert: DB has one billing_attempt, now STRIPE_CHARGED.
List attempts = billingAttemptRepository.findByCustomerId(customerId);
assertThat(attempts).hasSize(1);
assertThat(attempts.get(0).getStatus()).isEqualTo("STRIPE_CHARGED");
assertThat(attempts.get(0).getStripePaymentIntentId()).isEqualTo("pi_event_ok");
// Verify the DB key matches the Stripe key.
String dbKey = attempts.get(0).getIdempotencyKey();
String stripeKey = keys.iterator().next();
assertThat(dbKey).isEqualTo(stripeKey);
}
}
Entity detachment, @PrePersist, and other JPA-specific amplifiers
Two additional JPA-specific patterns can amplify the failure modes above or create their own UUID-generation problems in billing code.
Entity detachment on rollback
When Mode 1 occurs (the @Transactional proxy rolls back and the EntityManager is closed), any entity instances the method body held are detached. On the next attempt (Mode 1 retry or Mode 2 loop iteration), calling repository.save(existingEntityRef) with a detached entity triggers either a merge (if the entity has a populated ID) or an unexpected insert (if Hibernate treats it as a new entity). The billing_attempt table’s unique index on idempotency_key will catch UUID_A being re-inserted, but this is not the right failure signal — the right behavior is to re-load the entity from the database via a findById() call at the start of each retry attempt or iteration.
In Mode 2’s retry loop, the developer often calls repository.findById() again at the top of each iteration precisely to get a fresh entity with the latest version. This is correct for the @Version conflict, but does not help with the UUID-outside-the-loop problem.
@PrePersist as a UUID generator
Some Spring Data JPA codebases use @PrePersist callbacks on the entity to set the idempotency key:
@Entity
public class BillingAttempt {
@Id
@GeneratedValue
private Long id;
private String idempotencyKey;
// ...
@PrePersist
void generateIdempotencyKey() {
if (this.idempotencyKey == null) {
this.idempotencyKey = UUID.randomUUID().toString();
}
}
}
This pattern works correctly for Stripe idempotency only if the entity is created once and passed as a stable argument across any retry boundary. If the method annotated with @Retryable creates a new BillingAttempt() inside the method body on each invocation, the @PrePersist callback generates a new UUID on each repository.save() call in each retry attempt. From Stripe’s perspective, each retry attempt generates a new UUID, creating a new charge. The @PrePersist null-check guard (if (this.idempotencyKey == null)) only guards against calling @PrePersist twice on the same entity instance — it does not protect against a new entity instance being created on each retry. The fix is the same as for all other modes: compute the key in the non-retried facade and pass it as a stable parameter.
Summary
Three Spring Data JPA failure modes for Stripe idempotency, each driven by a different JPA mechanism:
- Mode 1 —
@Retryable+@Transactionalproxy order:@Retryable(MAX-5) is outer to@Transactional(MAX).proceed()re-opens theEntityManagerand re-executes the method body including UUID generation. JPA-specific amplifier: the EntityManager lifecycle is tied to the@Transactionalproxy; entity references from attempt 1 are detached on attempt 2. Fix: separate UUID computation into a non-@Retryablefacade; pass as stable parameter. - Mode 2 —
@Versionoptimistic locking retry loop: UUID inside the loop.ObjectOptimisticLockingFailureExceptionfromsave()does not roll back the Stripe charge that ran before the save. Next iteration generates UUID_B and charges again. JPA-specific amplifier:ObjectOptimisticLockingFailureExceptionmarks the transaction rollback-only; catching it inside the method avoidsUnexpectedRollbackExceptionbut does not prevent the Stripe duplication unless the UUID is outside the loop. Fix: content-hash UUID outside the loop; Stripe call after the@Version-guarded save. - Mode 3 —
@TransactionalEventListener(AFTER_COMMIT)+@Retryable: UUID computed inside the listener.@Retryable’sproceed()re-invokes the listener method including UUID generation;AFTER_COMMITsemantics do not create any UUID isolation boundary. Fix: UUID in the event payload, computed by the publisher as a content-hash; listener reads from event argument (stable acrossproceed()retries).
In all three modes, the fix is the same structural principle: UUID must be computed before the @Retryable scope begins. A content-hash key computed from stable, domain-meaningful inputs (customer ID, billing period) is more robust than a random UUID passed as a parameter, because it remains stable even if the calling chain is invoked more than once by an upstream scheduler restart or a duplicate HTTP request.
Related posts in this series: Spring JdbcTemplate + Stripe — @Transactional + @Retryable proxy order, TransactionTemplate inside @Retryable, batchUpdate() phase separation. jOOQ + Spring Boot + Stripe — DSLContext.transactionResult() inside @Retryable, jOOQ optimistic locking DataChangedException, batchInsert() deadlock retry. Spring Data R2DBC + Kotlin Coroutines + Stripe — @Transactional suspend fun + @Retryable fresh coroutine, flatMap placement of retryWhen(), TransactionalOperator wrapping UUID generation.
Keybrake: per-call enforcement for your agent’s Stripe calls
Your agent uses a vault key. Keybrake enforces a daily USD cap, endpoint allowlist, and merchant scope before each call exits — then logs every request with parsed cost. One-click revoke. No key rotation required.