Micronaut Data JDBC and Stripe Integration: How saveAll() Batch Retry Re-generates UUID Per Item When the Batch INSERT Fails After Items Were Already Charged by Stripe, @Query INSERT with ON CONFLICT DO NOTHING Guard Is Bypassed When the Idempotency Key Is UUID.randomUUID() Instead of a Content Hash, and READ COMMITTED Isolation Allows Two Concurrent findByIdempotencyKey() Checks to Both Return Empty Before Either Call Commits
The prior Micronaut Data post in this series covered three failure modes rooted in Micronaut’s compile-time AOP interceptor stack: @Retryable being outermost by default (retry wraps transaction, method body re-runs), CrudRepository.save()’s implicit @Transactional creating a committed Stripe charge before the repository’s own transaction can throw, and @Retryable(includes = OptimisticLockException.class) retrying a Stripe call that precedes the optimistically locked DB write. All three are AOP-layer issues. This post moves down to the JDBC data-access layer and focuses on three failure modes that arise from how Micronaut Data JDBC executes SQL: batch write semantics, custom-query guards with determinism errors, and database-isolation-level race conditions. Each mode describes a double-charge scenario where the underlying cause would not appear in a @Transactional-level analysis.
The common thread across all three is a misunderstanding of where atomicity lives. Stripe charges are external HTTP calls. Micronaut Data JDBC’s @Transactional boundary wraps JDBC operations. The two are not in the same transaction. A JDBC exception never rolls back a committed Stripe charge. An ON CONFLICT clause guards against a duplicate database row, not against a second Stripe call. A READ COMMITTED snapshot ensures you see the committed state at the instant of your read, not the committed state at the instant Stripe is called. The three failure modes in this post are each a different manifestation of this fundamental disconnect.
All three result in the same production observable: Stripe records two PaymentIntent or Charge objects for the same logical billing event, each carrying a distinct Idempotency-Key header or a different request body hash, and the affected customer’s card is debited twice.
Background: Micronaut Data JDBC architecture and how it differs from JPA
Micronaut Data JDBC is a compile-time data-access layer that generates repository implementations as bytecode during the build, not at runtime. Unlike Spring Data JPA, Micronaut Data JDBC does not use a JPA EntityManager. There is no persistence context, no first-level cache, no dirty-checking, and no deferred-flush queue. Every repository.save(entity), repository.update(entity), and repository.saveAll(entities) call translates directly to a JDBC statement that executes immediately against the underlying Connection.
This direct-to-JDBC model has important consequences for Stripe integration. In a JPA application, you might reason about whether an entity is in “managed” state and trust that the persistence context would have detected a prior persist() for the same entity object. In a Micronaut Data JDBC application, there is no such state tracking. If the service method constructs a new entity object with new BillingAttempt(), that object is always treated as a new row candidate. The repository has no memory of prior calls in the same request lifecycle.
Micronaut Data JDBC repositories are annotated with @JdbcRepository(dialect = Dialect.POSTGRES) (or the dialect of your choice). Generated implementations of CrudRepository methods carry @Transactional on the generated code. You can override this with your own @Transactional annotations. Custom methods annotated with @Query participate in the surrounding transaction if one is active; if no transaction is active they execute in auto-commit mode. This distinction matters for the batch and guard failure modes.
For the tests in this post, the stack is: Micronaut 4.x, micronaut-data-jdbc, PostgreSQL 15 (via Testcontainers), WireMock for Stripe stubs, and JUnit 5 with @MicronautTest.
Mode 1: saveAll() batch write fails after Stripe charges committed — @Retryable retries with new UUID per item — ch_B for every customer already charged
The developer writes a monthly billing service. Rather than charging customers one by one, the service collects all billing candidates for the billing period, calls Stripe for each, appends the charge ID and result to the corresponding BillingAttempt entity, and then persists the entire batch with billingAttemptRepository.saveAll(attempts). This is a reasonable pattern: batch the database writes to minimize round-trips. The developer adds @Retryable(attempts = "3", delay = "2s") to handle transient database connectivity issues.
// BillingService.java — unsafe: saveAll() after per-item Stripe calls + @Retryable
@Singleton
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final StripeClient stripeClient;
@Retryable(attempts = "3", delay = "2s")
@Transactional
public void runMonthlyBilling(List<BillingCandidate> candidates, String billingPeriod) {
List<BillingAttempt> attempts = new ArrayList<>();
for (BillingCandidate candidate : candidates) {
String idempotencyKey = UUID.randomUUID().toString(); // NEW UUID per item on each invocation
ChargeResult result = stripeClient.createCharge(
idempotencyKey, // Stripe Idempotency-Key header
candidate.getCustomerId(),
candidate.getAmountCents()
);
BillingAttempt attempt = new BillingAttempt();
attempt.setIdempotencyKey(idempotencyKey);
attempt.setCustomerId(candidate.getCustomerId());
attempt.setAmountCents(candidate.getAmountCents());
attempt.setBillingPeriod(billingPeriod);
attempt.setStripeChargeId(result.getChargeId());
attempt.setStatus("SUCCEEDED");
attempts.add(attempt);
}
billingAttemptRepository.saveAll(attempts); // <-- JDBC batch INSERT — can fail independently of Stripe
}
}
The developer’s mental model: the @Transactional ensures that either all the billing attempts are saved or none are. If saveAll() throws, the transaction rolls back and all billing attempts are in a clean state to retry. The @Retryable then re-runs the service method. New UUIDs are generated because “the prior attempt was rolled back.”
This reasoning is wrong in a specific, Stripe-specific way. The transaction that rolls back is the JDBC transaction. The JDBC transaction contains only the database writes: the INSERT statements in the saveAll() call. The Stripe API calls are external HTTP requests. They are not enrolled in the JDBC transaction. @Transactional in Micronaut Data JDBC is backed by DataSourceTransactionManager, which manages a JDBC Connection and its auto-commit flag. It does not have an XA coordinator or any mechanism to “rollback” an HTTP call to an external API.
The sequence of events when saveAll() fails on attempt 1:
- Service method enters.
@Transactionalinterceptor opens a JDBC transaction (setsautoCommit = falseon the connection). - Loop iteration 1:
UUID.randomUUID()= key_A1.stripeClient.createCharge(key_A1, customer1, 9900)succeeds. Stripe creates ch_A1 and stores key_A1 in its idempotency log.result.getChargeId()= “ch_A1”.BillingAttemptentity for customer1 built with key_A1. - Loop iteration 2:
UUID.randomUUID()= key_B1.stripeClient.createCharge(key_B1, customer2, 4900)succeeds. ch_B1 created. Entity for customer2 built with key_B1. - (Same for customers 3, 4, … N.)
billingAttemptRepository.saveAll(attempts)is called. Internally, Micronaut Data JDBC generates a JDBC batch with oneINSERTper entity. The batch executes. OneINSERTfails — for example, customer 7’ssubscription_idforeign key points to a soft-deleted subscription row that the FK constraint rejects.DataIntegrityViolationExceptionis thrown.@Transactionalinterceptor catches theRuntimeException. It marks the transaction for rollback and issuesconnection.rollback(). AllINSERTs are undone. The database has no record of any billing attempt.- The exception propagates out of the
@Transactionalboundary to the@Retryableinterceptor.@Retryablewaits 2 seconds. - Attempt 2:
@Retryablere-enters the method body from line 1.@Transactionalopens a new JDBC transaction. Loop iteration 1:UUID.randomUUID()= key_A2 (new UUID, different from key_A1).stripeClient.createCharge(key_A2, customer1, 9900)is called. Stripe checks its idempotency log: key_A2 is not there (key_A1 is). Stripe creates ch_A2. Customer 1 is charged twice: ch_A1 (from attempt 1) and ch_A2 (from attempt 2).
The same double-charge pattern applies to customers 2 through 6 (those whose Stripe calls succeeded in attempt 1 and whose batch INSERT rows were rolled back). Customer 7, whose FK constraint caused the failure, was not charged in attempt 1 and will be charged once in attempt 2. The net effect: customers 1–6 each have two charges on their card.
Why the developer misconception is plausible
The developer’s intuition — “if the transaction rolled back, the prior work is undone” — is correct for database operations. It is incorrect for external API calls. This distinction is easy to miss because, in typical CRUD service code, all side effects are database writes and the transactional guarantee is complete. Stripe integration is one of the first cases most backend engineers encounter where a service method combines transactional database writes with non-transactional external calls, and the failure modes only manifest under error conditions (not on the happy path).
The second contributing factor is the ordering: the developer writes Stripe calls first, database save second, reasoning that “the DB write records what Stripe did.” This ordering is natural but backwards for safety: the safe ordering is DB guard first, Stripe second, DB confirm third.
The fix
The fundamental change is to make the idempotency key deterministic (content-hash-based) and to check the database for a prior attempt before calling Stripe, per customer, rather than saving a batch after all Stripe calls have completed.
// BillingService.java — safe: per-item check → Stripe → per-item save
@Singleton
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final StripeClient stripeClient;
@Transactional
public void runMonthlyBilling(List<BillingCandidate> candidates, String billingPeriod) {
for (BillingCandidate candidate : candidates) {
String key = contentHashKey(candidate.getCustomerId(), billingPeriod, candidate.getAmountCents());
Optional<BillingAttempt> existing = billingAttemptRepository.findByIdempotencyKey(key);
if (existing.isPresent()) {
continue; // already successfully processed
}
ChargeResult result = stripeClient.createCharge(key, candidate.getCustomerId(), candidate.getAmountCents());
BillingAttempt attempt = new BillingAttempt();
attempt.setIdempotencyKey(key);
attempt.setCustomerId(candidate.getCustomerId());
attempt.setAmountCents(candidate.getAmountCents());
attempt.setBillingPeriod(billingPeriod);
attempt.setStripeChargeId(result.getChargeId());
attempt.setStatus("SUCCEEDED");
billingAttemptRepository.save(attempt); // per-item save, not batch at the end
}
}
private String contentHashKey(Long customerId, String billingPeriod, int amountCents) {
String raw = customerId + ":" + billingPeriod + ":" + amountCents;
return Hashing.sha256().hashString(raw, StandardCharsets.UTF_8).toString();
}
}
With this fix: the key is derived from the billing event’s content, not from UUID.randomUUID(). A retry of the service method for customer 1 with the same parameters produces the same key. The findByIdempotencyKey(key) check finds the row from the prior successful attempt and skips the Stripe call. The save(attempt) is per-item and immediately follows the Stripe call, so a failure on customer 7’s save does not undo customers 1–6’s already-saved rows (each save is its own JDBC statement within the outer @Transactional). Note: for true per-item isolation, use a nested @Transactional(propagation = Propagation.REQUIRES_NEW) on a helper method per customer, so a failure on one customer’s save doesn’t roll back the saves already committed for prior customers.
Mode 2: @Query INSERT ... ON CONFLICT DO NOTHING guard is always bypassed when the idempotency key is UUID.randomUUID()
The developer reads about using database-level uniqueness guards to prevent double-charging and implements the following pattern with Micronaut Data JDBC’s @Query annotation. The idea: use a PostgreSQL INSERT ... ON CONFLICT DO NOTHING to atomically attempt to insert the idempotency record. If the record already exists (conflict), the INSERT does nothing and the method returns an empty Optional. The service interprets the empty result as “already processed” and skips the Stripe call. If the record does not exist, the INSERT succeeds and returns the key, signaling “proceed to Stripe.”
// BillingAttemptRepository.java
@JdbcRepository(dialect = Dialect.POSTGRES)
public interface BillingAttemptRepository extends CrudRepository<BillingAttempt, Long> {
@Query("""
INSERT INTO billing_attempts (idempotency_key, customer_id, amount_cents, billing_period, status)
VALUES (:key, :customerId, :amountCents, :billingPeriod, 'PENDING')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key
""")
Optional<String> insertIfAbsent(
String key, Long customerId, int amountCents, String billingPeriod
);
Optional<BillingAttempt> findByIdempotencyKey(String idempotencyKey);
}
// BillingService.java — the guard is correct; the key generation is wrong
@Singleton
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final StripeClient stripeClient;
@Transactional
public ChargeResult initiateBilling(Long customerId, int amountCents, String billingPeriod) {
String key = UUID.randomUUID().toString(); // BUG: new UUID every invocation
Optional<String> inserted = billingAttemptRepository.insertIfAbsent(
key, customerId, amountCents, billingPeriod
);
if (inserted.isEmpty()) {
// Developer intends: "conflict detected — prior attempt exists — skip Stripe"
BillingAttempt existing = billingAttemptRepository
.findByIdempotencyKey(key)
.orElseThrow(() -> new IllegalStateException("Row should exist after conflict"));
return existing.toChargeResult();
}
// Guard passed: proceed to Stripe
ChargeResult result = stripeClient.createCharge(key, customerId, amountCents);
billingAttemptRepository.updateResult(key, result.getChargeId(), "SUCCEEDED");
return result;
}
}
The ON CONFLICT DO NOTHING mechanism is semantically correct as a guard. PostgreSQL evaluates the ON CONFLICT clause against the UNIQUE constraint on (idempotency_key). If a row with that key already exists, the INSERT is a no-op and the RETURNING clause returns no rows, producing an empty Optional. If no such row exists, the INSERT succeeds and returns the key. The logic is sound.
The failure is in the key generation line. UUID.randomUUID() produces a version-4 UUID from 122 bits of cryptographically random data. The probability of two invocations of UUID.randomUUID() producing the same value is negligibly close to zero — effectively guaranteed never to happen. Therefore:
- On invocation 1: key = “550e8400-e29b-41d4-a716-446655440000”. No row with that key exists. INSERT succeeds.
Optionalis non-empty. Stripe is called with key. ch_A created. - On invocation 2 (retry, with a network glitch on the prior Stripe response): key = “6ba7b810-9dad-11d1-80b4-00c04fd430c8”. No row with that key exists (because it’s a brand-new UUID; the prior attempt’s row has key “550e8400-...”). INSERT succeeds.
Optionalis non-empty. Stripe is called with the new key. ch_B created alongside ch_A.
The ON CONFLICT DO NOTHING guard is a perfect implementation of the wrong invariant. It prevents a second INSERT for a known key. But the service never presents a known key on retry because the key is regenerated. The guard is activated if and only if the exact same UUID appears twice, which the random UUID generator is specifically designed to prevent.
Why the developer misconception is plausible
The developer correctly identified that a database-level uniqueness guard is the right architectural approach. The guard is also the approach recommended in Stripe’s own documentation for implementing idempotency on the server side. The failure is in a single line — UUID.randomUUID() vs. a deterministic hash — and that line appears early in the method body, separate from the guard implementation. In code review, the guard looks correct (the SQL is right, the Optional check is right, the Stripe skip logic is right). The UUID generation line looks unremarkable because UUIDs are ubiquitous as identifiers. The combination — random identifier sent as an idempotency key — is the bug.
There is also a test-visibility problem. In a unit test or integration test that exercises the happy path (single invocation, no concurrent callers, no retries), the guard is never triggered and the bug is invisible. The bug is only observable when the same billing event is submitted twice, which is precisely the scenario that idempotency guards are intended to handle but that tests rarely simulate.
The fix
Replace UUID.randomUUID() with a deterministic content hash derived from the billing event’s identifying fields. The key must be identical for any two invocations that represent the same billing event.
// BillingService.java — safe: deterministic content-hash key + ON CONFLICT guard
@Singleton
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final StripeClient stripeClient;
@Transactional
public ChargeResult initiateBilling(Long customerId, int amountCents, String billingPeriod) {
String key = computeKey(customerId, amountCents, billingPeriod); // deterministic
Optional<String> inserted = billingAttemptRepository.insertIfAbsent(
key, customerId, amountCents, billingPeriod
);
if (inserted.isEmpty()) {
// Genuine conflict: a prior call with the same key (same billing event) was already processed
return billingAttemptRepository
.findByIdempotencyKey(key)
.map(BillingAttempt::toChargeResult)
.orElseThrow(() -> new IllegalStateException("Conflict but no row found — isolation anomaly"));
}
ChargeResult result = stripeClient.createCharge(key, customerId, amountCents);
billingAttemptRepository.updateResult(key, result.getChargeId(), "SUCCEEDED");
return result;
}
private String computeKey(Long customerId, int amountCents, String billingPeriod) {
String canonical = customerId + ":" + amountCents + ":" + billingPeriod;
return Hashing.sha256().hashString(canonical, StandardCharsets.UTF_8).toString();
}
}
With a deterministic key, the ON CONFLICT DO NOTHING guard operates as intended. A retry of initiateBilling(customerId=42, amountCents=9900, billingPeriod="2026-10") computes the same key as the original call. The INSERT finds the existing row for that key and returns empty. The service returns the persisted result from the prior attempt. Stripe is not called again. No second charge occurs.
One additional subtlety: if the service crashed after the insertIfAbsent() INSERT committed but before the Stripe call or the updateResult() completed, the retry will find a row with status = 'PENDING'. The service should handle this case: if the existing row is in PENDING state, it may proceed to call Stripe (using the same key, so Stripe’s own idempotency returns the same result if the charge was processed) and then update the row. This is a standard two-phase idempotency pattern (reserve → execute → confirm).
Mode 3: READ COMMITTED isolation allows two concurrent findByIdempotencyKey() checks to both return empty before either save() commits
The developer has adopted the correct approach: deterministic content-hash key, database guard before Stripe call. The implementation uses findByIdempotencyKey(key) to check for a prior attempt, and if absent, proceeds to Stripe and saves the result. The code is structurally correct for a single-threaded, single-request context. The failure mode only manifests under concurrent execution, and it is rooted in how READ COMMITTED isolation handles uncommitted rows.
// BillingService.java — structurally correct but vulnerable to concurrent requests
@Singleton
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final StripeClient stripeClient;
@Transactional
public ChargeResult initiateBilling(Long customerId, int amountCents, String billingPeriod) {
String key = computeKey(customerId, amountCents, billingPeriod);
// Guard: check for prior attempt
Optional<BillingAttempt> existing = billingAttemptRepository.findByIdempotencyKey(key);
if (existing.isPresent()) {
return existing.get().toChargeResult(); // idempotent return
}
// No prior attempt found — proceed
ChargeResult result = stripeClient.createCharge(key, customerId, amountCents);
BillingAttempt attempt = new BillingAttempt();
attempt.setIdempotencyKey(key);
attempt.setCustomerId(customerId);
attempt.setAmountCents(amountCents);
attempt.setBillingPeriod(billingPeriod);
attempt.setStripeChargeId(result.getChargeId());
attempt.setStatus("SUCCEEDED");
billingAttemptRepository.save(attempt);
return result;
}
}
The guard looks correct. With a deterministic key, two invocations of initiateBilling(customerId=42, amountCents=9900, billingPeriod="2026-10") compute the same key. If the first invocation has already committed its save(attempt), the second invocation’s findByIdempotencyKey(key) will find the row and return the prior result. This is exactly the intended behavior.
The failure occurs when both invocations execute concurrently and the first invocation’s row has not yet committed by the time the second invocation performs its check.
The concurrent execution timeline
The Micronaut HTTP server is non-blocking and handles concurrent requests on a small thread pool (by default, Netty event loop threads plus a worker pool). Two HTTP requests for the same customer billing event can arrive within milliseconds of each other — a common scenario when a client-side retry fires almost immediately after the original request, or when an orchestrator submits billing jobs in parallel without strict deduplication.
Thread timeline under READ COMMITTED isolation (the default for PostgreSQL, MySQL, and most production JDBC databases):
Time | Request A (thread 1) | Request B (thread 2)
------|----------------------------------------------|----------------------------------------------
T1 | @Transactional begins (tx_A) |
T2 | key = computeKey(42, 9900, "2026-10") = K |
T3 | findByIdempotencyKey(K) |
T4 | → SELECT ... WHERE idempotency_key = K | @Transactional begins (tx_B)
T5 | → 0 rows (nothing committed with K) | key = computeKey(42, 9900, "2026-10") = K
T6 | existing.isEmpty() → true → proceed | findByIdempotencyKey(K)
T7 | stripeClient.createCharge(K, 42, 9900) ... | → SELECT ... WHERE idempotency_key = K
T8 | (Stripe HTTP call in progress) | → 0 rows (tx_A has not committed yet)
T9 | | existing.isEmpty() → true → proceed
T10 | → Stripe returns ch_A | stripeClient.createCharge(K, 42, 9900) ...
T11 | billingAttemptRepository.save(attempt_A) | (Stripe HTTP call in progress)
T12 | @Transactional commits (tx_A committed) |
T13 | | → Stripe returns ch_B (or same ch_A if Stripe
| | deduplicates — see note below)
T14 | | billingAttemptRepository.save(attempt_B)
T15 | | → UNIQUE constraint violation on idempotency_key!
T16 | | DataIntegrityViolationException thrown
T17 | | @Transactional rolls back (tx_B)
At T7 and T10, both threads call Stripe with the same key K. Whether Stripe creates one charge or two depends on Stripe’s own idempotency semantics:
- If the request bodies are byte-for-byte identical (same amount, currency, customer, metadata): Stripe’s idempotency layer returns the same result for both — ch_A and ch_B are the same charge object. No double-charge at Stripe. However, Request B receives a
200 OKfrom Stripe but then fails at the database write (T14–T16), leaving its transaction rolled back. Request B’s caller gets aDataIntegrityViolationExceptionpropagated as an HTTP 500 or whatever the error handler maps it to. The customer is charged once, which is correct, but Request B’s caller receives an error rather than the charge result. - If the request bodies differ (for example,
metadata["request_id"]includes the HTTP request UUID, which differs between the two requests): Stripe treats them as two distinct requests. The first to arrive establishes the key. The second receives a400 IdempotencyErrorfrom Stripe indicating the key was used with a different request body. Request B fails without creating a charge. Again, one charge is created, but Request B fails. - If the idempotency key is NOT sent to Stripe (for example, if the developer sends the key only to the database guard but forgets to include it as the
Idempotency-Keyheader in the Stripe API call): Stripe creates two independent charges, ch_A and ch_B, regardless of the concurrency scenario. This is the worst case.
The two-charge worst case occurs when the Stripe call does not carry the Idempotency-Key header. But even in the best case (Stripe deduplicates), Request B fails with an unhandled exception. The caller receives an error. The customer is charged once but the system has inconsistent state: one billing attempt committed, one rolled back with no record. Billing reconciliation becomes difficult.
Why READ COMMITTED is insufficient for this guard
The developer’s misconception is often stated as: “READ COMMITTED is fine because as soon as Request A commits its row, any subsequent check by Request B will see it and return early.” This is true for a sequential execution scenario where Request B’s check happens after Request A’s commit. Under concurrent execution, Request B’s check (T7) happens before Request A’s commit (T12). READ COMMITTED means “see committed rows at the time of the read.” Request A’s row is not committed at T7. Request B’s read returns empty. The isolation level is working as specified; it is the pattern — check-then-act without locking the check — that is insufficient for concurrent safety.
The TOCTOU (time-of-check to time-of-use) pattern is a well-known concurrency hazard. In database-backed services, it typically requires either pessimistic locking (lock the guard row at read time) or optimistic locking (treat the uniqueness constraint as the authoritative check and handle the conflict at write time).
The fix: treat the uniqueness constraint as the real guard, handle DataIntegrityViolationException correctly
The database uniqueness constraint on idempotency_key is the actual concurrent-safe guard. It is enforced at commit time with row-level locking by the database, which serializes conflicting INSERTs correctly. The findByIdempotencyKey() pre-check is a performance optimization (avoid calling Stripe for a known duplicate) but cannot replace the constraint as a safety mechanism.
// BillingService.java — safe: pre-check for performance + constraint handles concurrent race
@Singleton
public class BillingService {
private final BillingAttemptRepository billingAttemptRepository;
private final StripeClient stripeClient;
@Transactional
public ChargeResult initiateBilling(Long customerId, int amountCents, String billingPeriod) {
String key = computeKey(customerId, amountCents, billingPeriod);
// Pre-check: fast path for sequential idempotent retries (not concurrent-safe on its own)
Optional<BillingAttempt> existing = billingAttemptRepository.findByIdempotencyKey(key);
if (existing.isPresent()) {
return existing.get().toChargeResult();
}
// Reserve slot: INSERT the PENDING row BEFORE calling Stripe.
// The UNIQUE constraint on idempotency_key is the actual concurrent-safe guard.
// If two concurrent requests race here, only one INSERT will succeed.
try {
BillingAttempt pending = new BillingAttempt();
pending.setIdempotencyKey(key);
pending.setCustomerId(customerId);
pending.setAmountCents(amountCents);
pending.setBillingPeriod(billingPeriod);
pending.setStatus("PENDING");
billingAttemptRepository.save(pending);
} catch (DataIntegrityViolationException e) {
// Lost the race: another concurrent request already inserted this key.
// Read and return the result from the winner (which may still be PENDING
// if the winner hasn't completed Stripe yet — caller should retry after brief delay).
return billingAttemptRepository
.findByIdempotencyKey(key)
.map(BillingAttempt::toChargeResult)
.orElseThrow(() -> new IllegalStateException("Constraint violated but row not found", e));
}
// This request won the race — proceed to Stripe with the reserved key
ChargeResult result = stripeClient.createCharge(key, customerId, amountCents);
billingAttemptRepository.updateResult(key, result.getChargeId(), "SUCCEEDED");
return result;
}
}
The key change is the ordering: the PENDING row is INSERTed before the Stripe call. The @Transactional boundary wraps both the INSERT and the Stripe call. If two concurrent requests race to the save(pending), one will succeed and proceed to Stripe; the other will catch DataIntegrityViolationException and read the existing row.
One important implementation note: in Micronaut Data JDBC with @Transactional, if a DataIntegrityViolationException is caught inside the method body (not propagated out), the @Transactional interceptor does not automatically roll back the transaction. This allows the catch block to read the existing row within the same transaction. However, after catching a constraint violation, the PostgreSQL JDBC driver typically marks the connection’s current transaction as requiring a savepoint rollback before further reads can succeed. The correct production pattern uses a nested transaction (via SAVEPOINT) or a separate read transaction for the post-conflict lookup. The simplest approach: throw an application-level DuplicateBillingEventException from the catch block, handle it in the HTTP layer to return a 409, and let the caller retry after a brief delay to allow the winner to complete.
Comparison table
| Mode | Root cause | Trigger | Developer misconception | Detection signal | Fix |
|---|---|---|---|---|---|
| 1. saveAll() batch retry | Stripe charges (external HTTP) happen before JDBC batch INSERT; @Transactional rollback only undoes JDBC writes, not Stripe charges |
saveAll() throws DataAccessException after all Stripe calls completed; @Retryable re-enters method body with new UUID per item |
“saveAll() threw, so the entire billing operation is rolled back — no charges went through” | Stripe PaymentIntents list shows N charges per customer for the billing period; Stripe created timestamps are seconds apart; billing_attempts table has 0 rows (all rolled back) but Stripe has N charges per customer |
Content-hash key per item; findByIdempotencyKey() pre-check per item; save per item immediately after Stripe call; no end-of-loop batch save |
| 2. ON CONFLICT DO NOTHING bypass | Guard is keyed on UUID.randomUUID() — each invocation produces a new UUID that has never been inserted; ON CONFLICT can never trigger |
Any invocation of the billing method — guard is bypassed 100% of the time, not just on retries | “ON CONFLICT DO NOTHING detects a prior attempt with the same key and returns empty, signaling skip” |
billing_attempts table accumulates one row per invocation with distinct UUIDs; Stripe charge count grows unboundedly; no conflicts ever recorded in application logs |
Replace UUID.randomUUID() with deterministic content-hash key; guard then correctly detects repeat invocations |
| 3. READ COMMITTED TOCTOU race | READ COMMITTED reads see only committed rows at the time of the read; concurrent transaction’s uncommitted PENDING row is invisible to the check |
Two concurrent requests for the same billing event arrive before either has committed; both findByIdempotencyKey() checks return empty; both proceed to Stripe |
“Once Request A commits, Request B’s check will see the row — the isolation level is fine” | Stripe duplicate PaymentIntent for same customer; billing_attempts has a unique constraint violation in logs (Request B’s save() fails); HTTP 500 on one of the two concurrent requests; Stripe amount_total for customer is doubled |
INSERT PENDING row before Stripe call; catch DataIntegrityViolationException on the INSERT as the concurrent-safe guard; use ON CONFLICT DO NOTHING or SAVEPOINT pattern to handle the race at the database level |
Integration tests
Each mode requires a specific test setup to reproduce reliably. The common infrastructure: @MicronautTest, a Testcontainers PostgreSQL instance, and a WireMock server standing in for the Stripe API.
Test for Mode 1: saveAll() batch retry generates new UUIDs
@MicronautTest
@Testcontainers
class SaveAllBatchRetryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Inject BillingService billingService;
@Inject BillingAttemptRepository billingAttemptRepository;
WireMockServer wireMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
@BeforeEach void setUp() {
wireMock.start();
// Stub Stripe: accept any request, return a unique charge ID per call
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\": \"ch_{{randomValue type='UUID'}}\", \"status\": \"succeeded\"}")
.withTransformers("response-template")));
}
@Test
void retryAfterSaveAllFailureSendsNewUUIDsToStripe() {
List<BillingCandidate> candidates = List.of(
new BillingCandidate(1L, 9900),
new BillingCandidate(2L, 4900)
);
// First invocation: Stripe charges succeed, saveAll() will fail
// (inject a FK violation by using a non-existent subscription ID on candidate 2)
assertThrows(DataAccessException.class, () ->
billingService.runMonthlyBillingUnsafe(candidates, "2026-10"));
// Verify Stripe was called twice on the first invocation
wireMock.verify(2, postRequestedFor(urlEqualTo("/v1/charges")));
// Second invocation (retry)
assertThrows(DataAccessException.class, () ->
billingService.runMonthlyBillingUnsafe(candidates, "2026-10"));
// Stripe was called 4 times total — double for each customer
wireMock.verify(4, postRequestedFor(urlEqualTo("/v1/charges")));
// Verify that attempts 1 and 3 (customer 1, invocations 1 and 2) used DIFFERENT keys
List<LoggedRequest> stripeRequests = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
String keyC1_attempt1 = extractIdempotencyKey(stripeRequests.get(0));
String keyC1_attempt2 = extractIdempotencyKey(stripeRequests.get(2));
assertThat(keyC1_attempt1).isNotEqualTo(keyC1_attempt2); // confirms double-charge root cause
}
private String extractIdempotencyKey(LoggedRequest request) {
return request.getHeader("Idempotency-Key");
}
}
Test for Mode 2: ON CONFLICT DO NOTHING guard is bypassed by random UUID
@MicronautTest
@Testcontainers
class OnConflictBypassTest {
@Inject BillingService billingService;
WireMockServer wireMock; // configured to return success
@Test
void onConflictGuardIsNeverTriggeredWithRandomUUID() {
// Call initiateBilling twice with the same arguments
billingService.initiateBillingUnsafe(42L, 9900, "2026-10"); // invocation 1
billingService.initiateBillingUnsafe(42L, 9900, "2026-10"); // invocation 2
// With a correct guard, Stripe should only be called once.
// With random UUID, Stripe is called on every invocation.
wireMock.verify(2, postRequestedFor(urlEqualTo("/v1/charges")));
// Verify both calls used different Idempotency-Key values
List<LoggedRequest> requests = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests.get(0).getHeader("Idempotency-Key"))
.isNotEqualTo(requests.get(1).getHeader("Idempotency-Key"));
// Also verify two rows in billing_attempts (the guard never fired)
long rowCount = billingAttemptRepository.count();
assertThat(rowCount).isEqualTo(2); // if guard had fired, count would be 1
}
@Test
void fixedVersionCallsStripeExactlyOnce() {
billingService.initiateBillingFixed(42L, 9900, "2026-10"); // invocation 1
billingService.initiateBillingFixed(42L, 9900, "2026-10"); // invocation 2
wireMock.verify(1, postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(billingAttemptRepository.count()).isEqualTo(1);
}
}
Test for Mode 3: concurrent requests both pass the guard under READ COMMITTED
@MicronautTest
@Testcontainers
class ReadCommittedToctouTest {
@Inject BillingService billingService;
WireMockServer wireMock;
// Stub with a fixed delay to ensure the two transactions overlap
@Test
void concurrentRequestsBothPassGuardUnderReadCommitted() throws Exception {
// Add a delay on the Stripe stub so the transaction window is wide enough
// for both threads to execute findByIdempotencyKey() before either commits
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withFixedDelay(200) // 200ms — enough for both threads to reach the guard
.withStatus(200)
.withBody("{\"id\": \"ch_test\", \"status\": \"succeeded\"}")));
CountDownLatch bothStarted = new CountDownLatch(2);
List<Exception> errors = new CopyOnWriteArrayList<>();
Thread threadA = new Thread(() -> {
bothStarted.countDown();
try { bothStarted.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
try { billingService.initiateBillingVulnerable(42L, 9900, "2026-10"); }
catch (Exception e) { errors.add(e); }
});
Thread threadB = new Thread(() -> {
bothStarted.countDown();
try { bothStarted.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
try { billingService.initiateBillingVulnerable(42L, 9900, "2026-10"); }
catch (Exception e) { errors.add(e); }
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
// One of the two threads should have caught a DataIntegrityViolationException
// (the save() uniqueness constraint violation)
assertThat(errors).hasSize(1);
assertThat(errors.get(0)).isInstanceOf(DataIntegrityViolationException.class);
// Stripe was called twice — the pre-check guard missed the race
wireMock.verify(2, postRequestedFor(urlEqualTo("/v1/charges")));
}
@Test
void fixedVersionHandlesRaceWithPendingInsertFirst() throws Exception {
// Same 200ms Stripe delay, two concurrent threads
CountDownLatch bothStarted = new CountDownLatch(2);
List<ChargeResult> results = new CopyOnWriteArrayList<>();
List<Exception> errors = new CopyOnWriteArrayList<>();
Thread threadA = new Thread(() -> {
bothStarted.countDown();
try { bothStarted.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
try { results.add(billingService.initiateBillingFixed(42L, 9900, "2026-10")); }
catch (Exception e) { errors.add(e); }
});
Thread threadB = new Thread(() -> {
bothStarted.countDown();
try { bothStarted.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
try { results.add(billingService.initiateBillingFixed(42L, 9900, "2026-10")); }
catch (Exception e) { errors.add(e); }
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
// With the fixed version: winner proceeds to Stripe (1 call); loser gets conflict exception
// (the caller should retry after the winner completes and the PENDING row becomes SUCCEEDED)
wireMock.verify(1, postRequestedFor(urlEqualTo("/v1/charges")));
// One result (winner), one error (loser — caller handles as 409/retry)
assertThat(results).hasSize(1);
assertThat(errors).hasSize(1);
}
}
Applying Keybrake to Micronaut Data JDBC billing services
Each of the three failure modes in this post represents a scenario where Stripe receives a charge it should not have received. Mode 1 sends ch_B alongside ch_A because saveAll() threw after Stripe already committed ch_A. Mode 2 sends ch_B on every invocation because the uniqueness guard is permanently bypassed. Mode 3 sends ch_B in a narrow concurrent race window. In each case, the charge is unauthorized by the billing event’s intent and would be caught by an audit log but not prevented by the application code alone.
Keybrake adds an enforcement layer between your agent or service and the Stripe API. Each service instance or agent is issued a vault_key_xxx scoped to a specific set of allowed Stripe endpoints and a daily spend cap. When your Micronaut billing service sends a charge request to proxy.keybrake.com/stripe/v1/charges, Keybrake enforces the policy before forwarding to Stripe: checks whether the Idempotency-Key header is present and non-random (configurable policy), validates the request body against the allowed endpoints, accumulates the day’s spend, and rejects the request if the daily cap would be exceeded. The audit log records every charge with the policy key, the customer, the amount, the Stripe response, and the originating service.
For the specific failures in this post: Mode 1 (batch retry sends new UUIDs) would be surfaced in the Keybrake audit log as two charges for the same customer in the same billing period within seconds of each other, with different idempotency keys. The daily spend cap would halt the second pass once the per-customer spend limit is reached. Mode 2 (guard bypassed by random UUID) would appear as an unbounded number of charges to the same customer across all invocations — an anomaly easily caught by the audit log’s “same customer, distinct idempotency keys, same amount” pattern. Mode 3 (concurrent race) is subtle, but the Keybrake proxy can enforce a per-key global lock at the proxy layer, ensuring only one Stripe request per idempotency key is in flight at any time.
Detection checklist for Micronaut Data JDBC + Stripe billing
- No
UUID.randomUUID()in service methods that call Stripe. Every idempotency key must be derived from the billing event’s content fields using a deterministic hash function. Audit with a grep forUUID.randomUUID()in files that also import or referenceStripeClient,stripe, orPaymentIntent. - Stripe calls happen after the idempotency DB guard, not before or in parallel. In any service method that calls both
billingAttemptRepository.save()(or equivalent) andstripeClient.createCharge(), the repository write must precede the Stripe call. AnON CONFLICTguard at the INSERT site or afindByIdempotencyKey()pre-check must appear before the line that calls Stripe. - No batch Stripe-then-saveAll() pattern. Any code that constructs a list of entities with per-item Stripe calls and then calls
saveAll()at the end is vulnerable to Mode 1. Break the batch into per-item check → Stripe → save sequences. - Concurrent-safe guard uses the uniqueness constraint, not just a pre-check. A
findByIdempotencyKey()pre-check is a performance optimization, not a concurrency guarantee. The uniqueness constraint onidempotency_keyis the actual guard. CatchDataIntegrityViolationExceptionon the INSERT and handle it as “another request won the race.” - Stripe call carries the
Idempotency-Keyheader. Even if the application guard is correct, if theIdempotency-Keyheader is omitted from the Stripe API call, Stripe cannot deduplicate concurrent requests. Every Stripe call in your billing code must include.setIdempotencyKey(key)in theRequestOptions(Java SDK) or equivalent. - WireMock tests assert on the
Idempotency-Keyheader, not just the URL. A WireMock stub that matches only the URL will accept anyIdempotency-Keyvalue, hiding UUID-stability bugs. Add.withHeader("Idempotency-Key", equalTo(expectedKey))to Stripe stubs in tests that exercise retry paths. Assert onwireMock.findAll()journal entries to verify key stability across retry calls.
Put the brakes on your billing agent’s Stripe keys
Keybrake is a scoped API-key proxy for the non-LLM SaaS APIs your agent calls — Stripe, Twilio, Resend — with per-vendor spend caps, endpoint allowlists, audit log, and one-click revoke. Early access for teams running autonomous agents against production billing APIs.