Java HttpClient and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

Java 11’s java.net.http.HttpClient — the JDK’s built-in HTTP client, available without any third-party dependency since Java 11 and improved with virtual-thread support in Java 21 — introduces three Stripe billing failure modes distinct from Apache HttpClient 5, OkHttp, and Spring WebClient. Three JDK HttpClient-specific failure modes: a CompletableFuture recursive retry that re-evaluates UUID.randomUUID() at the top of the billing method per recursive invocation — initial sendAsync() creates ch_A before IOException — retry recursive call creates ch_B via new UUID from method entry; an HttpRequest.Builder.header() retry loop that accumulates duplicate Idempotency-Key header values across retry attempts instead of replacing them — second attempt sends two Idempotency-Key values, the second being a fresh UUID, Stripe’s behavior with multiple values is implementation-defined — ch_B; and Java 21’s StructuredTaskScope.ShutdownOnFailure customer billing fan-out, where scope failure triggers a retry of the entire billing run — customers whose subtasks succeeded and committed ch_A before the scope was cancelled are not reverted — second run creates ch_B via fresh UUIDs.

This post covers all three failure modes with Java 11/17/21 code (java.net.http.HttpClient, HttpRequest.Builder, CompletableFuture, StructuredTaskScope), content-hash idempotency keys stable across async retries, the setHeader() vs header() distinction in HttpRequest.Builder, how StructuredTaskScope cooperative cancellation differs from transactional atomicity, pre-flight PostgreSQL ON CONFLICT DO NOTHING as the authoritative billing mutex — and per-billing-period vault keys via a spend-cap proxy as a hard financial backstop. For Apache HttpClient 5 interceptor and async producer patterns, see the Apache HttpClient 5 and Stripe Integration post. For OkHttp application interceptor patterns, see the OkHttp and Retrofit and Stripe Integration post. For Spring WebClient and Reactor retry operators, see the Spring WebFlux and Stripe Integration post.

Failure mode 1: HttpClient.sendAsync() + CompletableFuture recursive retry — UUID.randomUUID() at the top of the billing method re-evaluates per recursive invocation — initial async charge creates ch_A before IOException — retry’s recursive call into the same method creates ch_B via a new UUID evaluated at method entry

Java’s java.net.http.HttpClient has no built-in retry mechanism. Teams add retry on top of sendAsync() using CompletableFuture chaining — the most natural pattern being a recursive call back into the billing method from the exceptionally() or handle() callback. When the billing method computes the Idempotency-Key header value at method entry via UUID.randomUUID(), the recursive retry invocation re-evaluates that call and produces a new UUID for each retry attempt:

// BillingService.java — UNSAFE CompletableFuture recursive retry with UUID at method entry.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CompletableFuture;
import java.util.UUID;

public class BillingService {
    private final HttpClient client = HttpClient.newHttpClient();

    public CompletableFuture<ChargeResponse> chargeCustomer(String customerId, String period) {
        // BUG: UUID evaluated at method entry. Recursive retry re-enters this method
        // and evaluates UUID.randomUUID() again. First call: UUID_A. Retry: UUID_B.
        String idempotencyKey = UUID.randomUUID().toString();

        String body = "customer=" + customerId + "&amount=2999¤cy=usd";
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://api.stripe.com/v1/charges"))
            .header("Authorization", "Bearer " + stripeKey)
            .header("Idempotency-Key", idempotencyKey)
            .header("Content-Type", "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();

        return client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
            .exceptionally(ex -> {
                if (isRetryable(ex)) {
                    // Re-invokes chargeCustomer() — evaluates a NEW UUID at method entry.
                    // If ch_A was created before the IOException, ch_B is created on retry.
                    return chargeCustomer(customerId, period).join();
                }
                throw new RuntimeException(ex);
            })
            .thenApply(resp -> parseCharge(resp.body()));
    }
}

The mechanics: client.sendAsync() submits the POST /v1/charges to Stripe asynchronously. Stripe processes the request, creates ch_A (charge A for this customer and billing period), and then the underlying TCP connection is terminated by a network error before the HTTP response returns. From the HttpClient’s perspective, this is an IOException completing the CompletableFuture exceptionally. The exceptionally() handler fires, determines the error is retryable (an IOException — not a Stripe 4xx response), and calls chargeCustomer(customerId, period) again. That second invocation evaluates UUID.randomUUID() at its first line, producing UUID_B. The retry POST /v1/charges sends UUID_B as the Idempotency-Key. Stripe sees a key it has never processed before, processes the request as a new charge, and creates ch_B. The customer is charged twice.

The failure chain requires two conditions: (1) UUID.randomUUID() is evaluated at method entry rather than once before the retry boundary, and (2) the retry re-enters the billing method rather than re-sending the same HttpRequest object or calling a lower-level retry that operates below the UUID computation. In the CompletableFuture recursive pattern, both conditions are almost always true because the natural way to write retry in this style is “call the method again” and the UUID is at the top of the method body.

The subtler variant: retry utility wraps a Supplier<CompletableFuture<T>> — if the supplier is a lambda that calls the billing method, the supplier re-evaluates UUID at the billing method’s entry per supplier invocation — compared to a Supplier that re-sends the same pre-built HttpRequest

Teams that reach for a retry utility library (Failsafe, Resilience4j, or a hand-rolled helper that accepts a Supplier<CompletableFuture<T>> and re-invokes it on failure) face the same issue at one additional level of indirection. The utility does not know whether the supplier is idempotent; it simply calls supplier.get() on each attempt. If the supplier is () -> chargeCustomer(customerId, period), it re-invokes chargeCustomer() per attempt, re-evaluating the UUID at method entry each time. The subtler version is a supplier that looks safe but is not:

// UNSAFE: looks like the UUID is captured once, but the lambda delegates to a method
// that re-evaluates UUID.randomUUID() per invocation.
Supplier<CompletableFuture<ChargeResponse>> attempt =
    () -> billingService.chargeCustomer(customerId, period); // UUID re-evaluated per .get()

// A SAFE alternative: build the HttpRequest once (including UUID) and pass it to a helper
// that only calls sendAsync() on retry, not the entire billing method.
String key = sha256(customerId + ":" + period + ":httpclient-billing");
HttpRequest request = buildChargeRequest(key, customerId);
Supplier<CompletableFuture<HttpResponse<String>>> attempt =
    () -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()); // same request each time

The critical boundary is: compute the idempotency key before the supplier is created, and make the supplier’s job the network call only — not key generation plus network call. HttpRequest objects are immutable once built; a supplier that calls sendAsync(request, ...) with the same pre-built request object re-sends the same idempotency key on every attempt. A supplier that calls chargeCustomer() re-generates the key on every attempt.

Fix: compute content-hash key before the async chain; pass as an explicit parameter; retry re-uses the pre-computed value

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

public class BillingService {
    private final HttpClient client = HttpClient.newHttpClient();

    public CompletableFuture<ChargeResponse> chargeCustomer(String customerId, String period) {
        // Key computed once from stable billing fields — same value on every retry.
        String idempotencyKey = sha256(customerId + ":" + period + ":httpclient-billing");
        return chargeWithKey(idempotencyKey, customerId, period, 0);
    }

    private CompletableFuture<ChargeResponse> chargeWithKey(
            String key, String customerId, String period, int attempt) {
        if (attempt >= 3) {
            return CompletableFuture.failedFuture(new RuntimeException("Max retries exceeded"));
        }
        // Same HttpRequest (with the same Idempotency-Key) built from a stable key each time.
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://api.stripe.com/v1/charges"))
            .header("Authorization", "Bearer " + stripeKey)
            .header("Idempotency-Key", key)
            .header("Content-Type", "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString(
                "customer=" + customerId + "&amount=2999¤cy=usd"))
            .build();

        return client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
            .exceptionally(ex -> {
                if (isRetryable(ex) && attempt < 2) {
                    // Key is threaded as a parameter — recursive call reuses the same key.
                    return chargeWithKey(key, customerId, period, attempt + 1).join();
                }
                throw new RuntimeException(ex);
            })
            .thenApply(resp -> parseCharge(resp.body()));
    }

    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); // Stripe limit: 255 chars; 32 is clean
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

Three properties of this fix: (1) chargeCustomer() computes idempotencyKey once before delegating to the async chain — the key is not computed inside any lambda or callback; (2) chargeWithKey() takes the key as an explicit parameter, so the recursive retry passes the same pre-computed value regardless of how many times it recurses; (3) sha256(customerId + ":" + period + ":httpclient-billing") produces the same value for any given (customerId, period) pair on any JVM instance, on any retry, at any time — the :httpclient-billing suffix namespaces the key against collision with keys from other clients or billing contexts.

Failure mode 2: HttpRequest.Builder.header() accumulates duplicate Idempotency-Key header values across a retry loop — builder reused across attempts — second attempt sends two Idempotency-Key values (the original plus a fresh UUID) — Stripe’s behavior with multiple idempotency keys is implementation-defined — ch_B

Java’s HttpRequest.Builder has two methods for adding headers: header(name, value) appends a header value (multiple calls with the same header name produce multiple header instances in the request), and setHeader(name, value) replaces any previously set value for that name. The standard Java SE docs make this explicit, but the difference is easy to miss in a retry loop:

// BillingService.java — UNSAFE: Builder reused across retry loop, .header() appends.
public ChargeResponse chargeCustomerSync(String customerId, String period) throws Exception {
    String body = "customer=" + customerId + "&amount=2999¤cy=usd";

    // Builder is created once and reused across retry attempts.
    HttpRequest.Builder builder = HttpRequest.newBuilder()
        .uri(URI.create("https://api.stripe.com/v1/charges"))
        .header("Authorization", "Bearer " + stripeKey)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .POST(HttpRequest.BodyPublishers.ofString(body));

    IOException lastException = null;
    for (int attempt = 0; attempt < 3; attempt++) {
        // BUG: .header() APPENDS. On attempt 0: one Idempotency-Key (UUID_A).
        // On attempt 1: builder already has UUID_A; this call ADDS UUID_B.
        // The built HttpRequest has TWO Idempotency-Key header values: UUID_A and UUID_B.
        builder.header("Idempotency-Key", UUID.randomUUID().toString());

        HttpRequest request = builder.build();

        try {
            HttpResponse<String> response =
                client.send(request, HttpResponse.BodyHandlers.ofString());
            return parseCharge(response.body());
        } catch (IOException e) {
            lastException = e;
            Thread.sleep(200L * (1L << attempt));
        }
    }
    throw lastException;
}

On the first attempt (attempt=0), the builder has one Idempotency-Key: UUID_A header. The request is built and sent. If it succeeds, Stripe creates ch_A and the method returns — no problem. But if it fails with an IOException (say, the response was not received after Stripe committed), the loop continues to attempt=1. builder.header("Idempotency-Key", UUID.randomUUID().toString()) is called again. HttpRequest.Builder.header() adds the value; it does not replace. The builder now holds two Idempotency-Key header entries: UUID_A (from attempt 0) and UUID_B (from attempt 1). builder.build() produces an HttpRequest with two Idempotency-Key headers.

HTTP allows multiple header fields with the same name; their semantics depend on the specific header. Stripe’s Idempotency-Key is not a list-valued header — it is a single-value header specifying exactly one key per request. When Stripe’s API gateway receives a request with two Idempotency-Key values, the behavior is implementation-defined: Stripe may take the first, the last, or return a 400 error. If Stripe takes the last value (UUID_B), it processes the retry as a new request with a key it has never seen, creating ch_B for a customer whose ch_A was committed. If Stripe returns a 400 for the malformed request, the caller sees an unexpected error status and may escalate, but at least no duplicate charge is created. The problem is that Stripe’s behavior for this case is not documented as a guarantee and may change across API versions or edge deployments.

The subtler variant: HttpRequest.Builder has no .copy() method — developers who want a “template builder” pattern reach for a factory method that re-evaluates UUID.randomUUID() per call — the missing copy() makes it hard to safely reuse a partially-built request

Unlike many builder APIs, HttpRequest.Builder provides no copy() or clone() method. A developer who wants to create a template request (same URL, same auth headers, same body) with a fresh idempotency key per attempt cannot clone the builder. The workaround is either to call the full HttpRequest.newBuilder() chain from scratch on each attempt, or to extract a factory method. When that factory method calls UUID.randomUUID(), it produces a fresh key per factory invocation:

// Looks like a safe per-attempt request factory, but UUID re-evaluates per call.
private HttpRequest buildChargeRequest(String customerId, String amount) {
    return HttpRequest.newBuilder()
        .uri(URI.create("https://api.stripe.com/v1/charges"))
        .header("Authorization", "Bearer " + stripeKey)
        .header("Idempotency-Key", UUID.randomUUID().toString()) // new UUID per factory call
        .POST(HttpRequest.BodyPublishers.ofString("customer=" + customerId + "&amount=" + amount))
        .build();
}

for (int attempt = 0; attempt < 3; attempt++) {
    HttpRequest request = buildChargeRequest(customerId, amount); // fresh UUID every attempt
    // ...
}

The factory method pattern looks clean but produces a new UUID on every call, so every retry gets a new idempotency key. This is the same failure as failure mode 1, reached by a different path: the absence of a copy() method pushes developers toward factory methods, and factory methods evaluated per attempt re-compute the key per attempt.

Fix: compute the idempotency key before the retry loop; use setHeader() if the builder is reused, or build the HttpRequest once from the pre-computed key

// BillingService.java — FIXED.
public ChargeResponse chargeCustomerSync(String customerId, String period) throws Exception {
    // Compute stable key once — same value on every attempt.
    String idempotencyKey = sha256(customerId + ":" + period + ":httpclient-billing");
    String body = "customer=" + customerId + "&amount=2999¤cy=usd";

    // Option A: Build the HttpRequest once — immutable, same Idempotency-Key on every
    //           client.send() call. Cleanest: HttpRequest is thread-safe and reusable.
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.stripe.com/v1/charges"))
        .header("Authorization", "Bearer " + stripeKey)
        .header("Idempotency-Key", idempotencyKey) // stable key, not UUID.randomUUID()
        .header("Content-Type", "application/x-www-form-urlencoded")
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

    IOException lastException = null;
    for (int attempt = 0; attempt < 3; attempt++) {
        try {
            HttpResponse<String> response =
                client.send(request, HttpResponse.BodyHandlers.ofString()); // reuse same request
            return parseCharge(response.body());
        } catch (IOException e) {
            lastException = e;
            Thread.sleep(200L * (1L << attempt));
        }
    }
    throw lastException;
}

// Option B: If a per-attempt builder is required (e.g., body must be rebuilt each attempt),
//           use .setHeader() to replace, not .header() to accumulate.
HttpRequest.Builder builder = HttpRequest.newBuilder()
    .uri(URI.create("https://api.stripe.com/v1/charges"))
    .header("Authorization", "Bearer " + stripeKey)
    .header("Content-Type", "application/x-www-form-urlencoded");

for (int attempt = 0; attempt < 3; attempt++) {
    // .setHeader() replaces any previous Idempotency-Key value.
    // Combined with a stable pre-computed key: both conditions correct.
    builder.setHeader("Idempotency-Key", idempotencyKey);
    HttpRequest request = builder.POST(HttpRequest.BodyPublishers.ofString(body)).build();
    // ...
}

Option A is the preferred pattern for java.net.http.HttpClient: build an immutable HttpRequest once with a stable idempotency key, and pass the same request object to each client.send() or client.sendAsync() call in the retry loop. HttpRequest is immutable and thread-safe; there is no overhead to reusing it. Option B (per-attempt builder reuse with setHeader()) is correct when the body or other headers must change per attempt, but requires discipline to use setHeader() instead of header() for the idempotency key specifically.

The invariant is: by the time the first retry attempt’s network call is made, the Idempotency-Key header value in the outgoing request must be identical to the value in the first attempt’s request. Whether that is achieved by reusing the same HttpRequest object or by calling setHeader(idempotencyKey) with the same pre-computed string on a rebuilt request is an implementation detail — either is correct, but header(UUID.randomUUID()) in a retry loop is never correct.

Failure mode 3: Java 21 StructuredTaskScope.ShutdownOnFailure billing fan-out — one subtask fails — scope cancels remaining tasks — tasks that already committed Stripe charges are not reverted — developer retries the entire billing run — customers already charged get ch_B

Java 21’s structured concurrency API (java.util.concurrent.StructuredTaskScope) is designed to make concurrent code easier to reason about: all subtasks started in a scope are completed or cancelled before the scope closes, the parent thread waits at scope.join(), and ShutdownOnFailure cancels remaining tasks the moment any subtask fails. For a billing service that must charge a list of customers, this pattern is appealing:

// BillingService.java — UNSAFE: treats StructuredTaskScope as transactional.
import java.util.concurrent.StructuredTaskScope;

public void chargeAllCustomers(List<Customer> customers, String period) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        for (Customer customer : customers) {
            scope.fork(() -> {
                String key = UUID.randomUUID().toString(); // unique key per subtask
                return chargeViaHttpClient(key, customer.id(), period);
            });
        }
        scope.join();
        scope.throwIfFailed(); // throws if any subtask failed
    }
    // Developer assumes: if throwIfFailed() throws, no customers were charged.
    // WRONG: customers whose subtasks completed before the failure DID get charged.
}

public void chargeAllCustomersWithRetry(List<Customer> customers, String period) {
    try {
        chargeAllCustomers(customers, period); // First attempt
    } catch (Exception e) {
        // Retry the entire billing run — all customers, fresh UUIDs per subtask fork.
        // Customers already charged in the first run get ch_B from the retry.
        try {
            chargeAllCustomers(customers, period);
        } catch (Exception retryEx) {
            throw new RuntimeException(retryEx);
        }
    }
}

StructuredTaskScope.ShutdownOnFailure provides fail-fast cooperative cancellation: when any forked task fails, the scope calls scope.shutdown(), which interrupts or cancels remaining tasks. But “cancels remaining tasks” means tasks that have not yet completed — not tasks that already succeeded. For billing: if 10 customers are forked and customers 1–7 succeed (their subtasks call chargeViaHttpClient(), Stripe commits ch_A for each, and the subtask returns), then customer 8’s subtask fails with a network error, ShutdownOnFailure calls shutdown(), and customers 9–10’s subtasks are interrupted before they start their Stripe calls. The scope exits, scope.throwIfFailed() throws for customer 8’s failure, and the catch block calls chargeAllCustomers(customers, period) again.

The retry run forks fresh subtasks for all customers, including customers 1–7 who were successfully charged in the first run. Each new subtask evaluates UUID.randomUUID() independently, producing UUID_B for each customer. Stripe has never seen these keys before and creates ch_B for customers 1–7. The retry successfully charges customers 1–8 (ch_B) and 8 (the one that failed, now succeeds with ch_C) and 9–10 (ch_A from the retry, their first successful charge). Customers 1–7 are charged twice; customers 9–10 are charged once; customer 8 is charged once (from the retry). The billing reconciliation looks like a transient success but the bank statements show double charges for most customers.

The subtler variant: scope.throwIfFailed() semantics imply atomicity — the developer reads the Java SE documentation stating “if any task fails, the remaining tasks are cancelled” and concludes “if it threw, nothing succeeded” — but StructuredTaskScope is not transactional and Stripe is not in the scope’s execution boundary

The Java SE documentation for StructuredTaskScope.ShutdownOnFailure describes it as a scope that “shuts down the scope when any task fails.” The shutdown mechanism is cooperative interruption — it calls scope.shutdown(), which interrupts blocked tasks and signals running tasks via Thread.currentThread().isInterrupted(). Tasks that are not blocked and are not checking the interrupt flag will continue running to completion. More importantly, the JDK documentation does not claim that StructuredTaskScope provides atomicity or rollback of succeeded tasks. The developer who reads “shuts down when any task fails” may infer “therefore if it failed, any succeeded tasks will be retroactively cancelled.” No such guarantee exists. Stripe calls that returned before shutdown was initiated are committed.

The structural contrast is with a database transaction: a transaction rolls back all writes when it fails, even writes that already completed at the statement level. StructuredTaskScope has no such rollback mechanism for external side effects (Stripe API calls, emails sent, webhooks fired). Every external API call is permanent the moment the vendor acknowledges it.

Fix 1: track which customers succeeded in the first scope run; retry only customers that were not yet successfully charged

// BillingService.java — FIXED: track per-customer success; retry only failures.
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.StructuredTaskScope;

public void chargeAllCustomers(List<Customer> customers, String period) throws Exception {
    // Compute stable keys for all customers before ANY scope or retry.
    Map<String, String> keys = new HashMap<>();
    for (Customer c : customers) {
        keys.put(c.id(), sha256(c.id() + ":" + period + ":httpclient-billing"));
    }

    Set<String> succeeded = ConcurrentHashMap.newKeySet();
    List<Customer> remaining = new ArrayList<>(customers);

    for (int attempt = 0; attempt < 3 && !remaining.isEmpty(); attempt++) {
        List<Customer> toCharge = new ArrayList<>(remaining);
        List<Customer> failed = new ArrayList<>();

        try (var scope = new StructuredTaskScope<ChargeResult>()) {
            Map<Customer, StructuredTaskScope.Subtask<ChargeResult>> subtasks = new HashMap<>();
            for (Customer c : toCharge) {
                String key = keys.get(c.id()); // pre-computed stable key
                subtasks.put(c, scope.fork(() -> chargeViaHttpClient(key, c.id(), period)));
            }
            scope.join();

            for (Customer c : toCharge) {
                StructuredTaskScope.Subtask<ChargeResult> subtask = subtasks.get(c);
                if (subtask.state() == StructuredTaskScope.Subtask.State.SUCCESS) {
                    succeeded.add(c.id());
                } else {
                    failed.add(c); // will be retried in the next loop iteration
                }
            }
        }

        remaining = failed;
        if (!remaining.isEmpty() && attempt < 2) {
            Thread.sleep(500L * (1L << attempt));
        }
    }

    if (!remaining.isEmpty()) {
        throw new RuntimeException("Billing failed for customers: " +
            remaining.stream().map(Customer::id).collect(Collectors.joining(", ")));
    }
}

Fix 2: pre-flight database guard as the authoritative idempotency layer — INSERT ... ON CONFLICT DO NOTHING before each Stripe call — customers already charged are skipped on the retry run regardless of which keys are used

// BillingService.java — pre-flight ON CONFLICT guard per subtask.
private ChargeResult chargeViaHttpClient(String idempotencyKey,
                                          String customerId, String period) throws Exception {
    // Pre-flight: attempt to claim the billing slot for this customer+period.
    // ON CONFLICT DO NOTHING: if a row already exists, 0 rows inserted — skip.
    int inserted = db.execute(
        "INSERT INTO billing_records (customer_id, billing_period, idempotency_key, status) " +
        "VALUES (?, ?, ?, 'pending') ON CONFLICT (customer_id, billing_period) DO NOTHING",
        customerId, period, idempotencyKey);

    if (inserted == 0) {
        // A billing record for this customer+period already exists.
        // Either from a previous billing run or an earlier subtask. Skip.
        return ChargeResult.alreadyBilled(customerId);
    }

    // Build and send the Stripe charge with the pre-computed idempotency key.
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.stripe.com/v1/charges"))
        .header("Authorization", "Bearer " + stripeKey)
        .header("Idempotency-Key", idempotencyKey)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .POST(HttpRequest.BodyPublishers.ofString(
            "customer=" + customerId + "&amount=2999¤cy=usd"))
        .build();

    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
    ChargeResponse charge = parseCharge(response.body());

    db.execute(
        "UPDATE billing_records SET status='committed', stripe_charge_id=? " +
        "WHERE customer_id=? AND billing_period=?",
        charge.id(), customerId, period);

    return ChargeResult.charged(customerId, charge.id());
}

The pre-flight INSERT ... ON CONFLICT DO NOTHING uses a UNIQUE constraint on (customer_id, billing_period) as a cluster-wide billing mutex. Any billing attempt for a customer that already has a row — whether from the same scope run (a concurrent subtask that also passed the Stripe call) or from a previous scope run (the retry scenario) — returns 0 rows inserted and skips the Stripe charge. This holds even across multiple JVM instances, multiple scope runs, and partial retries: the database constraint is the authoritative guard, not the in-memory StructuredTaskScope state. When the retry runs fresh subtasks with stable pre-computed idempotency keys, customers who were already billed have an existing billing record and are silently skipped by the pre-flight check.

Note: with stable content-hash keys and the pre-flight guard, the two fixes are complementary. Stable keys mean that even if the pre-flight is bypassed under a race (two subtasks for the same customer pass the check at exactly the same instant), the Stripe Idempotency-Key collision is detected by Stripe and a second charge is not created — Stripe returns the first response from its cache. The pre-flight guard ensures the Stripe idempotency key is not even sent for already-billed customers. In combination they provide two independent safety layers.

Spend cap via vault key: the financial backstop for all three failure modes

All three failure modes above are protocol-level bugs: the same customer is charged twice because the idempotency key changes between attempts, or because the retry logic incorrectly assumes nothing succeeded when something did. Content-hash keys and pre-flight guards are the correct fixes. But there is a second category of risk: a bug in the retry logic, a misconfigured backoff, or a stuck billing loop that charges customers many more times than intended. A vault key with a per-billing-period spend cap provides a hard financial limit that is enforced outside the application code:

// Keybrake vault key for HttpClient billing service:
// - vendor: stripe
// - allowed endpoints: POST /v1/charges only
// - daily_usd_cap: expected_daily_total * 1.10 (10% headroom for legitimate variance)
// - expires_at: end of billing period + 1 hour

// The proxy sits between HttpClient and Stripe:
// client → proxy.keybrake.com/stripe/v1/charges → api.stripe.com/v1/charges

HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://proxy.keybrake.com/stripe/v1/charges")) // proxy endpoint
    .header("Authorization", "Bearer vault_key_xxx") // vault key, not raw Stripe key
    .header("Idempotency-Key", idempotencyKey)
    .header("Content-Type", "application/x-www-form-urlencoded")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

With a spend cap set to 110% of expected period total, a duplicate-charge bug creates one duplicate (110% of expected — one charge per customer — hits the cap immediately on the second customer’s duplicate charge). The proxy rejects the charge with a policy violation response, which the billing service sees as a non-retryable error, and the loop stops. The audit log records every charge with its idempotency key and policy verdict; duplicates surface immediately in the run report without requiring a manual reconciliation of Stripe dashboard data.

Putting it together: three JDK HttpClient failure modes and their fixes

The patterns across all three failure modes share a common thread: Java’s java.net.http.HttpClient is designed around immutable HttpRequest objects and CompletableFuture-based async composition, but the idempotency discipline lives entirely in the application code, not in the client. The client has no concept of idempotency keys, no retry mechanism, and no transactional semantics. This means the failure modes are about what the application does around the client, not about the client itself:

A fourth cross-cutting concern: with virtual threads (Java 21), HttpClient.send() (the blocking synchronous method) becomes safe to call from a virtual thread without tying up a platform thread. This makes it easy to fan out hundreds of concurrent billing calls using Executors.newVirtualThreadPerTaskExecutor() or StructuredTaskScope. The concurrency amplification increases the probability that two virtual threads for the same customer pass a “has this customer been billed?” check concurrently before either commits, making the pre-flight database guard even more important at Java 21 scale than at Java 11 scale where synchronous billing was typically sequential per customer.

Failure mode Root cause Fix
CompletableFuture recursive retry re-evaluates UUID.randomUUID() at billing method entry per recursive invocation chargeCustomer() computes UUID at its first line; exceptionally() calls chargeCustomer() again; recursive call produces UUID_B; Stripe creates ch_B for the same customer if ch_A committed before the IOException Compute content-hash key in chargeCustomer() before delegating to chargeWithKey(key, ...); thread key as explicit parameter through all recursive retry calls; recursive call passes the same key, not a new UUID
HttpRequest.Builder.header() accumulates Idempotency-Key values across retry loop iterations instead of replacing Builder reused across attempts; .header() appends; second attempt builds request with two Idempotency-Key values (UUID_A and UUID_B); Stripe processes UUID_B as a new request if it takes the last value; ch_B Compute stable key before retry loop; build HttpRequest once and reuse it (immutable, thread-safe) — or use builder.setHeader("Idempotency-Key", stableKey) per attempt to replace rather than accumulate
StructuredTaskScope.ShutdownOnFailure billing fan-out treated as transactional — retry retries all customers including those already charged scope.throwIfFailed() semantics imply atomicity; developer infers “nothing succeeded” from thrown exception; retry runs all customers with fresh UUIDs; customers charged in first scope run get ch_B in retry run; Stripe charges are external side effects, not in scope’s transactional boundary Track per-customer subtask state after scope.join(); retry only customers whose subtasks failed (not succeeded); compute stable content-hash keys before any scope run so retries use the same key; add pre-flight INSERT ... ON CONFLICT DO NOTHING per subtask to skip already-billed customers
All three Financial blast radius from any surviving duplicate charge path; virtual-thread concurrency at Java 21 scale increases TOCTOU race probability in billing fan-outs without cluster-wide serialization Per-billing-period vault key capped at expected_total × 1.10 via spend-cap proxy; pre-flight ON CONFLICT DO NOTHING on (customer_id, billing_period) as cluster-wide billing mutex; stable content-hash keys throughout

The common thread across all three Java HttpClient failure modes is the same pattern seen in the Apache HttpClient 5 and OkHttp posts: the HTTP client library has no opinion on idempotency, so idempotency discipline is entirely the application’s responsibility. What is specific to the JDK client is the shape of the patterns: CompletableFuture-based async retry naturally leads to recursive method calls (no built-in retry, so developers write their own), HttpRequest.Builder’s header()-vs-setHeader() distinction is a footgun unique to this API (Apache HttpClient’s request builders replace headers by default), and StructuredTaskScope’s cooperative-cancellation model is a Java 21-specific API with semantics that developers familiar with database transactions or actor-model supervisors may misread as rollback. Content-hash keys derived from stable billing fields (sha256(customerId:billingPeriod:httpclient-billing)[:32]) and a pre-flight database guard on (customer_id, billing_period) close all three failure modes: the key is the same whether computed before the first attempt or before the tenth retry, and the guard ensures the Stripe charge is not even attempted for customers already billed.

Protect Stripe billing from retry duplicates

Keybrake issues a per-billing-period vault key with a spend cap and audit log. Every charge is logged with its idempotency key and policy verdict — duplicates surface immediately in the run report.