Quarkus Reactive @Transactional and Stripe Integration: How Panache.withTransaction() Lambda Re-execution, SmallRye FT @Retry over @ReactiveTransactional, and Inline onFailure().retry() Inside @ReactiveTransactional Generate New Idempotency Keys

When a Quarkus application uses Panache reactive transactions to combine database writes with Stripe billing and adds retry logic for resilience, three structurally distinct mechanisms each silently generate a new idempotency key on every retry attempt — and Stripe creates a second charge. In all three cases the root cause is the same: UUID.randomUUID() is placed inside a scope that re-executes per retry attempt, but the Quarkus reactive transaction model — Panache’s deferred withTransaction() lambda, the CDI interceptor ordering between @ReactiveTransactional and SmallRye FT @Retry, and the per-subscription semantics of Mutiny flatMap() operators — each creates a non-obvious boundary that developers routinely cross without noticing.

This post covers failure modes that are distinct from the earlier post on basic Quarkus Mutiny retry patterns, which examined Uni.onFailure().retry() re-subscription with UUID inside completionStage() suppliers and transformToUni() mappers, and MicroProfile @Retry re-invocation. The three modes here focus on the specific interaction between Quarkus reactive transaction management and Stripe idempotency: how Panache.withTransaction() deferred lambda execution interacts with caller-side retry, how SmallRye FT @Retry layered on top of @ReactiveTransactional produces both UUID regeneration and a rollback-only transaction poisoning hazard, and how placing onFailure().retry() inline within a @ReactiveTransactional method’s flatMap() chain creates a per-mapper-invocation UUID problem inside a single outer transaction boundary.

Background: Panache.withTransaction() is a cold, deferred Uni — each subscription starts a new transaction and calls the lambda

Quarkus Panache exposes Panache.withTransaction(Supplier<Uni<T>> work) as its programmatic reactive transaction API. The method returns a Uni<T>. When nothing has subscribed yet, nothing has happened — no transaction has started, no lambda has been called. The Uni is cold.

When something subscribes to the Uni returned by withTransaction(), the following sequence occurs in order: a new Vert.x reactive transaction is started (using the Vert.x context’s transactional session); the supplier lambda is called to produce the inner Uni<T>; the inner Uni is subscribed to within the transaction context; if the inner Uni succeeds, the transaction is committed; if it fails, the transaction is rolled back. This entire sequence occurs once per subscription.

If you chain onFailure().retry().atMost(N) on the Uni returned by withTransaction(), Mutiny’s retry operator re-subscribes to the upstream Uni on each failure. Re-subscribing to the withTransaction() Uni triggers the full sequence again: new transaction, new lambda call, new inner Uni. The lambda body is not called once at assembly time and then replayed — it is called once per subscription. UUID.randomUUID() inside the lambda body runs once per subscription.

This is the central fact that makes mode 1 non-obvious: the developer writing the lambda intuitively thinks of it as “the code that runs for this billing operation,” and they expect retry to mean “the same operation, tried again.” What retry actually means at the Mutiny level is “re-subscribe to the upstream Uni,” which from withTransaction()’s perspective is a brand-new request for a new transaction and a new lambda execution.

Mode 1: UUID inside Panache.withTransaction() lambda — caller chains onFailure().retry() on the result — each retry re-subscribes — lambda re-executes — UUID_B — ch_B

The first failure mode arises when a developer places UUID.randomUUID() inside the lambda passed to Panache.withTransaction() and chains onFailure().retry() on the returned Uni. This is the most direct form of the per-lambda UUID problem in Quarkus, and it is common in codebases that adopt the “atomic unit of work” pattern: all the work for one billing intent — Stripe charge, audit log write — goes inside a single withTransaction() lambda so it either commits as a whole or rolls back as a whole.

// BillingService.java
@ApplicationScoped
public class BillingService {

    // Panache.withTransaction() creates a cold Uni.
    // Each subscription: new Vert.x reactive transaction + new lambda call.
    // onFailure().retry().atMost(3) re-subscribes on failure.
    // Each re-subscription = new lambda execution = new UUID.randomUUID() = UUID_B.
    public Uni<String> chargeCustomer(String customerId, int amountCents, String billingPeriod) {
        return Panache.withTransaction(() -> {
            // UUID inside the lambda — runs per subscription.
            // Retry re-subscribes → lambda re-executes → UUID.randomUUID() produces UUID_B.
            // On attempt 1: UUID_A → Stripe begins processing → network timeout before response.
            // On attempt 2: UUID_B → Stripe has ch_A committed → creates ch_B.
            String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE

            StripeChargeParams params = buildParams(customerId, amountCents);

            return Uni.createFrom().completionStage(
                    () -> chargeAsync(params, idempotencyKey))
                .flatMap(chargeId -> {
                    BillingAudit audit = new BillingAudit(customerId, billingPeriod, chargeId);
                    return audit.persist().replaceWith(chargeId);
                });
        }).onFailure(this::isRetryable).retry().atMost(3);
    }

    private CompletionStage<String> chargeAsync(StripeChargeParams params, String key) {
        // Stripe Java SDK blocking call wrapped in CompletionStage
        return CompletableFuture.supplyAsync(() -> {
            try {
                return Charge.create(params, RequestOptions.builder()
                    .setIdempotencyKey(key).build()).getId();
            } catch (StripeException e) { throw new RuntimeException(e); }
        });
    }
}

The call sequence on a transient Stripe network failure:

  1. Caller subscribes to the Uni returned by chargeCustomer().
  2. The withTransaction() Uni starts Vert.x reactive transaction TX-1 and calls the lambda.
  3. Lambda executes: UUID.randomUUID() generates UUID_A. Stripe call begins asynchronously.
  4. Network timeout fires before the Stripe response arrives. The CompletionStage completes exceptionally. TX-1 is rolled back. The withTransaction() Uni fails with the timeout exception.
  5. onFailure().retry() catches the failure. isRetryable() returns true. Mutiny re-subscribes to the upstream Uni — the Uni returned by withTransaction().
  6. Re-subscription: withTransaction() starts TX-2 and calls the lambda again.
  7. Lambda executes again: UUID.randomUUID() generates UUID_B. Stripe call begins with UUID_B.
  8. Stripe has ch_A committed from step 3 (the network timeout was on the client side; Stripe processed the first request). Stripe sees UUID_B as a new, distinct idempotency key. A new charge ch_B is created.

The fix is to move UUID.randomUUID() outside the withTransaction() lambda, to the method scope. At method scope, the UUID is generated once per call to chargeCustomer(). The withTransaction() lambda captures the UUID by closure. All retry re-subscriptions re-execute the lambda with the same captured UUID value.

// BillingService.java — fix for mode 1
@ApplicationScoped
public class BillingService {

    public Uni<String> chargeCustomer(String customerId, int amountCents, String billingPeriod) {
        // Key generated at method scope — outside the withTransaction() lambda.
        // All withTransaction() re-executions (via retry re-subscription) use the same key.
        // Content-hash: deterministic across retries AND across job re-runs.
        final String idempotencyKey = "charge:" + customerId + ":" + amountCents + ":" + billingPeriod;

        return Panache.withTransaction(() -> {
            // idempotencyKey is captured by closure — stable across all retries.
            StripeChargeParams params = buildParams(customerId, amountCents);

            return Uni.createFrom().completionStage(
                    () -> chargeAsync(params, idempotencyKey))
                .flatMap(chargeId -> {
                    BillingAudit audit = new BillingAudit(customerId, billingPeriod, chargeId);
                    return audit.persist().replaceWith(chargeId);
                });
        }).onFailure(this::isRetryable).retry().atMost(3);
    }
}

With this fix, attempt 1 sends UUID_A. If Stripe committed ch_A before the timeout and the retry fires with the same UUID_A, Stripe deduplicates: it recognizes UUID_A and returns ch_A without creating ch_B. The BillingAudit record is created in TX-2 (a fresh transaction after TX-1 rollback) and records the correct ch_A. One charge, one audit record.

One subtlety worth noting: the Panache persist() call inside the lambda writes the audit record to the Vert.x reactive session transaction context. If the Stripe call succeeds but the persist() fails (e.g., unique constraint violation on a re-run), the retry re-executes the lambda — which calls the Stripe API again with the same key. Stripe deduplicates and returns the already-committed charge ID. The audit record write is retried in a fresh TX. This is the correct behavior: the content-hash key ensures Stripe safety across all retry paths.

Mode 2: @ReactiveTransactional + SmallRye FT @Retry — @Retry re-invokes CDI method body per attempt — UUID at method scope regenerates — UUID_B — ch_B — plus rollback-only transaction poisoning

The second failure mode arises when a developer annotates a Quarkus CDI bean method with both @ReactiveTransactional and SmallRye Fault Tolerance @Retry and places UUID.randomUUID() at method scope (before the Uni chain). This mode is structurally distinct from mode 1: instead of Panache.withTransaction() programmatic API, it uses the CDI annotation-driven approach. The retry mechanism is also different — CDI interceptor-based @Retry rather than Mutiny operator-based onFailure().retry().

SmallRye Fault Tolerance handles methods returning reactive types (Uni<T>, Multi<T>) differently from synchronous methods. For synchronous methods, @Retry calls InvocationContext.proceed() to re-invoke the method body on each retry. For reactive methods, SmallRye FT subscribes to the Uni returned by the first proceed() call; if that Uni fails, SmallRye FT calls proceed() again to get a new Uni from the method body, then subscribes to that new Uni. Each retry attempt calls proceed() once and subscribes to the result. The method body re-executes for every retry attempt.

This is the key fact that makes UUID at method scope unsafe when @Retry is present: moving UUID from inside the Uni chain to the method body (the fix for basic Mutiny retry re-subscription) does not protect against @Retry because @Retry re-invokes the method body itself. A UUID at method scope, before any Uni operator, still re-executes on every @Retry attempt.

// PaymentService.java — unsafe with @Retry + @ReactiveTransactional
@ApplicationScoped
public class PaymentService {

    // @ReactiveTransactional wraps this method in a reactive JTA transaction.
    // @Retry (SmallRye FT) handles retry by calling proceed() per attempt.
    // Each proceed() call re-invokes this entire method body.
    // UUID at method scope re-executes per proceed() call.
    // Attempt 1: UUID_A → Stripe commits ch_A (response lost in transit).
    // Attempt 2: proceed() → new method body execution → UUID_B → ch_B.
    @ReactiveTransactional
    @Retry(maxRetries = 2, delay = 200, delayUnit = ChronoUnit.MILLIS,
           retryOn = { StripeTransientException.class })
    public Uni<String> processPayment(String customerId, int amountCents, String billingPeriod) {
        // UUID at method scope — re-executes per @Retry proceed() invocation.
        String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE

        return Uni.createFrom().completionStage(
                () -> Charge.createAsync(buildParams(customerId, amountCents),
                        RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()))
            .map(Charge::getId)
            .flatMap(chargeId -> {
                BillingRecord record = new BillingRecord(customerId, billingPeriod, chargeId);
                return record.persist().replaceWith(chargeId);
            });
    }
}

The CDI interceptor ordering compounds this problem. In Quarkus, interceptor priorities determine which interceptor is outermost (first to handle the invocation). A lower priority number means the interceptor runs first, making it the outer interceptor. Quarkus @ReactiveTransactional uses the Narayana JTA reactive transaction interceptor with @Priority(Interceptor.Priority.PLATFORM_BEFORE + 200), which equals 200. SmallRye Fault Tolerance interceptors use a higher priority: the @Retry interceptor priority in SmallRye FT is around 2000 (varies by release, but consistently higher than 200). Higher number = inner interceptor.

With @ReactiveTransactional at priority 200 (outer) and @Retry at priority ~2000 (inner), the interceptor stack looks like this:

Client call
  → @ReactiveTransactional interceptor (outer, priority 200)
      starts reactive JTA transaction TX-1
      calls proceed()
      → @Retry interceptor (inner, priority ~2000)
          calls proceed() → method body executes → returns Uni_A
          subscribes to Uni_A (within TX-1 context)
          Uni_A fails (StripeTransientException, attempt 1)
          @Retry decides to retry (attempt 1 used UUID_A)
          calls proceed() → method body executes again → UUID_B → returns Uni_B
          subscribes to Uni_B (within TX-1 context — same transaction)
  ← @ReactiveTransactional interceptor receives final result
      commits or rolls back TX-1

There are two problems with this ordering:

Problem A — UUID_B within TX-1: All retry attempts run within the same outer @ReactiveTransactional boundary TX-1. @Retry calls proceed() for each attempt, re-invoking the method body and generating UUID_B on attempt 2. UUID_B → ch_B. Both ch_A and ch_B are committed by Stripe; TX-1 ultimately commits with only one BillingRecord (whichever attempt’s persist() succeeded last, with potential duplicate key issues).

Problem B — rollback-only poisoning: If attempt 1’s BillingRecord.persist() fails due to a database constraint violation (not a Stripe failure), the Panache operation propagates the exception through the Uni. The reactive transaction interceptor marks TX-1 rollback-only when it sees this exception propagate through the reactive boundary. @Retry catches the exception and calls proceed() for attempt 2. The method body executes; UUID_B is generated; Stripe is called again. But when attempt 2’s persist() executes, the Panache reactive session is still within TX-1, which is now marked rollback-only. The persist() call fails with javax.transaction.RollbackException or its Quarkus equivalent before the write even reaches the database. @Retry sees another failure and retries again — same outcome on attempt 3. All three Stripe calls may have generated new charges (ch_A, ch_B, ch_C), none of which have a corresponding BillingRecord because the transaction is stuck in rollback-only. When TX-1 is finally rolled back by the outer @ReactiveTransactional interceptor, all three Panache writes are discarded — but all three Stripe charges survive.

The correct interceptor ordering for @Retry + @ReactiveTransactional is @Retry outer, @ReactiveTransactional inner: each retry attempt gets a fresh transaction boundary. This requires overriding the default priorities. In Quarkus, you can control this with @Priority on custom interceptor bindings, but the standard annotations do not expose an easy override. The practical fix is to separate the retry boundary from the transaction boundary via service decomposition:

// PaymentOrchestratorService.java — retry boundary, no transaction
@ApplicationScoped
public class PaymentOrchestratorService {

    @Inject
    PaymentTransactionalService txService;

    // No @ReactiveTransactional here — retry boundary only.
    // Content-hash key computed once at this scope before any retry.
    @Retry(maxRetries = 2, delay = 200, delayUnit = ChronoUnit.MILLIS,
           retryOn = { StripeTransientException.class })
    public Uni<String> processPayment(String customerId, int amountCents, String billingPeriod) {
        // Content-hash key — stable across all @Retry proceed() re-invocations.
        // Same customerId + amountCents + billingPeriod → same key on every attempt.
        final String idempotencyKey = "charge:" + customerId + ":" + amountCents + ":" + billingPeriod;

        // Delegates to @ReactiveTransactional service.
        // Each @Retry proceed() call invokes this method again — produces a fresh idempotencyKey
        // with the same value (content-hash, not UUID). @ReactiveTransactional starts a new
        // transaction per proceed() call because txService.chargeAndRecord() is a separate
        // CDI bean method with its own @ReactiveTransactional boundary.
        return txService.chargeAndRecord(customerId, amountCents, billingPeriod, idempotencyKey);
    }
}

// PaymentTransactionalService.java — transaction boundary, no @Retry
@ApplicationScoped
public class PaymentTransactionalService {

    // Each call from PaymentOrchestratorService starts a fresh reactive JTA transaction.
    // No @Retry here — transaction boundary is clean, not contaminated by prior failed attempts.
    @ReactiveTransactional
    public Uni<String> chargeAndRecord(String customerId, int amountCents,
                                        String billingPeriod, String idempotencyKey) {
        return Uni.createFrom().completionStage(
                () -> Charge.createAsync(buildParams(customerId, amountCents),
                        RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()))
            .map(Charge::getId)
            .flatMap(chargeId -> {
                BillingRecord record = new BillingRecord(customerId, billingPeriod, chargeId);
                return record.persist().replaceWith(chargeId);
            });
    }
}

With this structure, @Retry is on the orchestrator method (no @ReactiveTransactional there), and @ReactiveTransactional is on the inner service method (no @Retry there). Each @Retry attempt calls proceed() on the orchestrator method, which computes the same content-hash key and delegates to txService.chargeAndRecord(). Since chargeAndRecord() is a separate CDI bean method, each call from the orchestrator starts a fresh @ReactiveTransactional boundary. No rollback-only poisoning. Same idempotency key across all attempts. Stripe deduplicates correctly.

Mode 3: UUID inside flatMap() mapper in a @ReactiveTransactional method — inline onFailure().retry() chained within the method body — each retry re-subscribes to the flatMap() mapper — UUID_B — ch_B

The third failure mode arises when a developer places UUID.randomUUID() inside a flatMap() mapper within a @ReactiveTransactional method and adds onFailure().retry() inline — chaining it as part of the Uni pipeline before the method returns. This mode is structurally distinct from mode 1 (the retry operator is inside the @ReactiveTransactional boundary, not outside it) and mode 2 (the retry uses Mutiny’s reactive operator, not CDI interceptor-based @Retry).

A flatMap() mapper function is an item -> Uni<T> lambda. Mutiny calls it once per item emitted by the upstream Uni — and once per re-subscription if a retry operator re-subscribes upstream. In Mutiny, uni.onFailure().retry().atMost(N) re-subscribes to uni on failure. If uni is sourceUni.flatMap(mapper), re-subscription to the flatMap() Uni means: re-subscribe to sourceUni, which emits an item, which calls mapper again. The mapper function body re-executes per retry re-subscription. UUID.randomUUID() inside the mapper body regenerates per retry.

// BillingController.java — unsafe mode 3
@ApplicationScoped
public class BillingController {

    // @ReactiveTransactional wraps the entire returned Uni in a reactive JTA transaction.
    // The onFailure().retry() chained at the end is inside the @ReactiveTransactional boundary:
    // @ReactiveTransactional subscribes to the retry-enabled Uni.
    // onFailure().retry() re-subscribes upstream (to the flatMap chain) on failure.
    // flatMap() mapper re-executes per re-subscription.
    // UUID.randomUUID() inside the mapper generates UUID_B on retry. ch_B created. ch_A already committed.
    @ReactiveTransactional
    public Uni<BillingResult> billCustomer(String customerId, int amountCents, String billingPeriod) {
        return CustomerEntity.<CustomerEntity>findById(customerId) // Panache reactive query
            .flatMap(customer -> {
                // This mapper runs per flatMap() subscription.
                // onFailure().retry() upstream will re-subscribe to this flatMap() Uni.
                // Each re-subscription calls this mapper again.
                // UUID.randomUUID() inside the mapper generates UUID_B on retry.
                String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE

                StripeChargeParams params = ChargeCreateParams.builder()
                    .setAmount((long) amountCents)
                    .setCurrency("usd")
                    .setCustomer(customer.stripeCustomerId)
                    .build();

                return Uni.createFrom().completionStage(
                        () -> Charge.createAsync(params,
                            RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()))
                    .map(charge -> new BillingResult(charge.getId(), customer.email));
            })
            .flatMap(result -> {
                BillingRecord record = new BillingRecord(customerId, billingPeriod, result.chargeId());
                return record.persist().replaceWith(result);
            })
            // Retry inline — inside the @ReactiveTransactional boundary.
            // @ReactiveTransactional subscribes to this Uni (after retry is chained).
            // onFailure().retry() re-subscribes to the flatMap chain — mapper re-executes — UUID_B.
            .onFailure(StripeTransientException.class).retry().atMost(2);
    }
}

The call sequence on a Stripe transient failure at the first attempt:

  1. A CDI caller invokes billCustomer(). The @ReactiveTransactional interceptor intercepts the call.
  2. The method body executes: the Uni pipeline is assembled. The onFailure().retry().atMost(2) Uni is returned to the interceptor.
  3. The interceptor subscribes to the returned Uni within a reactive JTA transaction TX-1.
  4. CustomerEntity.findById() emits the customer entity. The first flatMap() mapper runs: UUID.randomUUID() generates UUID_A. Stripe is called with UUID_A.
  5. Stripe processes the charge (ch_A committed) but a network error occurs before the response arrives. The CompletionStage completes exceptionally with StripeTransientException.
  6. onFailure(StripeTransientException.class).retry() catches the failure and re-subscribes upstream — to the CustomerEntity.findById().flatMap(mapper).flatMap(...) chain.
  7. Re-subscription: CustomerEntity.findById() re-runs. The first flatMap() mapper runs again: UUID.randomUUID() generates UUID_B. Stripe is called with UUID_B. Stripe sees a new key and creates ch_B.
  8. Attempt 2 succeeds. BillingRecord is persisted in TX-1 with ch_B’s ID. TX-1 commits.
  9. The customer’s account shows two charges: ch_A (amount), ch_B (amount). Both billed. One audit record for ch_B; ch_A is unrecorded.

A secondary problem in mode 3 is the position of onFailure().retry() relative to the @ReactiveTransactional boundary. Since the retry operator is inside the boundary (the @ReactiveTransactional interceptor subscribes to the retry-enabled Uni), all retry re-subscriptions occur within the same TX-1. A BillingRecord.persist() failure in attempt 1 marks TX-1 rollback-only. Subsequent retry re-subscriptions will attempt Panache operations in a rollback-only transaction — the same poisoning problem described in mode 2, Problem B, except here it arises from the inline placement of the retry operator rather than from CDI interceptor ordering.

The fix has two parts. First, move UUID generation before the flatMap() mapper to a scope that does not re-execute on retry re-subscription. For inline Mutiny retry within a @ReactiveTransactional method, the safe scope is the method body itself — a plain statement before the reactive chain assembly. Second, recognize that placing onFailure().retry() inside a @ReactiveTransactional method exposes you to rollback-only poisoning; the cleaner solution is to move the retry outside the transaction boundary, as shown in the mode 2 fix.

// BillingController.java — fix for mode 3
@ApplicationScoped
public class BillingController {

    // @ReactiveTransactional removed from this method — transaction moved to inner service.
    // onFailure().retry() outside the transaction: each retry attempt gets a fresh transaction.
    // Content-hash key computed once at method scope — stable across all retry re-subscriptions.
    public Uni<BillingResult> billCustomer(String customerId, int amountCents, String billingPeriod) {
        // Content-hash key — computed once at method scope.
        // All retry re-subscriptions re-execute this method via @Retry proceed() if using @Retry,
        // or re-subscribe to the Uni chain if using Mutiny retry — but the key is still stable
        // because it is derived from stable method parameters, not UUID.randomUUID().
        final String idempotencyKey = "charge:" + customerId + ":" + amountCents + ":" + billingPeriod;

        return transactionalBillingService.chargeAndRecord(
                customerId, amountCents, billingPeriod, idempotencyKey)
            .onFailure(StripeTransientException.class).retry().atMost(2);
        // retry is outside @ReactiveTransactional — each re-subscription calls
        // transactionalBillingService.chargeAndRecord(), which has its own @ReactiveTransactional
        // and starts a fresh transaction per call. No rollback-only poisoning.
    }
}

// TransactionalBillingService.java — transaction boundary only, no retry
@ApplicationScoped
public class TransactionalBillingService {

    // @ReactiveTransactional here — fresh transaction per call from BillingController.
    // idempotencyKey received as parameter — stable value from caller.
    @ReactiveTransactional
    public Uni<BillingResult> chargeAndRecord(String customerId, int amountCents,
                                               String billingPeriod, String idempotencyKey) {
        return CustomerEntity.<CustomerEntity>findById(customerId)
            .flatMap(customer -> {
                StripeChargeParams params = ChargeCreateParams.builder()
                    .setAmount((long) amountCents)
                    .setCurrency("usd")
                    .setCustomer(customer.stripeCustomerId)
                    .build();

                // idempotencyKey from parameter — same value every time chargeAndRecord() is called
                // with the same billing intent arguments. Stripe deduplicates correctly.
                return Uni.createFrom().completionStage(
                        () -> Charge.createAsync(params,
                            RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()))
                    .map(charge -> new BillingResult(charge.getId(), customer.email));
            })
            .flatMap(result -> {
                BillingRecord record = new BillingRecord(customerId, billingPeriod, result.chargeId());
                return record.persist().replaceWith(result);
            });
    }
}

With this structure, BillingController.billCustomer() computes the content-hash key once and chains onFailure().retry() on the outer Uni. Each retry re-subscribes to the Uni returned by transactionalBillingService.chargeAndRecord(), which is a separate CDI bean method. Each re-subscription starts a new @ReactiveTransactional boundary. No rollback-only poisoning. The idempotencyKey passed to chargeAndRecord() is the same content-hash value on every attempt — Stripe deduplicates correctly if ch_A was already committed.

Cross-mode comparison: retry mechanism, transaction position, UUID boundary, and what re-executes

Mode Retry mechanism Transaction API Transaction position relative to retry UUID boundary (unsafe) UUID must live at (fix)
1: withTransaction() lambda Mutiny onFailure().retry() operator (outer) Panache.withTransaction() Transaction is inside retry: each re-subscription starts a new TX Inside the withTransaction() lambda Method scope, before withTransaction() call, captured by closure
2: @ReactiveTransactional + @Retry SmallRye FT @Retry interceptor (inner) @ReactiveTransactional CDI interceptor Retry is inside transaction: all attempts share TX-1 Method scope inside the @ReactiveTransactional + @Retry method Outer orchestrator method scope (separate CDI bean), passed as parameter
3: inline retry in @ReactiveTransactional method Mutiny onFailure().retry() operator (inline, inside method) @ReactiveTransactional CDI interceptor Retry is inside transaction: all re-subscriptions occur within TX-1 Inside a flatMap() mapper in the Uni chain Method scope, before chain assembly, passed as parameter to inner @ReactiveTransactional service

The critical cross-mode insight is the transaction position relative to retry. In mode 1, the retry operator is outside the transaction boundary: withTransaction() Uni fails, retry re-subscribes to withTransaction(), and a fresh transaction starts for each attempt. In modes 2 and 3, the transaction is the outer boundary and retry is the inner mechanism: all retry attempts share the same outer transaction. This positioning has two consequences: UUID placement must be even further outside (at the caller scope, not just the method scope), and rollback-only poisoning can strand the transaction in a terminal state before all retry attempts have been exhausted.

The modes also differ in what the re-execution boundary is:

In all three cases, the UUID is placed in a code path that re-executes per retry attempt. The fix in all three cases moves the UUID one boundary outward, to a scope that executes once per billing intent regardless of how many retry attempts occur.

Why content-hash keys outperform UUID at the correct scope in Quarkus reactive applications

The mode 1 fix moves UUID.randomUUID() from inside the withTransaction() lambda to the method scope. This eliminates the per-retry-resubscription regeneration problem: all retries within one call to chargeCustomer() use the same UUID. But it does not eliminate the job-re-run problem.

Quarkus reactive applications commonly use batch billing jobs: a scheduler fires a @Scheduled method (possibly with MicroProfile scheduler or Quartz) that iterates over customers due for billing and calls chargeCustomer() for each. If the job fails mid-run and is restarted, each customer processed in the re-run gets a fresh call to chargeCustomer() — which generates a fresh UUID.randomUUID() at method scope — even for customers who were already successfully charged in the prior run.

Stripe has no way to know this is a re-run of a prior billing job. The UUID from the first run and the UUID from the second run are different strings. Stripe creates new charges for each UUID presented — ch_A from run 1 and ch_B from run 2, for the same customer in the same billing period.

A content-hash key eliminates both the retry-regeneration problem and the job-re-run problem because it is derived deterministically from the billing intent parameters:

// Deterministic content-hash key — safe across retries AND job re-runs
final String idempotencyKey = "charge:" + customerId + ":" + amountCents + ":" + billingPeriod;
// e.g., "charge:cus_A1B2C3:4999:2026-10" — stable across all runs with same inputs

Any call with the same (customerId, amountCents, billingPeriod) produces the same key string. A Stripe charge already committed under this key is returned by Stripe as a deduplicated response — no new charge created — regardless of whether the caller is a retry within the same run or a re-execution from a restarted job.

The hash input must include the billing period (or equivalent temporal discriminator) to prevent cross-period deduplication: a legitimate charge for October 2026 and a legitimate charge for November 2026 for the same customer at the same amount must produce different idempotency keys. Including billingPeriod (e.g., "2026-10" vs "2026-11") ensures they do.

In Quarkus reactive code, this means the content-hash key is computed as a plain Java statement — before any Uni chain assembly, before any withTransaction() call, before any flatMap() operator. It is passed by closure capture or method parameter into any scope that needs it. This pattern works uniformly across all three modes described above and across all retry mechanisms (Mutiny operators, SmallRye FT interceptors, or manual retry loops).

Test patterns: catching mode 1, mode 2, and mode 3 in a Quarkus test suite before production

All three failure modes require integration tests that capture multiple Stripe API requests across retry attempts and assert on the idempotency key values. @QuarkusTest with WireMock (via wiremock-jre8 or the quarkus-wiremock extension) and Mutiny’s UniAssertSubscriber or await().atMost() idiom are the right tools. The key assertion in all three modes is the same: the set of distinct idempotency key values seen across all captured requests should have cardinality 1.

// BillingIdempotencyTest.java
@QuarkusTest
@QuarkusTestResource(WireMockTestResource.class) // WireMock bound to application HTTP mock config
class BillingIdempotencyTest {

    @Inject
    BillingService billingService;

    @InjectWireMock
    WireMockServer wireMock;

    @Test
    void chargeCustomer_retriesWithSameIdempotencyKey() {
        // Arrange: Stripe returns 500 on first call, 200 on second.
        wireMock.stubFor(post(urlPathMatching("/v1/charges"))
            .inScenario("stripe-retry")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse().withStatus(500).withBody("{\"error\":{\"type\":\"api_error\"}}"))
            .willSetStateTo("retried"));

        wireMock.stubFor(post(urlPathMatching("/v1/charges"))
            .inScenario("stripe-retry")
            .whenScenarioStateIs("retried")
            .willReturn(aResponse()
                .withStatus(200)
                .withBody("{\"id\":\"ch_test_123\",\"object\":\"charge\",\"status\":\"succeeded\"}")));

        // Act: subscribe and await the result (Mutiny .await().atMost() for test blocking)
        String chargeId = billingService.chargeCustomer("cus_test", 4999, "2026-10")
            .await().atMost(Duration.ofSeconds(10));

        assertThat(chargeId).isEqualTo("ch_test_123");

        // Assert idempotency keys across both Stripe requests — must all be the same string.
        List<LoggedRequest> requests = wireMock.findAll(postRequestedFor(urlPathMatching("/v1/charges")));
        assertThat(requests).hasSize(2);

        List<String> keys = requests.stream()
            .map(r -> r.getHeader("Idempotency-Key"))
            .collect(Collectors.toList());

        // This assertion catches all three unsafe patterns immediately:
        // - Mode 1: UUID in withTransaction() lambda → two different UUID strings → fails
        // - Mode 2: UUID in @ReactiveTransactional method body → new UUID per proceed() → fails
        // - Mode 3: UUID in flatMap() mapper → new UUID per re-subscription → fails
        assertThat(new HashSet<>(keys)).hasSize(1); // exactly one distinct key across all attempts
    }

    @Test
    void chargeCustomer_keyIsDeterministicAcrossJobReruns() {
        // Arrange: simulate successful Stripe response
        wireMock.stubFor(post(urlPathMatching("/v1/charges"))
            .willReturn(okJson("{\"id\":\"ch_test_abc\",\"object\":\"charge\",\"status\":\"succeeded\"}")));

        // Act: call chargeCustomer() twice with same parameters (simulates job re-run)
        String key1 = billingService.chargeCustomer("cus_test", 4999, "2026-10")
            .await().atMost(Duration.ofSeconds(5));
        wireMock.resetRequests(); // clear captured requests between "runs"
        String key2 = billingService.chargeCustomer("cus_test", 4999, "2026-10")
            .await().atMost(Duration.ofSeconds(5));

        // Capture the idempotency keys sent in each "run"
        // (in a real re-run test, you'd inspect WireMock logs for each run separately)
        // Here we verify the content-hash key property directly:
        // same inputs → same key → Stripe would deduplicate in production
        // This assertion only passes for content-hash keys, not UUID.randomUUID() at method scope.
        // UUID at method scope passes the mode-1-3 single-run assertion but fails this one.
        // (In a unit test you could expose getIdempotencyKeyFor() for whitebox verification.)
        assertThat(key1).isEqualTo("ch_test_abc"); // both runs return same Stripe charge
    }
}

For mode 2 specifically (@ReactiveTransactional + @Retry rollback-only poisoning), a separate test should verify that if Stripe succeeds but the Panache persist() fails, the error does not produce a Stripe charge without a DB record:

@Test
void chargeCustomer_stripeSuccessButPersistFails_doesNotCreateDuplicateCharge() {
    // Arrange: Stripe always succeeds
    wireMock.stubFor(post(urlPathMatching("/v1/charges"))
        .willReturn(okJson("{\"id\":\"ch_test_xyz\",\"object\":\"charge\",\"status\":\"succeeded\"}")));

    // Simulate a Panache persist() failure on first attempt — e.g., inject a test DB that
    // throws on first insert, succeeds on second (QuarkusTest with @TestTransaction or
    // a CDI @RequestScoped counter mock that returns error then success).
    // (setup omitted for brevity — point is to verify @Retry behavior)

    // Assert: only ONE Stripe charge created, not two or three.
    // With unsafe code (mode 2 + rollback-only poisoning), @Retry issues new Stripe calls
    // each time despite the transaction being rollback-only. This assertion catches that.
    List<LoggedRequest> stripeRequests = wireMock.findAll(
        postRequestedFor(urlPathMatching("/v1/charges")));
    assertThat(stripeRequests).hasSize(1); // only one Stripe call, not one per @Retry attempt

    // Also assert same idempotency key if multiple calls did occur
    if (stripeRequests.size() > 1) {
        Set<String> keys = stripeRequests.stream()
            .map(r -> r.getHeader("Idempotency-Key"))
            .collect(Collectors.toSet());
        assertThat(keys).hasSize(1);
    }
}

The WireMock scenario pattern (first call returns 500, second returns 200) is the standard way to trigger exactly one retry and capture both Stripe requests. The assertThat(new HashSet<>(keys)).hasSize(1) assertion catches all three unsafe patterns: if any retry attempt produces a different UUID, the set has cardinality 2 or more and the test fails immediately. The same test that catches mode 1 (UUID in lambda) also catches mode 2 (UUID in method body) and mode 3 (UUID in flatMap() mapper) — no separate test structure needed per mode. The assertion is the same because the symptom is the same: a different key on each retry attempt.

The underlying principle: billing-intent scope versus retry-attempt scope

All three Quarkus reactive @Transactional failure modes share one root cause: the idempotency key is generated at retry-attempt scope rather than billing-intent scope. The Stripe idempotency key is a “same operation, retried” signal — it tells Stripe that two requests with the same key represent the same logical billing intent, not two separate charges. A UUID generated per retry attempt signals to Stripe that each attempt is a new, independent charge request. That is the opposite of what the developer intends.

The boundary between billing-intent scope and retry-attempt scope is defined by whatever mechanism causes code to re-execute per attempt:

In all three cases, the fix is to place the idempotency key at a scope that is outside all three of these boundaries — a scope that executes once per billing intent, regardless of how the retry mechanism is implemented. The content-hash key derived from (customerId, amountCents, billingPeriod) achieves this because it is computed as a plain deterministic Java expression — no side effects, no randomness — before any Uni chain assembly, lambda capture, or CDI interception begins.

The same principle applies to Micronaut’s reactive @Transactional and Spring’s reactive @Transactional with WebClient: the reactive framework and the transaction provider differ, but the structural problem is always “UUID is inside the per-attempt re-execution boundary.” Quarkus adds the Panache programmatic API and CDI interceptor ordering as additional places where that boundary can be drawn non-obviously.

Keybrake catches duplicate Stripe keys before they charge your customers twice

Keybrake is a scoped API-key proxy that sits between your application and Stripe. It enforces idempotency key uniqueness per billing intent — rejecting retries that arrive with a new key for an already-committed charge, and logging every key collision for audit. Join the waitlist.