Micronaut RxJava3 Single.fromCallable() Retry and Stripe Integration: How UUID in Callable Re-subscription, flatMap Mapper UUID, and Micronaut @Retryable Method Re-invocation Generate Duplicate Charges or Idempotency Conflicts
RxJava3’s Single<T> is a cold reactive type: the computation is not started until a subscriber subscribes, and Single.fromCallable(callable) calls the Callable on every new subscription. Single.retry(N) re-subscribes to the upstream Single on failure — the Callable runs again. If UUID.randomUUID() lives inside that Callable, each retry subscription generates a fresh UUID. When Stripe has already committed ch_A on attempt 1 before a transient failure, the retry reaches Stripe with UUID_B — a key Stripe has never seen — and Stripe commits ch_B alongside ch_A. Micronaut’s @Retryable annotation on a method returning Single<T> re-invokes the method body on each attempt, so UUID.randomUUID() at method start — even outside the fromCallable() lambda — also regenerates per @Retryable attempt — UUID_B — ch_B.
Background: how RxJava3 Single subscription model works and why retry re-subscribes
RxJava3’s Single<T> is a cold reactive source that emits exactly one item or one error. “Cold” means the computation behind the Single does not start until a subscriber observes it, and — more importantly for idempotency — every call to subscribe() starts the computation independently from scratch. There is no shared state between subscriptions unless you introduce multicasting operators.
The method Single.fromCallable(Callable<T>) is the standard bridge between blocking synchronous code and the RxJava3 reactive world. It accepts a Callable and, on each subscription, invokes the callable, emits the returned value as the single item, or catches any checked or unchecked exception and emits it as the single error. The callable is not stored as a result — it is stored as a factory function. Every subscription triggers a new invocation. This is the correct and intended behavior for wrapping code that has side effects, but it becomes a problem for idempotency when the callable contains code that generates a fresh identifier on every call.
The Stripe Java SDK’s blocking call — Charge.create(params, opts), PaymentIntent.create(params, opts), or equivalent — is frequently wrapped inside Single.fromCallable() in Micronaut services that prefer RxJava3 as their reactive type. Micronaut supports RxJava3 natively alongside Project Reactor: any @Singleton service method can return Single<T>, Observable<T>, or Flowable<T>, and the Micronaut HTTP server, scheduler, and AOP interceptors understand these types. The Micronaut HTTP Client can similarly be configured to return RxJava3 types from @Client interface methods.
RxJava3’s Single.retry(long times) operator installs a retry handler that, on receiving an error signal from the upstream Single, re-subscribes to that upstream source up to times times before propagating the error. The key word is “re-subscribes”: the operator does not remember the upstream Single’s output and replay it; it calls subscribe() on the upstream source again. For a Single.fromCallable(callable), re-subscription means invoking the callable again. For a Single.just(value), re-subscription emits the same already-computed value and there is no side effect. For a Single.fromCallable(() -> { ... UUID.randomUUID() ... }), re-subscription calls the callable body again and evaluates UUID.randomUUID() again.
Variant retry operators — Single.retry(Predicate<Throwable>), Single.retry(long times, Predicate<Throwable>), and Single.retryWhen(Function<Observable<Throwable>, ObservableSource>) — all re-subscribe to the upstream Single on each retry signal. The UUID regeneration problem applies to all of them if the callable contains UUID.randomUUID().
Stripe’s idempotency system expects the Idempotency-Key header to carry the same value across all retry attempts of the same billing intent. The key must be a stable string that Stripe uses to detect duplicate requests and return the result of the first committed charge without processing a second one. If the key changes between attempts, Stripe treats each attempt as a brand-new billing intent, and if attempt 1 committed a charge before the transient failure, Stripe will commit a second charge on attempt 2 with no warning.
Failure mode 1: UUID.randomUUID() inside Single.fromCallable(callable) — retry re-subscribes to callable — UUID_B — ch_B
A Micronaut billing service that wraps the Stripe SDK’s blocking call inside Single.fromCallable() typically puts all the setup code inside the callable: building the ChargeCreateParams, constructing the RequestOptions with the idempotency key, and calling Charge.create(). Developers reason: “the callable runs once per billing operation.” That reasoning is correct when there are no retries. Under Single.retry(), the callable runs once per attempt.
// BillingService.java — UNSAFE: UUID.randomUUID() inside Single.fromCallable()
@Singleton
public class BillingService {
public Single<Charge> chargeCustomer(String customerId, long amountCents) {
return Single.fromCallable(() -> {
// UUID generated inside the callable — regenerates on every re-subscription.
String idempotencyKey = UUID.randomUUID().toString();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
return Charge.create(params, opts); // blocking Stripe SDK call
})
.retry(2); // on StripeException: re-subscribes — callable runs again — UUID_B — ch_B
}
}
Execution trace on a transient network failure after Stripe committed ch_A:
- Subscriber calls
subscribe()on theSingle.retry(2)pipeline. - Retry layer subscribes to the upstream
Single.fromCallable(callable). - Callable runs:
UUID.randomUUID()→UUID_A.Charge.create(params_A, opts_A)is dispatched to Stripe. - Stripe processes the request: charge
ch_Ais committed. A network error occurs before the HTTP response arrives at the service. The callable throwsStripeException(or a network I/O exception wrapping it). - The
Single.fromCallable()emits the exception as a terminal error. - The
retry(2)operator intercepts the error. Retry attempt 1 remaining. It re-subscribes toSingle.fromCallable(callable). - Callable runs again:
UUID.randomUUID()→UUID_B.Charge.create(params_B, opts_B)is dispatched. - Stripe has never seen
UUID_B. It processes the request as a new billing intent:ch_Bcommitted alongsidech_A.
The developer’s mental model — “the callable runs once per billing call” — is correct when no exception occurs and no retry fires. It breaks under retry() because the callable is a factory, not a stored result. Single.fromCallable(() -> computeSomething()) does not cache the output of the callable. It stores a reference to the callable object. Every call to the upstream Single’s subscribe() invokes callable.call().
Fix for failure mode 1
Move UUID.randomUUID() outside the callable and outside the Single pipeline. Capture the pre-computed value in a final local variable. The callable lambda closes over the variable reference, not over the UUID.randomUUID() expression. On every re-subscription, the lambda reads the same captured string.
// BillingService.java — SAFE: UUID computed before Single pipeline construction
@Singleton
public class BillingService {
public Single<Charge> chargeCustomer(String customerId, long amountCents) {
// UUID computed once, before any reactive pipeline — captured by lambda closure.
final String idempotencyKey = UUID.randomUUID().toString();
return Single.fromCallable(() -> {
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // stable reference — same on every re-subscription
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
return Charge.create(params, opts);
})
.retry(2);
}
}
A stronger fix replaces UUID.randomUUID() with a content-hash key derived from stable business data. A content-hash key is stable across JVM restarts, scheduler crashes, and duplicate invocations from the same billing cycle — properties that a randomly generated key cannot provide.
// BillingService.java — STRONGER FIX: content-hash idempotency key
@Singleton
public class BillingService {
public Single<Charge> chargeCustomer(String customerId, long amountCents,
String billingPeriod) {
// Content-hash key: stable for the same intent, unique across billing periods.
final String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
return Single.fromCallable(() -> {
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
return Charge.create(params, opts);
})
.retry(2);
}
}
Note that even with the final-variable fix, the developer who later adds Micronaut’s @Retryable to the chargeCustomer() method creates a new problem: @Retryable re-invokes the method body, and the final String idempotencyKey = UUID.randomUUID() at method start regenerates per @Retryable attempt. Failure mode 3 below explains this in detail.
Failure mode 2: UUID.randomUUID() inside flatMap(mapper) with outer retry() — retry re-subscribes through mapper — UUID_B — ch_B
A subscription billing service typically needs to look up a subscription record — to confirm the customer’s Stripe ID, billing amount, and current period — before creating a Stripe charge. A natural reactive expression of this is a Single from a Micronaut Data reactive repository, chained into a flatMap() that builds the Stripe charge. If the retry operator sits outside the flatMap(), it re-subscribes from the root of the pipeline, which includes the mapper. The mapper runs again with a new UUID.
// SubscriptionBillingService.java — UNSAFE: UUID in flatMap mapper + outer retry()
@Singleton
public class SubscriptionBillingService {
@Inject
private SubscriptionRepository subscriptionRepository; // Micronaut Data reactive repo
public Single<Charge> processSubscription(String subscriptionId) {
return subscriptionRepository.findById(subscriptionId)
.toSingle() // convert Optional Single to Single or NoSuchElementException
.flatMap(sub -> {
// UUID inside the flatMap mapper body — regenerates when mapper is re-entered.
String idempotencyKey = UUID.randomUUID().toString();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(sub.getStripeCustomerId())
.setAmount(sub.getAmountCents())
.setCurrency("usd")
.build();
return Single.fromCallable(() -> Charge.create(params, opts));
})
.retry(2); // outer retry re-subscribes from subscriptionRepository.findById
// — flatMap mapper runs again — UUID_B — ch_B
}
}
The outer .retry(2) is positioned after the flatMap(), which means its upstream is the entire chain starting from subscriptionRepository.findById(subscriptionId). When a StripeException propagates out of Single.fromCallable(() -> Charge.create(...)) through the flatMap() and triggers the outer retry, the retry operator re-subscribes to that upstream chain from the root. The re-subscription reaches subscriptionRepository.findById(), which re-executes the database read, then the flatMap mapper is called again with the freshly fetched Subscription object, and inside the mapper body UUID.randomUUID() evaluates again — UUID_B.
Two compounding effects make this failure mode worse than mode 1. First, the upstream database read is also repeated on every retry, which introduces redundant database load and, if the repository operation has side effects, potential data-consistency hazards. Second, the developer may intend the outer retry to catch both repository failures and Stripe failures, which makes it tempting to leave the retry outside the flatMap. Both concerns have solutions that do not require placing UUID generation inside the mapper.
Fix for failure mode 2
Scope the retry to the innermost Single that wraps the Stripe call only. Move the retry inside the flatMap mapper body, applied directly to the Single.fromCallable(() -> Charge.create(...)). The mapper runs once per root subscription. The UUID is computed once inside the mapper as a final local variable. The inner retry re-subscribes only to the inner Single.fromCallable(). Because the inner Single’s callable captures the already-computed UUID by reference, re-subscription re-uses the same UUID string.
// SubscriptionBillingService.java — SAFE: inner retry scoped to Stripe call only
@Singleton
public class SubscriptionBillingService {
@Inject
private SubscriptionRepository subscriptionRepository;
public Single<Charge> processSubscription(String subscriptionId) {
return subscriptionRepository.findById(subscriptionId)
.toSingle()
.flatMap(sub -> {
// UUID computed once per mapper invocation — stable for inner retries.
final String idempotencyKey = "charge:" + sub.getStripeCustomerId()
+ ":" + sub.getAmountCents() + ":" + sub.getBillingPeriod();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(sub.getStripeCustomerId())
.setAmount(sub.getAmountCents())
.setCurrency("usd")
.build();
return Single.fromCallable(() -> Charge.create(params, opts))
.retry(2); // inner retry re-subscribes only to this Single — UUID stable
});
// No outer retry here — if needed for repository failures, handle separately.
}
}
If you need retry coverage for repository failures as well, use separate retry operators with different predicates: an inner retryWhen() on the Stripe call that matches StripeException, and a separate outer retryWhen() on the repository read that matches DatabaseException. Never put both under a single outer retry() if the mapper between them contains UUID generation code.
An alternative architectural fix is to extract the Stripe call into a separate method that takes the pre-computed idempotency key as a parameter, and apply the retry to that method via Micronaut @Retryable. This separates the “assemble the parameters” concern (runs once, in the mapper) from the “send the charge with retry” concern (runs with retry, receives stable key as argument). Failure mode 3 below explains the specific pitfalls of Micronaut @Retryable on reactive methods and why the key must be a method parameter rather than generated inside the @Retryable-annotated method body.
Failure mode 3: Micronaut @Retryable on method returning Single<T> — DefaultRetryInterceptor re-invokes method body per attempt — UUID_B — ch_B
Micronaut’s @Retryable annotation (from io.micronaut.retry.annotation.Retryable) is an AOP interceptor applied by Micronaut’s proxy mechanism. When a @Singleton bean method is annotated with @Retryable, calls to that method from other beans go through the generated proxy, and the DefaultRetryInterceptor wraps the method invocation. For reactive return types, Micronaut’s interceptor subscribes to the returned reactive source, and if the source emits a terminal error, it re-invokes the method to obtain a new reactive source for the next attempt.
The operative word is re-invokes: on each retry attempt, Micronaut calls the method body again. It does not re-subscribe to the same Single<T> instance returned by the first method call. This is the fundamental difference from RxJava3’s own Single.retry() operator, which re-subscribes to the upstream Single without re-invoking a Java method. Both mechanisms cause UUID regeneration when UUID generation happens in the wrong scope, but the exact scope that is “wrong” differs between them.
// BillingService.java — UNSAFE: @Retryable on reactive method, UUID at method start
@Singleton
public class BillingService {
@Retryable(attempts = "3", delay = "1s")
public Single<Charge> chargeCustomer(String customerId, long amountCents) {
// UUID at method start — outside the Single pipeline — looks stable.
// But @Retryable re-invokes this method body on each attempt.
// UUID.randomUUID() is called again on attempt 2 and 3 — UUID_B — ch_B.
String idempotencyKey = UUID.randomUUID().toString();
return Single.fromCallable(() -> {
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // captured by closure — stable within one invocation
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
return Charge.create(params, opts);
});
// No .retry() here — developer expects @Retryable to handle retries.
}
}
Execution trace under @Retryable(attempts = "3"):
- Caller invokes
billingService.chargeCustomer(customerId, amountCents)through the Micronaut proxy. DefaultRetryInterceptorintercepts the call. Attempt 1: invokes the method body.- Method body runs:
UUID.randomUUID()→UUID_A. ASingle.fromCallable(...)is constructed and returned. - Interceptor subscribes to the returned
Single. Callable runs:Charge.create(params_A, opts_A). - Stripe commits
ch_A. Network error occurs. Callable throws exception.Singleemits terminal error. DefaultRetryInterceptorcatches the error. Attempts remaining: 2. Applies configured delay.- Attempt 2: re-invokes the method body.
- Method body runs again from the first line:
UUID.randomUUID()→UUID_B. A newSingle.fromCallable(...)is constructed and returned. - Interceptor subscribes to the new
Single. Callable runs:Charge.create(params_B, opts_B). - Stripe has never seen
UUID_B.ch_Bcommitted alongsidech_A.
This failure mode catches developers who believe they have already fixed the problem. A developer who encountered failure mode 1 — UUID inside Single.fromCallable() — and fixed it by moving UUID.randomUUID() to the first line of the method body has solved the re-subscription problem. The Single.retry() operator no longer causes UUID regeneration because the callable now captures a pre-computed reference. But when they subsequently add Micronaut @Retryable to provide retry with backoff, the moved UUID line is now inside the scope that @Retryable re-invokes. The fix for one mechanism becomes the bug for the other.
Fix for failure mode 3
The idempotency key must be generated outside the @Retryable-annotated method body and passed as a stable parameter. @Retryable passes the same argument values to every retry attempt, so a key passed as a method parameter is stable across all attempts.
// BillingService.java — SAFE: idempotency key as method parameter
@Singleton
public class BillingService {
// @Retryable passes the same idempotencyKey argument on every re-invocation.
@Retryable(attempts = "3", delay = "1s")
public Single<Charge> chargeCustomer(String customerId, long amountCents,
String idempotencyKey) {
return Single.fromCallable(() -> {
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // stable across all @Retryable attempts
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
return Charge.create(params, opts);
});
}
}
// BillingOrchestrator.java — caller generates the key once before calling @Retryable method
@Singleton
public class BillingOrchestrator {
@Inject
private BillingService billingService;
public Single<Charge> processCharge(String customerId, long amountCents,
String billingPeriod) {
// Key generated in the caller — outside @Retryable boundary.
String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
return billingService.chargeCustomer(customerId, amountCents, idempotencyKey);
}
}
An alternative fix replaces UUID.randomUUID() with a content-hash key derived entirely from the method’s existing parameters — customerId, amountCents, and billingPeriod. Because @Retryable passes the same argument values on every attempt, a key derived only from those arguments is automatically stable without requiring a separate key parameter.
// BillingService.java — ALTERNATIVE FIX: content-hash key derived from stable parameters
@Singleton
public class BillingService {
@Retryable(attempts = "3", delay = "1s")
public Single<Charge> chargeCustomer(String customerId, long amountCents,
String billingPeriod) {
// Derived from stable method parameters — same on every @Retryable attempt.
final String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
return Single.fromCallable(() -> {
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
return Charge.create(params, opts);
});
}
}
The content-hash approach is generally preferable because it removes the need for callers to manage and pass a key parameter, and it is inherently stable across scheduler restarts, duplicate invocations, and any code path that reaches the billing method with the same business intent.
The @Retryable vs Single.retry() distinction
The mechanism difference matters for diagnosis. Single.retry(N) is a pipeline operator that re-subscribes to the same Single instance. The method body runs once; the pipeline is constructed once; the callable is the unit of repetition. @Retryable is a method interceptor that re-invokes the method through the AOP proxy. The method body — including any Single construction logic before the reactive operators — is the unit of repetition.
A UUID.randomUUID() placed before fromCallable() but inside the method body is stable under Single.retry() (callable closes over it, retry re-subscribes to callable, same reference captured) and unstable under @Retryable (method body re-invoked, UUID.randomUUID() at line 1 runs again). A UUID.randomUUID() placed inside the callable is unstable under both (callable runs again per re-subscription, and @Retryable re-invokes the method which constructs a new callable with a new UUID call). Only a UUID placed as a stable method parameter or derived from stable method parameters is safe under both mechanisms simultaneously.
Double-retry amplification: @Retryable (outer) + Single.retry() (inner) on the same billing path
A common escalation pattern is for a developer to add both a Micronaut @Retryable annotation for high-level retry-with-backoff and a Single.retry() on the reactive chain for low-level transient-error recovery. When both are present on the same billing path, they interact to amplify the total number of Stripe requests and the number of distinct UUID values that can reach Stripe.
// BillingService.java — double-retry amplification risk
@Singleton
public class BillingService {
@Retryable(attempts = "3", delay = "5s") // outer: 3 attempts, 5-second delay
public Single<Charge> chargeCustomer(String customerId, long amountCents) {
// UUID at method start — regenerates per @Retryable attempt (mode 3 bug).
String idempotencyKey = UUID.randomUUID().toString();
return Single.fromCallable(() -> {
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
return Charge.create(params, opts);
})
.retry(2); // inner: 2 re-subscriptions — same idempotencyKey (captured final)
}
}
Trace for this double-retry setup when ch_A is committed on the very first Stripe request:
- @Retryable attempt 1, inner attempt 1:
UUID_Agenerated in method body. Callable runs,UUID_Asent. Stripe commitsch_A. Network timeout. - @Retryable attempt 1, inner attempt 2:
Single.retry(2)re-subscribes. Callable runs,UUID_Acaptured by closure — same key sent. Stripe returns cached result forUUID_A— no second charge. Network timeout again. - @Retryable attempt 1, inner attempt 3: Same.
UUID_Asent. Inner retry exhausted. Singleemits terminal error.DefaultRetryInterceptorfires. Applies 5-second delay.- @Retryable attempt 2: Method body re-invoked.
UUID.randomUUID()→UUID_B. Callable runs,UUID_Bsent. Stripe has never seenUUID_B— commitsch_Balongsidech_A.
The inner Single.retry() is safe given the key is correctly scoped outside the callable. The outer @Retryable is the source of UUID_B because it re-invokes the method body. Total duplicate charge potential: N @Retryable attempts × M inner retries = N×M total Stripe requests, with N distinct UUIDs, each used M times. For 3 outer × 2 inner: 9 total requests, up to 3 distinct charges if ch_A is committed on attempt 1 of each outer cycle.
The fix is identical to the mode 3 fix: remove UUID.randomUUID() from the method body entirely. Use a content-hash key derived from stable method parameters or pass the key as a parameter from the caller.
// BillingService.java — SAFE double-retry setup
@Singleton
public class BillingService {
@Retryable(attempts = "3", delay = "5s")
public Single<Charge> chargeCustomer(String customerId, long amountCents,
String billingPeriod) {
// Derived from stable parameters — same on every @Retryable attempt.
final String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
return Single.fromCallable(() -> {
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // same on all inner retries and all @Retryable attempts
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount(amountCents)
.setCurrency("usd")
.build();
return Charge.create(params, opts);
})
.retry(2);
}
}
Stripe’s idempotency system: why UUID_B is always treated as a new charge
Stripe’s idempotency system operates at the key level, not the payload level. When Stripe receives a request with Idempotency-Key: UUID_B, it looks up UUID_B in its idempotency store. If the key is not found, the request is processed as a new billing intent regardless of whether the request body is identical to a prior request made with UUID_A. Stripe does not infer that two requests with the same customer, amount, and currency are retries of the same intent. The idempotency signal is the key string alone.
This is the correct design. Stripe has no way to distinguish “I am retrying a prior request that I have not heard back from” from “I want to bill this customer the same amount again in a new billing cycle.” Both cases would present identical request bodies. The only information that disambiguates them is a stable key that the client controls and carries across retry attempts.
The 24-hour idempotency window means that a key presented within 24 hours of a prior request with the same key returns the cached result (either the committed charge data or the error). After 24 hours, the key is expired and a new request with that key is treated as a new intent. This window design imposes two constraints on key construction: the key must be stable within the duration of a billing operation (all retries within one billing attempt must carry the same key), and the key must be unique across distinct billing periods (a charge for August and a charge for September for the same customer must carry different keys, otherwise Stripe returns August’s result for September’s request if made within the same 24-hour window).
Content-hash keys that include a billing period component — "charge:" + customerId + ":" + amountCents + ":" + billingPeriod — satisfy both constraints naturally. UUID.randomUUID() satisfies uniqueness across billing periods (each call produces a different UUID) but fails the stability-within-retry-attempt constraint whenever the retry mechanism regenerates the UUID.
Cross-mode structural analysis
Mode 1 here vs. Quarkus Mutiny mode 1
Both failure modes involve a cold reactive factory wrapping a blocking SDK call: Quarkus uses Uni.createFrom().completionStage(supplier) (or Uni.createFrom().item(supplier)) to wrap an asynchronous CompletableFuture-returning API; this post uses RxJava3’s Single.fromCallable(callable) to wrap a blocking synchronous API. The re-subscription semantics are identical: the factory function — supplier in Mutiny, callable in RxJava3 — is invoked on every subscription. Retry operators in both libraries re-subscribe on failure. UUID in the factory regenerates on both. The fix is structurally the same: compute the UUID outside the factory function and pass it in as a captured reference. The only syntactic difference is the library-specific class names and factory methods.
Mode 2 here vs. Quarkus Mutiny mode 2
Both are “lookup then charge” reactive chains where an intermediate transformation operator — flatMap() in RxJava3, transformToUni() in Mutiny — contains UUID generation code, and an outer retry operator sits downstream of the transformation. In both cases, retry re-subscribes from the root of the chain through the transformation operator, causing the operator’s body to execute again with a new UUID. The fix in both cases is to scope the retry inside the transformation operator body, wrapping only the innermost reactive source that calls Stripe. The database lookup is excluded from the retry scope; only the Stripe network call is retried.
One Micronaut-specific nuance: Micronaut Data reactive repositories typically return RxJava2 types natively if configured with the RxJava2 dependency, and RxJava3 types if configured with the RxJava3 dependency or via type adapters. The .toSingle() call in the code examples adapts a Maybe<T> (returned for nullable findById) to Single<T> by throwing NoSuchElementException if empty. The same retry-scoping fix applies regardless of the Micronaut Data reactive type.
Mode 3 here vs. Quarkus MicroProfile FT @Retry
Both are AOP annotation-driven retry mechanisms applied at the method level. Quarkus’s @Retry (from MicroProfile Fault Tolerance, implemented by SmallRye) and Micronaut’s @Retryable (from the Micronaut Retry module) both re-invoke the annotated method body on each retry attempt. For reactive return types, both frameworks subscribe to the new reactive source returned by each method invocation and treat a terminal error from that source as a retry trigger. The downstream UUID problem is structurally identical: UUID at method start regenerates per attempt. The fix is structurally identical: pass the key as a parameter or derive it from stable parameters.
The difference between the two is in configuration syntax and behavior options. MicroProfile FT @Retry uses annotation attributes like maxRetries, delay, jitter, and retryOn. Micronaut @Retryable uses attempts, delay, multiplier (for exponential backoff), and includes/excludes for exception filtering. In both cases, the retry count and timing are configured externally to the reactive chain — the reactive chain itself contains no retry logic when the annotation approach is used. This external configuration is exactly what makes the annotation approach convenient and exactly what makes UUID-in-method-body dangerous: the developer looking at the method body sees UUID.randomUUID() as a one-time initialization and does not see the retry mechanism that will re-enter the method body.
Mode 3 here vs. Spring @Retryable
Spring’s @Retryable and Micronaut’s @Retryable exhibit the same behavior for blocking return types: re-invoke the method body on each retry attempt. For reactive return types, the behavior diverges in one important way. Spring’s Spring Retry does not natively understand reactive types in older versions; a @Retryable-annotated method returning Mono<T> or Flux<T> may retry at the method level (re-invoke the method body) or may not retry at all, depending on the Spring Retry version and configuration. Micronaut’s @Retryable explicitly supports RxJava3 Single<T> and other reactive types: it subscribes to the returned Single, detects a terminal error, and re-invokes the method body for the next attempt. This explicit reactive support makes Micronaut @Retryable on reactive methods behave as the developer expects — the method is retried transparently — but also makes the UUID-in-method-body bug more reliably reproducible: the retry mechanism is guaranteed to fire and re-invoke the method, not silently fail to retry.
Test patterns for all three failure modes
The test pattern for verifying idempotency key stability across retry attempts is consistent across all three modes. The goal is to capture the Idempotency-Key header from every outbound Stripe API request and assert that all captured values are equal.
Mode 1 test: Single.fromCallable() + retry()
// BillingServiceTest.java — Mode 1 test with WireMock
@MicronautTest
class BillingServiceTest {
@Inject
BillingService billingService;
@Test
void retryReusesIdempotencyKey() {
// Stub Stripe: 503 on first call, 200 on second.
WireMockServer wm = new WireMockServer(wireMockConfig().dynamicPort());
wm.start();
AtomicInteger callCount = new AtomicInteger(0);
List<String> capturedKeys = new CopyOnWriteArrayList<>();
wm.stubFor(post(urlPathEqualTo("/v1/charges"))
.willReturn(aResponse()
.withTransformerParameter("callCount", callCount)
.withStatus(200)
.withBody("{\"id\":\"ch_test\",\"object\":\"charge\",\"amount\":1000}")));
wm.addMockServiceRequestListener((request, response) -> {
String key = request.getHeader("Idempotency-Key");
if (key != null) capturedKeys.add(key);
});
// Configure Stripe to use WireMock host.
Stripe.overrideApiBase("http://localhost:" + wm.port());
// Use the SAFE implementation with content-hash key.
billingService.chargeCustomer("cus_test", 1000L, "2026-10")
.blockingGet();
// All captured keys must be identical.
assertThat(capturedKeys).isNotEmpty();
assertThat(new HashSet<>(capturedKeys)).hasSize(1);
wm.stop();
}
}
The assertion new HashSet<>(capturedKeys).hasSize(1) verifies that all requests — regardless of how many retries fired — carried the same idempotency key. With the unsafe (UUID-in-callable) implementation, the HashSet contains two distinct UUIDs and the assertion fails immediately.
Mode 3 test: Micronaut @Retryable with reactive method
// BillingServiceRetryableTest.java — Mode 3 test
@MicronautTest
class BillingServiceRetryableTest {
@Inject
BillingService billingService; // @Retryable-annotated version with content-hash key
@Test
void retryableReusesIdempotencyKeyAcrossMethodInvocations() {
List<String> capturedKeys = new CopyOnWriteArrayList<>();
// Intercept all outbound HTTP in WireMock.
WireMockServer wm = new WireMockServer(wireMockConfig().dynamicPort());
wm.start();
wm.addMockServiceRequestListener((req, resp) -> {
String key = req.getHeader("Idempotency-Key");
if (key != null) capturedKeys.add(key);
});
// Stub: fail twice, succeed on third attempt.
wm.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retries")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("first-fail"));
wm.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retries")
.whenScenarioStateIs("first-fail")
.willReturn(aResponse().withStatus(503))
.willSetStateTo("second-fail"));
wm.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retries")
.whenScenarioStateIs("second-fail")
.willReturn(aResponse().withStatus(200)
.withBody("{\"id\":\"ch_ok\",\"object\":\"charge\",\"amount\":2000}")));
Stripe.overrideApiBase("http://localhost:" + wm.port());
billingService.chargeCustomer("cus_abc", 2000L, "2026-10").blockingGet();
// All 3 attempts must have used the same idempotency key.
assertThat(capturedKeys).hasSize(3);
assertThat(new HashSet<>(capturedKeys)).hasSize(1);
wm.stop();
}
}
With the unsafe (UUID-in-method-body) implementation, capturedKeys contains three distinct UUIDs — one per @Retryable method invocation — and the hasSize(1) assertion fails. With the content-hash fix, all three elements are the same string and the assertion passes.
Observable and Flowable: the same re-subscription semantics apply
This post has focused on Single<T> as the appropriate RxJava3 type for a Stripe charge operation that produces exactly one result. The same analysis applies to Observable<T> and Flowable<T> in batch billing scenarios. Observable.fromCallable(callable) and Flowable.fromCallable(callable) invoke the callable on each subscription, and Observable.retry() and Flowable.retry() re-subscribe on failure. UUID inside the callable regenerates on every re-subscription in either case.
For batch billing with Flowable<Charge> — where each emitted item is one customer’s charge result — a common pattern is to use Flowable.fromIterable(customers).flatMap(customer -> chargeCustomer(customer).toFlowable()). The per-customer idempotency key must be stable across retries of that specific customer’s charge, not shared across all customers in the batch. The content-hash approach using per-customer data ("charge:" + customer.getId() + ":" + amountCents + ":" + billingPeriod) satisfies this naturally: each customer’s key is independently stable.
Detecting UUID instability in production
Catching this class of bug before charges hit real customers requires observability on the idempotency key itself. Structured logging of the Idempotency-Key header on every outbound Stripe API request is the minimum viable detection layer. A log line containing customerId, billingPeriod, attemptNumber, and idempotencyKey on each Stripe call makes UUID instability immediately visible: two log lines with different key values for the same (customerId, billingPeriod) within seconds indicates a retry is generating a new UUID.
A proxy layer between the service and Stripe provides detection without modifying the service code. Keybrake’s API key proxy logs every request it forwards to Stripe, including the idempotency key header. Because all retries pass through the proxy, it can observe multiple requests for the same (customer, amount) tuple and detect when the idempotency keys differ across those requests. An optional policy rule can block or alert when two distinct keys are seen for the same customer within a configurable window — preventing the second charge from reaching Stripe rather than detecting it after the fact.
Put a brake on runaway agent charges
Keybrake sits between your Micronaut service and Stripe, logging every charge attempt with its idempotency key. When a retry arrives with a new key, you see it in the audit log — and you can set a policy to block it before the second charge is committed.