AWS SDK v2 HTTP Clients and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

AWS SDK v2 ships three standalone HTTP client implementations that can send arbitrary HTTP requests to any endpoint, not just AWS services: ApacheHttpClient (sync, backed by Apache HttpComponents 4), NettyNioAsyncHttpClient (async, backed by Netty — the same client used internally by the async DynamoDB, S3, and SQS service clients), and UrlConnectionHttpClient (sync, backed by java.net.HttpURLConnection — zero extra transitive dependencies beyond the SDK core). Teams that already have software.amazon.awssdk:apache-client, software.amazon.awssdk:netty-nio-client, or software.amazon.awssdk:url-connection-client on the classpath for their AWS workloads often reuse these clients to call Stripe, Twilio, or Resend rather than pull in a separate HTTP client library. This creates three Stripe billing failure modes that are structurally distinct from anything covered in the Apache HttpClient 5, OkHttp, Reactor Netty, or Java HttpClient posts: (1) SdkHttpFullRequest immutability and per-retry rebuild: SdkHttpFullRequest is an immutable value object — developers who cannot mutate it between attempts rebuild it per retry, calling UUID.randomUUID() in the builder chain and producing UUID_B on the first retry; (2) NettyNioAsyncHttpClient CompletableFuture retry composition: execute(AsyncExecuteRequest, SdkAsyncHttpResponseHandler) returns CompletableFuture<Void>.exceptionally() retry handlers that rebuild AsyncExecuteRequest generate UUID_B; (3) ExecutionInterceptor.beforeTransmission() fires per wire attempt: developers who build a StripeClient wrapper using AWS SDK v2’s SdkClient SPI gain retry, tracing, and metrics for free, but beforeTransmission() fires on every HTTP wire attempt including those triggered by the SDK’s StandardRetryStrategyUUID.randomUUID() in beforeTransmission() produces UUID_B on the first retry.

This post covers all three failure modes with complete software.amazon.awssdk code, SdkHttpFullRequest immutability semantics, AsyncExecuteRequest and SdkAsyncHttpResponseHandler callback lifecycle, ExecutionInterceptor method firing order and retry semantics (beforeExecution vs beforeTransmission vs modifyHttpRequest), ExecutionAttributes context propagation, 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 Apache HttpClient 5 patterns (the library that ApacheHttpClient wraps), see the Apache HttpClient 5 and Stripe Integration post. For Netty pipeline handler patterns at a lower level than NettyNioAsyncHttpClient, see the Netty Pipeline and Stripe Integration post. For Reactor Netty (which also builds on Netty but exposes a reactive API), see the Reactor Netty and Stripe Integration post.

Failure mode 1: SdkHttpFullRequest rebuilt per retry attempt in an ApacheHttpClient retry loop — UUID.randomUUID() called in the builder chain produces UUID_B on the first retry — Stripe committed ch_A before the I/O error — retry with UUID_B creates ch_B

SdkHttpFullRequest is an immutable value object. Once built via SdkHttpFullRequest.builder().method(...).uri(...).putHeader(...).build(), the resulting object cannot be modified. Its toBuilder() method returns a new Builder pre-populated with the values of the current request, but calling build() on that builder produces a new SdkHttpFullRequest instance — it does not modify the original. This immutability is intentional: the SDK’s interceptor chain may modify a request copy at each stage without affecting earlier stages.

Developers who are accustomed to Apache HttpClient 4’s mutable HttpUriRequest (where you can call setHeader() on an existing request object) or OkHttp’s Request.newBuilder() (which produces a mutable builder from an existing request) sometimes discover that AWS SDK v2’s SdkHttpFullRequest cannot be resent unchanged. The response is to build a new request object on each retry attempt. If UUID.randomUUID() is called anywhere in the builder chain, it evaluates on every call to build():

// BillingService.java — UNSAFE: SdkHttpFullRequest rebuilt per retry,
// UUID.randomUUID() in builder chain produces UUID_B on first retry.
import software.amazon.awssdk.http.*;
import software.amazon.awssdk.http.apache.ApacheHttpClient;
import java.io.InputStream;
import java.net.URI;
import java.util.UUID;

public class BillingService {

    private static final SdkHttpClient httpClient = ApacheHttpClient.create();
    private static final String stripeKey = System.getenv("STRIPE_SECRET_KEY");

    public String chargeCustomer(String customerId, String period, int amountCents)
            throws Exception {
        int maxAttempts = 3;
        Exception lastException = null;

        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            // BUG: SdkHttpFullRequest.builder() called per loop iteration.
            // UUID.randomUUID().toString() evaluates at build() time, which is
            // called once per iteration. First iteration: UUID_A. Second: UUID_B.
            // If Stripe committed ch_A before the IOException on attempt 1,
            // the retry on attempt 2 carries UUID_B and creates ch_B.
            SdkHttpFullRequest request = SdkHttpFullRequest.builder()
                .method(SdkHttpMethod.POST)
                .uri(URI.create("https://api.stripe.com/v1/charges"))
                .putHeader("Authorization", "Bearer " + stripeKey)
                .putHeader("Content-Type", "application/x-www-form-urlencoded")
                .putHeader("Idempotency-Key", UUID.randomUUID().toString()) // BUG
                .contentStreamProvider(() ->
                    new java.io.ByteArrayInputStream(
                        ("customer=" + customerId + "&amount=" + amountCents + "¤cy=usd")
                            .getBytes(java.nio.charset.StandardCharsets.UTF_8)))
                .build();

            HttpExecuteRequest executeRequest = HttpExecuteRequest.builder()
                .request(request)
                .contentStreamProvider(request.contentStreamProvider().orElseThrow())
                .build();

            try {
                HttpExecuteResponse response = httpClient.prepareRequest(executeRequest).call();
                // parse response body and return charge id...
                return parseChargeId(response.responseBody().orElseThrow());
            } catch (Exception e) {
                lastException = e;
                if (attempt < maxAttempts) {
                    Thread.sleep(500L * attempt); // backoff
                }
            }
        }
        throw new RuntimeException("Billing failed after " + maxAttempts + " attempts",
            lastException);
    }
}

The call flow: chargeCustomer() enters the loop. attempt = 1: SdkHttpFullRequest.builder() is called. The chain evaluates UUID.randomUUID().toString() and assigns it to the Idempotency-Key header — call this UUID_A. build() produces an immutable SdkHttpFullRequest with UUID_A in its headers. HttpExecuteRequest wraps it. prepareRequest().call() opens a connection via the ApacheHttpClient-managed PoolingHttpClientConnectionManager, writes the request line, headers, and body to the socket, and waits for the response. Stripe receives the full request body, begins processing, deducts the charge from the customer’s payment method, records ch_A in its database — and then a TCP RST arrives before any response bytes are returned to the client. call() throws IOException. The catch block records lastException and sleeps 500ms.

attempt = 2: the loop body executes again. SdkHttpFullRequest.builder() is called again. UUID.randomUUID().toString() evaluates again and produces UUID_B. The new SdkHttpFullRequest carries Idempotency-Key: UUID_B. Stripe receives it, looks up UUID_B in its idempotency cache, finds no entry (the first request was keyed on UUID_A, not UUID_B), and processes it as a new charge request — creating ch_B. The customer is billed twice for the same period.

The reason AWS SDK v2 makes this error easy to introduce is that SdkHttpFullRequest’s immutability is explicit — there is no setHeader() method, no way to update an existing instance — so developers who want to retry naturally reach for “build a new one.” The connection between “build a new request object” and “evaluate UUID.randomUUID() again” is not obvious when the UUID generation is embedded three lines into a fluent builder chain.

The subtler variant: developer extracts request construction to a buildStripeRequest(customerId, period) factory method that generates UUID internally — factory called per retry attempt — UUID_B per call regardless of the method name

After a code review suggests extracting the request builder into a factory method for testability:

// BillingService.java — UNSAFE: factory method called per retry attempt.
// The method name "buildStripeRequest" implies it builds a request, not
// that it generates a new UUID on every invocation. The developer may not
// realize the factory is called inside the retry loop.

private SdkHttpFullRequest buildStripeRequest(String customerId, int amountCents) {
    // UUID.randomUUID() called here, inside the method. This runs every time
    // buildStripeRequest() is called — once per retry attempt.
    return SdkHttpFullRequest.builder()
        .method(SdkHttpMethod.POST)
        .uri(URI.create("https://api.stripe.com/v1/charges"))
        .putHeader("Authorization", "Bearer " + stripeKey)
        .putHeader("Content-Type", "application/x-www-form-urlencoded")
        .putHeader("Idempotency-Key", UUID.randomUUID().toString()) // BUG: inside method
        .contentStreamProvider(/* ... */)
        .build();
}

public String chargeCustomer(String customerId, String period, int amountCents)
        throws Exception {
    for (int attempt = 1; attempt <= 3; attempt++) {
        // Developer reads this as "build the request, then send it with retry."
        // Does not realize buildStripeRequest() generates a new UUID per call.
        SdkHttpFullRequest request = buildStripeRequest(customerId, amountCents);
        try {
            return sendRequest(request);
        } catch (Exception e) {
            if (attempt == 3) throw e;
            Thread.sleep(500L * attempt);
        }
    }
    throw new IllegalStateException("unreachable");
}

The factory method form is harder to catch in code review than the inline form, because the UUID generation is invisible at the call site. A reviewer reading the retry loop sees buildStripeRequest(customerId, amountCents) called once per attempt and may not check whether the factory is idempotent. The fix is not to avoid factory methods but to move UUID generation outside the factory, or outside the retry loop entirely.

Fix: compute the stable idempotency key before the retry loop — build SdkHttpFullRequest once using that key — reuse the same immutable request object on every attempt

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

// Deterministic content-hash key: same value for a given (customerId, period)
// on every JVM instance, every retry, every pod, every hour. Not UUID.randomUUID().
private static String stableIdempotencyKey(String customerId, String period) {
    try {
        String input = customerId + ":" + period + ":aws-sdk-billing";
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder(32);
        for (int i = 0; i < 16; i++) {
            sb.append(String.format("%02x", hash[i]));
        }
        return sb.toString(); // 32 hex chars
    } catch (java.security.NoSuchAlgorithmException e) {
        throw new RuntimeException(e);
    }
}

public String chargeCustomer(String customerId, String period, int amountCents)
        throws Exception {
    // Key computed once, before the retry loop.
    // stableIdempotencyKey() returns the same string for the same
    // (customerId, period) pair regardless of how many times it is called.
    String idempotencyKey = stableIdempotencyKey(customerId, period);

    // SdkHttpFullRequest built once, before the retry loop, carrying the stable key.
    // The immutable object is reused on every attempt.
    SdkHttpFullRequest request = SdkHttpFullRequest.builder()
        .method(SdkHttpMethod.POST)
        .uri(URI.create("https://api.stripe.com/v1/charges"))
        .putHeader("Authorization", "Bearer " + stripeKey)
        .putHeader("Content-Type", "application/x-www-form-urlencoded")
        .putHeader("Idempotency-Key", idempotencyKey) // stable, computed above
        .contentStreamProvider(() ->
            new java.io.ByteArrayInputStream(
                ("customer=" + customerId + "&amount=" + amountCents + "¤cy=usd")
                    .getBytes(StandardCharsets.UTF_8)))
        .build();

    HttpExecuteRequest executeRequest = HttpExecuteRequest.builder()
        .request(request)
        .contentStreamProvider(request.contentStreamProvider().orElseThrow())
        .build();

    int maxAttempts = 3;
    Exception lastException = null;
    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            // Same executeRequest on every attempt — same idempotencyKey on every attempt.
            // Stripe receives UUID_A on attempts 1, 2, and 3.
            // If ch_A was committed before attempt 1's error, attempt 2 returns ch_A
            // from the idempotency cache without creating ch_B.
            HttpExecuteResponse response = httpClient.prepareRequest(executeRequest).call();
            return parseChargeId(response.responseBody().orElseThrow());
        } catch (Exception e) {
            lastException = e;
            if (attempt < maxAttempts) Thread.sleep(500L * attempt);
        }
    }
    throw new RuntimeException("Billing failed", lastException);
}

Three properties of this fix: (1) stableIdempotencyKey(customerId, period) is a pure function — the same inputs always produce the same output — so the key is stable across retries within a single JVM, across pods in a replicated deployment, and across sessions if the customer is accidentally billed twice days apart; (2) the immutable SdkHttpFullRequest is built once and reused across all retry attempts — the SDK’s ApacheHttpClient does not mutate the request object; (3) the ContentStreamProvider lambda that supplies the request body is re-evaluated on each attempt (it wraps a new ByteArrayInputStream each time it is called), which is correct — the connection pool may return a different socket per attempt, and a new InputStream must be supplied per attempt; only the idempotency key header must remain stable.

Cap the financial damage before it reaches Stripe

Keybrake issues a vault key per billing run with a USD cap equal to expected_total × 1.10. When a retry loop fires more charges than expected (even with correct idempotency keys, mis-scoped retry logic can hit the same customer twice across different billing runs), the cap absorbs the overrun. Enter your email to try Keybrake on your next AWS SDK v2 billing deployment.

Failure mode 2: NettyNioAsyncHttpClientexecute() returns CompletableFuture<Void>.exceptionally() retry handler rebuilds AsyncExecuteRequest with UUID.randomUUID() — Stripe committed ch_A before onError() fired — retry with UUID_B creates ch_B

NettyNioAsyncHttpClient uses a non-blocking API. execute(AsyncExecuteRequest request, SdkAsyncHttpResponseHandler handler) returns immediately with a CompletableFuture<Void>. The response is delivered asynchronously via SdkAsyncHttpResponseHandler callbacks: onHeaders(SdkHttpResponse) fires when the response status line and headers arrive, onStream(Publisher<ByteBuffer>) fires to deliver the response body, and onError(Throwable) fires if the request fails at any point. When onError() fires, the CompletableFuture<Void> returned by execute() completes exceptionally.

The natural pattern for async retry with CompletableFuture is to chain .exceptionally(e -> retryOnce(e)) or .handle((result, ex) -> ex != null ? retryFuture() : result). If the retry handler builds a new AsyncExecuteRequest and that new request includes a freshly generated UUID.randomUUID(), UUID_B is produced:

// AsyncBillingService.java — UNSAFE: .exceptionally() retry builds new AsyncExecuteRequest
// with UUID.randomUUID() in the builder — UUID_B on first retry.
import software.amazon.awssdk.http.*;
import software.amazon.awssdk.http.async.*;
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
import java.nio.ByteBuffer;
import java.util.concurrent.CompletableFuture;

public class AsyncBillingService {

    private static final SdkAsyncHttpClient asyncClient =
        NettyNioAsyncHttpClient.create();

    public CompletableFuture<String> chargeCustomerAsync(
            String customerId, String period, int amountCents) {

        // BUG: buildAsyncRequest() generates UUID.randomUUID() internally.
        // This call is the first attempt's request — UUID_A.
        AsyncExecuteRequest firstRequest = buildAsyncRequest(customerId, amountCents);
        CompletableFuture<String> result = new CompletableFuture<>();

        executeWithHandler(firstRequest, result, customerId, amountCents,
            /* attemptsLeft= */ 2);

        return result;
    }

    private void executeWithHandler(AsyncExecuteRequest request, CompletableFuture<String> result,
            String customerId, int amountCents, int attemptsLeft) {

        StringResponseHandler handler = new StringResponseHandler();

        asyncClient.execute(request, handler)
            .exceptionally(ex -> {
                if (attemptsLeft > 0) {
                    // BUG: buildAsyncRequest() called again here.
                    // Generates UUID.randomUUID() again — UUID_B.
                    // If Stripe committed ch_A before ex fired (I/O error
                    // after full request transmission), this retry creates ch_B.
                    AsyncExecuteRequest retryRequest =
                        buildAsyncRequest(customerId, amountCents); // UUID_B
                    executeWithHandler(retryRequest, result, customerId, amountCents,
                        attemptsLeft - 1);
                } else {
                    result.completeExceptionally(ex);
                }
                return null;
            });

        handler.future().thenAccept(result::complete);
    }

    private AsyncExecuteRequest buildAsyncRequest(String customerId, int amountCents) {
        String body = "customer=" + customerId + "&amount=" + amountCents + "&currency=usd";
        byte[] bodyBytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8);

        SdkHttpRequest sdkRequest = SdkHttpFullRequest.builder()
            .method(SdkHttpMethod.POST)
            .uri(java.net.URI.create("https://api.stripe.com/v1/charges"))
            .putHeader("Authorization", "Bearer " + System.getenv("STRIPE_SECRET_KEY"))
            .putHeader("Content-Type", "application/x-www-form-urlencoded")
            .putHeader("Idempotency-Key", UUID.randomUUID().toString()) // BUG
            .build();

        return AsyncExecuteRequest.builder()
            .request(sdkRequest)
            .requestContentPublisher(new SimpleBodyPublisher(bodyBytes))
            .responseHandler(new StringResponseHandler())
            .build();
    }
}

The async call flow: chargeCustomerAsync() calls buildAsyncRequest() — UUID_A is generated. asyncClient.execute(firstRequest, handler) submits the request to Netty’s event loop. Netty encodes the HTTP/1.1 request (status line, headers including Idempotency-Key: UUID_A, body) and writes it to the TCP socket. The bytes leave the application. Stripe’s server reads the complete request, charges the customer’s payment method, writes ch_A to its database, and begins composing the HTTP response. Before any response bytes arrive, the TCP connection is reset (perhaps by the load balancer’s idle timeout). Netty’s ChannelInboundHandler fires exceptionCaught with a ClosedChannelException or IOException. The SdkAsyncHttpResponseHandler.onError() callback fires. The CompletableFuture<Void> returned by execute() completes exceptionally.

The .exceptionally() lambda fires. attemptsLeft > 0, so buildAsyncRequest() is called again. UUID.randomUUID() evaluates inside buildAsyncRequest() and produces UUID_B. The new AsyncExecuteRequest carries Idempotency-Key: UUID_B. asyncClient.execute(retryRequest, ...) sends it to Stripe. Stripe has UUID_B in its idempotency cache — no entry — processes it as a new charge — ch_B is created. The customer has been charged twice for the same (customerId, period) pair.

The subtler variant: recursive retryAsync(int attemptsLeft) method — UUID.randomUUID() called at method entry — recursive invocation re-computes UUID — UUID_B

// AsyncBillingService.java — UNSAFE: recursive retry method.
// UUID computed at method entry — recursive call computes UUID_B.

public CompletableFuture<String> chargeAsync(String customerId, String period,
        int amountCents) {
    // UUID_A generated here on first call.
    return retryAsync(customerId, period, amountCents,
        UUID.randomUUID().toString(), // BUG: generated once per chargeAsync() call
        3);
}

private CompletableFuture<String> retryAsync(String customerId, String period,
        int amountCents, String idempotencyKey, int attemptsLeft) {

    SdkHttpRequest request = SdkHttpFullRequest.builder()
        .method(SdkHttpMethod.POST)
        .uri(URI.create("https://api.stripe.com/v1/charges"))
        .putHeader("Authorization", "Bearer " + stripeKey)
        .putHeader("Content-Type", "application/x-www-form-urlencoded")
        .putHeader("Idempotency-Key", idempotencyKey) // UUID_A on first attempt
        .build();
    // ... build AsyncExecuteRequest, execute ...

    return future.exceptionally(ex -> {
        if (attemptsLeft > 0) {
            // BUG: recursive call passes a NEW UUID.randomUUID() as the key.
            // Developer thinks "each retry needs a fresh key" — this is wrong.
            retryAsync(customerId, period, amountCents,
                UUID.randomUUID().toString(), // UUID_B, UUID_C, ...
                attemptsLeft - 1);
        }
        // ...
        return null;
    }).toCompletableFuture();
}

This variant is common in teams that have worked with distributed systems where retry idempotency tokens must be unique per attempt (as in AWS SQS’s MessageDeduplicationId or DynamoDB’s ClientRequestToken). Those systems deduplicate by token per attempt; the token serves as a deduplication boundary, not a “this operation has this identity” marker. Stripe’s Idempotency-Key is the opposite: it identifies the logical operation, and the same key must be sent on every retry attempt to get the cache hit. AWS SDK v2’s own service clients handle this correctly in their internals, but the pattern is not surfaced in the low-level SdkAsyncHttpClient API.

Fix: compute stable key once before the async chain — pass it as a parameter through all retry invocations — never call UUID.randomUUID() inside the retry chain

// AsyncBillingService.java — FIXED.

public CompletableFuture<String> chargeCustomerAsync(
        String customerId, String period, int amountCents) {

    // Stable key computed once, before the async chain begins.
    // Same value for (customerId, period) across all retries and all pods.
    String idempotencyKey = stableIdempotencyKey(customerId, period);

    // Request built once with the stable key.
    SdkHttpRequest baseRequest = SdkHttpFullRequest.builder()
        .method(SdkHttpMethod.POST)
        .uri(URI.create("https://api.stripe.com/v1/charges"))
        .putHeader("Authorization", "Bearer " + stripeKey)
        .putHeader("Content-Type", "application/x-www-form-urlencoded")
        .putHeader("Idempotency-Key", idempotencyKey) // stable
        .build();

    byte[] bodyBytes = ("customer=" + customerId + "&amount=" + amountCents + "&currency=usd")
        .getBytes(StandardCharsets.UTF_8);

    return executeWithRetry(baseRequest, bodyBytes, 3);
}

private CompletableFuture<String> executeWithRetry(
        SdkHttpRequest request, byte[] bodyBytes, int attemptsLeft) {

    StringResponseHandler handler = new StringResponseHandler();

    AsyncExecuteRequest executeRequest = AsyncExecuteRequest.builder()
        .request(request)
        .requestContentPublisher(new SimpleBodyPublisher(bodyBytes))
        .responseHandler(handler)
        .build();

    return asyncClient.execute(executeRequest, handler)
        .thenCompose(ignored -> handler.future())
        .exceptionally(ex -> {
            if (attemptsLeft > 0) {
                // Retry with the SAME request object — same Idempotency-Key.
                // Stripe receives UUID_A on the retry and returns ch_A from cache.
                return executeWithRetry(request, bodyBytes, attemptsLeft - 1)
                    .join(); // only safe in non-event-loop thread; prefer thenCompose
            }
            throw new RuntimeException("Billing failed after all attempts", ex);
        });
}

The key invariant: idempotencyKey is computed by stableIdempotencyKey(customerId, period) before chargeCustomerAsync() builds any request object, and it is never recomputed inside any callback, lambda, or recursive method. The same SdkHttpRequest object is passed through all retry levels. NettyNioAsyncHttpClient does not modify the SdkHttpRequest headers between attempts. Stripe receives the same Idempotency-Key on every wire attempt and returns ch_A from its idempotency cache on any retry that follows a committed charge.

Async billing is harder to audit than sync billing

When NettyNioAsyncHttpClient’s callbacks fire on Netty event loop threads, the stack trace at a double-charge is often unreadable. Keybrake’s audit log records the Idempotency-Key, the Stripe Request-Id, the charge amount, and the vault key — so you can tell from the log alone whether two charges were created by a stable-key retry (safe) or a UUID_B retry (double charge). Join the waitlist to add the audit log to your async billing stack.

Failure mode 3: AWS SDK v2 ExecutionInterceptor.beforeTransmission() fires per wire attempt including SDK retries — UUID.randomUUID() in beforeTransmission() produces UUID_B on the first retry — ch_B

AWS SDK v2’s SdkClient SPI allows developers to build their own service clients using the same interceptor chain, retry policy, and metrics infrastructure that the SDK uses internally for DynamoDB, S3, and other services. Teams that want unified observability across all their API calls — AWS and non-AWS — sometimes build a thin StripeClient wrapper using this SPI. The result is a client that automatically gets exponential backoff with jitter (via StandardRetryStrategy), structured request/response logging, and ExecutionInterceptor-based cross-cutting concerns.

ExecutionInterceptor defines several methods that fire at different points in the request lifecycle. Understanding which methods fire once per API call and which fire once per wire attempt is critical for idempotency key placement:

Developers who write cross-cutting logic (correlation IDs, request signing, audit logging) as ExecutionInterceptor implementations may place UUID generation in beforeTransmission(), reasoning that “the key should be set right before the bytes are sent.” But beforeTransmission() fires on every wire attempt — including those triggered by StandardRetryStrategy — so UUID_B is generated on the first retry:

// IdempotencyKeyInterceptor.java — UNSAFE: UUID generated in beforeTransmission(),
// which fires per wire attempt including SDK retries.
import software.amazon.awssdk.core.interceptor.*;
import software.amazon.awssdk.http.SdkHttpRequest;
import java.util.UUID;

public class IdempotencyKeyInterceptor implements ExecutionInterceptor {

    @Override
    public SdkHttpRequest modifyHttpRequest(
            Context.ModifyHttpRequest context, ExecutionAttributes executionAttributes) {

        // BUG: UUID.randomUUID() called in modifyHttpRequest(), which fires
        // per wire attempt. On the first attempt, UUID_A is added.
        // When StandardRetryStrategy triggers a retry (e.g., on 500 or I/O error),
        // modifyHttpRequest() fires again. UUID.randomUUID() produces UUID_B.
        // Stripe committed ch_A before the error that triggered the retry.
        // UUID_B creates ch_B.
        return context.httpRequest().toBuilder()
            .putHeader("Idempotency-Key", UUID.randomUUID().toString()) // BUG
            .build();
    }
}

// StripeClientBuilder.java — wires interceptor into SDK client configuration.
import software.amazon.awssdk.core.client.config.SdkClientConfiguration;
import software.amazon.awssdk.core.retry.RetryPolicy;

SdkClientConfiguration config = SdkClientConfiguration.builder()
    .option(SdkClientOption.EXECUTION_INTERCEPTORS,
        List.of(new IdempotencyKeyInterceptor()))
    .option(SdkClientOption.RETRY_POLICY,
        RetryPolicy.defaultRetryPolicy()) // fires modifyHttpRequest() on each attempt
    .build();

The interceptor call flow across two attempts: Attempt 1beforeExecution() fires (UUID not yet generated). modifyHttpRequest() fires: UUID.randomUUID() produces UUID_A; request header set to UUID_A. beforeTransmission() fires (UUID_A already in headers). The request is transmitted. Stripe commits ch_A. An I/O error occurs before the response arrives. The SDK’s StandardRetryStrategy decides to retry.

Attempt 2 (retry)modifyHttpRequest() fires again: UUID.randomUUID() produces UUID_B; the header is replaced with UUID_B. beforeTransmission() fires: UUID_B is in the headers. The request is transmitted with UUID_B. Stripe looks up UUID_B: no cache entry. Stripe creates ch_B. The developer’s retry fired automatically from the SDK’s retry policy — there is no retry loop in the application code to blame, and the double charge may not be discovered until a customer complaint.

The subtler variant: developer moves UUID generation to modifyHttpRequest() thinking it fires once — it fires per wire attempt — same UUID_B problem; then moves to a custom attribute stored in ExecutionAttributes but reads the attribute incorrectly

// IdempotencyKeyInterceptor.java — STILL UNSAFE after apparent fix.
// Developer read the javadoc for beforeExecution() and saw "fires once per
// API call, before the retry loop." Moved UUID generation there.
// But then reads the attribute in modifyHttpRequest() with a null-safe fallback
// that generates a new UUID when the attribute is absent — the attribute
// is always present after beforeExecution() on attempt 1, but on attempt 2
// (the retry), the developer mistakenly reinitialises the attribute
// with UUID.randomUUID() rather than reading the existing one.

private static final ExecutionAttribute<String> IDEMPOTENCY_KEY_ATTR =
    new ExecutionAttribute<>("IdempotencyKey");

@Override
public void beforeExecution(
        Context.BeforeExecution context, ExecutionAttributes executionAttributes) {
    // Correct: fires once per API call. UUID_A generated and stored here.
    executionAttributes.putAttribute(IDEMPOTENCY_KEY_ATTR,
        UUID.randomUUID().toString()); // UUID_A stored correctly
}

@Override
public SdkHttpRequest modifyHttpRequest(
        Context.ModifyHttpRequest context, ExecutionAttributes executionAttributes) {

    // STILL BUG: developer used putAttribute() instead of getAttribute().
    // On attempt 2, this overwrites UUID_A with UUID_B in ExecutionAttributes
    // and sets UUID_B as the header value.
    String key = UUID.randomUUID().toString(); // BUG: should be getAttribute()
    executionAttributes.putAttribute(IDEMPOTENCY_KEY_ATTR, key);

    return context.httpRequest().toBuilder()
        .putHeader("Idempotency-Key", key)
        .build();
}

Fix: generate UUID once in beforeExecution(), store in ExecutionAttributes, read in modifyHttpRequest() without generating a new one

// IdempotencyKeyInterceptor.java — FIXED.

private static final ExecutionAttribute<String> IDEMPOTENCY_KEY_ATTR =
    new ExecutionAttribute<>("IdempotencyKey");

@Override
public void beforeExecution(
        Context.BeforeExecution context, ExecutionAttributes executionAttributes) {

    // beforeExecution() fires once per API call, before the retry loop.
    // Use a content-hash key derived from the request for robustness:
    // same (customerId, period) pair produces the same key across all
    // JVM instances and all retry sessions, not just within one call.
    //
    // If you have access to the logical billing parameters here, compute:
    //   stableIdempotencyKey(customerId, period)
    //
    // If the logical parameters are not available at interceptor level,
    // UUID is still correct as long as it is computed ONCE here and
    // reused on every attempt. UUID_A set in beforeExecution() is stable
    // across all attempts within one API call.
    executionAttributes.putAttribute(IDEMPOTENCY_KEY_ATTR,
        UUID.randomUUID().toString()); // Computed once. beforeExecution() does not fire on retry.
}

@Override
public SdkHttpRequest modifyHttpRequest(
        Context.ModifyHttpRequest context, ExecutionAttributes executionAttributes) {

    // Read the key stored in beforeExecution(). Do NOT generate a new UUID here.
    // getAttribute() returns the value set in beforeExecution().
    // On attempt 2 (retry), the same UUID_A is returned — not UUID_B.
    String idempotencyKey = executionAttributes.getAttribute(IDEMPOTENCY_KEY_ATTR);

    return context.httpRequest().toBuilder()
        .putHeader("Idempotency-Key", idempotencyKey) // UUID_A on every attempt
        .build();
}

// Additional guard: store the key alongside the Stripe Request-Id for audit.
@Override
public void afterExecution(
        Context.AfterExecution context, ExecutionAttributes executionAttributes) {
    String usedKey = executionAttributes.getAttribute(IDEMPOTENCY_KEY_ATTR);
    String stripeRequestId = context.httpResponse()
        .firstMatchingHeader("Request-Id").orElse("unknown");
    // log: usedKey + " -> " + stripeRequestId for audit trail
}

Why beforeExecution() and not modifyHttpRequest(): the AWS SDK v2 javadoc documents beforeExecution() as “called before the execution of the request, including any retry attempts.” In practice, the SDK’s retry loop begins after beforeExecution() returns, calls modifyHttpRequest() on each iteration, and only calls beforeExecution() once regardless of how many wire attempts are made. The same ExecutionAttributes object is threaded through all calls to modifyHttpRequest() and beforeTransmission() within one API call, so a value stored in beforeExecution() is readable in all subsequent interceptor methods for all retry attempts.

The content-hash key is preferable to a per-call UUID even in this model, because a UUID generated in beforeExecution() is stable within one API call but differs between calls. If the calling code crashes after beforeExecution() fires and before the response is parsed, and the caller retries the outer call (not the inner SDK retry), a new API call will begin, beforeExecution() will generate UUID_C, and the caller will receive ch_C from Stripe even though ch_A was already committed. A content-hash key derived from (customerId, period) returns ch_A from Stripe’s idempotency cache in this scenario.

Per-billing-period vault keys with spend caps

Keybrake issues a vault_key_xxx per billing run — scoped to a single merchant, a single day, and a USD cap. When the SDK’s StandardRetryStrategy fires more attempts than expected, each attempt hits the proxy first. Attempts beyond the cap return 429 before reaching Stripe. Join the waitlist to add the spend cap to your AWS SDK v2 billing stack.

Shared fix layer: pre-flight database guard and vault key spend cap

Stable content-hash idempotency keys are necessary but not sufficient. Two scenarios remain:

Scenario A: two pods start the same billing run simultaneously. Both compute stableIdempotencyKey("cus_123", "2026-09") and get the same value. Both call Stripe. Stripe’s idempotency layer handles concurrent same-key requests by serializing them — the second request waits for the first to complete and then returns the same result from cache. No double charge in this scenario. But the race window is real and can produce unexpected results if Stripe’s idempotency cache has not yet been written when the second request arrives (for extremely short-lived charges). A pre-flight database guard closes this entirely:

-- Pre-flight insert. Exactly one pod wins; others receive a conflict and skip.
INSERT INTO billing_runs (customer_id, billing_period, idempotency_key, created_at)
VALUES (?, ?, ?, NOW())
ON CONFLICT (customer_id, billing_period) DO NOTHING;

-- If rows_affected == 0, another pod already owns this billing run. Skip.
-- If rows_affected == 1, this pod owns it. Proceed to Stripe.
-- After Stripe returns the charge id, update the row:
UPDATE billing_runs SET stripe_charge_id = ?, completed_at = NOW()
WHERE customer_id = ? AND billing_period = ?;

Scenario B: the agent running the billing job crashes mid-run and a new agent instance starts a new run before the first instance’s work has been reconciled. Stable keys prevent duplicate charges for customers whose billing completed before the crash. The pre-flight guard prevents re-billing any customer whose billing_runs row was inserted before the crash, even if stripe_charge_id is not yet populated (that row can be reconciled via Stripe’s API using the stored idempotency_key). An AWS SDK v2 call to Stripe’s GET /v1/charges?payment_intent=<intent_id> or a lookup by idempotency key (GET /v1/charges?idempotency_key=<key> via the Stripe API) can complete the reconciliation.

Vault key spend cap: set the vault key cap at expected_total_charges × 1.10. A 10% buffer absorbs legitimate retries (a transient Stripe 500 that requires one retry per customer on a 10,000-customer batch adds at most 10,000 additional attempts, each returning the same charge from cache, not new charges — but each attempt counts against the daily API rate limit, not against the cap). If a bug in the retry logic causes actual new charges beyond the cap (a UUID_B bug that somehow survives the pre-flight guard), the proxy returns 429 before those charges reach Stripe. The cap is a financial backstop, not a substitute for correct idempotency key discipline.

Summary table

Failure mode Root cause Stripe outcome Fix
1. SdkHttpFullRequest rebuilt per retry (ApacheHttpClient) Immutable request object rebuilt in retry loop — UUID.randomUUID() in builder chain evaluates per build() call UUID_A on attempt 1, UUID_B on attempt 2 → ch_B alongside committed ch_A Compute stable key before the retry loop; build SdkHttpFullRequest once; reuse the same immutable object on every attempt
1b. Factory method called per retry (ApacheHttpClient) buildStripeRequest() factory generates UUID internally; factory called per attempt UUID_B per attempt → ch_B Move UUID generation out of the factory; accept stable key as a parameter
2. exceptionally() retry builds new AsyncExecuteRequest (NettyNioAsyncHttpClient) onError() fires after full request transmission; exceptionally() lambda calls buildAsyncRequest() with UUID.randomUUID() UUID_B in retry request → ch_B while ch_A already committed Compute stable key before the async chain; pass the same SdkHttpRequest object through all retry levels
2b. Recursive retryAsync() with new UUID per call Recursive method generates UUID at entry; recursive invocation produces UUID_B UUID_B on first recursive call → ch_B Thread stable key as parameter through all recursive invocations; never generate UUID inside the retry method
3. ExecutionInterceptor.modifyHttpRequest() fires per wire attempt modifyHttpRequest() fires on each attempt including SDK retries — UUID.randomUUID() inside produces UUID_B on first retry UUID_A on attempt 1, UUID_B on retry → ch_B Generate UUID in beforeExecution() (fires once per API call); store in ExecutionAttributes; read in modifyHttpRequest() without regenerating
3b. ExecutionAttributes overwritten in modifyHttpRequest() Developer calls putAttribute() instead of getAttribute() in modifyHttpRequest() — overwrites UUID_A with UUID_B on retry UUID_B on retry → ch_B Use getAttribute(IDEMPOTENCY_KEY_ATTR) in modifyHttpRequest(); never call putAttribute() for the idempotency key outside beforeExecution()

Per-run vault keys with spend caps for Stripe

Keybrake issues a vault_key_xxx per billing run. You set the USD cap and endpoint allowlist. When the run exceeds the cap or hits a blocked endpoint, the proxy stops forwarding — no code changes needed in the AWS SDK v2 interceptor chain. Join the waitlist to add Keybrake to your AWS SDK v2 billing stack.

Further reading