Spring Boot @Transactional + WebClient and Stripe Integration: How Reactor retryWhen() Re-subscription, @Retryable-over-block(), and Reactive Transaction Boundaries Generate New Idempotency Keys

When a Spring Boot service uses WebClient to call Stripe and adds either retryWhen() or @Retryable for resilience, three structurally distinct mechanisms each silently generate a new idempotency key — and Stripe creates a second charge. The root cause in each is the same: a UUID.randomUUID() call placed inside a code boundary that runs per retry attempt rather than once per billing intent.

Spring’s WebClient is the reactive HTTP client for Spring WebFlux and Spring Web MVC. Developers reach for it to avoid blocking I/O on outbound HTTP calls, to compose parallel requests, or because a greenfield service is built on the reactive stack. Stripe does not publish an official reactive client, so integrations using WebClient typically send raw HTTP to Stripe’s REST API instead of using the Stripe Java SDK. This introduces idempotency-key failure modes that are specific to Reactor’s subscription model and to how Spring’s AOP proxies interact with reactive chains.

A prior post on Spring WebFlux and Stripe Integration covered basic WebFlux patterns: Mono.fromCallable() threading, Flux-based batch billing, and @Scheduled reactive pipelines. This post covers a distinct failure surface: the three-way interaction between WebClient, retry mechanisms — both reactive (retryWhen()) and annotation-driven (@Retryable) — and @Transactional boundaries, including Spring Data R2DBC’s reactive transaction manager.

Background: how Reactor’s subscription model differs from imperative retry

The central concept behind all three failure modes is that Reactor is a declarative reactive library: a Mono or Flux is a description of a computation, not the computation itself. The computation executes when a subscriber subscribes. A single Mono instance can be subscribed to multiple times, and each subscription is an independent execution of the described computation.

Reactor’s retryWhen() operator implements retry by re-subscribing to the upstream Mono when the upstream terminates with an error. It does not catch and re-invoke a method in the way that @Retryable does. It subscribes again to the same upstream publisher — which, for Mono.fromCallable(), means calling the callable again; for Mono.defer(), means evaluating the deferred factory again; for a chain like webClient.post()...retrieve()...bodyToMono(), means initiating a new HTTP request from the beginning of the pipeline.

This subscription model is correct and intentional. It is also the source of idempotency-key bugs when developers place UUID.randomUUID() inside any operator whose supplier, mapper, or factory function executes per subscription. The key question for any placement of UUID.randomUUID() in a reactive chain is: “Does this code run per subscription, or does it run at chain assembly time?” Code inside Mono.fromCallable(), Mono.defer(), flatMap() mappers, and map() functions runs per subscription. Code in a plain Java statement before any reactive operator runs at chain assembly time — once per method invocation, stable across re-subscriptions.

Mode 1: UUID.randomUUID() inside Mono.fromCallable() + retryWhen() — re-subscription re-executes the callable — UUID_B — ch_B

The Stripe Java SDK is a blocking library. Every method call on a Charge, PaymentIntent, or Customer object performs a synchronous HTTP request. In a Spring WebFlux service, blocking the event-loop thread is forbidden — it prevents other requests from being processed and can cause the service to appear hung. The idiomatic way to lift a blocking call into a reactive pipeline is Mono.fromCallable() combined with subscribeOn(Schedulers.boundedElastic()), which dispatches the callable to a thread pool designed for blocking I/O.

Developers often place UUID.randomUUID() inside the callable passed to fromCallable(), reasoning that the key should be generated “right before” the Stripe call. The callable body appears to be atomic: one UUID, one Stripe call, one return. The mistake is that “right before” means “on every subscription,” and retryWhen() creates multiple subscriptions to the same upstream Mono.

// BillingService.java — Spring WebFlux service
@Service
public class BillingService {

    private final WebClient webClient;
    private final BillingAuditRepository auditRepository; // reactive R2DBC repository

    public Mono<String> chargeCustomer(String customerId, int amountCents,
                                        String billingPeriod) {
        // UUID computed inside the fromCallable body — runs per subscription.
        // retryWhen() re-subscribes to the upstream Mono on each retry attempt.
        // fromCallable() calls its callable on each new subscription.
        // UUID.randomUUID() re-executes per retry — UUID_B — ch_B.
        Mono<String> stripeChargeMono = Mono.fromCallable(() -> {
            String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE

            // Stripe Java SDK — blocking HTTP call — 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 error.
        // Each re-subscription = new callable invocation = new UUID.randomUUID() call.
        return stripeChargeMono
            .retryWhen(Retry.backoff(3, Duration.ofMillis(200))
                .filter(ex -> ex instanceof StripeException &&
                               ((StripeException) ex).getStatusCode() == 503))
            .flatMap(chargeId -> auditRepository.recordCharge(
                customerId, billingPeriod, chargeId));
    }
}

The failure sequence: the subscriber (typically a controller or another reactive chain) subscribes to the Mono returned by chargeCustomer(). Reactor dispatches the callable to a boundedElastic thread. The callable executes: UUID.randomUUID() generates UUID_A. The Stripe SDK sends a POST to /v1/charges with Idempotency-Key: UUID_A. Stripe receives and processes the request, creates ch_A, and begins sending the HTTP response. A network event — a socket timeout, a TLS RST, a load balancer forced-close — terminates the connection before the SDK receives the response body. The SDK wraps the socket exception in a StripeException with status code 503 and throws it. The callable body propagates the exception, and the fromCallable() operator emits it as a reactive error.

The error reaches retryWhen(). The filter function checks that the exception is a StripeException with status 503 — it matches. retryWhen() waits 200 ms (first backoff) and re-subscribes to stripeChargeMono. Re-subscription means Reactor calls the fromCallable() factory again, which calls the callable again. The callable executes from the first line. UUID.randomUUID() generates UUID_B. The Stripe SDK sends a POST with Idempotency-Key: UUID_B. Stripe has no record of UUID_B — UUID_A was committed as ch_A, but UUID_B is a distinct key — and creates ch_B. The callable returns ch_B’s ID. flatMap() writes ch_B’s ID to the audit record. The customer is charged twice.

The developer’s model is that fromCallable() captures the callable once, the same way a method reference or a lambda passed to a constructor is captured once. This model is correct for cold Mono instances subscribed exactly once. With retryWhen(), each re-subscription causes fromCallable() to call the callable again, as if it were a fresh invocation. The UUID is not captured at callable-creation time — it is evaluated at callable-execution time, and execution happens per subscription.

Why subscribeOn(Schedulers.boundedElastic()) does not affect this

subscribeOn() controls which thread pool dispatches the subscription signal. It does not affect how many times the callable is called per subscription. Adding subscribeOn(Schedulers.boundedElastic()) dispatches each subscription to the elastic thread pool — it does not merge subscriptions or deduplicate callable executions. The retry is still three separate subscriptions; the callable is still called three times; UUID.randomUUID() is still called three times.

Fix for mode 1

Compute the idempotency key outside the fromCallable() callable — as a plain Java statement before any reactive operator is applied. The key is a local variable at the method scope. The callable closes over the effectively-final local variable. Every retryWhen() re-subscription sees the same closure — the same key value. UUID.randomUUID() is called once per chargeCustomer() invocation, not once per subscription:

public Mono<String> chargeCustomer(String customerId, int amountCents,
                                    String billingPeriod) {
    // Key computed at method scope — before any reactive operator.
    // Captured by closure in the callable below.
    // Same value on every retryWhen() re-subscription.
    final String idempotencyKey = "charge:" + customerId + ":"
                                  + amountCents + ":" + billingPeriod;

    Mono<String> stripeChargeMono = Mono.fromCallable(() -> {
        // idempotencyKey is closed over from method scope — stable.
        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.recordCharge(
            customerId, billingPeriod, chargeId));
}

With this placement, UUID.randomUUID() evaluates once per call to chargeCustomer(). The callable captures idempotencyKey as a closed-over final string. Every retryWhen() re-subscription calls the callable, which reads the same string. Stripe sees the same Idempotency-Key on every attempt. If Stripe already committed ch_A on a previous attempt, it returns ch_A’s ID on the retry rather than creating ch_B.

If the content-hash pattern is preferred over a random UUID (for example, to handle exactly-once guarantees across service restarts where the same billing intent is submitted by two different processes), derive the key from stable business parameters: "charge:" + customerId + ":" + amountCents + ":" + billingPeriod. This key is deterministic — any retry of the same billing intent, from any code path, produces the same Stripe idempotency key.

Mode 2: @Transactional service calling WebClient...block() + @Retryable — UUID in method body — @Retryable re-invokes method — UUID_B — ch_B

Spring’s @Transactional works by binding a database connection to the current thread via TransactionSynchronizationManager, which uses a ThreadLocal. This ThreadLocal propagation works correctly in traditional Spring MVC (where each request runs on a dedicated servlet thread) but breaks in Spring WebFlux (where a request may switch threads mid-execution as it traverses reactive operators). When a developer writes a service that is partly reactive — using WebClient for outbound calls but wanting @Transactional for database writes — a common compromise is to call .block() on the Mono returned by WebClient, keeping the entire method synchronous on the calling thread so that @Transactional’s ThreadLocal propagation works.

This compromise introduces two independent problems: the @Retryable/@Transactional interceptor-ordering idempotency issue (same as any other @Retryable + @Transactional combination), and the .block()-in-reactive-context hazard that can cause the Stripe charge to be committed at Stripe while the calling thread receives an exception rather than the Stripe response.

// BillingService.java — blocking bridge pattern
@Service
public class BillingService {

    private final WebClient webClient;
    private final BillingAuditRepository auditRepository; // JPA repository

    // @Retryable is the OUTER proxy (order 2,147,483,642).
    // @Transactional is the INNER proxy (order 2,147,483,647).
    // @Retryable re-invokes the entire method body on retry.
    // UUID.randomUUID() in the method body generates UUID_B on retry.
    @Retryable(
        include  = WebClientResponseException.ServiceUnavailable.class,
        maxAttempts = 3,
        backoff  = @Backoff(delay = 500, multiplier = 2)
    )
    @Transactional(rollbackFor = RuntimeException.class)
    public String chargeCustomer(String customerId, int amountCents,
                                  String billingPeriod) {
        String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE

        // DB write: mark billing as PENDING.
        auditRepository.save(
            new BillingAudit(customerId, billingPeriod, idempotencyKey, PENDING));

        // WebClient call — blocked into synchronous result.
        // This keeps the @Transactional ThreadLocal valid but introduces .block() hazard.
        StripeChargeResponse stripeResponse = webClient.post()
            .uri("/v1/charges")
            .header("Idempotency-Key", idempotencyKey)
            .bodyValue(Map.of(
                "amount",   amountCents,
                "currency", "usd",
                "customer", customerId
            ))
            .retrieve()
            .onStatus(HttpStatusCode::is5xxServerError, resp ->
                resp.createException().map(ex ->
                    new WebClientResponseException.ServiceUnavailable(
                        ex.getMessage(), null, null, null, null)))
            .bodyToMono(StripeChargeResponse.class)
            .block(); // Blocks the calling thread to keep @Transactional ThreadLocal valid.

        auditRepository.updateChargeId(
            customerId, billingPeriod, stripeResponse.getId());
        return stripeResponse.getId();
    }
}

The failure sequence for the idempotency issue: identical to the @Retryable/@Transactional Feign client pattern. @Retryable (outer proxy, order 2,147,483,642) intercepts the call. @Transactional (inner proxy, order 2,147,483,647) opens a transaction. The method body executes: UUID_A is generated. The PENDING audit record is written. WebClient sends the request to Stripe with Idempotency-Key: UUID_A. Stripe commits ch_A. A 503 response or a network timeout occurs before .block() returns. WebClient throws WebClientResponseException.ServiceUnavailable. @Transactional rolls back (rollbackFor = RuntimeException). @Retryable (outer) catches the exception. Backoff delay. Method body re-invoked. UUID_B. Stripe creates ch_B.

The .block()-in-reactive-context hazard

The second problem is specific to the .block() bridge. Project Reactor detects when .block() is called on an event-loop thread (a Netty reactor-http-nio-N thread) and throws:

java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking,
which is not supported in thread reactor-http-nio-3
	at reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:83)
	at reactor.core.publisher.Mono.block(Mono.java:1777)
	at com.example.BillingService.chargeCustomer(BillingService.java:42)

This exception is thrown by the Reactor runtime, not by Stripe or by the WebClient. The underlying HTTP request to Stripe may already be in flight or already completed at the point Reactor throws the blocking check exception. If Stripe committed ch_A before the IllegalStateException propagates back through the call stack, @Retryable catches a RuntimeException (the IllegalStateException is a RuntimeException), waits its backoff, and calls the method body again — with UUID_B — creating ch_B.

Whether this sequence is possible depends on when the .block() check runs. Reactor performs the blocking check during subscription. The check runs before the reactive pipeline executes — specifically, before the outbound HTTP request is initiated. In the standard case, Reactor blocks the calling thread and raises the exception before WebClient sends anything, so Stripe never receives a request, and @Retryable retries without prior charge. However, the exact behaviour depends on Reactor and Netty versions and on whether the event loop enforces the check pre- or post-pipeline-execution. The safe position is: do not rely on the blocking check to prevent a Stripe call. If you use .block() in any context that could be a reactive thread, the check is not a safety net — it is a warning that you need to restructure the code.

Additionally, if the service is called from a Spring MVC controller (synchronous Tomcat thread) and .block() is safe on that thread, the idempotency-key issue still exists through @Retryable re-invocation — the blocking check hazard is separate from and additive to the UUID regeneration hazard.

Fix for mode 2

There are two independently correct fixes. Either: remove .block() and use Spring Data R2DBC for reactive database writes (covered in mode 3), or: keep .block() but compute the idempotency key from stable billing-intent parameters so that @Retryable re-invocations use the same key:

@Retryable(
    include  = WebClientResponseException.ServiceUnavailable.class,
    maxAttempts = 3,
    backoff  = @Backoff(delay = 500, multiplier = 2)
)
@Transactional(rollbackFor = RuntimeException.class)
public String chargeCustomer(String customerId, int amountCents,
                              String billingPeriod) {
    // Content-hash key: deterministic from stable billing intent parameters.
    // Same value on every @Retryable re-invocation with identical arguments.
    String idempotencyKey = "charge:" + customerId + ":"
                            + amountCents + ":" + billingPeriod;

    auditRepository.save(
        new BillingAudit(customerId, billingPeriod, idempotencyKey, PENDING));

    StripeChargeResponse stripeResponse = webClient.post()
        .uri("/v1/charges")
        .header("Idempotency-Key", idempotencyKey)
        .bodyValue(Map.of("amount", amountCents, "currency", "usd",
                          "customer", customerId))
        .retrieve()
        .onStatus(HttpStatusCode::is5xxServerError, resp ->
            resp.createException().map(ex ->
                new WebClientResponseException.ServiceUnavailable(
                    ex.getMessage(), null, null, null, null)))
        .bodyToMono(StripeChargeResponse.class)
        .block();

    auditRepository.updateChargeId(
        customerId, billingPeriod, stripeResponse.getId());
    return stripeResponse.getId();
}

With the content-hash key, @Retryable re-invocations compute the same key from the same arguments. Stripe sees the same Idempotency-Key and returns ch_A’s ID on the retry rather than creating ch_B. The .block() hazard remains — if this service method is ever called from a reactive event-loop thread, .block() throws and the duplicate-charge path re-opens. Eliminating .block() by migrating to a fully reactive stack (mode 3’s R2DBC pattern) is the long-term fix for services running on Spring WebFlux.

Mode 3: Spring Data R2DBC reactive @Transactional + WebClient + retryWhen() — UUID inside Mono.defer() or flatMap mapper — each re-subscription starts new reactive transaction — UUID_B — ch_B

Spring Data R2DBC supports @Transactional on reactive (Mono/Flux-returning) methods via ReactiveTransactionManager. Unlike the ThreadLocal-based PlatformTransactionManager, the reactive transaction manager binds the transaction to the reactive subscriber context — the Context object that flows through the reactive pipeline alongside the data. This means @Transactional works correctly on reactive methods without blocking.

When retryWhen() re-subscribes to the upstream Mono inside a reactive @Transactional method, Spring’s TransactionalOperator (which backs the reactive @Transactional proxy) starts a new reactive transaction for each re-subscription. This is the correct behaviour for data integrity: the failed R2DBC writes from the previous attempt are rolled back when that subscription terminates with an error, and the retry starts with a clean database context. But the new reactive transaction does not provide a new idempotency key — it only manages database-side atomicity. If the UUID is computed inside a reactive operator that executes per subscription, it regenerates on every retryWhen() re-subscription, producing UUID_B and ch_B.

// BillingService.java — fully reactive, Spring Data R2DBC
@Service
public class BillingService {

    private final WebClient webClient;
    private final BillingAuditRepository auditRepository; // R2DBC reactive repository

    // @Transactional on a reactive method — ReactiveTransactionManager used.
    // Transaction bound to subscriber Context, not ThreadLocal.
    // retryWhen() inside the method re-subscribes to the upstream chain.
    // Each re-subscription = new reactive transaction (correct for DB atomicity).
    // But each re-subscription also re-evaluates per-subscription operators.
    @Transactional
    public Mono<String> chargeCustomer(String customerId, int amountCents,
                                        String billingPeriod) {
        return Mono
            // Mono.defer() evaluates its factory per subscription.
            // UUID.randomUUID() inside defer re-executes on every retryWhen re-subscription.
            .defer(() -> {
                String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE

                return auditRepository
                    .save(new BillingAudit(customerId, billingPeriod,
                                          idempotencyKey, PENDING))
                    .then(webClient.post()
                        .uri("/v1/charges")
                        .header("Idempotency-Key", idempotencyKey)
                        .bodyValue(Map.of("amount",   amountCents,
                                          "currency", "usd",
                                          "customer", customerId))
                        .retrieve()
                        .onStatus(HttpStatusCode::is5xxServerError, resp ->
                            resp.createException()
                                .map(ex -> new BillingException("stripe-503", ex)))
                        .bodyToMono(StripeChargeResponse.class))
                    .flatMap(resp ->
                        auditRepository
                            .updateChargeId(customerId, billingPeriod, resp.getId())
                            .thenReturn(resp.getId()));
            })
            // retryWhen re-subscribes to the Mono.defer() factory on each retry.
            // New subscription = defer factory re-evaluated = UUID.randomUUID() re-called.
            .retryWhen(Retry.backoff(2, Duration.ofMillis(300))
                .filter(ex -> ex instanceof BillingException));
    }
}

The failure sequence: a subscriber subscribes to the Mono returned by chargeCustomer(). Spring’s reactive @Transactional proxy intercepts the subscription and starts a reactive transaction, binding it to the subscriber context. The subscription reaches Mono.defer(), which evaluates its factory: UUID.randomUUID() generates UUID_A. The R2DBC audit repository saves the PENDING record for UUID_A within the open reactive transaction. WebClient sends the Stripe request with Idempotency-Key: UUID_A. Stripe creates ch_A and begins sending the response. The connection is lost before the response body is received. WebClient emits a BillingException. The reactive transaction rolls back — the R2DBC PENDING write for UUID_A is rolled back.

The error reaches retryWhen(). The filter matches a BillingException. retryWhen() re-subscribes to Mono.defer(). Spring’s reactive transaction proxy starts a new reactive transaction for the new subscription. The Mono.defer() factory executes again: UUID.randomUUID() generates UUID_B. The PENDING record for UUID_B is written in the new transaction. WebClient sends the Stripe request with Idempotency-Key: UUID_B. Stripe has no record of UUID_B and creates ch_B. The transaction commits. The customer is charged twice.

Why Mono.defer() was used — and why it does not help with idempotency

Mono.defer() is the standard reactive pattern for ensuring that a side effect (like generating a UUID or reading a mutable value) happens at subscription time rather than at chain-assembly time. The developer’s reasoning is sound for general reactive programming: if you wrap UUID.randomUUID() in Mono.just(UUID.randomUUID()), the UUID is generated once when just()` is evaluated (at chain-assembly time) — before any subscription occurs. Mono.defer(() -> Mono.just(UUID.randomUUID())) defers generation to subscription time. For the common use case of generating a UUID per-request (where each HTTP request creates a new method invocation and therefore a new subscription), deferred generation is correct and desirable.

The failure case is when the same Mono is subscribed to multiple times — specifically via retryWhen(). The behaviour that Mono.defer() provides (generate UUID per subscription) is exactly the wrong behaviour when a single billing intent should use the same key across retry attempts. The Stripe idempotency requirement is not “generate a UUID per subscription” but “generate a UUID per billing intent” — and a retry is not a new billing intent.

The flatMap mapper variant

The same failure occurs when the UUID is inside a flatMap() mapper rather than a Mono.defer() factory:

// UNSAFE: UUID inside flatMap mapper — mapper runs per subscription.
return auditRepository.findOrCreateBillingPeriod(customerId, billingPeriod)
    .flatMap(period -> {
        String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE — runs per retryWhen re-subscription
        return webClient.post()
            .uri("/v1/charges")
            .header("Idempotency-Key", idempotencyKey)
            .bodyValue(/* ... */)
            .retrieve()
            .bodyToMono(StripeChargeResponse.class);
    })
    .retryWhen(Retry.backoff(2, Duration.ofMillis(300)));

The flatMap() mapper function is called on every item emitted by the upstream Mono. When retryWhen() re-subscribes to the upstream, the upstream re-emits the period object, and flatMap() calls the mapper again — the mapper creates a new UUID_B. The structural equivalence between the Mono.defer() and flatMap() variants is that both are lambda bodies that execute per subscription to their immediately enclosing operator.

Fix for mode 3

Compute the idempotency key outside any reactive operator — as a plain Java statement at the method scope, before Mono.defer(), before any flatMap(), before any chain construction. The key is captured by closure into any operator that needs it. Every re-subscription by retryWhen() sees the same captured value:

@Transactional
public Mono<String> chargeCustomer(String customerId, int amountCents,
                                    String billingPeriod) {
    // Key computed at method scope — pure Java, before any reactive operator.
    // Captured by closure into the defer factory and flatMap mapper below.
    // retryWhen() re-subscriptions read the same closed-over value.
    final String idempotencyKey = "charge:" + customerId + ":"
                                  + amountCents + ":" + billingPeriod;

    return Mono
        .defer(() ->
            auditRepository
                .save(new BillingAudit(customerId, billingPeriod,
                                       idempotencyKey, PENDING))
                .then(webClient.post()
                    .uri("/v1/charges")
                    .header("Idempotency-Key", idempotencyKey) // stable across re-subscriptions
                    .bodyValue(Map.of("amount",   amountCents,
                                      "currency", "usd",
                                      "customer", customerId))
                    .retrieve()
                    .onStatus(HttpStatusCode::is5xxServerError, resp ->
                        resp.createException()
                            .map(ex -> new BillingException("stripe-503", ex)))
                    .bodyToMono(StripeChargeResponse.class))
                .flatMap(resp ->
                    auditRepository
                        .updateChargeId(customerId, billingPeriod, resp.getId())
                        .thenReturn(resp.getId()))
        )
        .retryWhen(Retry.backoff(2, Duration.ofMillis(300))
            .filter(ex -> ex instanceof BillingException));
}

Mono.defer() is still correct for other reasons: it ensures that the R2DBC repository save() and the WebClient call are deferred to subscription time, keeping the reactive chain properly lazy. The only change is that the UUID computation is moved outside the defer factory so it is not re-evaluated per subscription. The reactive transaction proxy still starts a new transaction per retryWhen() re-subscription (correct for database atomicity), but the Stripe idempotency key is now stable across all re-subscriptions.

The upsert pattern for the audit write handles the case where the retry’s new reactive transaction writes a second PENDING record for the same (customerId, billingPeriod) key. Because idempotencyKey is stable, the second save() will attempt to write the same key value as the first attempt’s PENDING record, which was rolled back. A unique constraint on (customerId, billingPeriod) will not conflict because the first transaction was rolled back before the second subscription begins. The audit write proceeds cleanly.

Cross-mode analysis

Mode Retry mechanism UUID scope Why UUID regenerates Fix
1 Reactor retryWhen() Inside Mono.fromCallable() callable body fromCallable() calls callable on every subscription; retryWhen() re-subscribes per retry Compute key before fromCallable(); capture by closure
2 Spring @Retryable (annotation-driven AOP) In method body at method entry @Retryable is outer AOP proxy; re-invokes entire method body on exception Content-hash key from stable method parameters; eliminates .block() as long-term fix
3 Reactor retryWhen() with R2DBC reactive @Transactional Inside Mono.defer() factory or flatMap() mapper Both operators execute per subscription; retryWhen() re-subscribes per retry; each re-subscription starts new reactive transaction Compute key at method scope before any reactive operator; capture by closure

The structural distinction between modes 1 and 3 is where the retry operator sits relative to the Stripe call. In mode 1, retryWhen() wraps a Mono.fromCallable() that directly contains the Stripe SDK call — the re-subscription re-executes the callable. In mode 3, retryWhen() wraps a Mono.defer() that contains both R2DBC writes and a WebClient call — the re-subscription re-evaluates the entire deferred chain, including the defer() factory. The fix for both is the same — key outside any per-subscription operator — but the location of the operator that executes per subscription differs.

Mode 2 differs in kind from modes 1 and 3. The retry mechanism is annotation-driven AOP, not a Reactor operator. The unit of retry is a method invocation, not a reactive subscription. @Retryable re-invokes the entire method body; the UUID at the method body’s start regenerates because the entire method body re-runs. Modes 1 and 3 use Reactor’s retryWhen(), which re-subscribes to a reactive publisher; operators that execute per subscription regenerate the UUID because the subscription causes the operator to re-execute.

Mode 2 and the modes from Spring @Transactional + Feign Client and Spring @Transactional + @Scheduled share the same root mechanism: @Retryable (outer AOP proxy) re-invokes @Transactional (inner AOP proxy) method body. The client used for the Stripe HTTP call — Feign, WebClient.block(), or the Stripe Java SDK directly — does not affect the @Retryable/@Transactional interceptor ordering. The fix is identical across all variants: compute the idempotency key from stable billing-intent parameters, not from UUID.randomUUID() in the method body.

Test patterns

Mode 1: WireMock + Reactor StepVerifier + Idempotency-Key capture

@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BillingServiceMode1Test {

    @Autowired BillingService billingService;

    @Test
    void retryWhenDoesNotRegenerateIdempotencyKey() {
        // Stripe responds 503 twice, then 200.
        stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("stripe-retry")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse().withStatus(503))
            .willSetStateTo("first-fail"));

        stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("stripe-retry")
            .whenScenarioStateIs("first-fail")
            .willReturn(aResponse().withStatus(503))
            .willSetStateTo("second-fail"));

        stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("stripe-retry")
            .whenScenarioStateIs("second-fail")
            .willReturn(okJson("""
                {"id":"ch_test_OK","status":"succeeded","amount":5000}
                """)));

        // Subscribe and wait for result.
        StepVerifier.create(billingService.chargeCustomer("cus_A", 5000, "2026-10"))
            .expectNextCount(1)
            .verifyComplete();

        // Capture all Idempotency-Key headers sent to /v1/charges.
        List<String> idempotencyKeys = WireMock.getAllServeEvents().stream()
            .map(e -> e.getRequest().getHeader("Idempotency-Key"))
            .collect(Collectors.toList());

        // Three requests were made (503 × 2, then 200).
        assertThat(idempotencyKeys).hasSize(3);

        // All three must use the same key — retryWhen must not regenerate.
        assertThat(new HashSet<>(idempotencyKeys)).hasSize(1);
    }
}

The test subscribes once and lets retryWhen() drive three subscriptions internally. WireMock captures all three HTTP requests. The assertion on new HashSet<>(idempotencyKeys).size() == 1 fails immediately if UUID.randomUUID() is inside the callable — it produces three distinct UUIDs. With the fix (key computed at method scope), all three requests send the same key, and the assertion passes.

Mode 2: @SpringBootTest + WireMock + @Retryable observation

@SpringBootTest
@AutoConfigureWireMock(port = 0)
@EnableRetry
class BillingServiceMode2Test {

    @Autowired BillingService billingService;

    @Test
    void retryableDoesNotRegenerateIdempotencyKey() {
        stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("stripe-retry")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse().withStatus(503))
            .willSetStateTo("fail"));

        stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("stripe-retry")
            .whenScenarioStateIs("fail")
            .willReturn(okJson("""
                {"id":"ch_test_2","status":"succeeded","amount":4000}
                """)));

        billingService.chargeCustomer("cus_B", 4000, "2026-10");

        List<String> keys = WireMock.getAllServeEvents().stream()
            .map(e -> e.getRequest().getHeader("Idempotency-Key"))
            .collect(Collectors.toList());

        assertThat(keys).hasSize(2);
        assertThat(new HashSet<>(keys)).hasSize(1); // must be the same key across @Retryable attempts
    }
}

Mode 2 requires a full Spring context (@SpringBootTest) because @Retryable/@Transactional AOP proxy ordering is not testable with unit tests — only the full Spring context assembles the AOP proxy chain with the correct ordering. @EnableRetry activates Spring Retry. The test calls the service method once; @Retryable internally retries on 503. WireMock captures both HTTP requests. If UUID.randomUUID() is in the method body, the two requests have different Idempotency-Key headers, and the assertion fails. With the content-hash fix, both requests send the same key.

Mode 3: StepVerifier + WireMock + R2DBC embedded database

@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BillingServiceMode3Test {

    @Autowired BillingService billingService;

    @Test
    void reactiveTransactionalRetryDoesNotRegenerateKey() {
        stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("r2dbc-retry")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse().withStatus(503))
            .willSetStateTo("r2dbc-fail"));

        stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("r2dbc-retry")
            .whenScenarioStateIs("r2dbc-fail")
            .willReturn(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);
        assertThat(new HashSet<>(keys)).hasSize(1);
    }
}

Mode 3 requires an embedded R2DBC-compatible database (H2 in R2DBC mode, or @DataR2dbcTest slice with an in-memory database) so that the reactive @Transactional proxy and ReactiveTransactionManager are active. StepVerifier drives the subscription. WireMock captures both HTTP requests. The assertion on new HashSet<>(keys).size() == 1 fails if UUID.randomUUID() is inside the Mono.defer() factory or a flatMap() mapper, and passes when the key is computed at method scope before any reactive operator.

The underlying principle: billing intent scope vs. retry scope

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” — which is what UUID.randomUUID() placed inside a callable, defer factory, flatMap mapper, or @Retryable-wrapped method body delivers. Billing intent scope means “once per unique billing request, stable across all retry attempts for that request” — which is what a content-hash key derived from (customerId, amountCents, billingPeriod) delivers.

The specific mechanism — Reactor retryWhen() re-subscription, @Retryable AOP proxy method re-invocation, or R2DBC reactive transaction re-subscription — determines where exactly the per-retry-scope code runs: inside a callable, inside a defer factory, or at the top of a method body. The fix is always the same: move the key computation to the outermost scope, outside any boundary that runs per retry. In a reactive pipeline, that means method scope (before any operator). In an AOP-wrapped method, that means caller scope (passed as parameter) or content-hash derived from the method parameters, which are stable across @Retryable re-invocations because @Retryable calls the method with the same arguments.

This principle extends to every reactive framework covered in this blog’s series. Quarkus Mutiny’s Uni.onFailure().retry() re-subscribes to the upstream Uni on retry, just as Reactor’s retryWhen() does. Micronaut’s RxJava3 Single.retry() re-subscribes to the upstream Single. Vert.x’s Future.recover() calls the outer method again, which rebuilds the pipeline from the first step. In all cases, the key must be outside the re-executed boundary.

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.