Quarkus Mutiny Uni.onFailure().retry() and Stripe Integration: How UUID Regeneration on Uni Re-subscription, UUID in transformToUni Mapper, and @Retry Method Re-invocation Generate Duplicate Charges or Idempotency Conflicts
Quarkus Mutiny’s Uni is a lazy reactive type: the upstream pipeline is re-evaluated on every new subscription. Uni.onFailure().retry() triggers a re-subscription to the upstream Uni on each retry attempt. If UUID.randomUUID() lives inside a Uni.createFrom().completionStage(supplier) factory, inside a Uni.createFrom().item(supplier) factory, or inside a transformToUni(mapper) operator, each retry re-subscription evaluates that code again and generates a fresh UUID. When Stripe has already committed ch_A on attempt 1 before a transient network error prevented the response from arriving, the retry reaches Stripe with UUID_B — a key Stripe has never seen — and Stripe commits ch_B alongside ch_A. MicroProfile Fault Tolerance @Retry on a method returning Uni<T> re-invokes the method body on each attempt, so UUID.randomUUID() at method start also regenerates per @Retry attempt — UUID_B — ch_B.
Background: how Mutiny’s Uni subscription model works and why retry re-subscribes
Mutiny’s Uni<T> is a cold, lazy reactive type. “Cold” means the computation described by the Uni pipeline does not start until a subscriber subscribes to it. “Lazy” means every call to subscribe() triggers the full upstream pipeline from scratch — the computation is not shared across subscribers unless you explicitly introduce multicast operators like Uni.createFrom().item(value) where the value is already computed and passed by reference rather than re-evaluated in a factory lambda.
The distinction between Uni.createFrom().item(T) and Uni.createFrom().item(Supplier<T>) is critical for idempotency. Uni.createFrom().item(someValue) captures the already-computed value someValue and emits it to every subscriber without re-invoking any computation. Uni.createFrom().item(() -> computeValue()) captures a supplier lambda and calls computeValue() on each subscription. The same distinction applies to Uni.createFrom().completionStage(CompletionStage) vs Uni.createFrom().completionStage(Supplier<CompletionStage>): the former subscribes once to a specific CompletionStage instance regardless of how many Mutiny subscribers there are; the latter calls the supplier to obtain a new CompletionStage on each subscription.
The onFailure().retry() operator in Mutiny works by subscribing again to the upstream Uni when the upstream emits a failure. Concretely, the retry infrastructure calls subscribe(downstream) on the upstream Uni again. Since Uni is cold, this re-subscription triggers the upstream pipeline from scratch — all factory lambdas in createFrom(supplier) and all mapper functions in transformToUni(mapper) and chain(mapper) operators are called again. The effect is indistinguishable from calling the method that builds the Uni pipeline multiple times.
Stripe’s idempotency system depends on a stable idempotency key across all retry attempts of the same billing intent. The key must be the same string for every attempt so that Stripe can detect “I already processed this intent, here is the committed result” and return the prior result rather than processing a new charge. If UUID.randomUUID() is called inside any part of the Mutiny pipeline that is re-evaluated on re-subscription — a supplier factory, a mapper, or even inline in a chain or call operator — the idempotency key changes on each retry attempt. A changed key tells Stripe the request is a brand-new intent. If Stripe committed ch_A on the first attempt before the transient failure, the retry’s UUID_B produces ch_B — a second charge.
The Mutiny retry operators most relevant to Stripe billing are: onFailure().retry().atMost(N), which retries up to N times; onFailure().retry().withBackOff(Duration), which introduces exponential or fixed delay between retries; and onFailure().retry().until(predicate), which retries until a predicate on the failure returns false. All of them re-subscribe to the upstream Uni — the UUID regeneration problem applies to all.
Failure mode 1: UUID.randomUUID() inside Uni.createFrom().completionStage(supplier) factory — retry re-subscribes to supplier — UUID_B — ch_B
The most common pattern in Quarkus reactive code is to wrap an asynchronous SDK call — such as the Stripe Java SDK’s ChargesAsyncClient, or any CompletableFuture-returning API — inside a Uni.createFrom().completionStage(supplier). The supplier form is necessary when the CompletableFuture is obtained by calling a method, because calling the method is the act of kicking off the computation. Developers write the supplier as a lambda that performs the full setup — building request parameters, constructing the idempotency key, calling the SDK method. The idempotency key ends up inside the supplier lambda.
// BillingService.java — UNSAFE: UUID.randomUUID() inside the completionStage supplier.
// The supplier is called per subscription. onFailure().retry() re-subscribes.
// Each retry re-invokes the supplier lambda, which calls UUID.randomUUID() again.
@ApplicationScoped
public class BillingService {
@Inject
StripeClient stripeClient;
public Uni<String> chargeCustomer(
String customerId, String planId,
long amountCents, String billingPeriod) {
return Uni.createFrom().completionStage(() -> {
// BUG: UUID.randomUUID() is inside the supplier lambda.
// This lambda is called once per subscription.
// onFailure().retry() re-subscribes, which calls this lambda again.
// Each retry attempt generates a different UUID.
String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":" + UUID.randomUUID();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
return stripeClient.charges().createAsync(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
})
.onFailure(StripeException.class)
.retry()
.withBackOff(Duration.ofMillis(500))
.atMost(3);
}
}
The failure sequence for a $49.00 subscription charge:
- A
@Scheduledjob or an incoming HTTP request callsbillingService.chargeCustomer("cust_xyz", "pro-monthly", 4900L, "2026-10")and subscribes to the returnedUni. - Attempt 1: Mutiny subscribes to the
completionStageUni. The supplier lambda is called.UUID.randomUUID()producesUUID_A = "f3e7a...". The idempotency key is"cust_xyz:billing:pro-monthly:2026-10:f3e7a...". The Stripe HTTP request is sent. Stripe processes the charge and commitsch_A = "ch_3R...". ASocketTimeoutExceptionfires before the 200 OK arrives at the client. Mutiny receives anIOExceptionwrapping the timeout, which Mutiny wraps in aStripeException. - The
onFailure(StripeException.class).retry()operator intercepts the failure. This is attempt 1 of 3 retries. Mutiny waits 500 ms (the configured backoff), then re-subscribes to the upstreamcompletionStageUni. - Attempt 2: The supplier lambda is called again (because re-subscription triggers the cold
Unifrom scratch).UUID.randomUUID()producesUUID_B = "91c04...". The idempotency key is"cust_xyz:billing:pro-monthly:2026-10:91c04...". Stripe receives a charge request with this new idempotency key. Stripe has never seenUUID_B. Stripe processes a new charge and commitsch_B = "ch_4S...". Customercust_xyzis billed $49.00 twice in October.
The developer’s intention was to retry the same charge on a transient network failure. The code correctly uses an idempotency key. The mistake is locating the key generation inside the supplier lambda, where it is evaluated per subscription. The Uni is cold: subscribing twice is executing the pipeline twice, including the UUID.randomUUID() call. The Mutiny retry operator does nothing special to preserve the first subscription’s state — it is exactly equivalent to the caller subscribing to the Uni twice, each time constructing a fresh idempotency key.
A subtler variant of this failure mode uses Uni.createFrom().item(supplier) to generate the UUID and then chains it into the Stripe call:
// ALSO UNSAFE: UUID in Uni.createFrom().item(supplier) chained into the Stripe call.
// item(supplier) factory is called per subscription — same re-subscription problem.
return Uni.createFrom().item(() -> {
// BUG: UUID generated inside the item supplier — re-evaluated per subscription.
return customerId + ":billing:" + planId + ":" + billingPeriod
+ ":" + UUID.randomUUID();
})
.onItem().transformToUni(idempotencyKey -> {
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("billing_period", billingPeriod)
.build();
return Uni.createFrom().completionStage(
stripeClient.charges().createAsync(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()));
})
.onFailure().retry().atMost(3);
On retry, the re-subscription propagates from the onFailure().retry() operator upstream through transformToUni all the way to the root Uni.createFrom().item(supplier). The supplier is called again, producing UUID_B. The transformToUni mapper receives UUID_B and constructs the Stripe request with the new key. Same duplicate charge outcome.
The fix for failure mode 1
Generate the idempotency key from immutable business data before constructing the Uni pipeline. Capture the pre-computed key as a final local variable in the calling method. The key is a plain string that already exists in memory; the supplier lambda captures it by reference and uses the same value on every subscription.
// BillingService.java — SAFE: key computed before the Uni pipeline, captured by reference.
// The supplier lambda captures the already-computed String — same value every subscription.
@ApplicationScoped
public class BillingService {
@Inject
StripeClient stripeClient;
public Uni<String> chargeCustomer(
String customerId, String planId,
long amountCents, String billingPeriod) {
// SAFE: compute the key once, outside the Uni pipeline.
// Derived from immutable business data — same value for all retry attempts
// of this billing intent. No UUID.randomUUID() call inside any lambda.
final String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":v1";
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
return Uni.createFrom().completionStage(() ->
// The lambda captures idempotencyKey by reference — already computed.
// Every re-subscription sends the same key to Stripe.
// Stripe recognizes the key from attempt 1 and returns ch_A's result.
stripeClient.charges().createAsync(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()))
.onFailure(StripeException.class)
.retry()
.withBackOff(Duration.ofMillis(500))
.atMost(3);
}
}
On retry, the re-subscription calls the supplier lambda again, which calls stripeClient.charges().createAsync(...) with the same idempotencyKey string from the outer scope. Stripe receives the same idempotency key. Stripe finds the record of the previous attempt, sees that ch_A was committed, and returns the committed result without processing a new charge. The retry succeeds idempotently.
If the billing intent requires a truly random component — for example, if planId alone does not uniquely identify the intent within a billing period — generate a random nonce once in the calling code before the Uni pipeline and persist it (in a database or a distributed cache keyed to the billing record) so that it survives application restarts and is reused on re-entry. Never generate the nonce inside the Uni pipeline.
Failure mode 2: UUID.randomUUID() inside transformToUni mapper in the Uni pipeline — retry re-subscribes through the mapper — UUID_B — ch_B
A common pattern in reactive Quarkus code is to fetch prerequisite data from a reactive datasource, then use the fetched data as input to a downstream transformToUni (also called chain) to perform the Stripe call. The developer places the UUID.randomUUID() call inside the transformToUni mapper, reasoning that the mapper receives the fetched data and constructs the charge parameters from it. The developer may believe that the mapper is only executed once per top-level call. But the mapper is part of the upstream pipeline relative to the onFailure().retry() operator — re-subscription traverses through the mapper on each retry.
// BillingService.java — UNSAFE: UUID inside transformToUni mapper.
// The retry operator is downstream of the transformToUni.
// Re-subscription from the retry reaches back to the root Uni and flows forward
// through the transformToUni mapper, which runs again with a new UUID.
@ApplicationScoped
public class BillingService {
@Inject
StripeClient stripeClient;
@Inject
CustomerRepository customerRepository;
public Uni<String> chargeSubscriber(String subscriberId, String billingPeriod) {
return customerRepository.findByIdReactive(subscriberId) // Uni<Customer>
.onItem().transformToUni(customer -> {
// BUG: UUID inside the transformToUni mapper.
// This mapper is upstream of the onFailure().retry() operator.
// On each retry re-subscription, the mapper runs again.
// customer.getPlanId(), customer.getAmountCents() are stable — but
// UUID.randomUUID() is not. Each mapper invocation produces a new UUID.
String idempotencyKey = subscriberId + ":billing:"
+ customer.getPlanId() + ":" + billingPeriod
+ ":" + UUID.randomUUID();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(customer.getAmountCents())
.setCurrency("usd")
.setCustomer(customer.getStripeCustomerId())
.putMetadata("plan_id", customer.getPlanId())
.putMetadata("billing_period", billingPeriod)
.build();
return Uni.createFrom().completionStage(
stripeClient.charges().createAsync(params,
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()));
})
.onFailure(StripeException.class)
.retry()
.withBackOff(Duration.ofMillis(500))
.atMost(3);
}
}
The failure sequence:
- Subscription begins. The root
UniiscustomerRepository.findByIdReactive(subscriberId). Mutiny subscribes to it. The reactive datasource returns theCustomerrecord. ThetransformToUnimapper runs:UUID.randomUUID()producesUUID_A. The Stripe HTTP request is sent withidempotencyKeycontainingUUID_A. Stripe commitsch_A. A transientStripeException(wrapping a network timeout) fires before the 200 OK arrives. - The
onFailure(StripeException.class).retry()operator intercepts the failure. It re-subscribes to the upstreamUni. The upstream includes thetransformToUnimapper. - Re-subscription triggers
customerRepository.findByIdReactive(subscriberId)again (a redundant database read). The repository returns the sameCustomer. ThetransformToUnimapper runs again.UUID.randomUUID()producesUUID_B. Stripe receives the charge request withUUID_B. Stripe has no record ofUUID_B. Stripe commitsch_B. Subscriber billed twice.
This failure mode has an additional side effect beyond the duplicate charge: the retry re-executes the repository read. In this case the extra read is benign, but if the upstream pipeline includes a side-effectful step — such as creating a database record, sending a notification, or decrementing a counter — re-subscription duplicates those side effects as well. The reactive retry-by-re-subscription model is correct for truly idempotent pipelines. It breaks down whenever any operator in the upstream pipeline has side effects or generates non-deterministic values like random UUIDs.
The fix for failure mode 2
Separate the data-fetching step from the charge-construction step. Fetch the prerequisite data first, compute the idempotency key from the fetched data (which is now a stable, already-known value) before entering the Stripe call Uni, and then retry only the Stripe call itself rather than the entire pipeline from root.
// BillingService.java — SAFE: key computed inside the mapper but WITHOUT UUID.randomUUID().
// The key is derived entirely from immutable business data already present in 'customer'.
// No UUID.randomUUID() call in the mapper — same key on every re-subscription.
@ApplicationScoped
public class BillingService {
@Inject
StripeClient stripeClient;
@Inject
CustomerRepository customerRepository;
public Uni<String> chargeSubscriber(String subscriberId, String billingPeriod) {
return customerRepository.findByIdReactive(subscriberId)
.onItem().transformToUni(customer -> {
// SAFE: key derived from stable, deterministic fields — no UUID.randomUUID().
// subscriberId, planId, billingPeriod are all known before the Stripe call.
// The same combination always produces the same key string.
String idempotencyKey = subscriberId + ":billing:"
+ customer.getPlanId() + ":" + billingPeriod + ":v1";
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(customer.getAmountCents())
.setCurrency("usd")
.setCustomer(customer.getStripeCustomerId())
.putMetadata("plan_id", customer.getPlanId())
.putMetadata("billing_period", billingPeriod)
.build();
// Retry only the Stripe call, not the repository read.
// If the Stripe call fails and retries, the same idempotencyKey is used.
return Uni.createFrom().completionStage(
stripeClient.charges().createAsync(params,
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()))
.onFailure(StripeException.class)
.retry()
.withBackOff(Duration.ofMillis(500))
.atMost(3);
});
// No outer onFailure().retry() — the retry is scoped to the Stripe call only.
// The repository read is not retried on Stripe failure.
}
}
By moving the onFailure().retry() operator inside the transformToUni mapper — scoped to the Uni that wraps only the Stripe API call — the retry only re-subscribes to the inner Uni. The inner Uni is Uni.createFrom().completionStage(...) with the supplier lambda capturing the already-computed idempotencyKey string. Each retry re-subscribes to the inner completionStage supplier. The supplier captures the pre-computed key and passes the same value to every Stripe request. The repository read is not repeated. Stripe deduplicates the retry.
If the idempotency key cannot be derived entirely from immutable business data — for instance, if a nonce is truly needed — generate the nonce once before or at the start of the transformToUni mapper body (as a final local variable), then capture it in the inner completionStage supplier lambda. The nonce is generated once when the mapper first runs; the inner retry captures the same nonce value because it is closed over in the lambda rather than re-computed in the supplier.
// SAFE variant with a nonce: nonce generated once in the mapper body,
// captured by the inner Uni supplier — same value on every inner retry.
return customerRepository.findByIdReactive(subscriberId)
.onItem().transformToUni(customer -> {
// Nonce generated once per mapper invocation — not per inner retry.
final String nonce = UUID.randomUUID().toString();
final String idempotencyKey = subscriberId + ":billing:"
+ customer.getPlanId() + ":" + billingPeriod + ":" + nonce;
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(customer.getAmountCents())
.setCurrency("usd")
.setCustomer(customer.getStripeCustomerId())
.build();
// The supplier lambda captures idempotencyKey (final local) — same on every
// inner re-subscription. The nonce is not re-generated inside the supplier.
return Uni.createFrom().completionStage(
stripeClient.charges().createAsync(params,
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()))
.onFailure(StripeException.class)
.retry()
.atMost(3);
});
The tradeoff: if the outer transformToUni mapper is itself retried (by an outer retry operator), the nonce is regenerated per outer retry invocation — the inner and outer retry boundaries are not the same. If outer retries are possible, use a stable business-data key rather than a nonce, or persist the nonce to stable storage before the Uni pipeline so it survives re-entry.
Failure mode 3: MicroProfile Fault Tolerance @Retry on a method returning Uni<T> — Quarkus re-invokes the method body — UUID at method start — UUID_B — ch_B
Quarkus integrates MicroProfile Fault Tolerance (SmallRye Fault Tolerance) with Mutiny through reactive-aware interceptors. When a CDI method annotated with @Retry returns a reactive type (Uni<T>, Multi<T>, or CompletionStage<T>), Quarkus’s fault tolerance interceptor does not treat the returned reactive type as the computation to retry. Instead, it re-invokes the method body itself on each retry attempt to obtain a new reactive instance. This is the correct behavior for reactive methods: a method that returns a Uni is a factory that produces a new computation description on each invocation, and re-invoking it gives a fresh Uni to subscribe to for the retry attempt.
The consequence for idempotency: UUID.randomUUID() called at the start of the method body — outside any lambda, appearing to run only once when the method is called — is re-evaluated on each @Retry attempt because each attempt re-enters the method body from the top.
// BillingService.java — UNSAFE: UUID.randomUUID() at start of method body.
// Developer intent: "UUID is generated once at method entry, before the Uni pipeline."
// BUG: @Retry re-invokes the method body on each attempt.
// UUID.randomUUID() runs again on each @Retry invocation — UUID_B — ch_B.
@ApplicationScoped
public class BillingService {
@Inject
StripeClient stripeClient;
@Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS,
retryOn = StripeException.class)
public Uni<String> chargeCustomer(
String customerId, String planId,
long amountCents, String billingPeriod) {
// BUG: This line runs once per @Retry attempt, not once per top-level call.
// The developer expects it to run once before the pipeline is constructed.
// But @Retry calls this method again on each retry — so UUID.randomUUID()
// is called N times for N @Retry attempts.
String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":" + UUID.randomUUID();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
return Uni.createFrom().completionStage(
stripeClient.charges().createAsync(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()));
}
}
The failure sequence:
- A caller invokes
billingService.chargeCustomer("cust_abc", "pro-annual", 11988L, "2026-10"). The CDI proxy intercepts the call. The@Retryinterceptor calls the method body for attempt 1. - Method body attempt 1:
UUID.randomUUID()executes.UUID_A = "3b41f...". The method builds theUnipipeline and returns it. The interceptor subscribes to the returnedUni. The Stripe HTTP request is sent with idempotency key containingUUID_A. Stripe commitsch_A = "ch_5T...". ASocketTimeoutExceptionfires before the response arrives. TheUniemits a failure with aStripeException. - The
@Retryinterceptor catches the failure from the subscribedUni. The failure type isStripeException— it matchesretryOn = StripeException.class. The interceptor waits 500 ms, then calls the method body again for attempt 2. - Method body attempt 2:
UUID.randomUUID()executes again.UUID_B = "7d92e...". The method returns a newUni. The interceptor subscribes to this newUni. Stripe receives the charge request withUUID_B. Stripe has no record ofUUID_B. Stripe commitsch_B = "ch_6U...". Subscriber billed twice at $119.88 each.
A variant of this pattern that looks safer but is equally broken uses Uni.createFrom().deferred() or Uni.createFrom().voidItem().onItem().transformToUni(ignored -> ...) to defer execution while still placing UUID.randomUUID() in the method body before the pipeline:
// ALSO UNSAFE variant with Uni.createFrom().deferred().
// The deferred() supplier is called per subscription — but @Retry calls the METHOD again,
// so a new deferred Uni (with a new UUID from the method body) is created per @Retry attempt.
@Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS,
retryOn = StripeException.class)
public Uni<String> chargeCustomer(
String customerId, String planId,
long amountCents, String billingPeriod) {
// BUG: Still in the method body — @Retry re-invokes the method, UUID regenerates.
final String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":" + UUID.randomUUID();
return Uni.createFrom().deferred(() -> {
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
return Uni.createFrom().completionStage(
stripeClient.charges().createAsync(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()));
});
}
This looks safer: idempotencyKey is final and computed before the deferred lambda. But @Retry calls the method again, so a new idempotencyKey with a new UUID.randomUUID() is constructed for each @Retry attempt. The final keyword only prevents the local variable from being reassigned within a single method invocation — it does not prevent the method from being called again.
The fix for failure mode 3
The idempotency key must be stable across @Retry method re-invocations. The only way to achieve this when using @Retry on a reactive method is either to derive the key entirely from the method parameters (which are passed in identically on each retry, since @Retry calls the same method with the same arguments), or to pass a pre-generated stable key in as a parameter.
// BillingService.java — SAFE: key derived from method parameters, no UUID.randomUUID().
// @Retry calls the method again with the same arguments on each retry.
// The key derived from those arguments is identical on every attempt.
@ApplicationScoped
public class BillingService {
@Inject
StripeClient stripeClient;
@Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS,
retryOn = StripeException.class)
public Uni<String> chargeCustomer(
String customerId, String planId,
long amountCents, String billingPeriod) {
// SAFE: key derived entirely from method parameters.
// @Retry re-invokes with the same parameters — same key on every attempt.
// No UUID.randomUUID() — no random element that would change per invocation.
final String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":v1";
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
return Uni.createFrom().completionStage(
stripeClient.charges().createAsync(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()));
}
}
If the key must include a random component, generate it in the caller before invoking the @Retry-annotated method and pass it as an additional String idempotencyKey parameter:
// Caller — generates the idempotency key once, outside the @Retry boundary.
// Passes the pre-generated key to the billing method.
// @Retry re-invokes the method with the SAME key on every retry attempt.
public Uni<String> initiateCharge(
String customerId, String planId,
long amountCents, String billingPeriod) {
// Key generated once in the caller, not in the @Retry-annotated method.
final String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":" + UUID.randomUUID();
return billingService.chargeCustomerWithKey(
customerId, planId, amountCents, billingPeriod, idempotencyKey);
}
// BillingService.java — @Retry method accepts the key as a parameter.
// @Retry re-invokes with the same arguments — same key value passed in on every attempt.
@Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS,
retryOn = StripeException.class)
public Uni<String> chargeCustomerWithKey(
String customerId, String planId,
long amountCents, String billingPeriod,
String idempotencyKey) { // <-- passed in, not generated here
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.putMetadata("plan_id", planId)
.putMetadata("billing_period", billingPeriod)
.build();
return Uni.createFrom().completionStage(
stripeClient.charges().createAsync(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()));
}
The caller generates UUID.randomUUID() once. @Retry re-invokes chargeCustomerWithKey with the same idempotencyKey argument on every retry attempt. Stripe receives the same key on every attempt and deduplicates all retries after the first committed charge.
If the caller is itself reactive (returning a Uni), apply the same principle: generate the key outside the Uni pipeline or in a position that is not re-evaluated on the retry re-subscriptions that reach the @Retry-annotated method. The general rule is: the key must be a stable value at the point where it is passed to the @Retry-annotated method.
Combining Mutiny onFailure().retry() and MicroProfile @Retry: double-retry amplification
A subtle additional failure mode arises when a developer uses both Mutiny’s onFailure().retry() inside the Uni pipeline and MicroProfile’s @Retry annotation on the same method. The developer may reason that the Mutiny retry handles transient network errors at the HTTP level while the @Retry annotation handles higher-level transient errors. The result is layered retry: Mutiny’s retry fires N times per @Retry attempt, and @Retry fires M times total, for a worst-case product of N × M attempts.
// DOUBLE-RETRY BUG: Mutiny retry inside the pipeline + @Retry on the method.
// If UUID is in any re-evaluated position, both retry layers regenerate it.
// Even if UUID is computed correctly for Mutiny inner retries (outside the supplier),
// @Retry re-invokes the method body — a new UUID is generated per @Retry attempt.
@Retry(maxRetries = 2, delay = 500, delayUnit = ChronoUnit.MILLIS,
retryOn = StripeException.class)
public Uni<String> chargeCustomer(
String customerId, String planId,
long amountCents, String billingPeriod) {
// BUG: UUID in method body — regenerated per @Retry method re-invocation.
final String idempotencyKey = customerId + ":billing:" + planId + ":"
+ billingPeriod + ":" + UUID.randomUUID();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents).setCurrency("usd").setCustomer(customerId).build();
return Uni.createFrom().completionStage(
stripeClient.charges().createAsync(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()))
// Inner Mutiny retry: correct for Mutiny-level retries if key is stable.
// But @Retry outer layer calls the method again — new key per @Retry attempt.
.onFailure().retry().atMost(2);
}
With this code, a transient failure during the first attempt triggers Mutiny’s inner retry (attempt 1.1, attempt 1.2) using the same UUID_A from the first method invocation — those retries are correctly idempotent. If Mutiny’s retry exhausts without success, it emits a failure to @Retry’s interceptor. @Retry then re-invokes the method body, generating UUID_B. The @Retry attempt starts a new Mutiny retry chain with UUID_B. If ch_A committed during the first method invocation, the @Retry attempt’s Mutiny retries (attempts 2.1, 2.2) carry UUID_B — ch_B.
Avoid combining both retry mechanisms unless you understand the interaction clearly. If you use both, ensure the idempotency key is passed as a method parameter (generated by the caller, outside both retry boundaries) so it is stable for all inner and outer retry attempts.
How Stripe’s idempotency system sees these failures
Stripe’s idempotency layer stores the outcome of a request (committed or failed with a deterministic error) keyed by the idempotency key and the Stripe account ID. When a request arrives with a key Stripe has seen before for the same account and endpoint, Stripe returns the stored outcome without re-processing. When a request arrives with a key Stripe has never seen — regardless of whether the body parameters are identical to a prior request — Stripe processes it as a new request.
From Stripe’s perspective, a retry with UUID_B is not a retry at all. It is a new, unrelated charge creation request. The fact that it was produced by a software retry mechanism, and that the business intent was “retry the same charge,” is not visible to Stripe. Stripe cannot infer idempotency from the request body (the same customer, amount, and currency) because Stripe correctly allows multiple charges for the same amount to the same customer within a short period — the merchant is responsible for using the idempotency key system to signal when two requests are the same intent.
Stripe does enforce a 24-hour window on idempotency keys: if you reuse the same key for a different set of request parameters within 24 hours, Stripe returns a 422 with an idempotency_key_reuse error. This means that a stable key derived from business data must be unique per intent (the same customerId + planId + billingPeriod combination should not map to two different intended charges), not just stable across retries of the same intent. The design in the safe examples above satisfies both constraints: the key is stable within a retry session (same string on every attempt for the same billing intent) and unique across billing periods (billingPeriod is included in the key, so October and November charges have different keys).
Detecting double charges from Mutiny retry UUID regeneration
The first signal of a double charge is typically a complaint from a customer who sees two charges on their card for the same billing period. On the Stripe side, the dashboard shows two distinct charge IDs (ch_A and ch_B) with the same amount, customer, and metadata, created within seconds of each other. The idempotency keys on the two charges differ by their UUID component — the key prefix (customerId:billing:planId:billingPeriod:) is the same, but the UUID suffix differs.
In application logs, the signature is a retried Stripe request log entry immediately following a timeout or network error, where the logged idempotency key changes between the initial attempt and the retry. If the key is not logged, the duplicate is often discovered only after customer complaints or a financial reconciliation. Adding structured logging of the idempotency key at the point where it is passed to Stripe is the most reliable way to catch this during development before it reaches production.
A Keybrake proxy between your Quarkus service and Stripe provides a second layer of defense: the proxy logs every outbound Stripe API call with its full request headers (including Idempotency-Key), and the audit log makes UUID changes across retried requests immediately visible. A policy rule configured on the proxy can flag — or block — two charge creation requests carrying different idempotency keys for the same (customer, amount, billing period) metadata tuple within a configurable time window. This catches the UUID-per-retry bug in production before it causes customer-visible double charges and before the developer realizes the retry implementation is broken.
Summary: Mutiny Uni subscription model and Stripe idempotency
| Pattern | UUID position | Re-subscription behavior | Result |
|---|---|---|---|
Uni.createFrom().completionStage(supplier) + onFailure().retry() |
Inside supplier lambda | Supplier called per subscription — UUID regenerated per retry | UUID_B — ch_B |
Uni.createFrom().item(supplier) + transformToUni + onFailure().retry() |
Inside item supplier |
Supplier called per subscription — UUID regenerated per retry | UUID_B — ch_B |
transformToUni(mapper) + onFailure().retry() |
Inside mapper lambda | Mapper runs per subscription from root — UUID regenerated per retry | UUID_B — ch_B |
@Retry on method returning Uni<T> |
In method body | Method body re-invoked per @Retry attempt — UUID regenerated per attempt |
UUID_B — ch_B |
Uni.createFrom().completionStage(supplier) with key pre-computed outside |
Outside supplier, captured by reference | Supplier captures final String — same value every subscription | Idempotent |
Inner onFailure().retry() scoped to Stripe call only, key in mapper before inner Uni |
As final local before inner Uni | Key computed once per mapper invocation — inner retry uses same value | Idempotent |
@Retry with key derived from stable method parameters |
Derived from parameters in method body | Same parameters on every @Retry invocation — same key |
Idempotent |
@Retry with key passed as parameter from caller |
Generated once in caller, passed in | Same parameter value on every @Retry invocation |
Idempotent |
The core invariant is: every code path that executes once before the retry mechanism should be outside the scope the retry mechanism re-evaluates. For Mutiny’s onFailure().retry(), “outside” means outside the upstream Uni pipeline from the root to the retry operator. For MicroProfile @Retry, “outside” means outside the method body. Both retry mechanisms re-evaluate everything in their respective scopes. UUID.randomUUID() is not a constant and must not appear inside either scope.
See also: Quarkus MicroProfile Fault Tolerance @Retry and Stripe integration for coverage of @Bulkhead, @CircuitBreaker, and fallback interaction with Stripe idempotency, and Micronaut HTTP Client and Stripe integration for the equivalent subscription-based retry patterns in Micronaut’s reactive client.
Keybrake catches UUID-per-retry bugs before they cause double charges
Keybrake proxies your Stripe API calls and logs every request with its full idempotency key. If your Quarkus service retries a charge with a different key, Keybrake’s audit log shows the UUID change immediately — and an optional policy rule can block or alert on two distinct idempotency keys for the same (customer, amount) pair within a configurable window. Catch Mutiny retry UUID bugs in staging before they reach production billing.