Reactor Netty and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

reactor.netty.http.client.HttpClient (Reactor Netty, the HTTP layer that powers Spring WebFlux’s WebClient) introduces three Stripe billing failure modes that are structurally distinct from those covered in the Spring WebFlux and Stripe Integration post. The failure modes arise from three properties specific to using HttpClient directly: HttpClient.headers(Consumer<HttpHeaders>) registers a consumer that is called at request-send time, not at Mono assembly time — when retryWhen(Retry.backoff(3, Duration.ofSeconds(1))) triggers a retry, Reactor Netty builds a new outbound request and re-invokes the consumer, so UUID.randomUUID() inside the consumer produces UUID_B for the retry, creating ch_B when ch_A was already committed before the I/O error; HttpClient.doOnRequest(BiConsumer<HttpClientRequest, Connection>) is a lifecycle observer hook that fires for every HTTP request Reactor Netty sends to the wire — developers use it for cross-cutting concerns and may generate a fresh UUID inside, not realising that retryWhen causes the hook to fire again with UUID_B, overwriting UUID_A and creating ch_B; and Mono.timeout(Duration) applied to the response publisher fires TimeoutException while the original request is still being processed by Stripe — an onErrorResume retry handler that constructs a new request with UUID.randomUUID() in .headers() sends UUID_B while Stripe finishes committing ch_A, producing ch_B.

This post covers all three failure modes with Reactor Netty 1.x code (HttpClient, headers(), doOnRequest(), Mono.timeout(), HttpClient.responseTimeout(), retryWhen(Retry)), the invocation-timing semantics of headers() vs assembly-time computation, the doOnRequest() execution-order interaction with headers(), TCP-level semantics that explain why timeout() and responseTimeout() fire after Stripe has committed the charge, content-hash idempotency keys stable across all retry paths — and per-billing-period vault keys via a spend-cap proxy as a hard financial backstop. For Spring WebFlux WebClient patterns including Mono.defer() factory re-evaluation and Flux.retryWhen() level errors, see the Spring WebFlux and Stripe Integration post. For low-level Netty ChannelPipeline handler patterns, see the Netty Pipeline and Stripe Integration post. For org.asynchttpclient (Async HTTP Client, also built on Netty), see the Async HTTP Client and Stripe Integration post.

Failure mode 1: HttpClient.headers(Consumer<HttpHeaders>) is called at request-send time, not at Mono assembly time — UUID.randomUUID() inside the consumer generates UUID_B when retryWhen triggers a retry — Stripe committed ch_A before the I/O error — retry with UUID_B creates ch_B

Reactor Netty’s HttpClient.headers(Consumer<HttpHeaders>) method configures an outbound headers consumer that is applied when Reactor Netty builds the actual HTTP request to send over the wire. The consumer receives the mutable HttpHeaders object for the outbound request and can set, add, or remove headers. This is a common pattern for attaching cross-cutting headers — authentication tokens, correlation IDs, idempotency keys — without coupling them to every individual call site:

// BillingService.java — UNSAFE: UUID.randomUUID() inside headers() consumer.
import reactor.netty.http.client.HttpClient;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import java.time.Duration;
import java.util.UUID;

public class BillingService {

    private final HttpClient httpClient = HttpClient.create()
        .headers(h -> {
            // BUG: UUID.randomUUID() is called inside the headers consumer.
            // This consumer is invoked per request-send, not per Mono assembly.
            // The first send sets Idempotency-Key: UUID_A.
            // If retryWhen triggers, Reactor Netty builds a new outbound request
            // and calls this consumer again, generating UUID_B.
            h.set("Idempotency-Key", UUID.randomUUID().toString());
            h.set("Authorization", "Bearer " + stripeKey);
            h.set("Content-Type", "application/x-www-form-urlencoded");
        });

    public Mono<ChargeResponse> chargeCustomer(String customerId, String period) {
        String body = "customer=" + customerId + "&amount=2999&currency=usd";

        return httpClient
            .post()
            .uri("https://api.stripe.com/v1/charges")
            .send(reactor.netty.ByteBufMono.fromString(Mono.just(body)))
            .responseSingle((res, bytes) -> bytes.asString()
                .map(json -> parseCharge(json)))
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
                .filter(e -> e instanceof java.io.IOException));
    }
}

The mechanics: chargeCustomer() assembles the reactive chain at subscription time. When the first subscriber arrives, Reactor Netty opens a connection to Stripe and begins building the outbound POST /v1/charges request. During this phase, the headers() consumer is invoked — UUID.randomUUID().toString() runs and produces UUID_A, which is written into the Idempotency-Key header of the first request. Stripe receives the request, creates charge ch_A, and then a TCP reset occurs before the HTTP response bytes are delivered. The IOException propagates up the reactive chain. retryWhen(Retry.backoff(3, ...)) catches it and signals a retry after the backoff delay.

On the retry, Reactor Netty re-executes the send operation. It builds a new HttpClientRequest for the retry and re-applies all registered consumers and configuration — including the headers() consumer. The consumer runs again. UUID.randomUUID().toString() produces UUID_B, a completely new, unrelated key. The retry request carries Idempotency-Key: UUID_B. Stripe receives UUID_B, finds no matching entry in its idempotency cache, and treats the request as a new charge. ch_B is created. The customer is charged twice.

The critical distinction from Mono.defer() patterns (covered in the Spring WebFlux post) is where the evaluation happens. With Mono.defer(), UUID re-evaluation happens at the outer Mono subscription boundary — the entire Mono factory re-runs on retryWhen re-subscription. With HttpClient.headers(), UUID re-evaluation happens at the request-send boundary inside Reactor Netty’s send pipeline. The headers() consumer is part of the client configuration that Reactor Netty applies each time it actually constructs and sends an HTTP request to the network. A developer who avoids Mono.defer() entirely — computing the UUID outside the Mono chain — but puts the UUID computation inside headers() is still vulnerable.

The subtler variant: method reference to a stateless helper that computes UUID.randomUUID() per call — developer believes the key is “set once at client initialization” because they used a method reference rather than an inline lambda

A common refactoring pattern extracts the headers consumer into a named method to avoid inline lambda sprawl:

// BillingService.java — UNSAFE: method reference, but the method still
// calls UUID.randomUUID() on each invocation.

public class BillingService {

    // Developer's reasoning: "I extracted the lambda into a method.
    // The method reference is captured at HttpClient construction time.
    // Therefore the UUID is set once when the client is built."
    // This reasoning is wrong. A method reference is not a memoised value.
    // It is a reference to a method that will be called each time the consumer fires.

    private HttpHeaders buildHeaders(HttpHeaders h) {
        h.set("Idempotency-Key", UUID.randomUUID().toString()); // still called per-invocation
        h.set("Authorization", "Bearer " + stripeKey);
        return h; // method reference, not a cached result
    }

    private final HttpClient httpClient = HttpClient.create()
        .headers(this::buildHeaders); // method reference — does NOT memoize the result

    public Mono<ChargeResponse> chargeCustomer(String customerId, String period) {
        return httpClient.post()
            .uri("https://api.stripe.com/v1/charges")
            .send(/* body */)
            .responseSingle((res, bytes) -> bytes.asString().map(this::parseCharge))
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)));
    }
}

HttpClient.headers(this::buildHeaders) stores a reference to the buildHeaders method. Each time Reactor Netty needs to apply the headers consumer — which is once per request sent to the wire — it calls buildHeaders(headers). UUID.randomUUID() evaluates inside the method body on each call. The UUID is not memoized at method-reference capture time. The method reference is not a value; it is a callable. The developer’s mental model (“I set this up once at initialization”) would only be correct if the consumer computed the UUID and stored it in a final field, rather than computing it inside the consumer body itself.

Fix: compute a stable content-hash key before the HttpClient chain; pass it into headers() via closure capture from outside the consumer

// BillingService.java — FIXED.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

public class BillingService {

    // HttpClient has NO headers consumer that generates UUIDs.
    // The base client only sets static headers.
    private final HttpClient baseClient = HttpClient.create()
        .headers(h -> {
            h.set("Authorization", "Bearer " + stripeKey);
            h.set("Content-Type", "application/x-www-form-urlencoded");
        });

    public Mono<ChargeResponse> chargeCustomer(String customerId, String period) {
        // Stable key computed here, before the reactive chain is built.
        // sha256(customerId:period:reactor-netty-billing)[:32] produces the
        // same 32-char hex string for a given (customerId, period) pair on
        // every JVM instance, every retry, every session.
        final String idempotencyKey = sha256(customerId + ":" + period + ":reactor-netty-billing");

        // The key is captured in the closure below. The lambda closes over
        // `idempotencyKey`, a final local. No matter how many times
        // Reactor Netty calls this consumer (once per request-send), the
        // consumer reads the same frozen value from the closure.
        return baseClient
            .headers(h -> h.set("Idempotency-Key", idempotencyKey)) // stable value in closure
            .post()
            .uri("https://api.stripe.com/v1/charges")
            .send(buildBody(customerId))
            .responseSingle((res, bytes) -> bytes.asString().map(this::parseCharge))
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
                .filter(e -> e instanceof java.io.IOException));
    }

    private static String sha256(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder();
            for (byte b : hash) sb.append(String.format("%02x", b));
            return sb.toString().substring(0, 32);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

Three properties of this fix: (1) sha256(customerId + ":" + period + ":reactor-netty-billing") is called once before the reactive chain is assembled — the same value is produced for any (customerId, period) pair on any JVM instance at any time; (2) idempotencyKey is a final local variable captured by the .headers(h -> h.set("Idempotency-Key", idempotencyKey)) lambda closure — closures in Java capture the reference (or value, for primitives) at the point of closure creation, and because the variable is effectively final, it never changes — every invocation of the consumer reads the same frozen value; (3) the baseClient and the per-request .headers()` consumer are separate because the idempotency key is per-billing-operation, not per-client — different calls to chargeCustomer() with different (customerId, period) arguments produce different keys, which is correct behaviour.

Stop a stuck retry loop before it runs up your Stripe account

Keybrake issues a scoped vault key per agent run with a daily USD cap. When a misconfigured retry loop hits the cap, the proxy returns 429 — the charge never reaches Stripe. Enter your email to try it on your next agent deployment.

Failure mode 2: HttpClient.doOnRequest(BiConsumer<HttpClientRequest, Connection>) is a lifecycle observer hook that fires for every HTTP request Reactor Netty sends to the wire — UUID.randomUUID() inside the hook generates UUID_B on retry — overwrites UUID_A already set in headers() — Stripe sees UUID_B as a new request — ch_B

Reactor Netty exposes a doOnRequest(BiConsumer<HttpClientRequest, Connection>) hook that fires immediately before each HTTP request is written to the network. It receives the mutable HttpClientRequest object and the active Connection, allowing the consumer to read request state (headers, URI, method), read connection state (channel attributes, remote address), and mutate the outbound headers via HttpClientRequest.header(String, String). This hook is commonly used for cross-cutting concerns — signing requests with HMAC, adding distributed tracing headers, recording request start timestamps for duration metrics, or attaching per-request correlation IDs.

A developer using doOnRequest for idempotency key injection encounters a subtle timing problem. The hook runs at the Reactor Netty lifecycle level, after the headers() consumer has already run. It fires once per actual HTTP request sent to the wire:

// BillingService.java — UNSAFE: UUID.randomUUID() inside doOnRequest().
import reactor.netty.http.client.HttpClient;
import reactor.netty.http.client.HttpClientRequest;

public class BillingService {

    private final HttpClient httpClient = HttpClient.create()
        .headers(h -> {
            h.set("Authorization", "Bearer " + stripeKey);
            h.set("Content-Type", "application/x-www-form-urlencoded");
        })
        .doOnRequest((req, conn) -> {
            // Developer's intent: "Centralise idempotency key generation here
            // so I don't have to set it at every call site."
            // BUG: doOnRequest fires once per actual HTTP send, including retried sends.
            // On the initial send: req.header() sets Idempotency-Key: UUID_A.
            // On the retried send: req.header() sets Idempotency-Key: UUID_B.
            // req.header(name, value) REPLACES the existing value if the header already exists.
            // Stripe sees UUID_A on the first request (ch_A committed), UUID_B on the retry (ch_B).
            req.header("Idempotency-Key", UUID.randomUUID().toString()); // BUG
            req.header("X-Request-Id", UUID.randomUUID().toString()); // correlation ID — fine
        });

    public Mono<ChargeResponse> chargeCustomer(String customerId, String period) {
        String body = "customer=" + customerId + "&amount=2999&currency=usd";

        return httpClient
            .post()
            .uri("https://api.stripe.com/v1/charges")
            .send(reactor.netty.ByteBufMono.fromString(Mono.just(body)))
            .responseSingle((res, bytes) -> bytes.asString().map(this::parseCharge))
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)));
    }
}

The mechanics: on the initial send, doOnRequest fires. UUID.randomUUID().toString() generates UUID_A. req.header("Idempotency-Key", UUID_A) sets the header on the outbound request object before it is serialised to bytes and written to the TCP socket. Stripe receives POST /v1/charges with Idempotency-Key: UUID_A, processes it, commits ch_A, and then the TCP connection is reset before the response is delivered. The IOException propagates. retryWhen catches it.

On the retry, Reactor Netty builds a new HttpClientRequest for the retry send. It runs the registered lifecycle hooks in order, including doOnRequest. The hook fires again. UUID.randomUUID().toString() generates UUID_B. req.header("Idempotency-Key", UUID_B) sets the header on the retry request. Note that HttpClientRequest.header() replaces the header value if it already exists — there is no double-header accumulation issue here (unlike RequestBuilder.addHeader() in the Async HTTP Client failure mode 2). The retry carries a clean single Idempotency-Key: UUID_B. But UUID_B is entirely new. Stripe creates ch_B.

The subtler variant: doOnRequest used for audit logging overwrites a stable key set in headers() with UUID.randomUUID() — developer adds headers() stable key as fix but doOnRequest runs after headers() and replaces it

After reading about the headers() consumer problem, a developer might add a stable key in headers() as the fix, while keeping the existing doOnRequest hook for audit logging — not noticing that the audit logging hook also sets the idempotency key:

// BillingService.java — PARTIALLY FIXED but still broken.
// Developer added stable key in headers() to fix the first pattern.
// But doOnRequest() still fires AFTER headers() and overwrites the stable key.

private final HttpClient httpClient = HttpClient.create()
    .headers(h -> {
        // Developer added this to "fix the idempotency key problem".
        // The stable key is set here, correctly, before the first send.
        // But this is not the last word on the Idempotency-Key header.
        h.set("Idempotency-Key", stableKey); // stableKey computed elsewhere
        h.set("Authorization", "Bearer " + stripeKey);
    })
    .doOnRequest((req, conn) -> {
        // Developer added this earlier for audit logging — copy-pasted from
        // another service that used UUID for correlation IDs.
        // They forgot (or didn't know) this also sets Idempotency-Key.
        req.header("Idempotency-Key", UUID.randomUUID().toString()); // overwrites stable key
        req.header("X-Audit-Trace", UUID.randomUUID().toString()); // OK for tracing
        log.info("Sending Stripe request trace={}", req.requestHeaders().get("X-Audit-Trace"));
    });

// Result: headers() sets Idempotency-Key: stableKey at consumer invocation time.
// Then doOnRequest() fires and calls req.header("Idempotency-Key", UUID.randomUUID()),
// which replaces stableKey with UUID_A on the first send and UUID_B on the retry.
// The stable key from headers() is entirely discarded. ch_B on retry.

Reactor Netty applies consumer configuration in the order it was added to the client. headers() consumers are applied during request preparation, and doOnRequest hooks fire after request preparation is complete but before the bytes are written to the channel. In the above example, the headers() consumer sets Idempotency-Key: stableKey first, then doOnRequest fires and calls req.header("Idempotency-Key", UUID.randomUUID()) — which replaces the stable key with a fresh UUID. The headers() fix is entirely undone by the later doOnRequest hook. The audit logging code introduced the bug without the developer noticing, because the idempotency key generation in doOnRequest was copied from a different service where correlation IDs were correctly per-request.

Fix: generate the stable key before the reactive chain; pass it explicitly into both headers() and doOnRequest() via closure capture; have doOnRequest() read the stable key from the request headers rather than generating a new one

// BillingService.java — FIXED.
public class BillingService {

    private final HttpClient baseClient = HttpClient.create()
        .headers(h -> {
            h.set("Authorization", "Bearer " + stripeKey);
            h.set("Content-Type", "application/x-www-form-urlencoded");
            // No idempotency key set here — it's per-billing-operation, not per-client.
        })
        .doOnRequest((req, conn) -> {
            // Audit logging only: read the idempotency key that was already set,
            // do not generate a new one. This hook fires per-send (initial + retries).
            String idempotencyKey = req.requestHeaders().get("Idempotency-Key");
            String traceId = UUID.randomUUID().toString(); // OK: unique trace per send is fine
            log.info("Stripe send trace={} idempotency_key={} attempt={}",
                traceId, idempotencyKey, req.requestHeaders().get("X-Attempt"));
            req.header("X-Audit-Trace", traceId); // per-send trace ID is correct
            // Do NOT call req.header("Idempotency-Key", ...) here.
        });

    public Mono<ChargeResponse> chargeCustomer(String customerId, String period) {
        final String idempotencyKey = sha256(customerId + ":" + period + ":reactor-netty-billing");
        final String[] attemptHolder = {"0"};

        return baseClient
            .headers(h -> {
                // Stable key set via closure capture — same value on every retry-triggered send.
                h.set("Idempotency-Key", idempotencyKey);
                h.set("X-Attempt", incrementAndGet(attemptHolder)); // diagnostic only
            })
            .post()
            .uri("https://api.stripe.com/v1/charges")
            .send(buildBody(customerId))
            .responseSingle((res, bytes) -> bytes.asString().map(this::parseCharge))
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
                .filter(e -> e instanceof java.io.IOException));
    }
}

The key property: doOnRequest reads req.requestHeaders().get("Idempotency-Key") — it reads the value already set by the headers() consumer, which is the stable closure-captured value — and never calls UUID.randomUUID() for the idempotency key. The X-Audit-Trace header is correctly a fresh UUID per send because its purpose is to correlate a specific network request with a log entry, which is a per-send concern, not a per-billing-operation concern. The idempotency key and the trace ID serve different purposes and have different stability requirements: the idempotency key must be stable across retries of the same logical billing operation; the trace ID must be unique per actual network request. Mixing these requirements is the root cause of the failure mode.

Failure mode 3: Mono.timeout(Duration) fires TimeoutException while waiting for Stripe’s response — Stripe already committed ch_A during the wait — onErrorResume(TimeoutException.class) retry with UUID.randomUUID() in .headers() creates ch_B before the original response arrives

A common pattern for circuit-breaking slow downstream APIs is to chain .timeout(Duration.ofSeconds(5)) on a reactive publisher and handle TimeoutException with .onErrorResume(). For Stripe billing calls, this pattern contains a race condition that can produce a duplicate charge regardless of whether the idempotency key is stable in the retry:

// BillingService.java — UNSAFE: timeout() + onErrorResume() retry with new UUID.
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.TimeoutException;

public class BillingService {

    private final HttpClient httpClient = HttpClient.create()
        .headers(h -> {
            h.set("Authorization", "Bearer " + stripeKey);
            h.set("Content-Type", "application/x-www-form-urlencoded");
        });

    public Mono<ChargeResponse> chargeCustomer(String customerId, String period) {
        String body = "customer=" + customerId + "&amount=2999&currency=usd";

        // BUG: timeout() fires on the response Mono, not on the TCP send.
        // When TimeoutException fires, Stripe has already received the request
        // and may have committed the charge. The onErrorResume() retry
        // generates a new UUID_B, creating ch_B alongside ch_A.
        return httpClient
            .post()
            .uri("https://api.stripe.com/v1/charges")
            .send(reactor.netty.ByteBufMono.fromString(Mono.just(body)))
            .responseSingle((res, bytes) -> bytes.asString().map(this::parseCharge))
            .timeout(Duration.ofSeconds(5))
            .onErrorResume(TimeoutException.class, e -> {
                // Developer's intent: "If the request took too long, try again."
                // Problem: the original request may have succeeded at Stripe.
                // A new UUID_B here creates ch_B independently of ch_A.
                return httpClient
                    .headers(h -> h.set("Idempotency-Key", UUID.randomUUID().toString()))
                    .post()
                    .uri("https://api.stripe.com/v1/charges")
                    .send(reactor.netty.ByteBufMono.fromString(Mono.just(body)))
                    .responseSingle((res, bytes) -> bytes.asString().map(this::parseCharge));
            });
    }
}

Understanding why this fails requires understanding what Mono.timeout(Duration.ofSeconds(5)) does at the TCP level. .timeout() is a Reactor operator, not a network-level timeout. It creates a race between the wrapped publisher emitting its first item and a 5-second timer. When the timer wins, TimeoutException is signalled. At this point:

The TimeoutException is a client-side event. Stripe is not aware of it. Stripe continues processing the original request (or has already finished processing it). The onErrorResume handler fires immediately when TimeoutException is signalled. It constructs a new httpClient.post() call with UUID.randomUUID() in .headers() and sends it. Stripe receives this second request with UUID_B. Since UUID_B has no entry in Stripe’s idempotency cache (only UUID_A from the original request would), Stripe treats it as a new billing request and creates ch_B. The customer is charged twice: ch_A (from the original request that timed out on the client side) and ch_B (from the onErrorResume retry with UUID_B).

The subtler variant: developer uses HttpClient.responseTimeout(Duration) instead of Mono.timeout() and adds retryWhen catching ReadTimeoutException — wraps the billing Mono in Mono.defer() to “make it lazy” — retryWhen re-subscribes the deferred source, re-generating UUID

Reactor Netty provides a native response timeout mechanism: HttpClient.responseTimeout(Duration). When configured, Reactor Netty adds a ReadTimeoutHandler to the Netty pipeline that fires ReadTimeoutException (a io.netty.handler.timeout.ReadTimeoutException) after the configured duration passes with no bytes received from the server. Unlike Mono.timeout(), this timeout is applied at the Netty pipeline level, closer to the actual I/O. But it fires under the same condition: waiting for the server to send response bytes.

A developer moves from Mono.timeout() to HttpClient.responseTimeout() and wraps the billing call in Mono.defer() to make it “lazily evaluated”:

// BillingService.java — UNSAFE: Mono.defer() wraps UUID.randomUUID().
// retryWhen re-subscribes the deferred source, re-generating the UUID.

public class BillingService {

    private final HttpClient httpClient = HttpClient.create()
        .responseTimeout(Duration.ofSeconds(5)); // Reactor Netty native timeout

    public Mono<ChargeResponse> chargeCustomer(String customerId, String period) {
        String body = "customer=" + customerId + "&amount=2999&currency=usd";

        // Mono.defer() is used to make the billing call "lazy".
        // BUG: UUID.randomUUID() inside defer() re-evaluates per subscription.
        // retryWhen() causes re-subscription of the deferred Mono.
        // UUID_A on first attempt, UUID_B on retry after ReadTimeoutException.
        return Mono.defer(() -> {
            String idempotencyKey = UUID.randomUUID().toString(); // BUG: per-subscription
            return httpClient
                .headers(h -> {
                    h.set("Idempotency-Key", idempotencyKey);
                    h.set("Authorization", "Bearer " + stripeKey);
                })
                .post()
                .uri("https://api.stripe.com/v1/charges")
                .send(reactor.netty.ByteBufMono.fromString(Mono.just(body)))
                .responseSingle((res, bytes) -> bytes.asString().map(this::parseCharge));
        })
        .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
            .filter(e -> e instanceof io.netty.handler.timeout.ReadTimeoutException));
    }
}

This failure mode is covered in the Spring WebFlux post as failure mode 1 (Mono.defer() factory re-evaluation on retryWhen re-subscription). It is included here to clarify that moving from Mono.timeout() to HttpClient.responseTimeout() does not eliminate the Mono.defer() re-subscription problem. The timeout mechanism is different; the UUID generation problem is orthogonal to which timeout mechanism is used.

Fix: compute the stable key before the timeout boundary; use the same key for the fallback request; establish the key in Stripe’s cache on the original request so any retry path finds the cached result

// BillingService.java — FIXED.
public class BillingService {

    private final HttpClient httpClient = HttpClient.create()
        .headers(h -> {
            h.set("Authorization", "Bearer " + stripeKey);
            h.set("Content-Type", "application/x-www-form-urlencoded");
        })
        .responseTimeout(Duration.ofSeconds(10)); // generous timeout — see note below

    public Mono<ChargeResponse> chargeCustomer(String customerId, String period) {
        final String idempotencyKey = sha256(customerId + ":" + period + ":reactor-netty-billing");
        final String body = "customer=" + customerId + "&amount=2999&currency=usd";

        // Pre-flight check: insert billing record if not already present.
        // If INSERT succeeds, we can safely call Stripe.
        // If INSERT fails (duplicate key), this billing period was already processed.
        return insertBillingRecord(customerId, period)
            .then(
                httpClient
                    .headers(h -> h.set("Idempotency-Key", idempotencyKey)) // stable
                    .post()
                    .uri("https://api.stripe.com/v1/charges")
                    .send(reactor.netty.ByteBufMono.fromString(Mono.just(body)))
                    .responseSingle((res, bytes) -> bytes.asString().map(this::parseCharge))
                    // On timeout, retry using the SAME stable idempotency key.
                    // Stripe's idempotency cache will return the original charge
                    // if it was committed, rather than creating ch_B.
                    .retryWhen(Retry.backoff(3, Duration.ofSeconds(2))
                        .filter(e -> e instanceof io.netty.handler.timeout.ReadTimeoutException
                                  || e instanceof java.io.IOException))
            )
            .onErrorResume(DuplicateBillingException.class, e ->
                fetchExistingCharge(customerId, period)); // idempotent fallback
    }

    // PostgreSQL: INSERT INTO billing_records (customer_id, period)
    //             VALUES ($1, $2) ON CONFLICT DO NOTHING
    //             RETURNING id
    // Returns Mono<Void> if inserted, DuplicateBillingException if row already existed.
    private Mono<Void> insertBillingRecord(String customerId, String period) { /* ... */ }
}

Two properties work together here: (1) idempotencyKey is computed before any retry boundary — the same stable value is set on both the initial request and all retried requests via closure capture in .headers(h -> h.set("Idempotency-Key", idempotencyKey)); when a timeout fires and retryWhen re-sends the request, the retry carries the same UUID_A, and if Stripe already committed ch_A, its idempotency cache returns the cached result rather than creating ch_B; (2) the pre-flight INSERT ... ON CONFLICT DO NOTHING serves as the cluster-wide billing mutex — even if the timeout-plus-retry pattern produces two concurrent calls to Stripe with UUID_A (possible in some race conditions), Stripe’s idempotency cache guarantees at-most-one commit for UUID_A within the idempotency window.

A note on timeout configuration: Stripe’s P99 response latency for charge creation is typically under 2 seconds, but during degraded conditions can reach 10–20 seconds. Setting responseTimeout(Duration.ofSeconds(5)) means timing out before many valid slow responses from Stripe, which triggers unnecessary retries. The retries themselves are safe when the idempotency key is stable, but they increase Stripe API usage. A timeout of 10–15 seconds is a better trade-off: it handles genuine hangs (TCP established but Stripe silent) without triggering spurious retries on slow-but-valid responses.

Pre-flight database guard: the authoritative layer beneath all three fixes

All three failure mode fixes above rely on a stable idempotency key. Stable keys eliminate the primary cause of duplicate charges. But they do not eliminate all duplicate charge risk. Two additional failure scenarios require the database pre-flight guard:

Scenario 1: The same logical billing operation is triggered concurrently. Two instances of a monthly billing job fire at the same time (duplicate cron trigger, two pods with unsynchronised clocks, a manual backfill overlapping with the scheduled run). Both compute the same sha256(customerId:period:reactor-netty-billing) key and send POST /v1/charges to Stripe simultaneously with the same idempotency key. Stripe’s idempotency cache provides at-most-one guarantee for sequential requests (a second request with the same key within the window returns the cached result). For concurrent requests with the same key arriving before the first has completed, Stripe’s behaviour is to either serialise them (one waits, gets the cached result) or return a 409 idempotency_key_in_use error for the second request. The 409 response does not mean the charge failed — it means Stripe is still processing the original. The billing job needs to handle 409 gracefully (retry after a delay with the same key) and distinguish it from other errors.

Scenario 2: Stripe’s idempotency window expires before the retry. Stripe stores idempotency key results for 24 hours. A job that fails and does not retry for more than 24 hours loses the idempotency guarantee. If the billing job retries after 24 hours with the same key, Stripe treats it as a new request and creates ch_B. The pre-flight database guard — SELECT id FROM billing_records WHERE customer_id = $1 AND period = $2 before any Stripe call — catches this: if ch_A was committed and the job failed after committing the database record but before the caller received the response, the pre-flight guard finds the existing record and skips the Stripe call entirely.

The complete pre-flight guard in PostgreSQL:

-- billing_records table (created once at startup):
CREATE TABLE billing_records (
    id          BIGSERIAL PRIMARY KEY,
    customer_id TEXT NOT NULL,
    period      TEXT NOT NULL,          -- e.g. "2026-09"
    stripe_charge_id TEXT,              -- filled in after Stripe confirmation
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    CONSTRAINT billing_records_unique UNIQUE (customer_id, period)
);

-- Pre-flight insert (returns the row id if inserted, nothing if duplicate):
INSERT INTO billing_records (customer_id, period)
VALUES ($1, $2)
ON CONFLICT (customer_id, period) DO NOTHING
RETURNING id;

-- If RETURNING id returns a row: this billing has not been attempted; proceed to Stripe.
-- If RETURNING id returns nothing: this billing was already processed; skip Stripe call.

-- After successful Stripe response: update the record with the charge ID.
UPDATE billing_records
SET stripe_charge_id = $3
WHERE customer_id = $1 AND period = $2;

The database guard and the stable idempotency key protect different scenarios. The stable key protects the retry window (same key across attempts of the same operation, within 24 hours). The database guard protects against concurrent duplicate triggers and expired idempotency windows. Both together provide reliable billing regardless of the failure pattern.

Vault keys and spend caps: the proxy layer backstop

The fixes above eliminate the duplicate charge risk from the three Reactor Netty failure modes. A residual financial risk remains: any code path that reaches Stripe can, if sufficiently broken (or sufficiently adversarial in the case of autonomous agents), exceed intended billing amounts. A pre-flight database guard prevents duplicate charges for the same (customer_id, period) pair, but it does not prevent a billing loop that creates a new period key per iteration from running indefinitely.

The structural solution is a per-billing-period vault key issued via a spend-cap proxy. The key properties:

The three Reactor Netty failure modes, the stable-key fix, the pre-flight database guard, and the vault key spend cap together form a layered defence: the stable key prevents retries from creating duplicate charges via idempotency key rotation; the database guard prevents concurrent duplicate triggers and expired-window retries; the vault key cap prevents a runaway billing loop from exceeding a defined financial bound regardless of what the application code does. No single layer is sufficient alone; each layer catches failures the others miss.

Summary table

Failure mode Root cause Stripe outcome Fix
1. headers() consumer UUID.randomUUID() inside Consumer<HttpHeaders> runs per request-send, including retried sends UUID_A on first send, UUID_B on retry → ch_B Compute sha256(customerId:period:context)[:32] before chain; close over it in headers() lambda
2. doOnRequest() hook UUID.randomUUID() inside BiConsumer<HttpClientRequest, Connection> overwrites stable key set in headers() UUID_A from headers() overwritten by UUID_B from doOnRequest() on every send In doOnRequest(), read existing key via req.requestHeaders().get("Idempotency-Key"); never call UUID.randomUUID() for Idempotency-Key in lifecycle hooks
3. timeout() + onErrorResume() Client-side timeout fires after Stripe committed ch_A; retry with UUID_B arrives before ch_A’s response ch_A committed + ch_B from retry = two charges Use same stable key in onErrorResume() fallback; add pre-flight ON CONFLICT DO NOTHING

Per-run vault keys with spend caps for Stripe

Keybrake issues a vault_key_xxx per billing run. You set the USD cap. When the run exceeds it, the proxy stops forwarding — no code changes needed, no hooks in the billing loop. Join the waitlist to add Keybrake to your Reactor Netty billing stack.

Further reading