Micronaut Data Reactive @Transactional and Stripe Integration: How UUID in Mono.fromCallable() Under retryWhen() Re-subscription, @Retryable Method Re-invocation on Mono<T>, and onErrorResume() Recovery Lambda Generate New Idempotency Keys
When a Micronaut Data service using reactive R2DBC repositories and @Transactional adds retry logic for resilience against transient Stripe errors, three structurally distinct mechanisms each silently generate a new idempotency key on every retry attempt — and Stripe creates a second charge. The root cause in each is the same: a UUID.randomUUID() call placed inside a code boundary that re-executes per retry rather than once per billing intent.
Micronaut Data supports reactive repositories via Project Reactor (Mono/Flux) on top of R2DBC, backed by ReactorReactiveTransactionOperations. When a service method is annotated with Micronaut’s @Transactional and returns a reactive publisher, the framework binds the transaction to the Reactor subscriber context rather than to a thread-local. Micronaut also provides a @Retryable annotation that explicitly supports reactive return types — unlike Spring’s @Retryable, which has undefined behavior on Mono/Flux return types. These two features together create idempotency failure surfaces that are specific to Micronaut’s reactive transaction model and to how its AOP interceptors handle reactive publishers.
Earlier posts in this series covered overlapping but distinct territory: Micronaut Data @Transactional (blocking) covered @Retryable/@Transactional interceptor ordering with blocking transactions, REQUIRES_NEW inner service retry, and RetryTemplate lambda scope. Micronaut RxJava3 retry covered Single.fromCallable() re-subscription, flatMap mapper UUID, and @Retryable on Single<T> return type. This post covers the three-way interaction between Micronaut’s reactive @Transactional (Reactor-based), retryWhen(), and the specific properties of Micronaut’s @Retryable interceptor for reactive methods — failure modes not present in either the blocking Micronaut post or the RxJava3 post.
Background: how Micronaut Data reactive @Transactional binds to the Reactor subscriber context
In Micronaut Data with R2DBC, reactive transactions are managed by ReactorReactiveTransactionOperations. When a method annotated with @io.micronaut.transaction.annotation.Transactional returns a Mono<T> or Flux<T>, Micronaut’s ReactiveTransactionInterceptor intercepts the method invocation and wraps the returned publisher in a transaction-managed pipeline using contextWrite(). The current ReactiveTransactionStatus is stored in the Reactor subscriber context under a well-known key. Any downstream Micronaut Data repository operation that needs the connection retrieves the bound transaction from the context using Mono.deferContextual() or contextView.getOrEmpty(ReactiveTransactionStatus.class).
This design has a critical implication for retry: the transaction is bound to a specific subscriber context chain. Reactor’s retryWhen() operator implements retry by re-subscribing to the upstream Mono. Each re-subscription creates a new, independent subscriber context chain. The new chain does not inherit the transaction from the prior subscription (transaction states are not shared between subscriber contexts). ReactorReactiveTransactionOperations detects the absence of a transaction in the new context and starts a fresh reactive transaction. This is correct behavior for database atomicity: the failed first-attempt R2DBC writes should not bleed into the second attempt. But the per-subscription transaction boundary is independent of Stripe idempotency key assignment. A new reactive transaction and a new Stripe idempotency key are two separate concerns, and only one of them is handled automatically by the framework.
The practical consequence: in a Micronaut Data reactive @Transactional service that calls Stripe and uses retryWhen(), each retry attempt produces a fresh transaction and a fresh idempotency key — unless the key is computed at a scope that persists across re-subscriptions. The framework handles the former correctly; the developer must handle the latter explicitly.
Mode 1: UUID.randomUUID() inside Mono.fromCallable() callable + retryWhen() in reactive @Transactional service — UUID_B — ch_B
The Stripe Java SDK is a blocking library. In a Micronaut reactive service built on Project Reactor, calling the SDK directly on a boundedElastic scheduler requires lifting the blocking call into a Mono.fromCallable() with subscribeOn(Schedulers.boundedElastic()). Developers place UUID.randomUUID() inside the callable, reasoning that “the key should be generated at the moment the Stripe call is made.” The problem is that retryWhen() re-subscribes to the upstream Mono on each retry, and Mono.fromCallable() evaluates its callable on every new subscription.
// BillingService.java — Micronaut Data reactive service
@Singleton
public class BillingService {
private final BillingAuditReactiveRepository auditRepository; // Micronaut Data R2DBC
public BillingService(BillingAuditReactiveRepository auditRepository) {
this.auditRepository = auditRepository;
}
@Transactional // io.micronaut.transaction.annotation.Transactional
public Mono<String> chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// UUID computed inside the fromCallable body — runs per subscription.
// retryWhen() re-subscribes to stripeChargeMono on each retry attempt.
// fromCallable() calls the callable on every new subscription.
// UUID.randomUUID() inside the callable regenerates per retry — UUID_B — ch_B.
Mono<String> stripeChargeMono = Mono.fromCallable(() -> {
String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE
// Stripe Java SDK — blocking, must run on boundedElastic.
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
Charge charge = Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
}).subscribeOn(Schedulers.boundedElastic());
// retryWhen re-subscribes to stripeChargeMono on StripeException.
// Each re-subscription = new callable invocation = new UUID.randomUUID() = UUID_B.
// Each re-subscription also = new Micronaut reactive transaction context.
return stripeChargeMono
.retryWhen(Retry.backoff(3, Duration.ofMillis(200))
.filter(ex -> ex instanceof StripeException &&
((StripeException) ex).getStatusCode() == 503))
.flatMap(chargeId -> auditRepository.saveCharge(
customerId, billingPeriod, chargeId));
}
}
The failure sequence. The subscriber invokes chargeCustomer(). Micronaut’s ReactiveTransactionInterceptor wraps the returned Mono pipeline in a transaction-managed context via contextWrite(), binding the first reactive transaction (TX-1) to the subscriber context. Reactor dispatches the fromCallable() callable to a boundedElastic thread. The callable executes: UUID.randomUUID() generates UUID_A. The SDK sends a POST to /v1/charges with Idempotency-Key: UUID_A. Stripe receives the request, creates ch_A, and begins sending the HTTP response. A network timeout terminates the connection before the SDK receives the full response. The SDK throws a StripeException with status 503. The callable propagates the exception; fromCallable() emits it as a reactive error.
The error reaches retryWhen(). The filter function matches — a 503 StripeException. retryWhen() waits 200 ms and re-subscribes to stripeChargeMono. Re-subscription creates a new subscriber context chain. Micronaut’s ReactiveTransactionInterceptor — which wraps the entire returned Mono from chargeCustomer(), not just the inner stripeChargeMono — sees the new inner subscription. But the transaction wrapping applies to the outer Mono; the inner stripeChargeMono.retryWhen() sits below the transaction boundary in the chain. The re-subscription to stripeChargeMono does not cross the outer transaction boundary — TX-1 from the first subscription is not involved. The callable executes again. UUID.randomUUID() generates UUID_B. The SDK sends a POST with Idempotency-Key: UUID_B. Stripe has no record of UUID_B — UUID_A was committed as ch_A — and creates ch_B. The customer is charged twice.
Why the reactive transaction boundary does not prevent UUID regeneration
Developers sometimes reason that because each retryWhen() re-subscription starts a new Micronaut reactive transaction, the first attempt’s work is rolled back, making the retry “clean.” The rollback of TX-1 correctly undoes the R2DBC database writes from the first attempt (any auditRepository.saveCharge() calls within TX-1 are rolled back). But Stripe is not a participant in the R2DBC transaction. The Stripe charge ch_A is already committed on Stripe’s side the moment the SDK completes the HTTP request — the network timeout happened during Stripe’s response, not during Stripe’s processing. Stripe received, validated, and committed ch_A before the network failure. There is no distributed transaction that can roll back a committed Stripe charge from a database rollback.
The two concerns are orthogonal: Micronaut’s reactive transaction manager handles R2DBC database atomicity correctly per re-subscription; Stripe idempotency key stability across retries must be handled by the developer explicitly, independently of the transaction boundary.
Fix for mode 1
Compute the idempotency key outside the fromCallable() callable body, as a plain Java statement before any reactive operator. The key is a local variable at the method scope. The callable closes over the effectively-final local variable. Every retryWhen() re-subscription reuses the same closure — the same key value. UUID.randomUUID() is called once per chargeCustomer() invocation.
The more robust form uses a content-hash key derived from the stable method parameters. A content-hash key eliminates the UUID entirely: the key is deterministic from (customerId, amountCents, billingPeriod). If the same billing intent is submitted twice (e.g., a @Scheduled job re-running after a crash), a content-hash key returns the same string both times, and Stripe deduplicates the second request against the first committed charge — returning ch_A without creating ch_B. A UUID.randomUUID() key, even moved to method scope, does not have this property: a new method invocation generates a new UUID, so a job re-run after a crash will produce a new key and a new charge.
@Transactional
public Mono<String> chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// Content-hash key — derived from stable method parameters.
// Computed once at method scope, before any reactive operator.
// Stable across all retryWhen() re-subscriptions for this invocation.
// Also stable across method re-invocations with the same arguments (job re-run safety).
final String idempotencyKey = "charge:" + customerId + ":"
+ amountCents + ":" + billingPeriod;
Mono<String> stripeChargeMono = Mono.fromCallable(() -> {
// idempotencyKey is closed over from method scope — the same value on every
// fromCallable() invocation regardless of how many times retryWhen() re-subscribes.
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
Charge charge = Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
}).subscribeOn(Schedulers.boundedElastic());
return stripeChargeMono
.retryWhen(Retry.backoff(3, Duration.ofMillis(200))
.filter(ex -> ex instanceof StripeException &&
((StripeException) ex).getStatusCode() == 503))
.flatMap(chargeId -> auditRepository.saveCharge(
customerId, billingPeriod, chargeId));
}
Why subscribeOn(Schedulers.boundedElastic()) does not change the analysis
subscribeOn() controls which thread pool executes the callable — it dispatches subscriptions to the bounded elastic pool so that the blocking Stripe SDK call does not run on a Reactor event-loop thread. It does not deduplicate subscriptions, batch them, or prevent the callable from being called once per subscription. Three retryWhen() re-subscriptions produce three callable invocations on three elastic pool threads; UUID.randomUUID() is called three times. subscribeOn() is not a solution to per-subscription UUID regeneration.
Mode 2: Micronaut @Retryable on Mono<T> return type — DefaultRetryInterceptor calls context.proceed() per attempt — UUID_B — ch_B
Micronaut’s @io.micronaut.retry.annotation.Retryable explicitly supports reactive return types including Mono<T>, Flux<T>, and RxJava publishers. This is a significant difference from Spring’s @Retryable, which does not support reactive return types — on a method returning Mono<T>, Spring’s RetryOperationsInterceptor treats the Mono object itself as the return value and retries on exceptions thrown by the method invocation, not on exceptions emitted by the Mono after subscription. In practice this means Spring @Retryable on a reactive method never retries Stripe errors (the method invocation succeeds in returning a Mono; the Stripe error is emitted later during subscription). Micronaut’s DefaultRetryInterceptor handles this correctly: it subscribes to the publisher, observes emission errors, and retries if the policy allows.
The retry mechanism for reactive publishers in Micronaut’s DefaultRetryInterceptor: the interceptor calls context.proceed() to get the Mono<T> from the first method invocation, wraps it in a reactive retry loop using Publishers.convertPublisher(), and on each error checks the retry policy. If retries remain and the exception type matches, it calls context.proceed() again. This second call to context.proceed() is not a re-subscription to the same Mono — it is a fresh call to the method body that returns a new Mono instance. Every piece of code in the method body between the opening brace and the first reactive operator executes again. UUID.randomUUID() at the method entry generates UUID_B.
// BillingService.java — Micronaut @Retryable on Mono<T> return type
@Singleton
public class BillingService {
private final BillingAuditReactiveRepository auditRepository;
// @Retryable on a method returning Mono<T> is supported in Micronaut.
// DefaultRetryInterceptor calls context.proceed() per retry attempt.
// context.proceed() re-invokes this method body — not re-subscribes.
// UUID.randomUUID() at the start of the method body regenerates per attempt.
// UUID_B — ch_B.
@Retryable(
delay = "200ms",
maxDelay = "2s",
multiplier = "1.5",
attempts = "3",
includes = StripeException.class
)
@Transactional
public Mono<String> chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// UUID at method scope — BEFORE any reactive operator.
// Developer rationale: "this is before the reactive chain, so it runs once."
// This reasoning is correct for retryWhen() (re-subscribes to same Mono).
// It is WRONG for @Retryable (calls context.proceed() = new method body invocation).
String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE under @Retryable
return Mono.fromCallable(() -> {
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
Charge charge = Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
})
.subscribeOn(Schedulers.boundedElastic())
.flatMap(chargeId -> auditRepository.saveCharge(
customerId, billingPeriod, chargeId));
}
}
The failure sequence. A caller (another service, a controller, a @Scheduled method) calls chargeCustomer(). Micronaut’s AOP proxy intercepts the call. The proxy chain executes: @Retryable interceptor first (it has a higher priority than @Transactional in the Micronaut default ordering), followed by @Transactional interceptor. The @Transactional interceptor wraps the method result in a transaction-managed publisher. But the @Retryable interceptor controls the outer loop.
First attempt: DefaultRetryInterceptor calls context.proceed(). The @Transactional interceptor calls the actual method body. The method body executes from the first line: UUID.randomUUID() generates UUID_A. The method returns a Mono. The @Transactional interceptor wraps it in a transaction context. The @Retryable interceptor subscribes to the resulting Mono. The subscription fires: the callable executes on a boundedElastic thread, the SDK sends a POST with Idempotency-Key: UUID_A, Stripe creates ch_A, the connection drops, the SDK throws StripeException (503). The Mono emits an error. The @Retryable interceptor receives the error: it matches StripeException.class, and attempts remaining (3 total; 2 remaining). It waits 200 ms.
Second attempt: DefaultRetryInterceptor calls context.proceed() again. This is not a re-subscription — it is a new call to the method body through the AOP proxy chain. The method body executes from the first line again. UUID.randomUUID() generates UUID_B — a different value. The method returns a new Mono instance. The new Mono is subscribed to; the callable executes; the SDK sends a POST with Idempotency-Key: UUID_B. Stripe has no record of UUID_B and creates ch_B. The customer is charged twice.
The critical distinction between retryWhen() re-subscription and @Retryable method re-invocation
This mode exposes a subtle difference in where “method scope before any reactive operator” is safe to place a UUID. With Reactor’s retryWhen(), re-subscription re-executes operators in the pipeline — inside fromCallable(), inside flatMap(), inside defer() factories — but does not re-execute Java statements above the first reactive operator at method scope. The statement String idempotencyKey = UUID.randomUUID().toString(); placed before any Mono operator runs once when the method body runs, and its value is captured by subsequent operators via closure. retryWhen() re-subscribes to the Mono built from those operators — it does not re-run the method body that built the Mono.
With Micronaut’s @Retryable, DefaultRetryInterceptor retries by calling context.proceed(), which re-invokes the method body in its entirety. There is no distinction between “before the first reactive operator” and “inside an operator” — everything in the method body runs again on each proceed() call. A UUID placed at the very first line of the method body regenerates on every retry just as much as a UUID inside a fromCallable() callable.
The fix must therefore work at a scope that survives both proceed() re-invocations and re-subscriptions: the parameters passed to the method from the caller. Method parameters are stable across @Retryable re-invocations because DefaultRetryInterceptor calls proceed() with the same argument values that arrived at the interceptor boundary. A content-hash key derived from the method parameters is computed identically on every re-invocation. UUID.randomUUID(), by contrast, produces a new value on every re-invocation regardless of where in the method body it is placed.
Fix for mode 2
Replace UUID.randomUUID() with a content-hash key derived from the stable method parameters. The content-hash computes the same string whether this is the first invocation or the third retry:
@Retryable(
delay = "200ms",
maxDelay = "2s",
multiplier = "1.5",
attempts = "3",
includes = StripeException.class
)
@Transactional
public Mono<String> chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// Content-hash key — derived from method parameters, which are the same on every
// @Retryable context.proceed() re-invocation (DefaultRetryInterceptor passes the
// same arguments). The same string is produced on all three retry attempts.
final String idempotencyKey = "charge:" + customerId + ":"
+ amountCents + ":" + billingPeriod;
return Mono.fromCallable(() -> {
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
Charge charge = Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
})
.subscribeOn(Schedulers.boundedElastic())
.flatMap(chargeId -> auditRepository.saveCharge(
customerId, billingPeriod, chargeId));
}
An alternative fix is to pass the idempotency key as a method parameter from the caller, pre-computed as a content-hash before the @Retryable boundary is reached. This is appropriate when the billing intent is assembled by an orchestration layer that controls the key lifecycle. The @Retryable service method becomes a pure retry-safe executor that never generates keys itself:
// Orchestration layer — calls @Retryable service, controls key lifecycle.
public Mono<String> initiateCharge(String customerId, int amountCents,
String billingPeriod) {
// Key computed here, at the orchestration scope — outside @Retryable boundary.
String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
return billingService.chargeCustomerWithKey(
customerId, amountCents, billingPeriod, idempotencyKey);
}
// In BillingService — key passed as parameter, never generated inside @Retryable method.
@Retryable(delay = "200ms", attempts = "3", includes = StripeException.class)
@Transactional
public Mono<String> chargeCustomerWithKey(String customerId, int amountCents,
String billingPeriod,
String idempotencyKey) {
return Mono.fromCallable(() -> {
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build();
Charge charge = Charge.create(
params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
})
.subscribeOn(Schedulers.boundedElastic())
.flatMap(chargeId -> auditRepository.saveCharge(
customerId, billingPeriod, chargeId));
}
@Transactional interaction with @Retryable in Micronaut
In Micronaut’s default AOP interceptor ordering, @Retryable has a lower order value (executes earlier, wraps the outer layer) than @Transactional. This means @Retryable is the outer proxy and @Transactional is the inner proxy. Each context.proceed() call by the @Retryable interceptor passes through the @Transactional interceptor, which starts a new reactive transaction for each retry attempt.
This ordering differs from the blocking Micronaut @Transactional + @Retryable case covered in the prior Micronaut Data @Transactional post, where the same outer/@Retryable and inner/@Transactional ordering applied to method-level transactions that commit or roll back synchronously. In the reactive case, the “transaction per attempt” behavior is correct — each retry should have a clean transaction scope with no half-committed R2DBC state from the prior attempt. But UUID regeneration is not fixed by the per-attempt transaction; the fix is the content-hash key that is identical across all proceed() calls regardless of transaction scope.
Mode 3: onErrorResume() manual retry — UUID inside Mono.defer() recovery factory — each recovery invocation generates UUID_B — ch_B
Some developers prefer not to use @Retryable — they want fine-grained control over which exceptions trigger a retry, the exact backoff timing, or the ability to log intermediate state. Reactor’s onErrorResume() operator provides this: it catches a reactive error and substitutes a recovery Mono. The recovery Mono acts as the retry path. Developers often write:
// BillingService.java — onErrorResume() manual retry
@Singleton
public class BillingService {
private final BillingAuditReactiveRepository auditRepository;
@Transactional
public Mono<String> chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
return chargeWithStripe(customerId, amountCents, billingPeriod)
.flatMap(chargeId -> auditRepository.saveCharge(
customerId, billingPeriod, chargeId))
.onErrorResume(StripeException.class, ex -> {
// Only retry on 503 — connection timeout, transient outage.
if (ex.getStatusCode() != 503) {
return Mono.error(ex);
}
// Wait 200ms then retry — UUID is inside the recovery Mono.defer() factory.
return Mono.delay(Duration.ofMillis(200))
.then(Mono.defer(() -> {
// UUID computed inside the defer() factory.
// onErrorResume handler is called each time the upstream emits an error.
// Mono.defer() evaluates the factory on each subscription.
// Two error sources: onErrorResume fires once, defer fires once per subscribe.
// Net effect: each onErrorResume() invocation evaluates the defer factory = UUID_B.
String retryKey = UUID.randomUUID().toString(); // UNSAFE
return chargeWithStripeKey(customerId, amountCents, retryKey);
}));
});
}
private Mono<String> chargeWithStripe(String customerId, int amountCents,
String billingPeriod) {
// First attempt — key also generated here, same problem.
String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE — different bug
return Mono.fromCallable(() -> {
Charge charge = Charge.create(
ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
}).subscribeOn(Schedulers.boundedElastic());
}
private Mono<String> chargeWithStripeKey(String customerId, int amountCents,
String idempotencyKey) {
return Mono.fromCallable(() -> {
Charge charge = Charge.create(
ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
}).subscribeOn(Schedulers.boundedElastic());
}
}
There are two distinct UUID bugs in this code. The first: chargeWithStripe() generates its idempotency key as a local variable in the method body, called once per chargeCustomer() invocation. This is safe against onErrorResume() retry (because chargeWithStripe() is only called once, not retried). But it is not safe against a job re-run (new invocation of chargeCustomer() generates a new UUID, and Stripe has no way to deduplicate it against the prior committed charge). The second, more immediate bug: the recovery path generates retryKey as UUID.randomUUID() inside the Mono.defer() factory. This runs on each onErrorResume() invocation, producing UUID_B on the retry and ch_B on Stripe. The first attempt’s committed ch_A is not rolled back by any mechanism — it is a permanent Stripe charge.
The failure sequence. The subscriber subscribes to chargeCustomer(). The first attempt: chargeWithStripe() is called, generating UUID_A. The callable executes, the SDK sends Idempotency-Key: UUID_A, Stripe creates ch_A, the connection drops, StripeException(503) propagates up from the fromCallable(). The error reaches onErrorResume(StripeException.class, ex -> ...). The handler checks ex.getStatusCode() == 503 — true. Mono.delay(Duration.ofMillis(200)) delays 200 ms. Then Mono.defer(() -> {...}) is subscribed to. The defer factory executes: UUID.randomUUID() generates UUID_B. chargeWithStripeKey() is called with UUID_B. The callable executes, the SDK sends Idempotency-Key: UUID_B. Stripe has no record of UUID_B and creates ch_B. The customer is charged twice.
The Micronaut @Transactional complication in mode 3: rolled-back transaction state in the recovery path
A Micronaut-specific complication in mode 3 arises when @Transactional wraps the entire outer method including the onErrorResume() handler. Micronaut’s reactive @Transactional binds the transaction to the subscriber context. When StripeException propagates from the fromCallable() in the first attempt, Micronaut’s reactive transaction interceptor detects the error signal and marks the reactive transaction for rollback. The @Transactional interceptor wraps the outer returned Mono — including the onErrorResume() operator attached to it.
Whether onErrorResume() runs within the rolled-back transaction or outside it depends on how the operator chain is constructed. If the entire chain (including the onErrorResume() recovery) is within the publisher wrapped by @Transactional, the recovery Mono returned by onErrorResume() runs within the same subscriber context as the failed attempt. The reactive transaction status in the context may be marked rollback-only. Any auditRepository write in the recovery path that participates in the same transaction will fail with TransactionSystemException: Transaction has already been rolled back or io.micronaut.transaction.exceptions.TransactionUsageException: Transaction rollback-only.
The developer’s mental model — “onErrorResume() is a fresh recovery, like a catch block in imperative code” — is partially correct for exception handling but incorrect for Micronaut reactive transaction state. A Java try-catch in an imperative @Transactional method that catches a RuntimeException (without re-throwing) prevents Spring/Micronaut from marking the transaction for rollback: the exception is handled in userspace before the transaction interceptor sees it. But in a reactive pipeline, the error signal propagates through the operator chain to the @Transactional interceptor’s subscriber regardless of whether onErrorResume() recovers from it: the error signal still traveled past the transaction-bound subscriber context in order to reach onErrorResume(). Whether Micronaut marks the transaction rollback-only at that point depends on the error signal handling order in the reactive context, which is framework-version-dependent.
The correct approach for retry in a @Transactional reactive service is to use @Retryable (which creates a new transaction per attempt via the outer DefaultRetryInterceptor + inner @Transactional interceptor ordering) or to use retryWhen() on a sub-pipeline that does not include R2DBC writes — retrying only the Stripe call and committing the R2DBC write only after the Stripe call succeeds.
Fix for mode 3
Compute the idempotency key at method scope before the reactive chain is assembled. Both the first-attempt call and the onErrorResume() recovery path close over the same effectively-final local variable. The key is stable across both paths:
@Transactional
public Mono<String> chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// Content-hash key computed at method scope — before any reactive operator.
// Both the first attempt (chargeWithStripeKey) and the onErrorResume recovery
// path close over this same variable. Same value on both paths.
// No UUID_B from the recovery lambda.
final String idempotencyKey = "charge:" + customerId + ":"
+ amountCents + ":" + billingPeriod;
return chargeWithStripeKey(customerId, amountCents, idempotencyKey)
.flatMap(chargeId -> auditRepository.saveCharge(
customerId, billingPeriod, chargeId))
.onErrorResume(StripeException.class, ex -> {
if (ex.getStatusCode() != 503) {
return Mono.error(ex);
}
// Recovery path uses the same idempotencyKey — closed over from method scope.
// If Stripe received and committed ch_A before the timeout, this retry with
// the same key returns ch_A without creating ch_B.
return Mono.delay(Duration.ofMillis(200))
.then(chargeWithStripeKey(customerId, amountCents, idempotencyKey));
});
}
private Mono<String> chargeWithStripeKey(String customerId, int amountCents,
String idempotencyKey) {
return Mono.fromCallable(() -> {
Charge charge = Charge.create(
ChargeCreateParams.builder()
.setAmount((long) amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
return charge.getId();
}).subscribeOn(Schedulers.boundedElastic());
}
The @Transactional rollback-only complication is addressed by restructuring the chain so that R2DBC writes only occur after the Stripe call succeeds. The chargeWithStripeKey() method contains only the Stripe SDK call — no R2DBC operations. The auditRepository.saveCharge() is in the flatMap() downstream, which only executes on successful Stripe response. If the Stripe call fails, the error propagates to onErrorResume() before any R2DBC write participates in the transaction — the transaction has no writes to roll back from the first attempt. The recovery path calls chargeWithStripeKey() again (same key); if it succeeds, flatMap() executes auditRepository.saveCharge() in the recovery subscriber context. Whether that write is in the original transaction or a new one depends on whether the transaction was marked rollback-only before onErrorResume(); using @Retryable instead of manual onErrorResume() avoids this ambiguity entirely by guaranteeing a fresh transaction per attempt.
Cross-mode comparison
| Mode | Retry mechanism | UUID re-execution cause | Is “method scope before operators” safe? | Fix |
|---|---|---|---|---|
| 1 | Reactor retryWhen() |
Mono.fromCallable() re-executed per re-subscription |
Yes — Java statement before any operator is not re-executed by retryWhen() |
Move UUID to method scope (or use content-hash) |
| 2 | Micronaut @Retryable (reactive) |
DefaultRetryInterceptor.proceed() re-invokes entire method body |
No — method body is re-invoked; everything in the body runs again | Content-hash from method parameters (stable across proceed() calls) |
| 3 | Manual onErrorResume() |
Mono.defer() factory re-evaluated inside recovery lambda |
Yes — method scope variable is stable; bug is the defer factory inside onErrorResume() |
Compute key at method scope, close over it in recovery lambda |
Mode 1 and mode 3 share the same fix direction (method scope before any operator) but the bug location differs: mode 1 has the UUID inside a fromCallable() callable, mode 3 has it inside a Mono.defer() factory inside the onErrorResume() handler. Mode 2 requires a different fix: content-hash from method parameters rather than pre-chain UUID, because no placement within the method body is stable across DefaultRetryInterceptor.proceed() re-invocations.
The distinction between mode 1 and mode 2 maps precisely to the distinction between Reactor retryWhen() and Micronaut @Retryable on reactive methods. This distinction is Micronaut-specific: it does not arise with Spring @Retryable on reactive methods (which doesn’t retry at all on reactive publishers) and it does not arise with Quarkus SmallRye @Retry on Uni<T> (which re-invokes the method body via InvokerConfiguration.invoke(), identical mechanism to Micronaut proceed() — see the Quarkus Mutiny post for the Quarkus-specific details).
Relationship to prior Micronaut posts
The Micronaut Data blocking @Transactional post covered three failure modes with blocking reactive transactions: @Retryable/@Transactional interceptor ordering where UUID is in the service method body (identical mechanism to mode 2 above but with blocking @Transactional); @Transactional(propagation=REQUIRES_NEW) inner service called from a retry loop (each call is a new method invocation — UUID regenerates); and RetryTemplate lambda scope (UUID inside lambda body re-executes per doWithRetry()). The current post’s mode 2 overlaps with the first case from that post in terms of the @Retryable/proceed() mechanism, but the reactive transaction context adds the additional complication that each proceed() call starts a new reactive transaction via the subscriber context rather than a new thread-local transaction. The fix is identical (content-hash from method parameters), but the transaction boundary behavior differs.
The Micronaut RxJava3 post covered Single.fromCallable() re-subscription (same mechanism as mode 1 above but with RxJava3 Single.retry() instead of Reactor retryWhen()), flatMap() mapper UUID with Single.retry(), and @Retryable on Single<T> return type (same mechanism as mode 2 above but with RxJava3 instead of Reactor). The current post covers the Reactor equivalents (Mono/retryWhen()) with Micronaut Data’s reactive R2DBC transaction context, which adds the subscriber-context-bound transaction behavior not present in the RxJava3 post.
Testing patterns: Micronaut Test + WireMock + StepVerifier
All three modes are testable with Micronaut Test, WireMock for Stripe API simulation, and Reactor’s StepVerifier for reactive assertion. The key assertion in all cases is new HashSet<>(capturedKeys).size() == 1 — all retry attempts must send the same Idempotency-Key header.
Mode 1: @MicronautTest + WireMock + StepVerifier
@MicronautTest
class BillingServiceMode1Test {
@Inject BillingService billingService;
// WireMock server started separately, Stripe base URL overridden in application-test.yml
// stripe.base-url: http://localhost:${wiremock.server.port}
@Test
void retryWhenDoesNotRegenerateIdempotencyKey() {
// Arrange: first two requests fail with 503, third succeeds.
WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/v1/charges"))
.inScenario("stripe-retry-mode1")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(WireMock.aResponse().withStatus(503))
.willSetStateTo("fail-1"));
WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/v1/charges"))
.inScenario("stripe-retry-mode1")
.whenScenarioStateIs("fail-1")
.willReturn(WireMock.aResponse().withStatus(503))
.willSetStateTo("fail-2"));
WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/v1/charges"))
.inScenario("stripe-retry-mode1")
.whenScenarioStateIs("fail-2")
.willReturn(WireMock.okJson(
"{\"id\":\"ch_test_1\",\"status\":\"succeeded\",\"amount\":5000}")));
// Act.
StepVerifier.create(billingService.chargeCustomer("cus_A", 5000, "2026-10"))
.expectNextCount(1)
.verifyComplete();
// Assert: all three HTTP requests sent the same Idempotency-Key header.
List<String> keys = WireMock.getAllServeEvents().stream()
.map(e -> e.getRequest().getHeader("Idempotency-Key"))
.collect(Collectors.toList());
assertThat(keys).hasSize(3);
assertThat(new HashSet<>(keys)).hasSize(1); // fails if UUID inside fromCallable()
}
}
The Micronaut-specific configuration: override the Stripe base URL in src/test/resources/application-test.yml so that the Stripe Java SDK points to the WireMock server. Set Stripe.overrideApiBase("http://localhost:" + wireMockPort) in a @BeforeEach setup method or via an ApplicationEventListener<StartupEvent> in the test context. The WireMock scenario sequences three HTTP requests: fail, fail, succeed. StepVerifier subscribes and drives retryWhen() through all three subscriptions. The assertion on new HashSet<>(keys).size() == 1 fails immediately with UUID.randomUUID() inside the callable (three distinct UUIDs) and passes with the content-hash fix (same string on all three requests).
Mode 2: @MicronautTest + WireMock + blocking call observation
@MicronautTest
class BillingServiceMode2Test {
@Inject BillingService billingService;
@Test
void retryableDoesNotRegenerateIdempotencyKey() {
WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/v1/charges"))
.inScenario("stripe-retry-mode2")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(WireMock.aResponse().withStatus(503))
.willSetStateTo("fail"));
WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/v1/charges"))
.inScenario("stripe-retry-mode2")
.whenScenarioStateIs("fail")
.willReturn(WireMock.okJson(
"{\"id\":\"ch_test_2\",\"status\":\"succeeded\",\"amount\":4000}")));
// @Retryable is at the proxy boundary — subscribe to the returned Mono.
StepVerifier.create(billingService.chargeCustomer("cus_B", 4000, "2026-10"))
.expectNextCount(1)
.verifyComplete();
List<String> keys = WireMock.getAllServeEvents().stream()
.map(e -> e.getRequest().getHeader("Idempotency-Key"))
.collect(Collectors.toList());
// Two requests: first 503, second 200.
assertThat(keys).hasSize(2);
// Must be the same key — @Retryable proceed() must not regenerate it.
assertThat(new HashSet<>(keys)).hasSize(1); // fails with UUID.randomUUID() in method body
}
}
Mode 2 requires the full Micronaut application context (@MicronautTest) because @Retryable/@Transactional AOP proxy ordering is established by Micronaut’s bean factory during context startup. A unit test with a manually instantiated BillingService would bypass both the @Retryable and @Transactional AOP proxies. StepVerifier subscribes to the Mono returned by the @Retryable-wrapped proxy. Micronaut’s DefaultRetryInterceptor subscribes internally and on the first error calls proceed() again. WireMock captures both HTTP requests. The assertion fails with UUID.randomUUID() anywhere in the method body and passes with the content-hash fix.
Mode 3: @MicronautTest + WireMock + StepVerifier
@MicronautTest
class BillingServiceMode3Test {
@Inject BillingService billingService;
@Test
void onErrorResumeRecoveryDoesNotRegenerateIdempotencyKey() {
WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/v1/charges"))
.inScenario("stripe-retry-mode3")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(WireMock.aResponse().withStatus(503))
.willSetStateTo("fail"));
WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/v1/charges"))
.inScenario("stripe-retry-mode3")
.whenScenarioStateIs("fail")
.willReturn(WireMock.okJson(
"{\"id\":\"ch_test_3\",\"status\":\"succeeded\",\"amount\":3000}")));
StepVerifier.create(billingService.chargeCustomer("cus_C", 3000, "2026-10"))
.expectNextCount(1)
.verifyComplete();
List<String> keys = WireMock.getAllServeEvents().stream()
.map(e -> e.getRequest().getHeader("Idempotency-Key"))
.collect(Collectors.toList());
assertThat(keys).hasSize(2);
// First attempt (503) and recovery attempt must use the same key.
assertThat(new HashSet<>(keys)).hasSize(1); // fails if defer() generates new UUID
}
}
Mode 3 uses @MicronautTest with an embedded R2DBC-compatible test database (H2 in R2DBC mode configured in application-test.yml, or a Testcontainers PostgreSQL instance with @TestContainers) so that auditRepository operations can complete without a real database connection error. WireMock simulates a 503 on the first request and 200 on the second. StepVerifier subscribes and observes the full reactive chain including the onErrorResume() recovery. The assertion fails if UUID.randomUUID() is inside the Mono.defer() factory in the recovery lambda (two different keys) and passes with the content-hash key closed over from method scope (same key on both requests).
The underlying principle: billing intent scope vs. retry scope, applied to Micronaut reactive transactions
All three failure modes are instances of the same misalignment: the idempotency key is computed at retry scope rather than billing intent scope. Retry scope means “once per attempt”. Billing intent scope means “once per unique billing request, stable across all retry attempts for that request.”
In Micronaut’s reactive stack, each retry mechanism defines its “retry scope” boundary differently. For retryWhen(), the boundary is the reactive subscription: any code inside an operator whose supplier or factory runs per subscription is at retry scope. For @Retryable on reactive methods, the boundary is the AOP method invocation: any code inside the method body is at retry scope, regardless of whether it is inside a reactive operator or a plain Java statement. For onErrorResume() with Mono.defer(), the boundary is the defer factory evaluation: any code inside the factory lambda is at retry scope.
The fix is identical in principle across all three mechanisms: compute the key at a scope that is outside all retry boundaries. For mode 1 and mode 3, “method scope before any reactive operator” is outside the retryWhen() and onErrorResume() boundaries. For mode 2, “method parameters” is the only scope outside the @Retryable method-body boundary — because proceed() re-runs the method body but passes the same parameter values each time. A content-hash key derived from those parameters is deterministic and identical across all proceed() calls.
This principle is consistent with the broader series. Quarkus Mutiny’s Uni.onFailure().retry() re-subscribes to the upstream Uni (same mechanism as mode 1). Spring Data R2DBC reactive @Transactional + retryWhen() has the same per-subscription transaction boundary behavior as mode 1 but with Spring’s TransactionalOperator and Mono.deferContextual() instead of Micronaut’s contextWrite(). The fix for both is the same: key at method scope before fromCallable() or defer(). Micronaut RxJava3 @Retryable on Single<T> has the same proceed() re-invocation mechanism as mode 2 but with RxJava3 semantics; the fix is the same content-hash from method parameters. The specific framework and library change the operator names and AOP interceptor class names but not the underlying principle.
Put the brakes on your agent’s keys
Keybrake is a scoped API-key proxy for the non-LLM SaaS APIs your agent calls — Stripe, Twilio, Resend — with per-vendor spend caps, allowlists, audit log, and one-click revoke. Get notified when we launch.