Quarkus MicroProfile Fault Tolerance and Stripe Integration: How SmallRye FT Interceptor Priority, Mutiny Uni Retry Subscriptions, and Panache OptimisticLockException Retries Generate New Idempotency Keys on Retry
Quarkus MicroProfile Fault Tolerance introduces three Stripe billing failure modes that are structurally distinct from the Micronaut Data post and from other frameworks in this series. The three modes are: in Quarkus, the CDI @Transactional interceptor (Narayana JTA, priority 200) is the outer interceptor and the SmallRye FT composite interceptor (priority 1000) is the inner interceptor when both annotations appear on the same method — the opposite of Micronaut’s default — @Retry retries the method body within the same open transaction, but UUID.randomUUID() at method entry regenerates on every retry attempt regardless of the surrounding transaction context, so a Stripe network timeout on attempt 1 (committing ch_A) is followed by a retry with UUID_B that commits ch_B; when a service method returns io.smallrye.mutiny.Uni<T> and is annotated with @Retry, SmallRye FT applies retry by re-invoking the method body per attempt and subscribing to the returned Uni — code that appears before the Uni chain assembly, including UUID.randomUUID(), re-executes per retry invocation — the developer who places UUID before the reactive chain to avoid re-evaluation inside a lazy Uni supplier still gets UUID_B on retry; and @Retry(retryOn = OptimisticLockException.class) with a Panache @Version-annotated entity retries the entire method body on DB version conflict — if the method calls Stripe before the @Version-checked persist(), the retry generates UUID_B and Stripe creates ch_B — single-threaded @QuarkusTest runs never trigger version conflicts so the double-charge path goes untested until concurrent production load.
Background: CDI interceptor priority ordering in Quarkus — @Transactional is outer, SmallRye FT is inner
In CDI 2.0 and later (which Quarkus uses), when multiple interceptors apply to the same method, their execution order is determined by the @Priority annotation on each interceptor class. The interceptor with the lowest priority number runs outermost — it is the first wrapper the caller’s call enters, and the last to exit when the method body returns or throws.
Two key interceptors in a Quarkus billing service annotated with both @Transactional and @Retry:
- Quarkus JTA
@Transactional:io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorRequired(and its siblings for other transaction types) is annotated with@Priority(Interceptor.Priority.PLATFORM_BEFORE + 200).Interceptor.Priority.PLATFORM_BEFOREis 0, so the effective priority is 200. - SmallRye FT composite interceptor:
io.smallrye.faulttolerance.FaultToleranceInterceptorhandles all MicroProfile Fault Tolerance annotations (@Retry,@CircuitBreaker,@Fallback,@Bulkhead,@Timeout) through a single composite interceptor annotated with@Priority(Interceptor.Priority.LIBRARY_BEFORE).Interceptor.Priority.LIBRARY_BEFOREis 1000, so the effective priority is 1000.
Because 200 < 1000, the JTA @Transactional interceptor runs before the SmallRye FT interceptor. The call chain for a method annotated with both is:
[Caller]
→ [TransactionalInterceptor (@Transactional, priority 200, OUTER)] ← opens T1
→ [FaultToleranceInterceptor (@Retry, priority 1000, INNER)]
→ [method body] ← attempt 1 and all retries execute here
This is the opposite of Micronaut’s default. In Micronaut 4.x, RecoveryInterceptor (@Retryable) precedes TransactionalInterceptor (@Transactional) in the AOP chain, making @Retryable the outer wrapper and opening a new transaction per retry. In Quarkus, @Transactional is outer: transaction T1 opens once per caller invocation, and all SmallRye FT retry attempts for @Retry execute within that same T1.
This difference has consequences for how developers reason about idempotency. A developer who migrates from Micronaut to Quarkus (or who reads the Micronaut post in this series) might assume that each @Retry attempt opens a fresh transaction. In Quarkus, it does not. The transaction context is shared across all retry attempts. This changes the failure signature but does not eliminate the UUID regeneration problem — UUID.randomUUID() at method entry still produces a new value on every entry of the method body, regardless of whether a transaction is already open.
Failure mode 1: @Transactional outer (priority 200), SmallRye FT @Retry inner (priority 1000) — UUID.randomUUID() at method entry regenerates per retry within the same transaction — Stripe commits ch_B
A developer builds a Quarkus billing service and annotates a method with both @Retry (for Stripe network resilience) and @Transactional (to keep the Stripe charge and local billing record atomic):
// BillingService.java — UNSAFE: @Retry (priority 1000) is INNER to @Transactional (priority 200).
// Transaction T1 opens once per caller invocation. @Retry retries the method body inside T1.
// UUID.randomUUID() at method entry generates UUID_A on attempt 1.
// On @Retry retry: method body re-enters — UUID.randomUUID() generates UUID_B.
// Stripe network timeout on attempt 1 may commit ch_A. UUID_B on retry creates ch_B.
@ApplicationScoped
public class BillingService {
@Inject StripeClient stripeClient;
@Inject BillingRecordRepository billingRepo;
@Retry(
maxRetries = 3,
delay = 500,
delayUnit = ChronoUnit.MILLIS,
retryOn = { StripeNetworkException.class, PersistenceException.class }
)
@Transactional
public BillingResult chargeAndRecord(String customerId, long amountCents, String billingPeriod) {
// UNSAFE: this expression executes on EVERY @Retry attempt.
// @Transactional (priority 200) opened T1 before the @Retry (priority 1000) loop started.
// Each retry re-enters this method body. UUID.randomUUID() produces a new UUID per entry.
// There is no "once per transaction" evaluation here — UUID is not transaction-scoped.
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) {
// StripeNetworkException is a StripeException subclass thrown on read timeout.
// Whether Stripe committed the charge before the timeout is unknown here.
throw new StripeNetworkException("Stripe network error on attempt", e);
}
billingRepo.persist(
new BillingRecord(customerId, charge.getId(), billingPeriod, amountCents));
return new BillingResult(charge.getId(), idempotencyKey);
}
}
The failure sequence when a Stripe network read timeout occurs on the first attempt:
- Caller invokes
chargeAndRecord("cus_A", 5000, "2026-Q4"). TransactionalInterceptor(outer, priority 200) opens transaction T1. Control passes toFaultToleranceInterceptor(inner, priority 1000), which begins the@Retryloop.@Retrycalls the method body for attempt 1.- Method body:
UUID.randomUUID()produces UUID_A.idempotencyKey = "cus_A:2026-Q4:UUID_A". - Stripe API call with
UUID_A. Stripe processes the charge and commitsch_A($50) before the network response is delivered. A TCP read timeout fires on the client side.stripeClient.charges().create()throwsStripeNetworkException(wrappingApiConnectionException). - The catch block rethrows
StripeNetworkException. The exception propagates toFaultToleranceInterceptor.StripeNetworkExceptionis in theretryOnlist.@Retrywaits 500 ms. @Retrycalls the method body again for attempt 2. Transaction T1 is still open —@Transactional(outer) has not seen any exception yet.- Method body:
UUID.randomUUID()produces UUID_B.idempotencyKey = "cus_A:2026-Q4:UUID_B". - Stripe API call with
UUID_B. Stripe has never seenUUID_B— it cannot return the cachedch_A. Stripe processes and commitsch_B($50). HTTP 200 returned to client. billingRepo.persist()writes a record forch_Bwithin T1. Method body returns aBillingResultreferencingch_B.FaultToleranceInterceptorsees a successful return. Control passes back toTransactionalInterceptor, which commits T1. One DB record exists forch_B.ch_Ais committed at Stripe with no corresponding local record.
The customer is charged $100 for a $50 billing period. ch_A has no local billing record — it is an orphan charge visible only in the Stripe Dashboard. The method returned ch_B’s ID as the canonical charge, so any downstream audit that queries the local DB sees only $50 billed. The discrepancy surfaces only when reconciling Stripe payouts against local revenue records.
The subtle variant: believing transaction scope makes UUID stable across retries
A developer who understands CDI interceptor priority ordering and confirms that @Transactional is outer in Quarkus may reason: “T1 opens once per caller invocation. All @Retry attempts run within T1. Since the transaction is a single logical unit of work, UUID.randomUUID() at method entry is evaluated once per T1 — it stays stable across retries.”
This reasoning is wrong. CDI interceptor priority controls which interceptor wraps which other interceptor, not where within the method body expressions are evaluated. UUID.randomUUID() is a Java expression in the method body. The method body is re-entered on every @Retry attempt. There is no mechanism that memoizes an expression in the method body for the lifetime of the surrounding transaction. UUID.randomUUID() produces a new 128-bit random value on every call, regardless of what transactions are open on the calling thread.
The difference from Micronaut is behavioral, not about UUID stability. In Micronaut (outer @Retryable), each retry opens a new transaction. In Quarkus (outer @Transactional), all retries share one transaction. In both cases, UUID.randomUUID() in the method body regenerates on every retry attempt. The transaction scope changes how a JPA exception is handled (a SQL error within T1 marks the EntityManager rollback-only, which causes subsequent JPA operations within T1 to fail — but Stripe calls are external and proceed regardless), not whether the UUID is stable.
The fix: compute the idempotency key outside both interceptors
The method body is re-entered per @Retry attempt. Any expression in the method body that produces a new value on each call will produce different values on successive retries. The only position where an idempotency key is evaluated exactly once per logical billing request is before both the @Transactional and @Retry boundaries — in the code that invokes the service method:
// BillingService.java — SAFE: idempotency key passed as parameter.
// Key is computed by the caller before any interceptor runs.
// @Retry (inner) re-enters the method body with the same key on every attempt.
// @Transactional (outer) provides the transaction context, unchanged.
@ApplicationScoped
public class BillingService {
@Retry(
maxRetries = 3,
delay = 500,
delayUnit = ChronoUnit.MILLIS,
retryOn = { StripeNetworkException.class, PersistenceException.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.
// Stripe's idempotency guarantee: if Stripe committed ch_A with UUID_A on attempt 1
// and returned a timeout, attempt 2 with the same UUID_A returns the cached ch_A
// object instead of creating ch_B. This is the correct use of Stripe idempotency.
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 StripeNetworkException("Stripe network error", e);
}
billingRepo.persist(
new BillingRecord(customerId, charge.getId(), billingPeriod, amountCents));
return new BillingResult(charge.getId(), idempotencyKey);
}
}
// BillingResource.java — JAX-RS resource computes the stable key before service call.
@Path("/billing")
@ApplicationScoped
public class BillingResource {
@Inject BillingService billingService;
@POST
@Path("/charge")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response charge(ChargeRequest req) {
// Content-hash key: deterministic from inputs, no UUID.randomUUID() involved.
// Same customer + billing period + namespace always produces the same 32-char hex.
// This is evaluated once per HTTP request, before @Retry or @Transactional run.
String idempotencyKey = sha256Hex(
req.getCustomerId() + ":" + req.getBillingPeriod() + ":quarkus-billing"
).substring(0, 32);
BillingResult result = billingService.chargeAndRecord(
req.getCustomerId(), req.getAmountCents(), req.getBillingPeriod(), idempotencyKey);
return Response.ok(result).build();
}
}
Passing the idempotency key as a method parameter makes the stability contract explicit in the type signature: the caller is responsible for computing a key that survives multiple invocations of the service method body. Stripe’s idempotency guarantee then handles the retry correctly — if ch_A was committed on attempt 1 and the network timed out, attempt 2 with the same key returns the cached ch_A object rather than creating ch_B.
Failure mode 2: Quarkus Reactive @Retry on Uni<T> — SmallRye FT re-invokes the method body per reactive retry — UUID.randomUUID() before the Uni chain regenerates per retry invocation
Quarkus’s reactive programming model is built on Mutiny. A service method that returns io.smallrye.mutiny.Uni<T> is evaluated lazily — the Uni represents a deferred computation that produces at most one value and executes only when subscribed. Developers familiar with Mutiny’s lazy evaluation model understand that code inside a Uni.createFrom().item(supplier) supplier executes on subscription, not at assembly time. A developer applying this knowledge to idempotency keys might move UUID.randomUUID() outside the reactive chain to avoid re-evaluation on re-subscription:
// BillingService.java — UNSAFE: developer believes UUID before Uni chain is computed once.
// Wrong: SmallRye FT @Retry on a Uni-returning method re-invokes the ENTIRE METHOD BODY
// per retry attempt. UUID.randomUUID() before the Uni assembly is in the method body.
// Method body re-executes on retry → UUID.randomUUID() produces UUID_B on retry.
@ApplicationScoped
public class BillingService {
@Inject ReactiveStripeClient stripeClient;
@Inject BillingRecordRepository billingRepo;
@Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS,
retryOn = StripeNetworkException.class)
@Transactional
public Uni<BillingResult> chargeAsync(String customerId, long amountCents, String billingPeriod) {
// Developer intent: compute UUID once at method entry, capture in closure,
// so the Uni chain uses the same key even if Mutiny re-subscribes internally.
//
// WRONG: SmallRye FT re-invokes this method body per @Retry attempt.
// UUID.randomUUID() executes again on attempt 2 → UUID_B.
// The Uni chain assembled on attempt 2 captures UUID_B in its closure.
String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();
return Uni.createFrom().item(() -> buildChargeParams(customerId, amountCents, idempotencyKey))
.flatMap(params -> stripeClient.createCharge(params))
.flatMap(charge ->
billingRepo.persist(new BillingRecord(customerId, charge.getId(), billingPeriod))
.map(ignored -> new BillingResult(charge.getId(), idempotencyKey))
);
}
private ChargeCreateParams buildChargeParams(String customerId, long amountCents, String key) {
return ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.setIdempotencyKey(key)
.build();
}
}
The developer’s reasoning — “UUID is computed at method entry before the Uni chain, so it is evaluated at assembly time and captured as a stable closure variable” — is incorrect for SmallRye FT’s reactive retry implementation. When a method annotated with @Retry returns a Uni<T>, SmallRye FT does not simply re-subscribe to the same Uni instance on failure. It re-invokes the method body to obtain a new Uni assembly for each retry attempt. The Mutiny lazy evaluation model applies to the Uni’s internal operators — the method body that produces the Uni is re-executed by SmallRye FT per retry.
The failure sequence for a Stripe network timeout on attempt 1:
- Caller subscribes to the
Uni<BillingResult>returned by the service (or Quarkus RESTEasy Reactive does this implicitly for a JAX-RS endpoint returningUni). TransactionalInterceptor(outer, priority 200) opens transaction T1. Control passes toFaultToleranceInterceptor(inner, priority 1000), which begins the reactive retry loop.- SmallRye FT invokes the method body for attempt 1.
UUID.randomUUID()produces UUID_A. The method body assembles and returns aUniwithUUID_Acaptured in the closure. - SmallRye FT subscribes to the
Unifrom attempt 1. Mutiny evaluates the chain.Uni.createFrom().item()supplier fires, passingUUID_AtobuildChargeParams.stripeClient.createCharge()is called withUUID_A. - Stripe commits
ch_A($50) before the network response is delivered. A read timeout fires.stripeClient.createCharge()fails theUniwithStripeNetworkException. - SmallRye FT sees a failure on the
Unisubscription.StripeNetworkExceptionis inretryOn. After 500 ms, SmallRye FT re-invokes the method body for attempt 2. UUID.randomUUID()in the method body produces UUID_B. A newUniis assembled withUUID_Bin the closure.- SmallRye FT subscribes to the new
Uni. Stripe receives the charge request withUUID_B. Stripe has never seenUUID_B. Stripe commitsch_B($50). - The Uni chain completes successfully. A DB record is persisted for
ch_B. T1 commits. The caller receives aBillingResultreferencingch_B.ch_Ais committed at Stripe with no local record.
The subtle variant: Uni.createFrom().deferred() does not help
A developer who reads about Mutiny’s lazy types might try a different approach to prevent UUID re-evaluation: wrapping the entire Uni assembly in a Uni.createFrom().deferred() factory, reasoning that deferred evaluates the factory once per subscription and would therefore capture UUID at subscription time rather than assembly time:
// ATTEMPTED FIX: Uni.createFrom().deferred() — STILL UNSAFE.
// Developer reasoning: deferred evaluates the factory at subscription time (lazy),
// so UUID is computed once per subscription, captured in the inner Uni, and stable.
//
// Why it fails: SmallRye FT re-invokes the method body per retry, not re-subscribes
// to an existing Uni. The deferred() wrapper is inside the method body.
// Per @Retry retry, the method body re-executes: deferred() is re-created with a new
// factory closure. UUID.randomUUID() inside the factory executes per re-invocation.
@Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS)
@Transactional
public Uni<BillingResult> chargeAsync(String customerId, long amountCents, String billingPeriod) {
return Uni.createFrom().deferred(() -> {
// This supplier runs once per method body invocation by SmallRye FT.
// SmallRye FT invokes the method body once per retry attempt,
// so this supplier also runs once per retry attempt.
String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();
return Uni.createFrom().item(() -> buildChargeParams(customerId, amountCents, idempotencyKey))
.flatMap(params -> stripeClient.createCharge(params))
.flatMap(charge ->
billingRepo.persist(new BillingRecord(customerId, charge.getId(), billingPeriod))
.map(ignored -> new BillingResult(charge.getId(), idempotencyKey))
);
});
}
Uni.createFrom().deferred(supplier) creates a new lazy Uni whose assembly is deferred to subscription time — the supplier is called when the Uni is subscribed to. This is useful for avoiding premature computation of values that should be evaluated at subscription time rather than at the time the pipeline is assembled. It does not memoize or cache the result: the supplier runs every time the Uni is subscribed to.
SmallRye FT re-invokes the method body per retry. Each method body invocation creates a new deferred() wrapper with a new factory closure. When SmallRye FT subscribes to the new Uni from attempt 2, the deferred() supplier runs, calling UUID.randomUUID() and producing UUID_B. Neither the outer deferred() nor moving UUID anywhere inside the method body prevents re-evaluation when the method body itself is re-invoked.
The fix: pass the idempotency key as a method parameter
The same fix applies as in failure mode 1: compute the key in the caller, before any SmallRye FT or CDI interceptor runs, and pass it as a stable method parameter:
// BillingService.java — SAFE: idempotency key passed as parameter.
// SmallRye FT re-invokes the method body per retry.
// The method body receives the same key on every invocation.
@ApplicationScoped
public class BillingService {
@Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS,
retryOn = StripeNetworkException.class)
@Transactional
public Uni<BillingResult> chargeAsync(String customerId, long amountCents,
String billingPeriod, String idempotencyKey) {
return Uni.createFrom().item(() -> buildChargeParams(customerId, amountCents, idempotencyKey))
.flatMap(params -> stripeClient.createCharge(params))
.flatMap(charge ->
billingRepo.persist(new BillingRecord(customerId, charge.getId(), billingPeriod))
.map(ignored -> new BillingResult(charge.getId(), idempotencyKey))
);
}
}
// BillingResource.java — JAX-RS resource computes key before passing to service.
@Path("/billing")
@ApplicationScoped
public class BillingResource {
@Inject BillingService billingService;
@POST
@Path("/charge")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Uni<Response> charge(ChargeRequest req) {
// Key computed once per HTTP request, outside all SmallRye FT and CDI interceptors.
String idempotencyKey = sha256Hex(
req.getCustomerId() + ":" + req.getBillingPeriod() + ":quarkus-reactive-billing"
).substring(0, 32);
return billingService
.chargeAsync(req.getCustomerId(), req.getAmountCents(), req.getBillingPeriod(), idempotencyKey)
.map(result -> Response.ok(result).build());
}
}
The key property of this pattern is that idempotencyKey is computed in BillingResource.charge(), which is invoked once per HTTP request by the JAX-RS runtime. SmallRye FT interceptors do not apply to the resource method in this design — only to billingService.chargeAsync(). The resource method is never re-entered by @Retry. The service method receives the same idempotencyKey value on attempt 1 and on all retries.
Failure mode 3: @Retry(retryOn = OptimisticLockException.class) with Panache @Version — entire method body retried on version conflict — Stripe call before @Version-checked persist() — UUID_B per contention retry
Quarkus Panache supports optimistic locking via the standard JPA @Version annotation on entity fields. When two concurrent requests load the same entity and both attempt to modify and persist it, the second commit detects a version mismatch and throws jakarta.persistence.OptimisticLockException. A Panache entity with @Version looks like this:
// AccountEntity.java — Panache active-record entity with @Version for optimistic locking.
@Entity
@Table(name = "accounts")
public class AccountEntity extends PanacheEntity {
public String customerId;
public String billingStatus; // "active", "suspended", "past_due"
public String lastChargeId;
public long lastChargeAmountCents;
@Version
public long version; // JPA increments this on every successful persist/merge.
// If caller loaded version=5 and DB has version=6 on commit,
// Hibernate throws OptimisticLockException.
}
A common pattern in Quarkus is to annotate a service method with @Retry(retryOn = OptimisticLockException.class) to transparently handle version conflicts. The developer’s intent: re-load the entity with the latest version and retry the DB write. If the method also calls Stripe before the @Version-checked DB update, the retry retries more than the DB write:
// AccountBillingService.java — UNSAFE: @Retry retries the ENTIRE method body on OptimisticLockException.
// Developer intent: retry only the DB write when version conflict detected.
// Actual behavior: @Retry retries Stripe call + DB write together.
// UUID.randomUUID() at method entry regenerates per retry → UUID_B on contention retry.
// Concurrent load triggers OptimisticLockException rarely → only under production concurrency.
// Single-threaded @QuarkusTest never triggers OptimisticLockException → test always passes green.
@ApplicationScoped
public class AccountBillingService {
@Inject StripeClient stripeClient;
@Retry(
maxRetries = 3,
delay = 200,
delayUnit = ChronoUnit.MILLIS,
retryOn = OptimisticLockException.class
)
@Transactional
public BillingResult chargeAndUpdateAccount(String customerId, long amountCents, String billingPeriod) {
// UNSAFE: UUID generated at method entry.
// On OptimisticLockException retry, @Retry re-enters this method body.
// UUID.randomUUID() produces UUID_B. Stripe creates ch_B alongside committed ch_A.
String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();
// Stripe charge: committed externally to the JTA transaction.
// ch_A committed on attempt 1 before the OptimisticLockException is raised.
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);
}
// @Version-checked update: loads the entity within T1, checks version, updates fields.
// If another transaction committed a version bump between our load and this commit,
// Hibernate raises OptimisticLockException when the transaction tries to flush.
AccountEntity account = AccountEntity.find("customerId", customerId).firstResult();
account.billingStatus = "active";
account.lastChargeId = charge.getId();
account.lastChargeAmountCents = amountCents;
// Panache defers the flush to transaction commit.
// If concurrent update bumped version, OptimisticLockException fires at flush/commit time.
// @Transactional (outer) marks T1 rollback-only. FaultToleranceInterceptor catches the
// exception propagated from @Transactional, sees OptimisticLockException in retryOn,
// and re-invokes the method body for attempt 2 within a NEW transaction (because T1
// is now rolled back and @Transactional must open T2).
// UUID.randomUUID() in the method body produces UUID_B. ch_B committed at Stripe.
return new BillingResult(charge.getId(), idempotencyKey);
}
}
The failure sequence under concurrent load:
- Two concurrent HTTP requests both trigger account billing for
cus_Aat the same time. - Request R1 invokes
chargeAndUpdateAccount("cus_A", 5000, "2026-Q4").TransactionalInterceptoropens T1. - Request R2 also invokes
chargeAndUpdateAccount("cus_A", 5000, "2026-Q4").TransactionalInterceptoropens T2 on a separate thread. - R1 method body:
UUID.randomUUID()produces UUID_A1. Stripe commitsch_A1.AccountEntity.find()within T1 loadsaccountwithversion=5. Entity fields updated. - R2 method body:
UUID.randomUUID()produces UUID_A2. Stripe commitsch_A2.AccountEntity.find()within T2 also loadsaccountwithversion=5. Entity fields updated. - R1 T1 commits successfully. Hibernate sets DB
version=6. - R2 T2 tries to commit. Hibernate detects
version=5in the UPDATE statement but DB hasversion=6. Hibernate throwsOptimisticLockException. T2 is rolled back. FaultToleranceInterceptoron R2’s thread catchesOptimisticLockException(inretryOnlist). It waits 200 ms and calls the method body again for R2’s attempt 2.TransactionalInterceptoropens a new T3.- R2 method body retry:
UUID.randomUUID()produces UUID_B2. Stripe has never seenUUID_B2. Stripe commitsch_B2. The customer (cus_A) now hasch_A1,ch_A2, andch_B2committed at Stripe for the same billing period.
In practice, for monthly billing where each billing period is unique, ch_A1 and ch_A2 might both be from R1 and R2 concurrently billing the same customer — which itself is a logic bug. But the triple-charge scenario (ch_A2 from R2’s attempt 1, plus ch_B2 from R2’s retry) represents a pure artifact of the @Retry + UUID regeneration interaction, independent of the concurrent billing duplication.
The subtle variant: single-threaded @QuarkusTest never triggers OptimisticLockException
OptimisticLockException requires two concurrent transactions to modify the same entity with the same version number. A single-threaded integration test runs one transaction at a time: it never creates the concurrent-write scenario that triggers the exception. @Retry(retryOn = OptimisticLockException.class) never fires in the test. The double-charge path — from @Retry re-entering the method body with UUID_B — is never exercised.
The test passes green. The service ships. Under production load, where multiple worker threads process billing events concurrently for the same high-activity accounts, version conflicts occur at low but non-zero frequency. Each conflict triggers a retry. Each retry generates UUID_B and commits a second Stripe charge. The bug appears as an intermittent “occasional double charge” that reproduces only under load and disappears in isolated test runs.
A developer attempting to test this in @QuarkusTest must explicitly set up concurrent requests and force a version conflict — not straightforward with Panache’s transaction model in a test harness. The correct approach is to structure the code so that @Retry on OptimisticLockException never wraps a Stripe call in the first place.
The fix: separate the Stripe call from the @Version-checked DB write
The structural fix is to ensure that @Retry(retryOn = OptimisticLockException.class) applies only to the DB write, never to the Stripe call. Two methods: one that calls Stripe (no @Retry), one that performs the @Version-checked update (with @Retry limited to OptimisticLockException). The Stripe call receives the stable idempotency key from the caller:
// AccountBillingService.java — SAFE: Stripe call and @Version-checked DB write are separated.
// chargeAndUpdateAccount(): called once per billing request. Computes stable key.
// Calls Stripe (no @Retry). Passes chargeId to updateAccountWithRetry().
// updateAccountWithRetry(): @Retry(OptimisticLockException) applies only to the DB write.
// Never calls UUID.randomUUID(). Never calls Stripe. Safe to retry.
@ApplicationScoped
public class AccountBillingService {
@Inject StripeClient stripeClient;
// Entry point: no @Retry here. Stripe is called exactly once.
// If Stripe fails with a network error, the caller retries from outside with the same key.
@Transactional
public BillingResult chargeAndUpdateAccount(String customerId, long amountCents,
String billingPeriod, String idempotencyKey) {
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);
}
// Pass chargeId to the @Version-checked update method.
// updateAccountWithRetry() can retry on OptimisticLockException safely
// because it never calls Stripe — it only updates the DB record with
// the chargeId that was already returned from Stripe.
updateAccountWithRetry(customerId, charge.getId(), amountCents);
return new BillingResult(charge.getId(), idempotencyKey);
}
// Inner method: @Retry applies only here.
// retryOn = OptimisticLockException.class: retries only on DB version conflict.
// No UUID.randomUUID() call. No Stripe call. Safe to retry N times.
// chargeId is passed as a stable parameter from chargeAndUpdateAccount().
@Retry(
maxRetries = 5,
delay = 50,
delayUnit = ChronoUnit.MILLIS,
retryOn = OptimisticLockException.class
)
@Transactional(Transactional.TxType.REQUIRES_NEW)
void updateAccountWithRetry(String customerId, String chargeId, long amountCents) {
// Re-loads the entity with the LATEST version on every retry.
// If T_outer committed version=6 between our load and our commit,
// Hibernate sees the mismatch and throws OptimisticLockException.
// @Retry re-enters this method: entity is reloaded with version=6 → update succeeds.
// Stripe is never called here. chargeId is a stable input parameter.
AccountEntity account = AccountEntity.find("customerId", customerId).firstResult();
account.billingStatus = "active";
account.lastChargeId = chargeId;
account.lastChargeAmountCents = amountCents;
// Flush happens at REQUIRES_NEW transaction commit.
// OptimisticLockException fires here on version mismatch.
// @Retry catches it and re-enters this method with fresh entity load.
}
}
REQUIRES_NEW on updateAccountWithRetry() is important: if the outer method’s transaction is still open when updateAccountWithRetry() is called, REQUIRED would join the outer transaction. A PersistenceException within the joined transaction would mark it rollback-only. REQUIRES_NEW opens a separate inner transaction for the DB write, allowing the outer transaction’s state to remain unaffected by the inner retry logic. The trade-off is that the Stripe charge and the DB update are no longer in the same ACID transaction — but for external API calls like Stripe, true ACID across HTTP boundaries is impossible anyway. The idempotency key provides the correctness guarantee instead.
Integration tests with @QuarkusTest and WireMock
The integration test goal for all three failure modes is the same: assert that all Stripe requests within a single logical billing operation carry an identical Idempotency-Key header value. For failure modes 1 and 2, this requires triggering a retry (WireMock returns HTTP 503 on attempt 1). For failure mode 3, this requires triggering an OptimisticLockException on the first attempt (which requires concurrent transactions — a more complex test setup).
// BillingServiceTest.java
@QuarkusTest
@QuarkusTestResource(WireMockTestResource.class)
class BillingServiceTest {
@Inject BillingService billingService;
@InjectWireMock
WireMockServer wireMock;
// Test for failure mode 1: @Retry (inner) retries method body within @Transactional (outer).
// Verifies that both Stripe requests carry identical Idempotency-Key headers.
@Test
@Transactional
void idempotencyKeyStableAcrossRetries_blockingService() {
// WireMock: fail attempt 1 with 503, succeed attempt 2.
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retry-scenario")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("retry-ready"));
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retry-scenario")
.whenScenarioStateIs("retry-ready")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_test_A\",\"object\":\"charge\",\"amount\":5000,\"status\":\"succeeded\"}")));
// Compute stable key as the JAX-RS resource would.
String stableKey = sha256Hex("cus_A:2026-Q4:quarkus-billing").substring(0, 32);
// Fixed implementation: key passed as parameter → stable across retries.
assertDoesNotThrow(() -> billingService.chargeAndRecord("cus_A", 5000, "2026-Q4", stableKey));
List<LoggedRequest> stripeRequests = wireMock.findAll(
postRequestedFor(urlPathEqualTo("/v1/charges")));
assertThat(stripeRequests).hasSize(2);
String keyOnAttempt1 = stripeRequests.get(0).getHeader("Idempotency-Key");
String keyOnAttempt2 = stripeRequests.get(1).getHeader("Idempotency-Key");
// Unfixed version: keyOnAttempt1 != keyOnAttempt2 (UUID_A vs UUID_B).
// Fixed version: both carry the same content-hash key.
assertThat(keyOnAttempt2)
.as("Idempotency-Key must be identical across @Retry attempts")
.isEqualTo(keyOnAttempt1);
}
// Test for failure mode 2: SmallRye FT reactive @Retry on Uni<T>.
// Verifies that both Stripe requests carry identical Idempotency-Key headers
// when SmallRye FT re-invokes the Uni method body per retry.
@Test
void idempotencyKeyStableAcrossRetries_reactiveService() {
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("reactive-retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("retry-ready"));
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("reactive-retry")
.whenScenarioStateIs("retry-ready")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_test_B\",\"object\":\"charge\",\"amount\":5000,\"status\":\"succeeded\"}")));
String stableKey = sha256Hex("cus_B:2026-Q4:quarkus-reactive-billing").substring(0, 32);
// Fixed reactive service: key passed as parameter to chargeAsync().
BillingResult result = billingService.chargeAsync("cus_B", 5000, "2026-Q4", stableKey)
.await().atMost(Duration.ofSeconds(5));
assertThat(result).isNotNull();
List<LoggedRequest> stripeRequests = wireMock.findAll(
postRequestedFor(urlPathEqualTo("/v1/charges")));
assertThat(stripeRequests).hasSize(2);
String keyOnAttempt1 = stripeRequests.get(0).getHeader("Idempotency-Key");
String keyOnAttempt2 = stripeRequests.get(1).getHeader("Idempotency-Key");
// The unfixed version (UUID in method body) fails this assertion:
// SmallRye FT re-invokes the method body → UUID_B in closure on attempt 2 → keys differ.
assertThat(keyOnAttempt2)
.as("Idempotency-Key must be identical across SmallRye FT reactive retries")
.isEqualTo(keyOnAttempt1);
}
// Test for failure mode 3: @Retry(OptimisticLockException) + Panache @Version.
// The fixed implementation calls Stripe exactly once and passes chargeId to the inner
// @Retry method. This test verifies no second Stripe call is made.
@Test
void stripeCalledOnce_onOptimisticLockRetry() {
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_test_C\",\"object\":\"charge\",\"amount\":5000,\"status\":\"succeeded\"}")));
String stableKey = sha256Hex("cus_C:2026-Q4:quarkus-account-billing").substring(0, 32);
// Fixed implementation: chargeAndUpdateAccount() calls Stripe once, then
// delegates to updateAccountWithRetry() which has @Retry(OptimisticLockException).
// The OptimisticLockException retry loop never calls Stripe.
assertDoesNotThrow(() -> accountBillingService.chargeAndUpdateAccount(
"cus_C", 5000, "2026-Q4", stableKey));
List<LoggedRequest> stripeRequests = wireMock.findAll(
postRequestedFor(urlPathEqualTo("/v1/charges")));
// Fixed: Stripe called exactly once regardless of how many OptimisticLockException
// retries updateAccountWithRetry() makes internally.
assertThat(stripeRequests).hasSize(1);
// The single Stripe request carries the stable key from the caller.
assertThat(stripeRequests.get(0).getHeader("Idempotency-Key"))
.isEqualTo(stableKey);
}
}
The WireMock scenario-based stubbing for failure modes 1 and 2 simulates the transient 503 that triggers a SmallRye FT retry. The key assertions are the header equality checks: on the unfixed implementations where UUID.randomUUID() is in the method body, keyOnAttempt1 != keyOnAttempt2 and the isEqualTo assertion fails, revealing the bug. For failure mode 3, the assertion that WireMock received exactly one request verifies that the @Retry(OptimisticLockException) inner method does not re-enter the Stripe call path.
Summary: three Quarkus MicroProfile Fault Tolerance – specific Stripe double-charge patterns
| Failure mode | Root cause | Observable symptom | Fix |
|---|---|---|---|
Mode 1: @Transactional outer (priority 200), @Retry inner (priority 1000) |
Quarkus CDI priority ordering makes @Transactional the outer wrapper — @Retry retries the method body within the same transaction — UUID.randomUUID() at method entry regenerates per retry — Stripe network timeout on attempt 1 commits ch_A — UUID_B on retry commits ch_B |
Two Stripe charges with different Idempotency-Key headers for the same billing period; second charge visible in Stripe Dashboard but paired with a local DB record (because ch_B’s attempt succeeded); ch_A is an orphan with no DB record |
Pass idempotency key as method parameter; compute stable content-hash key in caller before @Retry boundary |
Mode 2: SmallRye FT @Retry on Uni<T> — method body re-invoked per reactive retry |
SmallRye FT re-invokes the method body per retry to get a new Uni assembly — UUID.randomUUID() before the Uni chain executes per method-body invocation — UUID_B in the new Uni’s closure on retry |
Same as Mode 1; the reactive Uni chain gives false confidence that code “before the Uni” is assembly-time and therefore stable — the bug affects both blocking and reactive SmallRye FT services identically |
Same fix as Mode 1; additionally: Uni.createFrom().deferred() does not prevent re-evaluation — only a method-parameter key is safe |
Mode 3: @Retry(OptimisticLockException) + Panache @Version |
@Retry retries the entire method body on DB version conflict — Stripe call precedes the @Version-checked DB update — UUID_B on every contention retry — single-threaded tests never trigger OptimisticLockException |
Intermittent double charges under concurrent load; absent in test runs; correlates with high-contention accounts; reproduces only with two concurrent requests modifying the same entity | Separate Stripe call (no @Retry) from @Version-checked DB write (@Retry(OptimisticLockException) inner method); pass chargeId as stable parameter to inner method; use REQUIRES_NEW on inner method to isolate transaction state |
All three modes share the root pattern: UUID.randomUUID() inside a method body that SmallRye FT re-enters on retry. The transaction context changes between modes (one transaction shared across all retries in modes 1 and 2; a fresh transaction per contention retry in mode 3), but the UUID regeneration problem is independent of transaction scope. The fix is always to compute the key outside the retry boundary — before the method body that @Retry or SmallRye FT re-invokes.
The CDI priority ordering in Quarkus — where @Transactional (priority 200) is outer and SmallRye FT (priority 1000) is inner — is the inverse of Micronaut’s default. Developers who work across both frameworks or who read documentation for one while building in the other may have incorrect mental models of which interceptor is outermost. Both orderings exhibit the same UUID regeneration failure: the mechanism is different (fresh transaction per Micronaut retry vs. shared transaction per Quarkus retry), but the observable outcome — two Stripe charges for the same billing period — is identical.
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 service receives an explicit error rather than silently creating an unauthorized second charge.
Related posts in this series: Quarkus and Stripe Integration (core post) — MicroProfile REST Client and Quarkus @QuarkusTest Stripe Integration — Micronaut Data @Transactional and Stripe Integration — Spring Cloud Gateway 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 SmallRye FT @Retry 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.