Jersey and JAX-RS Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

JAX-RS’s ClientRequestFilter.filter() is called once per client.target().request().post() invocation — not once per logical billing operation. A billing retry loop that calls client.target(stripeUrl).request().post(entity) again on failure re-runs the complete ClientRequestFilter chain, including an idempotency filter that calls UUID.randomUUID() inside filter(ClientRequestContext requestContext). The initial POST /v1/charges creates ch_A before a ProcessingException wrapping a SocketTimeoutException from a stale pooled connection; the retry’s filter() evaluates a new UUID, causing Stripe to create ch_B. Three Jersey and JAX-RS-specific Stripe billing failure modes: a ClientRequestFilter computes UUID.randomUUID() inside filter() — subtler variant: a ClientResponseFilter that implements retry by re-submitting via requestContext.getClient().target(...).request().post(...) fires the ClientRequestFilter chain again on each re-submission, generating a fresh UUID per retry without any code change to the filter itself; a MicroProfile Rest Client @RegisterRestClient interface with MicroProfile Fault Tolerance @Retry on the calling CDI bean method — UUID.randomUUID() at the call site argument position re-evaluates per @Retry re-invocation — subtler variant: @Retry placed on the interface method via a ClientRequestFilter registered with @RegisterProvider that calls UUID.randomUUID() inside filter(), so each @Retry re-invocation fires a distinct JAX-RS request, the filter pipeline fires per invocation, fresh UUID per @Retry attempt → ch_B; and Jersey’s async AsyncInvoker with InvocationCallback.failed() retry — failed() calls target.request().header("Idempotency-Key", UUID.randomUUID().toString()).post(entity, callback)UUID.randomUUID() is at the header() argument position, evaluated when failed() executes, not at request-build time — initial request creates ch_A before SocketTimeoutException, failed() evaluates a new UUID, Stripe creates ch_B — subtler variant: Jersey’s rx() CompletableFuture chain with .exceptionally() retry evaluates UUID.randomUUID() at lambda invocation time, not at chain assembly time.

This post covers all three failure modes with Java code, content-hash idempotency keys stable across ClientRequestFilter re-invocations, MicroProfile Fault Tolerance retry compounding, and async retry patterns, a ClientRequestContext property bag as the key-passing mechanism from calling code to the filter, pg_try_advisory_lock() for cross-pod scheduler serialization, and pre-flight PostgreSQL ON CONFLICT DO NOTHING as a cluster-wide billing mutex — and per-billing-period vault keys via a spend-cap proxy as a hard financial backstop. For the Feign RequestInterceptor.apply() pattern, see the Feign and Spring Cloud OpenFeign Stripe Integration post. For the Apache HttpClient 5 HttpRequestInterceptor pattern, see the Apache HttpClient 5 and Stripe Integration post. For the OkHttp application interceptor pattern, see the OkHttp and Retrofit Stripe Integration post.

Failure mode 1: ClientRequestFilter.filter() computes UUID.randomUUID() — retry loop re-invokes client.target().request().post() — filter chain fires again with a fresh UUID per retry — initial request creates ch_A before ProcessingException — retry creates ch_B

JAX-RS’s client-side filter model is built around two interfaces: ClientRequestFilter, whose filter(ClientRequestContext requestContext) method is called before the outbound HTTP request is sent, and ClientResponseFilter, whose filter(ClientRequestContext requestContext, ClientResponseContext responseContext) method is called after the response arrives. A filter registered via client.register(new StripeIdempotencyFilter()) participates in every request made through that Client instance. The critical behavioral difference from Feign’s RequestInterceptor is scoping: Feign’s executeAndDecode() loop drives retry internally and re-fires interceptors within the same logical method invocation; JAX-RS has no built-in retry mechanism in its client SPI. Each call to target.request().post(entity) is an independent JAX-RS request invocation, and the filter pipeline fires fresh for each independent invocation.

This becomes a duplicate-charge risk when a retry loop calls client.target(stripeUrl).request().post(entity) again after a failure, and the registered ClientRequestFilter computes the idempotency key inside filter():

// StripeIdempotencyFilter.java
// UNSAFE: UUID.randomUUID() computed inside filter() — called per JAX-RS request invocation.
// If the billing service retries by re-calling client.target(...).request().post(...),
// the filter fires again with a fresh UUID — ch_B created on the retry.

import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import java.io.IOException;
import java.util.UUID;

public class StripeIdempotencyFilter implements ClientRequestFilter {

    @Override
    public void filter(ClientRequestContext requestContext) throws IOException {
        // UNSAFE: UUID.randomUUID() called per filter() invocation.
        // First call:   UUID = "4c8a3b1d-2e5f-4b7e-9c0a-1a2b3c4d5e6f" → ch_A
        // Retry call:   UUID = "d7e2f4a6-3b8c-4f1e-0d9a-2b3c4d5e6f7a" → ch_B ← duplicate
        requestContext.getHeaders().putSingle("Idempotency-Key", UUID.randomUUID().toString());
    }
}

// BillingService.java — UNSAFE retry loop: each iteration re-invokes the full JAX-RS call.
// The ClientRequestFilter fires on each client.target(...).request().post(...) call.

import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.Form;
import jakarta.ws.rs.core.Response;

public class BillingService {

    private final Client client;

    public BillingService() {
        this.client = ClientBuilder.newBuilder()
            .register(new StripeIdempotencyFilter())  // UNSAFE — UUID.randomUUID() in filter()
            .connectTimeout(5, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build();
    }

    public ChargeResponse chargeCustomer(String customerId, String billingPeriod, long amountCents) {
        Form form = buildChargeForm(customerId, amountCents);

        // UNSAFE retry loop — each iteration is a new JAX-RS request invocation.
        // The filter pipeline fires fresh per iteration, including StripeIdempotencyFilter.
        // Iteration 1: filter() → UUID "4c8a3b1d-..." → POST to Stripe → ch_A created
        //              → ProcessingException (SocketTimeoutException on stale pooled connection)
        // Iteration 2: filter() → UUID "d7e2f4a6-..." → POST to Stripe → Stripe sees new UUID
        //              → ch_B created — customer charged twice.
        int maxAttempts = 3;
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                Response response = client.target("https://api.stripe.com/v1/charges")
                    .request()
                    .post(Entity.form(form));   // ← filter() fires here, fresh UUID per call
                if (response.getStatus() == 200 || response.getStatus() == 201) {
                    return response.readEntity(ChargeResponse.class);
                }
                if (response.getStatus() >= 400 && response.getStatus() < 500) {
                    throw new BillingException("Stripe client error: " + response.getStatus());
                }
                // 5xx — retryable
            } catch (ProcessingException e) {
                if (attempt == maxAttempts) throw new BillingException("Billing failed after retries", e);
                try { Thread.sleep(100L * attempt); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
            }
        }
        throw new BillingException("Billing exhausted retries");
    }
}

The failure scenario: on attempt 1, target.request().post(entity) is called. JAX-RS executes the outbound filter chain. StripeIdempotencyFilter.filter() evaluates UUID.randomUUID() and sets Idempotency-Key: 4c8a3b1d-.... The HTTP request reaches Stripe. Stripe processes the charge. ch_A is committed in Stripe’s ledger. The underlying TCP connection to Stripe was a keep-alive connection from the JAX-RS client’s connection pool — the connection had been idle for 61 seconds and the upstream load balancer silently closed it at the 60-second idle timeout. Jersey’s connector (Apache HttpClient under the hood via jersey-apache-connector) reads the response and gets a SocketTimeoutException or an EOF on the reset connection. Jersey wraps this in a ProcessingException and throws it. The retry loop catches ProcessingException, sleeps 100 ms, and increments the attempt counter. On attempt 2, target.request().post(entity) is called again. The outbound filter chain fires from the beginning. StripeIdempotencyFilter.filter() is a new invocation; it calls UUID.randomUUID() again and returns "d7e2f4a6-...". The retry request reaches Stripe with a completely new Idempotency-Key. Stripe looks up "d7e2f4a6-..." in its idempotency cache, finds nothing, and processes the charge again. ch_B is created. The customer is charged twice.

The failure is structurally invisible in code review because the filter implementation looks correct in isolation: a single-purpose class, a clean interface, no obvious state mutation. The invariant that is violated is not in the JAX-RS specification or the ClientRequestFilter Javadoc: the specification says only that filter() is called “before the request is sent”. It does not say whether “request” refers to an HTTP attempt or a logical billing operation. A developer who writes the retry loop as a natural extension of the service class will assume the filter’s behavior is analogous to a middleware that fires once per business operation, because that is how most web framework middleware is described.

The subtler variant: a ClientResponseFilter implements retry by re-submitting via requestContext.getClient().target(...).request().post(...) — the re-submission fires ClientRequestFilter again — fresh UUID — ch_B without any change to the idempotency filter

A ClientResponseFilter registered on the same Client instance has access to both the original ClientRequestContext and the ClientResponseContext. A pattern that appears in billing codebases is a response filter that handles transient Stripe 5xx errors by re-submitting the request, with the intent of encapsulating retry logic inside the filter rather than in the calling service. The filter uses requestContext.getClient() to get the same Client instance and re-invokes the request:

// StripeRetryFilter.java — ClientResponseFilter that retries on 5xx.
// UNSAFE: re-submitting via requestContext.getClient().target(...).request().post(...)
// fires the full ClientRequestFilter chain, including StripeIdempotencyFilter.
// StripeIdempotencyFilter.filter() evaluates UUID.randomUUID() on the retry submission
// even though StripeIdempotencyFilter was not changed.

import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientResponseContext;
import jakarta.ws.rs.client.ClientResponseFilter;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.Response;
import java.io.IOException;

public class StripeRetryFilter implements ClientResponseFilter {

    private static final int MAX_RETRIES = 2;

    @Override
    public void filter(ClientRequestContext requestContext,
                       ClientResponseContext responseContext) throws IOException {

        if (responseContext.getStatus() < 500) return;  // non-5xx — no retry

        Integer retryCount = (Integer) requestContext.getProperty("stripeRetryCount");
        if (retryCount == null) retryCount = 0;

        if (retryCount >= MAX_RETRIES) return;

        requestContext.setProperty("stripeRetryCount", retryCount + 1);

        // UNSAFE: re-submitting via requestContext.getClient() fires the full filter chain.
        // StripeIdempotencyFilter.filter() fires on the re-submission — fresh UUID.randomUUID().
        // ch_A was already created before Stripe returned 503 — ch_B will be created on retry.
        Object entity = requestContext.getEntity();
        Response retryResponse = requestContext.getClient()
            .target(requestContext.getUri())
            .request(requestContext.getAcceptableMediaTypes()
                         .stream()
                         .findFirst()
                         .orElse(jakarta.ws.rs.core.MediaType.WILDCARD_TYPE))
            .post(Entity.entity(entity, requestContext.getMediaType()));

        // Replace the response context — caller sees retry response.
        responseContext.setStatus(retryResponse.getStatus());
        responseContext.setEntityStream(retryResponse.readEntity(java.io.InputStream.class));
    }
}

// Both filters registered on the same Client:
Client client = ClientBuilder.newBuilder()
    .register(new StripeIdempotencyFilter())  // UNSAFE — UUID per filter() call
    .register(new StripeRetryFilter())        // re-submission fires StripeIdempotencyFilter again
    .build();

The coupling between the two filters is invisible at registration time. The developer who wrote StripeRetryFilter may not have known about the idempotency filter’s internal implementation. The developer who wrote the idempotency filter may not have known that a response filter would re-submit requests. Both filters look correct individually. The failure emerges only from their interaction: the response filter’s re-submission fires the request filter chain, and the request filter’s fresh UUID generation is not visible to the response filter.

The fix for failure mode 1

The idempotency key must be computed once per billing operation, before any JAX-RS call is made, and passed through the request filter via the ClientRequestContext property bag. The property bag survives for the lifetime of a single request; a re-submitted request via requestContext.getClient().target(...) starts a new request with an empty property bag, which is the correct place to inject the pre-computed key. Calling code sets the property before the first JAX-RS invocation, and the filter reads it:

// Safe StripeIdempotencyFilter.java — reads caller-supplied key from property bag.
// Never calls UUID.randomUUID() inside filter().

import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import java.io.IOException;

public class StripeIdempotencyFilter implements ClientRequestFilter {

    public static final String IDEMPOTENCY_KEY_PROPERTY = "stripe.idempotencyKey";

    @Override
    public void filter(ClientRequestContext requestContext) throws IOException {
        // Safe: read the key set by calling code before the first invocation.
        String key = (String) requestContext.getProperty(IDEMPOTENCY_KEY_PROPERTY);
        if (key == null) {
            throw new IllegalStateException(
                "stripe.idempotencyKey property not set on request — " +
                "compute stableKey() in calling code and set property before JAX-RS call");
        }
        requestContext.getHeaders().putSingle("Idempotency-Key", key);
    }
}

// BillingService.java — SAFE: stable key computed once, passed as property.

import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.Invocation;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class BillingService {

    private final Client client;

    public BillingService() {
        this.client = ClientBuilder.newBuilder()
            .register(new StripeIdempotencyFilter())  // safe — reads property, not UUID
            .connectTimeout(5, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build();
    }

    public ChargeResponse chargeCustomer(String customerId, String billingPeriod, long amountCents) {
        // Computed once per billing operation — deterministic, stable across retries.
        String idempotencyKey = stableKey(customerId, billingPeriod);

        // Pre-flight: claim the billing slot before any network activity.
        boolean claimed = billingRepository.claimSlot(customerId, billingPeriod, idempotencyKey);
        if (!claimed) {
            return billingRepository.findExistingCharge(customerId, billingPeriod);
        }

        Form form = buildChargeForm(customerId, amountCents);
        int maxAttempts = 3;
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                // property() sets a per-request property that survives in the filter's context.
                // If the retry loop calls target.request() again, the property() call is repeated
                // with the same idempotencyKey — the filter reads the same stable value every time.
                Response response = client.target("https://api.stripe.com/v1/charges")
                    .request()
                    .property(StripeIdempotencyFilter.IDEMPOTENCY_KEY_PROPERTY, idempotencyKey)
                    .post(Entity.form(form));
                if (response.getStatus() == 200 || response.getStatus() == 201) {
                    return response.readEntity(ChargeResponse.class);
                }
                if (response.getStatus() >= 400 && response.getStatus() < 500) {
                    throw new BillingException("Stripe client error: " + response.getStatus());
                }
            } catch (ProcessingException e) {
                if (attempt == maxAttempts) throw new BillingException("Billing failed after retries", e);
                try { Thread.sleep(100L * attempt); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
            }
        }
        throw new BillingException("Billing exhausted retries");
    }

    public static String stableKey(String customerId, String billingPeriod) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(
                (customerId + ":" + billingPeriod + ":jaxrs-billing")
                    .getBytes(StandardCharsets.UTF_8)
            );
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < 16; i++) {
                sb.append(String.format("%02x", hash[i]));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 not available", e);
        }
    }
}

The key insight is that Invocation.Builder.property(key, value) sets a property on the specific Invocation instance, not on the shared Client or WebTarget. Each call to target.request().property(...) creates a new Invocation.Builder with the property set for that specific request. The retry loop sets .property(IDEMPOTENCY_KEY_PROPERTY, idempotencyKey) on each iteration — using the same idempotencyKey value computed before the loop. The filter reads the property and sets the header to the same stable value on every retry invocation. Stripe receives the same Idempotency-Key across all retry attempts and returns the cached ch_A result after the first success.

Failure mode 2: MicroProfile Rest Client @RegisterRestClient with MicroProfile Fault Tolerance @Retry on the calling CDI bean method — UUID.randomUUID() at the call site re-evaluates per @Retry re-invocation — initial call creates ch_A before WebApplicationException — first @Retry re-invocation creates ch_B

MicroProfile Rest Client’s @RegisterRestClient generates a CDI bean that implements a typed Java interface, where each interface method maps to a JAX-RS HTTP call. In Quarkus and Helidon applications, this is the primary way to call external HTTP APIs without writing explicit JAX-RS client code. MicroProfile Fault Tolerance’s @Retry annotation, when placed on a CDI bean method that calls the @RegisterRestClient proxy, intercepts the method via CDI interceptors and re-invokes the full method body on each retry attempt. If the calling bean method computes UUID.randomUUID() inside the method body and passes it as an interface method argument, the UUID re-evaluates on each @Retry re-invocation:

// StripeBillingClient.java — MicroProfile Rest Client interface.
// @RegisterRestClient generates a CDI proxy that makes JAX-RS calls per method invocation.

import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;

@RegisterRestClient(baseUri = "https://api.stripe.com")
@Path("/v1")
public interface StripeBillingClient {

    @POST
    @Path("/charges")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    ChargeResponse createCharge(
        @HeaderParam("Idempotency-Key") String idempotencyKey,  // set by caller
        Map<String, String> formData
    );
}

// BillingService.java — UNSAFE: @Retry at CDI bean level re-invokes method body.
// UUID.randomUUID() at the argument expression position re-evaluates per @Retry attempt.

import org.eclipse.microprofile.faulttolerance.Retry;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.UUID;

@ApplicationScoped
public class BillingService {

    @Inject
    @RestClient
    StripeBillingClient stripeClient;

    @Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS,
           retryOn = { WebApplicationException.class, ProcessingException.class })
    public ChargeResponse chargeCustomer(String customerId, String billingPeriod, long amountCents) {
        // UNSAFE: UUID.randomUUID() evaluated at the argument expression position.
        // MicroProfile Fault Tolerance @Retry CDI interceptor re-invokes this method body
        // on each retry attempt. UUID.randomUUID() re-evaluates per re-invocation.
        //
        // Attempt 1: UUID = "4c8a3b1d-..." → ch_A created before WebApplicationException(503)
        // Attempt 2: UUID = "d7e2f4a6-..." → Stripe sees new key → ch_B  ← duplicate charge
        // Attempt 3: UUID = "a9b3c7e1-..." → ch_C  ← third charge on same billing period
        return stripeClient.createCharge(
            UUID.randomUUID().toString(),                // UNSAFE: re-evaluated per @Retry attempt
            buildChargeForm(customerId, amountCents)
        );
    }
}

The failure mechanism is identical to Resilience4j’s @Retry pattern: the CDI interceptor wraps the annotated method and re-invokes the method body on each attempt. The method body’s argument expressions — UUID.randomUUID().toString() is such an expression — are evaluated from scratch on each invocation because the CDI interceptor has no special knowledge of argument expressions. It calls the method with the arguments computed by each fresh invocation of the method body. The re-evaluation is entirely invisible to code review because the call to stripeClient.createCharge(UUID.randomUUID().toString(), ...); looks like a single statement with a clear argument structure. The @Retry annotation is on the method signature, visually separated from the argument expression, and a reviewer must understand the CDI interceptor model to see the connection.

The subtler variant: @Retry placed on the @RegisterRestClient interface method with a @RegisterProvider ClientRequestFilter that calls UUID.randomUUID() inside filter() — each @Retry re-invocation is a distinct JAX-RS proxy method invocation — filter pipeline fires per invocation — fresh UUID per @Retry attempt

MicroProfile Fault Tolerance allows placing @Retry directly on the @RegisterRestClient interface method. The CDI proxy generated by the MicroProfile Rest Client runtime wraps the JAX-RS invocation with the MicroProfile Fault Tolerance interceptors declared on the interface method. When @Retry is on the interface method, each retry attempt by the Fault Tolerance interceptor triggers a new JAX-RS proxy method invocation — which fires the full JAX-RS client filter pipeline. A ClientRequestFilter registered with @RegisterProvider on the interface that calls UUID.randomUUID() inside filter() fires with a fresh UUID on each @Retry re-invocation:

// StripeBillingClient.java — @Retry on the interface method + @RegisterProvider filter.
// UNSAFE if StripeIdempotencyFilter.filter() calls UUID.randomUUID().

import org.eclipse.microprofile.faulttolerance.Retry;
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import java.time.temporal.ChronoUnit;

@RegisterRestClient(baseUri = "https://api.stripe.com")
@Path("/v1")
@RegisterProvider(StripeIdempotencyFilter.class)  // UNSAFE if filter calls UUID.randomUUID()
public interface StripeBillingClient {

    @POST
    @Path("/charges")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    // @Retry on the interface method — fault tolerance interceptor wraps each proxy invocation.
    // Each retry attempt triggers a new JAX-RS client invocation.
    // StripeIdempotencyFilter.filter() fires per invocation — fresh UUID per @Retry attempt.
    @Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS)
    ChargeResponse createCharge(Map<String, String> formData);
    // Note: no @HeaderParam — the filter sets the Idempotency-Key header.
    // The filter calls UUID.randomUUID() — fresh UUID per filter() invocation.
    // Each @Retry attempt = new filter() invocation = new UUID = ch_B, ch_C...
}

The subtlety is that @Retry on an interface method is syntactically equivalent to @Retry on a CDI bean method — both produce a CDI interceptor that re-invokes the method on failure. But the interface method maps directly to a JAX-RS invocation, so the retry re-invokes the full JAX-RS request pipeline, including filters. The developer who registered StripeIdempotencyFilter via @RegisterProvider may have done so to avoid adding a parameter to the interface method (a common motivation for filter-based header injection), without realizing that @Retry on the interface makes the filter fire per retry attempt.

The fix for failure mode 2

The idempotency key must be computed before the outermost @Retry boundary and passed through as a parameter that survives all retry re-invocations:

// BillingService.java — SAFE: stable key computed before @Retry boundary,
// passed as parameter that does not re-evaluate on @Retry re-invocations.

@ApplicationScoped
public class BillingService {

    @Inject
    @RestClient
    StripeBillingClient stripeClient;

    @Inject
    BillingRepository billingRepository;

    // Outer method — NOT annotated with @Retry.
    // Computes stable key once before any fault tolerance boundary.
    public ChargeResponse chargeCustomer(String customerId, String billingPeriod, long amountCents) {
        String idempotencyKey = stableKey(customerId, billingPeriod);

        boolean claimed = billingRepository.claimSlot(customerId, billingPeriod, idempotencyKey);
        if (!claimed) {
            return billingRepository.findExistingCharge(customerId, billingPeriod);
        }

        return chargeWithRetry(customerId, billingPeriod, amountCents, idempotencyKey);
    }

    // Inner method — annotated with @Retry.
    // idempotencyKey is a parameter value captured before the retry boundary.
    // MicroProfile FT @Retry CDI interceptor re-invokes chargeWithRetry() on failure,
    // passing the same parameter value that the outer method supplied on the first call.
    @Retry(maxRetries = 3, delay = 500, delayUnit = ChronoUnit.MILLIS,
           retryOn = { WebApplicationException.class, ProcessingException.class })
    ChargeResponse chargeWithRetry(String customerId, String billingPeriod,
                                   long amountCents, String idempotencyKey) {
        // Safe: idempotencyKey is the same String reference on every @Retry re-invocation.
        return stripeClient.createCharge(idempotencyKey, buildChargeForm(customerId, amountCents));
    }

    public static String stableKey(String customerId, String billingPeriod) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(
                (customerId + ":" + billingPeriod + ":jaxrs-billing")
                    .getBytes(StandardCharsets.UTF_8)
            );
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < 16; i++) {
                sb.append(String.format("%02x", hash[i]));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 not available", e);
        }
    }
}

// StripeBillingClient.java — safe interface: idempotency key as explicit @HeaderParam.
// @Retry removed from interface method — no fault tolerance at the JAX-RS layer.
// Fault tolerance is handled at the CDI bean layer (chargeWithRetry) where the stable key
// is already computed and passed as a parameter.

@RegisterRestClient(baseUri = "https://api.stripe.com")
@Path("/v1")
public interface StripeBillingClient {

    @POST
    @Path("/charges")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    ChargeResponse createCharge(
        @HeaderParam("Idempotency-Key") String idempotencyKey,  // stable key from caller
        Map<String, String> formData
    );
}

The outer chargeCustomer() method computes the stable key once before calling any retry-wrapped code. MicroProfile FT’s @Retry CDI interceptor re-invokes chargeWithRetry() but passes the same parameter values that the caller provided on the first invocation — CDI interceptors do not re-evaluate the arguments of the intercepted method’s callers, only the intercepted method body. idempotencyKey is a String value captured at the outer call site and passed by value; it is the same value on every retry re-invocation of chargeWithRetry(). The MicroProfile Rest Client proxy receives the same Idempotency-Key header value on every attempt. Stripe returns the cached ch_A result on all retry attempts after the first success.

Failure mode 3: Jersey async AsyncInvoker with InvocationCallback.failed() retry — failed() calls target.request().header(“Idempotency-Key”, UUID.randomUUID().toString()).post(entity, callback)UUID.randomUUID() at the header() argument position evaluates at failed() invocation time, not at request-build time — initial request creates ch_A before ProcessingExceptionfailed() creates ch_B on the first async retry

Jersey’s async client API uses AsyncInvoker and InvocationCallback<T> for non-blocking HTTP calls. The async model places the retry logic in the failed(Throwable throwable) callback method, which is invoked on a Jersey-managed thread when the asynchronous request fails. A common async billing pattern submits the initial request and implements retry by re-invoking the async POST from failed(). If the idempotency key is set as a header argument at the .header() call site inside failed(), it is evaluated when failed() executes — at each failure event — not at the time the original request was first built:

// BillingService.java — UNSAFE async retry in InvocationCallback.failed().
// UUID.randomUUID() at the .header() argument position evaluates per failed() invocation.
// Initial request creates ch_A before failure; failed() creates ch_B on first async retry.

import jakarta.ws.rs.client.AsyncInvoker;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.InvocationCallback;
import jakarta.ws.rs.core.Form;
import jakarta.ws.rs.core.Response;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;

public class BillingService {

    private final Client client = ClientBuilder.newClient();

    public CompletableFuture<ChargeResponse> chargeCustomer(
            String customerId, String billingPeriod, long amountCents) {

        CompletableFuture<ChargeResponse> result = new CompletableFuture<>();
        Form form = buildChargeForm(customerId, amountCents);
        AtomicInteger retryCount = new AtomicInteger(0);

        // UNSAFE: UUID.randomUUID() is at the .header() argument position inside the callback.
        // The initial call evaluates UUID.randomUUID() before submitting the async request.
        // If the callback's failed() method is invoked (e.g., SocketTimeoutException),
        // the retry re-invokes target.request().header(..., UUID.randomUUID().toString())
        // which evaluates a fresh UUID at that point in time.
        //
        // Initial request: UUID = "4c8a3b1d-..." at T=0 → ch_A committed at Stripe
        //                  → SocketTimeoutException → failed() invoked
        // Retry in failed(): UUID = "d7e2f4a6-..." at T=0.5s → Stripe sees new UUID
        //                    → ch_B created — customer charged twice.

        submitCharge(customerId, billingPeriod, amountCents, form, retryCount, result);
        return result;
    }

    private void submitCharge(String customerId, String billingPeriod, long amountCents,
                               Form form, AtomicInteger retryCount,
                               CompletableFuture<ChargeResponse> result) {

        // UNSAFE: UUID.randomUUID() evaluated here — once when submitCharge() is called
        // on the initial attempt, and once for each recursive call from failed().
        client.target("https://api.stripe.com/v1/charges")
            .request()
            .header("Idempotency-Key", UUID.randomUUID().toString())  // UNSAFE: fresh UUID per call
            .async()
            .post(Entity.form(form), new InvocationCallback<Response>() {

                @Override
                public void completed(Response response) {
                    if (response.getStatus() == 200 || response.getStatus() == 201) {
                        result.complete(response.readEntity(ChargeResponse.class));
                    } else {
                        result.completeExceptionally(
                            new BillingException("Stripe error: " + response.getStatus()));
                    }
                }

                @Override
                public void failed(Throwable throwable) {
                    // UNSAFE: recursive submitCharge() call re-evaluates UUID.randomUUID()
                    // at the .header() argument position on each retry invocation.
                    if (retryCount.incrementAndGet() <= 3) {
                        submitCharge(customerId, billingPeriod, amountCents,
                                     form, retryCount, result);  // ← ch_B on first retry
                    } else {
                        result.completeExceptionally(throwable);
                    }
                }
            });
    }
}

The failure scenario: submitCharge() is called for the first time. The argument expression UUID.randomUUID().toString() at the .header() call is evaluated, producing "4c8a3b1d-...". The async POST is submitted to Jersey’s async executor. The request reaches Stripe. Stripe processes the charge and commits ch_A. The underlying connection to Stripe is reset before the response arrives — a common scenario with cloud-managed connection pools that enforce idle timeouts between the billing service’s connection pool and Stripe’s API endpoint. Jersey’s async executor catches the SocketTimeoutException, wraps it in a ProcessingException, and invokes callback.failed(processingException) on a Jersey worker thread. failed() increments retryCount and calls submitCharge() recursively. This is a new call to submitCharge() at the normal Java method invocation level. The argument expression UUID.randomUUID().toString() at the .header() position is a distinct method call — it evaluates UUID.randomUUID() fresh, producing "d7e2f4a6-...". The retry request reaches Stripe with the new Idempotency-Key. Stripe looks up "d7e2f4a6-...", finds no cached response, and processes the charge again. ch_B is created.

The failure is particularly hard to spot in code review because the UUID call looks like a normal argument: .header("Idempotency-Key", UUID.randomUUID().toString()). Nothing in the visual structure of this call indicates that it will be evaluated multiple times. The recursive submitCharge() call in failed() is indistinguishable from any other method call in the callback. A reviewer would need to trace the data flow from the header() argument expression through the callback parameter to the recursive call to identify that the same expression evaluates multiple times.

The subtler variant: Jersey’s rx() CompletableFuture chain with .exceptionally() retry — UUID.randomUUID() inside the .exceptionally() lambda evaluates at lambda invocation time, not at chain assembly time — ch_B on first exceptional completion

Jersey’s reactive client extension (jersey-rx-client-java8) provides Invocation.Builder.rx() that returns a CompletionStageRxInvoker, whose post() returns a CompletionStage<Response>. A retry pattern implemented with .exceptionally() evaluates its lambda only when an exception occurs — at lambda invocation time, not at chain assembly time. A UUID call inside that lambda evaluates only when the exception fires, but evaluates fresh on each exceptional completion:

// BillingService.java — UNSAFE: .exceptionally() retry evaluates UUID at exception time.
// Chain assembled once, but lambda inside .exceptionally() evaluates UUID per execution.

import org.glassfish.jersey.client.rx.java8.RxCompletionStageInvoker;

public CompletableFuture<ChargeResponse> chargeCustomer(
        String customerId, String billingPeriod, long amountCents) {

    Form form = buildChargeForm(customerId, amountCents);

    // First request: UUID.randomUUID() at initial .header() call evaluates at chain-assembly time.
    // But the .exceptionally() retry lambda evaluates UUID.randomUUID() at exception-invocation time.

    return (CompletableFuture<ChargeResponse>) client.target("https://api.stripe.com/v1/charges")
        .request()
        .header("Idempotency-Key", UUID.randomUUID().toString())  // UNSAFE: fresh UUID per chain assembly
        .rx()
        .post(Entity.form(form))
        .thenApply(response -> response.readEntity(ChargeResponse.class))
        .exceptionally(throwable -> {
            // UNSAFE: retry by building a new request — UUID.randomUUID() evaluated here,
            // at the time this lambda executes (exception time), not when the chain was built.
            // First exceptional completion: UUID = "d7e2f4a6-..." → ch_B if ch_A was created.
            return client.target("https://api.stripe.com/v1/charges")
                .request()
                .header("Idempotency-Key", UUID.randomUUID().toString())  // UNSAFE
                .rx()
                .post(Entity.form(form))
                .thenApply(response -> response.readEntity(ChargeResponse.class))
                .toCompletableFuture()
                .join();  // block the exceptionally thread — not ideal but illustrative
        });
}

The subtlety here is timing: the initial UUID.randomUUID() at the first .header() call is evaluated at chain-assembly time — once, before the async request is submitted. But the UUID.randomUUID() inside .exceptionally() is evaluated at exception invocation time — once per exceptional completion of the upstream stage. If the initial request creates ch_A and then the stage completes exceptionally (timeout, connection reset), the .exceptionally() lambda executes, evaluates a new UUID, and submits a retry request that creates ch_B.

The fix for failure mode 3

The stable key must be computed before the async call chain is assembled and captured as a final local variable in the enclosing scope. Lambda closures and anonymous classes capture the variable’s value at the time the closure is created; a final String idempotencyKey captured by the retry lambda is the same value on every lambda invocation:

// BillingService.java — SAFE: stable key captured by closure before async submission.

public CompletableFuture<ChargeResponse> chargeCustomer(
        String customerId, String billingPeriod, long amountCents) {

    // Computed once — stable value captured by all lambdas below.
    final String idempotencyKey = stableKey(customerId, billingPeriod);

    // Pre-flight: claim the billing slot synchronously before any async work.
    boolean claimed = billingRepository.claimSlot(customerId, billingPeriod, idempotencyKey);
    if (!claimed) {
        return CompletableFuture.completedFuture(
            billingRepository.findExistingCharge(customerId, billingPeriod));
    }

    Form form = buildChargeForm(customerId, amountCents);

    // Safe: idempotencyKey is a captured final variable.
    // Every invocation of any lambda in this chain that uses idempotencyKey
    // reads the same String value — not a call to UUID.randomUUID().
    return submitAsyncWithRetry(form, idempotencyKey, 3);
}

private CompletableFuture<ChargeResponse> submitAsyncWithRetry(
        Form form, String idempotencyKey, int retriesLeft) {

    CompletableFuture<ChargeResponse> result = new CompletableFuture<>();

    client.target("https://api.stripe.com/v1/charges")
        .request()
        .header("Idempotency-Key", idempotencyKey)  // safe: same value on initial and retry submissions
        .async()
        .post(Entity.form(form), new InvocationCallback<Response>() {

            @Override
            public void completed(Response response) {
                if (response.getStatus() == 200 || response.getStatus() == 201) {
                    result.complete(response.readEntity(ChargeResponse.class));
                } else {
                    result.completeExceptionally(
                        new BillingException("Stripe error: " + response.getStatus()));
                }
            }

            @Override
            public void failed(Throwable throwable) {
                if (retriesLeft > 0) {
                    // Recursive retry — idempotencyKey is captured from the outer scope.
                    // submitAsyncWithRetry() passes the same idempotencyKey on every retry.
                    // The .header() call inside submitAsyncWithRetry() uses the same stable value.
                    submitAsyncWithRetry(form, idempotencyKey, retriesLeft - 1)
                        .thenAccept(result::complete)
                        .exceptionally(t -> { result.completeExceptionally(t); return null; });
                } else {
                    result.completeExceptionally(throwable);
                }
            }
        });

    return result;
}

idempotencyKey is computed once in chargeCustomer() and passed as a parameter to submitAsyncWithRetry(). Every recursive call to submitAsyncWithRetry() from failed() receives the same idempotencyKey value. The .header("Idempotency-Key", idempotencyKey) call sets the header to the same stable value on the initial request and on every retry submission. Stripe receives the same Idempotency-Key across all async retry attempts and returns the cached ch_A result after the first success. The pre-flight claimSlot() call before the async chain means that even if two concurrent async chains are racing (e.g., two separate billing jobs both claim to be processing the same customer), only one can commit the billing slot; the other sees claimed = false and returns the existing charge without submitting a JAX-RS request.

Vault keys as the financial backstop

Content-hash idempotency keys, pg_try_advisory_lock(), and pre-flight ON CONFLICT DO NOTHING prevent duplicate charges at the application logic level. A vault key scoped per billing period with a spend cap adds the financial layer that operates independently of application code:

// Per-billing-period vault key via Keybrake:
//
// Before the billing batch starts:
// 1. Issue a vault key scoped to Stripe with a daily USD cap:
//    vault_key = keybrake.issueKey({
//        vendor: "stripe",
//        daily_usd_cap: expected_total_usd * 1.10,  // 10% margin above expected
//        allowed_endpoints: ["/v1/charges", "/v1/customers"],
//        expires_at: billingPeriod.endOfMonth()
//    })
//
// 2. Register the vault key as the bearer token in the JAX-RS ClientRequestFilter:
//
//    public class StripeAuthFilter implements ClientRequestFilter {
//        private final String vaultKey;
//        public StripeAuthFilter(String vaultKey) { this.vaultKey = vaultKey; }
//        @Override
//        public void filter(ClientRequestContext ctx) {
//            ctx.getHeaders().putSingle("Authorization", "Bearer " + vaultKey);
//        }
//    }
//
// 3. When the billing batch exceeds the cap (retry storm, logic bug, runaway loop),
//    Keybrake returns HTTP 429 — Jersey throws ProcessingException(WebApplicationException(429))
//    with body: {"error":"daily_usd_cap_exceeded","cap_usd":450.00,"spent_usd":450.01}
//    The billing job catches 429 as a non-retryable terminal condition and stops cleanly.
//
// 4. Keybrake's audit log records every proxied Stripe request with the vault key,
//    billing period, customer ID (from the request body), and charge amount (from
//    the Stripe response). Independent of the billing service's own database —
//    cross-reference for reconciliation.

The vault key’s spend cap catches the cases where software-layer guards succeed at preventing duplicate charges but application logic has a bug — charging 20% more customers than intended, or charging twice the intended amount due to a currency conversion error. The cap converts a silent revenue discrepancy into a hard stop with a clear error message. The billing team learns about it from the Keybrake alert rather than from customer disputes.

Putting it together

JAX-RS’s ClientRequestFilter, MicroProfile Rest Client’s @Retry at the CDI bean layer, and Jersey’s async InvocationCallback.failed() retry each represent a structurally distinct way that idempotency key generation ends up inside a scope that executes more than once per logical billing operation. The ClientRequestFilter executes per JAX-RS request invocation; a retry loop that re-invokes the JAX-RS call re-fires it. The @Retry CDI interceptor re-invokes the annotated method body; UUID generation inside that body re-evaluates. The async failed() callback executes per async failure event; UUID generation inside that callback evaluates fresh on each event.

The fix in every case is the same pattern: compute a deterministic, content-hash key outside all retry and re-invocation boundaries — once per billing operation, before any network activity — and capture or pass it in a way that no retry re-evaluation can change. For synchronous JAX-RS, use the ClientRequestContext property bag set before the loop. For CDI bean @Retry, compute before the outer non-retried method and pass as a parameter to the retried inner method. For async InvocationCallback, capture as a final local variable before submitting the first async request.

The layering of defenses — stable key, pre-flight ON CONFLICT DO NOTHING, advisory lock, and vault key spend cap — corresponds to the layering of retry and concurrency mechanisms that production billing systems actually use. No single defense covers every failure class. The key insight is that each defense operates at a different layer: the stable key addresses the idempotency layer, the pre-flight constraint addresses the database layer, the advisory lock addresses the scheduling layer, and the vault key addresses the financial layer.

The JAX-RS filter that looks completely correct in isolation becomes a billing hazard the day someone adds a retry loop in the service class. The stable key is the thing that makes the filter safe regardless of how many times the calling code decides to re-invoke the request.

For the Feign RequestInterceptor.apply() retry pattern, see Feign and Spring Cloud OpenFeign Stripe Integration. For the Apache HttpClient 5 HttpRequestInterceptor pattern, see Apache HttpClient 5 and Stripe Integration. For the OkHttp application interceptor pattern, see OkHttp and Retrofit Stripe Integration. For the Spring Boot @Retryable and RestTemplate interceptor patterns, see Spring Boot and Stripe Integration. For the full series, see the blog index.

Put a spend cap on your agent’s Stripe key

Keybrake issues scoped vault keys for Stripe, Twilio, and Resend with per-period spend caps, allowed-endpoint allowlists, and an audit log of every proxied request. When the cap is hit, the agent gets a 429 — not a surprise invoice. Join the waitlist.