Micronaut Data @Transactional and Stripe Integration: How @Retryable Interceptor Ordering, CrudRepository Implicit Transactions, and OptimisticLockException Retries Generate New Idempotency Keys on Retry

Micronaut Data introduces three Stripe billing failure modes that are structurally different from the Micronaut HTTP Client post (which covers transport-layer retry) and from standard Micronaut service patterns. The three modes are: in Micronaut 4.x, RecoveryInterceptor occupies a higher position in the AOP interceptor chain than TransactionalInterceptor, making @Retryable the outer interceptor when both annotations appear on the same method — each @Retryable retry is a new method invocation from TransactionalInterceptor inward, so a fresh transaction opens per retry and UUID.randomUUID() at method entry regenerates with UUID_B; Micronaut Data generates repository implementations at compile time and the generated save() method carries @Transactional without the interface source showing it — a @Retryable service that calls Stripe then invokes billingRepository.save() finds Stripe already committed ch_A when save() throws DataAccessException — the retry generates UUID_B and Stripe creates ch_B; and @Retryable(includes = OptimisticLockException.class) is a common Micronaut Data pattern for handling concurrent-update contention on @Version-annotated entities — it retries the entire method body including the Stripe call that preceded the @Version-checked DB write — UUID.randomUUID() at method entry regenerates with UUID_B — double charge on every contention-triggered retry.

Background: how Micronaut AOP interceptor ordering differs from Spring Boot for @Retryable and @Transactional

In Micronaut, AOP interceptors are invoked in a chain ordered by each interceptor’s position value. A lower position value means the interceptor runs earlier in the chain — it is the “outer” wrapper that controls whether the inner interceptors and the method body are invoked at all, and how many times. A higher position value means the interceptor runs later — closer to the method body itself, as the “inner” wrapper.

When a service method is annotated with both @Retryable (from io.micronaut.retry.annotation.Retryable) and @Transactional (from io.micronaut.transaction.annotation.Transactional or jakarta.transaction.Transactional), Micronaut constructs an interceptor chain from the registered interceptor beans. io.micronaut.retry.intercept.RecoveryInterceptor handles @Retryable. io.micronaut.transaction.interceptor.TransactionalInterceptor handles @Transactional. The relative ordering of these two interceptors determines whether retries happen inside a single open transaction or whether each retry opens a new transaction — and, critically, whether UUID.randomUUID() placed at the method body entry regenerates per retry attempt or only once per caller invocation.

In Micronaut 4.x, RecoveryInterceptor runs before TransactionalInterceptor in the interceptor chain. The resulting call order from the caller’s perspective is:

[Caller]
    → [RecoveryInterceptor (@Retryable — outer)]
        → [TransactionalInterceptor (@Transactional — inner)]
            → [method body]

This ordering means that on a @Retryable retry, RecoveryInterceptor re-invokes the chain from TransactionalInterceptor inward. A new transaction begins for each retry attempt. The method body is re-entered, and any expression evaluated at method entry — including UUID.randomUUID() — produces a new value per retry.

Spring Boot (with Spring Retry and Spring’s @Transactional) has a historically different default: @Transactional tends to have a higher AOP advisor priority, making it the outer wrapper. Retries happen inside the same open transaction. This means that in Spring Boot, a method-body UUID.randomUUID() still regenerates per retry — but the behavior of the surrounding transaction is different: the transaction is not rolled back between Spring Retry retries (the exception is swallowed by the retry interceptor, not the transaction interceptor). Micronaut’s outer-@Retryable behavior is architecturally sound — each retry gets a clean transaction — but it exposes the UUID regeneration problem more clearly by making the new-transaction-per-retry behavior explicit.

Developers who migrate a Spring Boot service to Micronaut and add @Retryable for network resilience often carry an implicit assumption from Spring Boot that the interceptor ordering will be similar. It is not. The Micronaut-specific ordering is the root cause of the failure modes that follow.

Failure mode 1: @Retryable outer, @Transactional inner — default Micronaut interceptor ordering — UUID.randomUUID() at method entry regenerates per retry — fresh transaction per retry — Stripe commits ch_B

A developer builds a billing service that annotates a single method with both @Retryable (for Stripe network resilience and database transient failures) and @Transactional (to ensure the Stripe charge and the local billing record commit or roll back atomically):

// BillingService.java — UNSAFE: @Retryable is the OUTER interceptor in Micronaut 4.x.
// RecoveryInterceptor runs before TransactionalInterceptor in the AOP chain.
// Each @Retryable retry is a new invocation from TransactionalInterceptor inward:
//   new transaction opens + method body re-entered + UUID.randomUUID() re-evaluates.
// On attempt 1: UUID_A sent to Stripe — ch_A committed.
// On @Retryable retry: UUID_B sent to Stripe — ch_B created alongside ch_A.
@Singleton
public class BillingService {

    private final StripeClient stripeClient;
    private final BillingRecordRepository billingRepository;

    @Retryable(
        attempts = "3",
        delay = "500ms",
        multiplier = "2",
        includes = {StripeException.class, DataAccessException.class}
    )
    @Transactional
    public BillingResult chargeAndRecord(String customerId, long amountCents, String billingPeriod) {
        // UNSAFE: method body is re-entered per @Retryable retry attempt.
        // UUID.randomUUID() is not a constant — it generates a new UUID on every call.
        // The @Retryable interceptor (outer) re-invokes the @Transactional interceptor (inner)
        // which re-enters this method body with a fresh transaction and a fresh UUID.
        String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();

        Charge charge;
        try {
            charge = stripeClient.charges().create(
                ChargeCreateParams.builder()
                    .setAmount(amountCents)
                    .setCurrency("usd")
                    .setCustomer(customerId)
                    .build(),
                RequestOptions.builder()
                    .setIdempotencyKey(idempotencyKey)
                    .build());
        } catch (StripeException e) {
            throw new BillingException("Stripe charge failed", e);
        }

        // DB write follows the Stripe call. If this throws DataAccessException,
        // the @Transactional interceptor (inner) rolls back this transaction.
        // The exception propagates to the @Retryable interceptor (outer),
        // which sees a retryable exception and schedules another attempt.
        // The next attempt opens a new transaction and calls UUID.randomUUID() again.
        billingRepository.save(
            new BillingRecord(customerId, charge.getId(), billingPeriod, amountCents));

        return new BillingResult(charge.getId(), idempotencyKey);
    }
}

The failure sequence when a transient DataAccessException hits the database write on the first attempt:

  1. Caller invokes chargeAndRecord("cus_A", 5000, "2026-Q4").
  2. RecoveryInterceptor (outer) begins the retry loop. It calls TransactionalInterceptor (inner).
  3. TransactionalInterceptor opens transaction T1 and enters the method body.
  4. Method body: UUID.randomUUID() produces UUID_A. idempotencyKey = "cus_A:2026-Q4:UUID_A".
  5. Stripe API call with UUID_A. Stripe processes the charge and commits ch_A ($50). Stripe returns HTTP 200 with the charge object.
  6. billingRepository.save() executes within T1. The JDBC driver throws a DataAccessException (e.g., a unique constraint violation on (customer_id, billing_period) from a duplicate request in flight).
  7. TransactionalInterceptor catches the unchecked exception and rolls back T1. The DB write is undone. The exception propagates outward to RecoveryInterceptor.
  8. RecoveryInterceptor sees DataAccessException in its includes list. It waits 500 ms and retries by calling TransactionalInterceptor again.
  9. TransactionalInterceptor opens transaction T2 (a new, distinct transaction) and re-enters the method body.
  10. Method body: UUID.randomUUID() produces UUID_B. idempotencyKey = "cus_A:2026-Q4:UUID_B".
  11. Stripe API call with UUID_B. Stripe has never seen UUID_B before — it cannot return the cached ch_A. Stripe commits ch_B ($50). The customer is charged twice.

The double charge is invisible to the caller. The method eventually succeeds (if the DataAccessException was transient) and returns a BillingResult that references ch_B. ch_A is committed in Stripe with no corresponding local billing record (transaction T1 rolled back). The customer’s Stripe balance is debited $100 for a $50 billing period.

The subtle variant: assuming annotation declaration order determines interceptor order

A developer who knows that AOP interceptor ordering matters may try to control the order by changing the declaration sequence of the annotations, placing @Transactional before @Retryable in the source file:

// ATTEMPTED FIX: reordering annotation declarations.
// Does NOT change the interceptor execution order in Micronaut.
// Micronaut AOP interceptor ordering is determined by RecoveryInterceptor.POSITION
// and TransactionalInterceptor.POSITION — not by annotation declaration order.
@Transactional  // declared first — developer expects this to be the outer wrapper
@Retryable(attempts = "3", delay = "500ms", includes = DataAccessException.class)
public BillingResult chargeAndRecord(String customerId, long amountCents, String billingPeriod) {
    String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();
    // ... same code ...
}

In Micronaut, annotation declaration order on the method has no effect on AOP interceptor execution order. The execution order is determined solely by the position values of the registered interceptor beans. Reordering the annotations produces no change in behavior. The developer observes the same double-charge failure in production.

The fix: compute the idempotency key outside the @Retryable boundary

The correct fix is to ensure the idempotency key is computed exactly once per logical billing attempt, regardless of how many times @Retryable retries the method. The method body cannot be the source of the key if the method body is what gets retried. Two options:

Option 1: Compute the key before the @Retryable boundary (pass as parameter). The caller computes a deterministic content-hash key and passes it in. The method body receives the key as a parameter and never calls UUID.randomUUID():

// BillingService.java — SAFE: idempotency key passed as parameter.
// The key is computed once by the caller before @Retryable enters the retry loop.
// Every retry invocation of the method body receives the same key value.
@Singleton
public class BillingService {

    @Retryable(attempts = "3", delay = "500ms", includes = DataAccessException.class)
    @Transactional
    public BillingResult chargeAndRecord(String customerId, long amountCents,
                                          String billingPeriod, String idempotencyKey) {
        // idempotencyKey is a stable parameter — same value on attempt 1 and all retries.
        // The caller is responsible for computing a deterministic key before invoking this method.
        Charge charge;
        try {
            charge = stripeClient.charges().create(
                ChargeCreateParams.builder()
                    .setAmount(amountCents)
                    .setCurrency("usd")
                    .setCustomer(customerId)
                    .build(),
                RequestOptions.builder()
                    .setIdempotencyKey(idempotencyKey)
                    .build());
        } catch (StripeException e) {
            throw new BillingException("Stripe charge failed", e);
        }

        billingRepository.save(
            new BillingRecord(customerId, charge.getId(), billingPeriod, amountCents));
        return new BillingResult(charge.getId(), idempotencyKey);
    }
}

// BillingController.java — caller computes stable key before @Retryable boundary.
@Controller("/billing")
public class BillingController {

    private final BillingService billingService;

    @Post("/charge")
    public HttpResponse<BillingResult> charge(@Body ChargeRequest req) {
        // Content-hash key: same inputs always produce the same output.
        // sha256Hex is deterministic — no UUID.randomUUID() involved.
        // The key is computed here, outside the @Retryable retry loop.
        String key = sha256Hex(req.getCustomerId() + ":" + req.getBillingPeriod()
                               + ":micronaut-billing").substring(0, 32);

        BillingResult result = billingService.chargeAndRecord(
            req.getCustomerId(), req.getAmountCents(), req.getBillingPeriod(), key);
        return HttpResponse.ok(result);
    }
}

Option 2: Use an explicit interceptor order annotation. If splitting the method signature is not acceptable, Micronaut’s @Order can be applied to a custom wrapper interceptor that computes and stores the idempotency key in a ThreadLocal before @Retryable retries. This is more complex and error-prone — Option 1 is preferred because it makes the key’s stability explicit in the type signature and testable in isolation.

Failure mode 2: Micronaut Data CrudRepository.save() implicit @Transactional — @Retryable service catches DataAccessException from repository — Stripe committed before save throws — UUID_B on retry

Micronaut Data generates repository implementations at compile time using the Micronaut Data annotation processor. The generated implementation of CrudRepository.save() (and other write methods such as update(), delete(), and saveAll()) carries @Transactional on the generated method body. This @Transactional is invisible to the developer who reads only the CrudRepository<T, ID> interface source — it is present in the generated bytecode but not in the interface signature.

A developer who writes a service method with @Retryable but without @Transactional — intending to keep the service layer free of transaction management — may not realize that every call to billingRepository.save() opens and closes its own @Transactional context. If that context throws and rolls back, the exception propagates to the service’s @Retryable interceptor, which retries the entire service method body. Stripe is called again with a new UUID:

// BillingService.java — UNSAFE: @Retryable on service, no @Transactional.
// Developer intends @Retryable to retry on transient Stripe errors only.
// But billingRepository.save() carries implicit @Transactional from the
// Micronaut Data generated implementation. If save() throws DataAccessException,
// @Retryable catches it and retries — billingService method body re-enters —
// UUID.randomUUID() generates UUID_B — Stripe creates ch_B alongside committed ch_A.
@Singleton
public class BillingService {

    private final StripeClient stripeClient;
    private final BillingRecordRepository billingRepository;  // extends CrudRepository<BillingRecord, Long>

    @Retryable(
        attempts = "3",
        delay = "500ms",
        // Intended: retry only on Stripe network errors.
        // Actual: also retries on DataAccessException from billingRepository.save().
        includes = {StripeConnectException.class, StripeApiConnectionException.class,
                    DataAccessException.class}
    )
    public BillingResult chargeCustomer(String customerId, long amountCents, String billingPeriod) {
        // UNSAFE: UUID.randomUUID() at method entry — regenerates per @Retryable retry.
        String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();

        Charge charge;
        try {
            charge = stripeClient.charges().create(
                ChargeCreateParams.builder()
                    .setAmount(amountCents)
                    .setCurrency("usd")
                    .setCustomer(customerId)
                    .build(),
                RequestOptions.builder()
                    .setIdempotencyKey(idempotencyKey)
                    .build());
        } catch (StripeApiConnectionException | StripeConnectException e) {
            // Stripe network error — possibly committed, possibly not.
            // @Retryable will catch this and retry. UUID_B on retry.
            throw new BillingException("Stripe connection error", e);
        } catch (StripeException e) {
            throw new BillingException("Stripe error — not retrying", e);
        }

        // billingRepository.save() is generated by Micronaut Data annotation processor.
        // The generated implementation carries @Transactional on the save() method.
        // If save() throws DataAccessException, the generated @Transactional rolls back
        // the save() transaction (which is self-contained — no outer transaction to join).
        // The exception propagates to @Retryable on this method.
        // @Retryable retries — UUID.randomUUID() generates UUID_B — ch_B alongside ch_A.
        billingRepository.save(
            new BillingRecord(customerId, charge.getId(), billingPeriod, amountCents));

        return new BillingResult(charge.getId(), idempotencyKey);
    }
}

The failure sequence when the repository save() throws on the first attempt:

  1. Caller invokes chargeCustomer("cus_A", 5000, "2026-Q4").
  2. RecoveryInterceptor begins the retry loop. No @Transactional on the service — no outer transaction opens. The method body executes directly.
  3. Method body: UUID.randomUUID() produces UUID_A.
  4. Stripe call with UUID_A. Stripe commits ch_A. Stripe returns HTTP 200.
  5. billingRepository.save() is called. The Micronaut Data generated proxy intercepts the call and begins a self-contained @Transactional transaction T1. The save executes the INSERT. The JDBC layer throws a constraint violation. The generated @Transactional rolls back T1. A DataAccessException is thrown.
  6. The DataAccessException propagates out of billingRepository.save() and back to the chargeCustomer method body, which does not catch it. The exception reaches RecoveryInterceptor.
  7. RecoveryInterceptor finds DataAccessException in its includes list. It waits 500 ms and retries by re-entering the method body.
  8. Method body (retry): UUID.randomUUID() produces UUID_B.
  9. Stripe call with UUID_B. Stripe has never seen UUID_B. Stripe commits ch_B.

The subtle variant: adding @Transactional to the service method does not fix the UUID problem

A developer who diagnoses the problem as “save() has its own transaction that doesn’t coordinate with the service” may add @Transactional to the service method, expecting the repository save() to join the outer transaction (using REQUIRED propagation):

// ATTEMPTED FIX: adding @Transactional to service method.
// Now billingRepository.save() joins the outer service transaction (propagation = REQUIRED).
// BUT: @Retryable (outer) still retries the @Transactional (inner) method body.
// UUID.randomUUID() at method entry still regenerates per @Retryable retry.
// @Transactional (inner) rolls back the joined transaction when save() throws,
// then @Retryable (outer) starts a new retry — new transaction — UUID_B.
// Adding @Transactional to the service changes transaction propagation semantics
// but does NOT fix the UUID regeneration on @Retryable retry.
@Retryable(attempts = "3", delay = "500ms", includes = DataAccessException.class)
@Transactional
public BillingResult chargeCustomer(String customerId, long amountCents, String billingPeriod) {
    String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID(); // still regenerates
    // ... same code ...
}

Adding @Transactional to the service method changes the transaction propagation behavior (now save() joins the outer transaction), but it does not change the interceptor ordering: RecoveryInterceptor is still outer, TransactionalInterceptor is still inner, and UUID.randomUUID() at the method body entry still regenerates per @Retryable retry. The double-charge failure persists.

The fix: stable content-hash key computed before any retry boundary

// BillingService.java — SAFE: stable key passed as parameter.
// billingRepository.save() implicit @Transactional is irrelevant to idempotency
// because the key never changes across @Retryable retry attempts.
@Singleton
public class BillingService {

    @Retryable(attempts = "3", delay = "500ms",
               includes = {StripeApiConnectionException.class, DataAccessException.class})
    public BillingResult chargeCustomer(String customerId, long amountCents,
                                         String billingPeriod, String idempotencyKey) {
        // idempotencyKey is stable — computed once by the caller before @Retryable entry.
        // Every retry receives the same value. Stripe returns ch_A from its cache
        // when it sees UUID_A again on a retry where ch_A was already committed.
        Charge charge;
        try {
            charge = stripeClient.charges().create(
                ChargeCreateParams.builder()
                    .setAmount(amountCents)
                    .setCurrency("usd")
                    .setCustomer(customerId)
                    .build(),
                RequestOptions.builder()
                    .setIdempotencyKey(idempotencyKey)
                    .build());
        } catch (StripeException e) {
            throw new BillingException("Stripe error", e);
        }

        billingRepository.save(
            new BillingRecord(customerId, charge.getId(), billingPeriod, amountCents));
        return new BillingResult(charge.getId(), idempotencyKey);
    }
}

Additionally, a pre-flight INSERT INTO billing_records (...) ON CONFLICT (customer_id, billing_period) DO NOTHING before the Stripe call (within a separate non-retried transaction) acts as an authoritative billing mutex at the database layer. If the mutex insert fails (key already present), the billing call is skipped entirely — no Stripe call, no UUID generated, no retry needed.

Failure mode 3: @Retryable(includes = OptimisticLockException.class) for Micronaut Data contention — method body retried on DB version conflict — Stripe call before @Version-checked write — UUID_B on every contention retry

Micronaut Data supports optimistic locking via the @Version annotation on entity fields. When two concurrent requests read the same entity and both attempt to update it, the second update will find that the version column in the database has advanced since the entity was loaded. Micronaut Data throws io.micronaut.data.exceptions.OptimisticLockException. This is a normal, expected part of concurrent application behavior in systems that prefer optimistic concurrency over database row-level locks.

A developer handling a billing flow that involves updating a @Version-annotated CustomerAccount entity may use @Retryable(includes = OptimisticLockException.class) to transparently handle contention. The intent is clear: if the DB update fails due to a version conflict, reload the entity and retry the update. The problem arises when the method body also calls Stripe before the @Version-checked DB write:

// CustomerAccount.java — Micronaut Data entity with @Version for optimistic locking.
@MappedEntity
public class CustomerAccount {

    @Id
    @GeneratedValue
    private Long id;

    private String stripeCustomerId;
    private String currentBillingPeriod;
    private Long lastChargeAmountCents;

    @Version  // Micronaut Data increments this on every update.
              // Concurrent update with stale version throws OptimisticLockException.
    private Long version;

    // ... getters, setters ...
}
// BillingService.java — UNSAFE: @Retryable(includes = OptimisticLockException.class)
// retries the entire method body, not just the DB write.
// UUID.randomUUID() at method entry generates UUID_B on every contention retry.
// Stripe creates ch_B alongside ch_A when the version conflict fires after the charge.
@Singleton
public class BillingService {

    private final StripeClient stripeClient;
    private final CustomerAccountRepository accountRepository;
    private final BillingRecordRepository billingRepository;

    @Retryable(
        attempts = "5",
        delay = "100ms",
        multiplier = "1.5",
        // Developer intent: retry only when the DB update loses an optimistic lock race.
        // Actual behavior: @Retryable retries the ENTIRE method body — including the
        // Stripe call that precedes the @Version-checked update. UUID.randomUUID() at
        // method entry regenerates on every retry. Stripe creates ch_B if ch_A was
        // committed before the OptimisticLockException on the first attempt.
        includes = OptimisticLockException.class
    )
    @Transactional
    public BillingResult processMonthlyCharge(String customerId, long amountCents, String billingPeriod) {
        CustomerAccount account = accountRepository.findByStripeCustomerId(customerId)
            .orElseThrow(() -> new IllegalArgumentException("Customer not found: " + customerId));

        // UNSAFE: UUID.randomUUID() generates a new key per method invocation.
        // @Retryable(OptimisticLockException) retries this method body on version conflict.
        // attempt 1: UUID_A → ch_A committed on Stripe → @Version-checked update throws
        //            OptimisticLockException → @Transactional (inner) rolls back → @Retryable retries
        // attempt 2: UUID_B → Stripe sees new key → ch_B created — DOUBLE CHARGE
        String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();

        // Stripe call happens BEFORE the @Version-checked entity update.
        // Developer places Stripe first to avoid updating account state before confirming payment.
        // This is a reasonable ordering for billing correctness — but it means Stripe commits
        // ch_A before the @Version check fires. If the @Version check throws, ch_A is already real.
        Charge charge;
        try {
            charge = stripeClient.charges().create(
                ChargeCreateParams.builder()
                    .setAmount(amountCents)
                    .setCurrency("usd")
                    .setCustomer(customerId)
                    .build(),
                RequestOptions.builder()
                    .setIdempotencyKey(idempotencyKey)
                    .build());
        } catch (StripeException e) {
            throw new BillingException("Stripe charge failed", e);
        }

        // Update the CustomerAccount with the new billing period.
        // Micronaut Data checks @Version here — if account.version in DB != account.version in memory,
        // this throws OptimisticLockException. The inner @Transactional rolls back.
        // The outer @Retryable catches OptimisticLockException and schedules a retry.
        // On retry: UUID.randomUUID() produces UUID_B — ch_B alongside ch_A.
        account.setCurrentBillingPeriod(billingPeriod);
        account.setLastChargeAmountCents(amountCents);
        accountRepository.update(account);  // throws OptimisticLockException on version conflict

        billingRepository.save(
            new BillingRecord(customerId, charge.getId(), billingPeriod, amountCents));

        return new BillingResult(charge.getId(), idempotencyKey);
    }
}

The failure sequence when a concurrent request updates CustomerAccount between the entity load and the update in the first attempt:

  1. Request R1 calls processMonthlyCharge("cus_A", 5000, "2026-Q4"). Concurrently, request R2 is updating the same CustomerAccount for a different reason (e.g., a plan downgrade).
  2. RecoveryInterceptor begins the retry loop. TransactionalInterceptor opens transaction T1. Method body entered.
  3. Method body: UUID.randomUUID() produces UUID_A. idempotencyKey = "cus_A:2026-Q4:UUID_A".
  4. accountRepository.findByStripeCustomerId("cus_A") loads the entity with version = 7.
  5. Stripe call with UUID_A. Stripe commits ch_A ($50). Stripe returns HTTP 200.
  6. R2 commits its update to CustomerAccount — DB row now has version = 8.
  7. accountRepository.update(account) in R1 executes UPDATE customer_account SET ... WHERE id = ? AND version = 7. The DB returns 0 rows affected (version is now 8, not 7). Micronaut Data throws OptimisticLockException.
  8. TransactionalInterceptor rolls back T1. OptimisticLockException propagates to RecoveryInterceptor.
  9. RecoveryInterceptor finds OptimisticLockException in includes. It waits 100 ms and retries.
  10. TransactionalInterceptor opens transaction T2. Method body re-entered.
  11. Method body (retry): UUID.randomUUID() produces UUID_B. idempotencyKey = "cus_A:2026-Q4:UUID_B".
  12. Stripe call with UUID_B. Stripe has never seen UUID_B. Stripe commits ch_B. The customer is charged twice.

The developer intended @Retryable(includes = OptimisticLockException.class) to only retry the DB update step. But @Retryable is a method-level interceptor — it retries the entire method body from the beginning. Narrowing includes to a specific exception type controls which exceptions trigger retry, not which code in the method body is retried. The Stripe call is always retried along with the DB update.

The subtle variant: includes = OptimisticLockException.class appears safe because tests do not trigger version conflicts

A developer testing this service in a single-threaded integration test calls processMonthlyCharge() once with no concurrent write activity. The @Version-checked update succeeds on the first attempt. OptimisticLockException is never thrown. @Retryable never fires. The idempotency key is stable across the single test invocation. WireMock records exactly one Stripe request. The test passes.

A developer who adds a concurrency test to simulate the version conflict may write a test where two threads call processMonthlyCharge() simultaneously. Thread A commits first. Thread B throws OptimisticLockException. Thread B’s @Retryable fires. Thread B’s retry loads the now-updated entity (version = 8) and succeeds on the second attempt. The test verifies that the customer is only charged once — but this test assumption is wrong: the WireMock assertion must capture all Stripe requests from both Thread B’s attempts and verify that the Idempotency-Key header is the same on both. If the assertion only checks the final Stripe response (charge object from the successful second attempt), it misses that Thread B’s first attempt (UUID_A) and second attempt (UUID_B) sent different keys to Stripe.

The fix: separate the Stripe call from the @Version-retried DB update

The cleanest fix restructures the method so that @Retryable covers only the @Version-checked DB write, and the Stripe call happens exactly once outside the retry boundary:

// BillingService.java — SAFE: Stripe call outside @Retryable boundary.
// @Retryable covers only the @Version-checked entity update and audit record.
// Stripe is called once with a stable key before @Retryable entry.
@Singleton
public class BillingService {

    private final StripeClient stripeClient;
    private final CustomerAccountRepository accountRepository;
    private final BillingRecordRepository billingRepository;

    public BillingResult processMonthlyCharge(String customerId, long amountCents, String billingPeriod) {
        // Compute stable key once — before any retry boundary.
        // sha256Hex is deterministic: same inputs always produce the same hash.
        String idempotencyKey = sha256Hex(customerId + ":" + billingPeriod
                                          + ":monthly-billing").substring(0, 32);

        // Call Stripe once. The stable idempotencyKey ensures that if this request
        // reaches Stripe and succeeds, any retry of this outer method (if the caller
        // retries at a higher level) will receive ch_A from Stripe's idempotency cache.
        Charge charge;
        try {
            charge = stripeClient.charges().create(
                ChargeCreateParams.builder()
                    .setAmount(amountCents)
                    .setCurrency("usd")
                    .setCustomer(customerId)
                    .build(),
                RequestOptions.builder()
                    .setIdempotencyKey(idempotencyKey)
                    .build());
        } catch (StripeException e) {
            throw new BillingException("Stripe charge failed", e);
        }

        // Only the @Version-checked DB writes are inside the @Retryable boundary.
        // UUID_B cannot occur because UUID is not generated inside this method.
        persistBillingWithOptimisticRetry(customerId, charge.getId(), amountCents, billingPeriod);
        return new BillingResult(charge.getId(), idempotencyKey);
    }

    @Retryable(
        attempts = "5",
        delay = "100ms",
        multiplier = "1.5",
        includes = OptimisticLockException.class
    )
    @Transactional
    protected void persistBillingWithOptimisticRetry(String customerId, String chargeId,
                                                      long amountCents, String billingPeriod) {
        // This method ONLY performs the @Version-checked entity update and audit save.
        // No UUID.randomUUID() is called here — the Stripe charge is already committed
        // and the chargeId is passed in as a stable parameter.
        CustomerAccount account = accountRepository.findByStripeCustomerId(customerId)
            .orElseThrow(() -> new IllegalArgumentException("Customer not found: " + customerId));

        account.setCurrentBillingPeriod(billingPeriod);
        account.setLastChargeAmountCents(amountCents);
        accountRepository.update(account);  // @Version-checked — retried on conflict

        billingRepository.save(
            new BillingRecord(customerId, chargeId, billingPeriod, amountCents));
    }
}

The Stripe call is outside the @Retryable boundary. The stable content-hash idempotencyKey ensures that if the outer processMonthlyCharge() is somehow retried at a higher level (e.g., by a job scheduler), Stripe returns the cached ch_A without creating ch_B. The inner persistBillingWithOptimisticRetry() retries only the DB operations, with no Stripe interaction and no UUID generation.

Note on self-invocation: In Micronaut AOP, calling this.persistBillingWithOptimisticRetry() from within the same bean does pass through the AOP proxy by default when the caller and callee are in the same Micronaut-managed singleton. Unlike Spring AOP (which uses proxy-based interception where this.method() bypasses the proxy), Micronaut’s compile-time AOP instruments the class itself at compile time — self-invocation within a @Singleton bean is intercepted correctly. However, the safest practice is to separate the two concerns into different @Singleton beans to make the boundary explicit and testable independently.

Integration test pattern: verifying idempotency key stability across all retry attempts

The integration test that catches all three failure modes captures the Idempotency-Key header from every Stripe request in the retry sequence and asserts that all values are equal. The test configures WireMock to return a 503 on the first attempt and a 200 on the second, then inspects all recorded requests:

// BillingServiceIntegrationTest.java — Micronaut test with WireMock and @MicronautTest.
// Tests failure modes 1, 2, and 3 by asserting Idempotency-Key stability across retries.
@MicronautTest
class BillingServiceIntegrationTest {

    @Inject
    BillingService billingService;

    @Inject
    WireMockServer wireMock;

    @BeforeEach
    void setUp() {
        // Reset WireMock between tests — ensures each test starts with no recorded requests.
        wireMock.resetAll();
    }

    // Test for failure mode 1 and 2: key stability when @Retryable fires on DataAccessException.
    // Uses a stable key passed as parameter (the fixed version).
    @Test
    void idempotencyKey_stableAcrossRetries_dataAccessException() {
        // WireMock stubbed: first Stripe call succeeds (simulating ch_A committed).
        // The test verifies the key on the single Stripe call — the @Retryable retry
        // (triggered by the subsequent DataAccessException from save()) should NOT
        // reach Stripe again if the stable-key fix is applied correctly.
        // (In the unfixed version, a second Stripe call appears with UUID_B.)
        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"id\":\"ch_test_A\",\"object\":\"charge\",\"amount\":5000,\"status\":\"succeeded\"}")));

        // Compute stable key before calling the service — simulates the fix from option 1.
        String stableKey = sha256Hex("cus_A:2026-Q4:micronaut-billing").substring(0, 32);

        // Invoke the fixed service method with the stable key parameter.
        // The underlying save() may throw DataAccessException, triggering @Retryable.
        // The retry MUST use the same stableKey — no UUID.randomUUID() re-evaluation.
        assertDoesNotThrow(() -> billingService.chargeCustomer(
            "cus_A", 5000, "2026-Q4", stableKey));

        // Assert: exactly ONE Stripe request was made (no duplicate from @Retryable retry).
        // If UUID.randomUUID() were called per retry, WireMock would have TWO requests.
        List<LoggedRequest> stripeRequests = wireMock.findAll(postRequestedFor(
            urlPathEqualTo("/v1/charges")));
        assertThat(stripeRequests).hasSize(1);
        assertThat(stripeRequests.get(0).getHeader("Idempotency-Key")).isEqualTo(stableKey);
    }

    // Test for failure mode 1: when @Retryable DOES retry (Stripe network error), key is stable.
    // WireMock returns 503 on first attempt, 200 on second.
    @Test
    void idempotencyKey_stableAcrossStripeNetworkRetries() {
        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .inScenario("retry")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
            .willSetStateTo("second_attempt"));

        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .inScenario("retry")
            .whenScenarioStateIs("second_attempt")
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"id\":\"ch_test_A\",\"object\":\"charge\",\"amount\":5000,\"status\":\"succeeded\"}")));

        String stableKey = sha256Hex("cus_B:2026-Q4:micronaut-billing").substring(0, 32);

        assertDoesNotThrow(() -> billingService.chargeCustomer(
            "cus_B", 5000, "2026-Q4", stableKey));

        // Assert: WireMock received exactly TWO Stripe requests (attempt 1 + retry).
        // Both must carry the SAME Idempotency-Key.
        List<LoggedRequest> requests = wireMock.findAll(postRequestedFor(
            urlPathEqualTo("/v1/charges")));
        assertThat(requests).hasSize(2);

        String keyOnAttempt1 = requests.get(0).getHeader("Idempotency-Key");
        String keyOnAttempt2 = requests.get(1).getHeader("Idempotency-Key");

        // CRITICAL ASSERTION: both attempts must carry the same key.
        // If UUID.randomUUID() were called at method entry per retry,
        // keyOnAttempt1 != keyOnAttempt2 and this assertion would FAIL — revealing the bug.
        assertThat(keyOnAttempt2)
            .as("Idempotency-Key must be identical on @Retryable retry (attempt 2 = attempt 1)")
            .isEqualTo(keyOnAttempt1);
    }

    // Test for failure mode 3: OptimisticLockException retry does NOT cause a second Stripe call.
    // This test requires two concurrent threads to trigger a version conflict.
    @Test
    void stripeNotCalledTwice_onOptimisticLockRetry() throws Exception {
        wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"id\":\"ch_test_A\",\"object\":\"charge\",\"amount\":5000,\"status\":\"succeeded\"}")));

        // In the fixed implementation, Stripe is called once before @Retryable entry.
        // The @Retryable retries only persistBillingWithOptimisticRetry() — no Stripe call.
        // We simulate the concurrent update externally to force OptimisticLockException.

        // For a direct test: call the fixed processMonthlyCharge() and assert single Stripe call.
        assertDoesNotThrow(() -> billingService.processMonthlyCharge(
            "cus_C", 5000, "2026-Q4"));

        // The fixed implementation calls Stripe exactly once regardless of DB retry count.
        List<LoggedRequest> stripeRequests = wireMock.findAll(postRequestedFor(
            urlPathEqualTo("/v1/charges")));
        assertThat(stripeRequests).hasSize(1);
    }
}

The key assertion in every test is the equality check on Idempotency-Key across all recorded WireMock requests. The unfixed versions — where UUID.randomUUID() runs at method entry per @Retryable retry — will produce different header values on each attempt, causing the equality assertion to fail and revealing the bug before it reaches production.

Summary: three Micronaut Data – specific Stripe double-charge patterns

Failure mode Root cause Observable symptom Fix
Mode 1: @Retryable outer + @Transactional inner RecoveryInterceptor precedes TransactionalInterceptor in Micronaut 4.x — each retry opens a new transaction and re-enters the method body — UUID.randomUUID() at method entry generates UUID_B Two Stripe charges with different Idempotency-Key headers for the same billing period; second charge visible in Stripe Dashboard, absent from local billing_records table (T1 rolled back) Pass idempotency key as method parameter; compute stable content-hash key in caller before @Retryable boundary
Mode 2: CrudRepository.save() implicit @Transactional Micronaut Data–generated save() carries its own @Transactional — invisible at interface level — @Retryable on service catches DataAccessException from repository — Stripe already committed — UUID_B on retry Same as Mode 1 symptom: two Stripe charges; second has no local record because save() threw and DB rolled back Same fix as Mode 1; additionally: pre-flight ON CONFLICT DO NOTHING as billing mutex before Stripe call
Mode 3: @Retryable(OptimisticLockException) @Retryable retries entire method body on DB version conflict — Stripe call before @Version-checked update — UUID_B on contention retry Two Stripe charges on concurrent-update events; may appear intermittent in low-traffic systems where contention is rare Separate Stripe call (outside @Retryable) from @Version-checked DB write (inside inner @Retryable method) — pass chargeId as stable parameter to inner method

All three modes share the same root pattern: a key derived from UUID.randomUUID() inside a method body that is re-entered by an outer retry interceptor. The fix for all three is to compute the key outside the retry boundary — in a position where it is evaluated exactly once per logical billing operation. For Micronaut Data services, that position is always the caller of the @Retryable-annotated method, not the method body itself.

A proxy-level spend cap at expected_monthly_revenue × 1.10 (set on a scoped API key per vendor) provides a backstop that catches any double-charge regardless of which code path produced it — including undiscovered failure modes and edge cases not covered by integration tests. When a Stripe charge would exceed the cap, the proxy returns a 403 before the request reaches Stripe. The agent receives an explicit error rather than silently creating an unauthorized second charge.

Related posts in this series: Micronaut and Stripe Integration (core post) — Micronaut HTTP Client and Stripe Integration (transport-layer retry) — Spring WebMVC Async and Stripe Integration — Spring Security OAuth2 Resource Server and Stripe Integration.

Put a spend cap on your Stripe key before the next retry bug ships

Keybrake lets you issue a scoped vault key per agent with a per-vendor daily spend cap. When a runaway @Retryable loop generates duplicate idempotency keys, the cap fires before the second charge reaches Stripe. No code change required in the agent — swap the key, set the cap, done.