RESTEasy Client and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

RESTEasy Client’s MicroProfile Rest Client integration introduces Stripe billing failure modes rooted in the MicroProfile Fault Tolerance @Retry CDI interceptor. Three places where duplicate charges appear: @Retry on a @RegisterRestClient service method — the CDI interceptor re-invokes the method body on each retry attempt, so UUID.randomUUID() inside the method evaluates fresh and Stripe sees a new idempotency key; RESTEasy Reactive’s Mutiny Uni.createFrom().item() supplier re-called on onFailure().retry() re-subscription — UUID inside the supplier re-evaluates per subscription; and @Scheduled firing on all Kubernetes replicas simultaneously with no distributed coordination — each pod calls the RESTEasy proxy independently and Stripe creates ch_A, ch_B, and ch_C per customer.

This post covers all three failure modes with Java code (RESTEasy 6.x, Quarkus 3.x, MicroProfile Rest Client 3.x, Mutiny 2.x), the CDI interceptor invocation model and how context.proceed() re-runs the method body, the @Retry + @Timeout stacking trap where @Timeout fires after Stripe has already committed the charge, Mutiny Uni subscription semantics and how onFailure().retry() re-subscribes the upstream pipeline including supplier lambdas, pg_try_advisory_lock() for cross-pod billing serialization, and pre-flight PostgreSQL ON CONFLICT DO NOTHING as the cluster-wide billing mutex — plus per-billing-period vault keys via a spend-cap proxy as a hard financial backstop. For the general MicroProfile Fault Tolerance retry patterns, see the Spring Retry and Stripe Integration post. For Quarkus-specific reactive patterns without RESTEasy, see the Quarkus and Stripe Integration post. The RESTEasy Client failure modes are structurally distinct from both: the MicroProfile Rest Client proxy layer introduces an extra delegation hop between the caller and the actual HTTP request that interacts with @Retry in a non-obvious way.

Failure mode 1: MicroProfile Fault Tolerance @Retry CDI interceptor wraps the @RegisterRestClient service method — UUID.randomUUID() inside the method body re-evaluates on each CDI interceptor re-invocation via context.proceed() — initial attempt creates ch_A before socket timeout — first retry creates ch_B

RESTEasy’s MicroProfile Rest Client integration exposes Stripe’s API as a typed Java interface annotated with @RegisterRestClient. You inject the proxy with @RestClient and call methods on it as if they were local method calls — RESTEasy generates an HTTP client under the hood that maps each method call to the corresponding HTTP request, including any @HeaderParam, @PathParam, and @QueryParam annotations. When you add MicroProfile Fault Tolerance @Retry to the calling service method, the CDI interceptor wraps that service method, not the proxy method. On each retry attempt, the interceptor calls context.proceed(), which re-invokes the underlying service method body from its first executable statement. Any UUID.randomUUID() computed as a local variable inside the service method body evaluates fresh on each invocation.

The failure sequence: the initial @Retry attempt runs the service method body, computes UUID_A, passes it to the @RestClient proxy as a header parameter, RESTEasy constructs the HTTP request and sends it to Stripe; Stripe receives the request and creates ch_A; before Stripe’s response returns through the network, a socket timeout fires; the socket timeout propagates as a WebApplicationException or ProcessingException; @Retry catches the exception and schedules a retry; on the retry, context.proceed() re-runs the service method body from line 1; UUID.randomUUID() evaluates again and produces UUID_B; RESTEasy sends the request with Idempotency-Key: UUID_B; Stripe sees a new key and creates ch_B while ch_A is already committed in its ledger:

// UNSAFE: UUID.randomUUID() inside @Retry service method body — re-evaluates per CDI re-invocation.
// @Retry interceptor calls context.proceed() on each attempt — method body re-runs from line 1.

import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.eclipse.microprofile.faulttolerance.Retry;
import org.eclipse.microprofile.faulttolerance.exceptions.TimeoutException;

import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import java.util.UUID;

// Step 1: Define the MicroProfile Rest Client interface for Stripe.
@RegisterRestClient(baseUri = "https://api.stripe.com")
@Path("/v1")
public interface StripeRestClient {

    @POST
    @Path("/charges")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    @Produces(MediaType.APPLICATION_JSON)
    ChargeResponse createCharge(
        @HeaderParam("Idempotency-Key") String idempotencyKey,
        @HeaderParam("Authorization") String authorization,
        MultivaluedMap<String, String> formParams
    );
}

// Step 2: Service bean with @Retry — the unsafe pattern.
@ApplicationScoped
public class BillingService {

    @Inject
    @RestClient
    StripeRestClient stripeClient;

    private final String stripeSecretKey = System.getenv("STRIPE_SECRET_KEY");

    // UNSAFE: @Retry wraps this method at the CDI interceptor level.
    // context.proceed() re-runs the entire method body on each retry attempt.
    // UUID.randomUUID() at line 1 of the body evaluates fresh per re-invocation.
    //
    // Attempt 1 (initial):
    //   UUID_A = "7a3f1b2c-..."  ← computed at method entry
    //   stripeClient.createCharge("7a3f1b2c-...", "Bearer sk_live_...", form)
    //   RESTEasy → POST /v1/charges  Idempotency-Key: 7a3f1b2c-...
    //   Stripe commits ch_A in its ledger
    //   Socket timeout fires after 30s — Stripe response lost in transit
    //   WebApplicationException propagates through the proxy to the CDI interceptor
    //
    // Attempt 2 (first retry, after 1s delay):
    //   context.proceed() re-enters method body
    //   UUID_B = "c9d8e7f6-..."  ← NEW UUID — method body starts fresh
    //   stripeClient.createCharge("c9d8e7f6-...", "Bearer sk_live_...", form)
    //   Stripe sees new idempotency key — creates ch_B  ← DUPLICATE CHARGE
    @Retry(maxRetries = 3, delay = 1000, retryOn = WebApplicationException.class)
    public ChargeResponse chargeCustomer(String customerId, int amountCents, String billingPeriod) {
        // UUID computed inside @Retry method body — re-evaluates on every retry re-invocation.
        String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE

        MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
        form.putSingle("amount", String.valueOf(amountCents));
        form.putSingle("currency", "usd");
        form.putSingle("customer", customerId);

        return stripeClient.createCharge(
            idempotencyKey,
            "Bearer " + stripeSecretKey,
            form
        );
    }
}

A subtler variant occurs when @Retry and @Timeout are stacked on the same method. MicroProfile Fault Tolerance applies interceptors in a specific order: @Timeout wraps the method before @Retry, meaning @Timeout fires based on elapsed wall time from the start of the current attempt, but @Retry decides whether to retry based on the exception thrown. When @Timeout fires and throws TimeoutException, it does so at the Java level — but the HTTP bytes may already be in Stripe’s network stack, Stripe may have already parsed the request, created the charge, and queued the response before the JVM-side timeout fired. The charge (ch_A) is committed in Stripe’s ledger. @Retry catches the TimeoutException, re-invokes the method with a new UUID (UUID_B), and Stripe creates ch_B:

// UNSAFE @Retry + @Timeout combination.
// MicroProfile FT interceptor stack: Bulkhead > CircuitBreaker > Retry > Timeout > Fallback.
// @Timeout fires based on wall-clock elapsed time from attempt start.
// Stripe may have committed ch_A before @Timeout fires at the JVM level.

@Retry(maxRetries = 2, delay = 2000, retryOn = {WebApplicationException.class, TimeoutException.class})
@Timeout(value = 8, unit = ChronoUnit.SECONDS)
public ChargeResponse chargeCustomerWithTimeout(String customerId, int amountCents, String billingPeriod) {
    // @Timeout fires after 8s at the JVM level.
    // If Stripe latency is 9s, @Timeout fires at 8s but Stripe receives request at t=0s
    // and creates ch_A at t=7s (before Stripe sends response).
    // @Retry fires 2s after @Timeout exception, re-invokes method body → UUID_B → ch_B.
    String idempotencyKey = UUID.randomUUID().toString(); // re-evaluated on each @Retry attempt

    MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
    form.putSingle("amount", String.valueOf(amountCents));
    form.putSingle("customer", customerId);
    form.putSingle("currency", "usd");

    return stripeClient.createCharge(idempotencyKey, "Bearer " + stripeSecretKey, form);
}

The fix is to compute the idempotency key from stable, deterministic inputs before the @Retry method is entered, and pass it as a parameter to the method. The CDI interceptor passes the same parameter values on every context.proceed() call — method parameters are not re-evaluated by the interceptor between retry attempts. The key must not include UUID.randomUUID(), System.currentTimeMillis(), any retry counter, thread ID, request timestamp, or any other value that changes between invocations. sha256(customerId + ":" + billingPeriod + ":resteasy-billing")[:32] is stable across all retry re-invocations, across all Kubernetes pods, and across time within the Stripe 24-hour idempotency cache window:

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

// Stable content-hash key computed before @Retry method is entered.
// CDI interceptor passes same parameter values on every context.proceed() call.
// Key must not include UUID.randomUUID(), System.currentTimeMillis(), attempt counter, or hostname.

public class StableKeyHelper {

    public static String billingKey(String customerId, String billingPeriod, String service) {
        try {
            String input = customerId + ":" + billingPeriod + ":" + service;
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            // Stripe idempotency key: max 255 chars, URL-safe hex is fine.
            // Use first 32 hex chars (128 bits) — collision probability negligible.
            return HexFormat.of().formatHex(hash).substring(0, 32);
        } catch (Exception e) {
            throw new RuntimeException("SHA-256 unavailable", e);
        }
    }
}

// Caller: compute stable key before entering @Retry method.
// @Retry interceptor passes the same idempotencyKey parameter on every retry re-invocation.
@ApplicationScoped
public class BillingOrchestrator {

    @Inject
    BillingService billingService;

    public void runMonthlyBilling(List<Customer> customers, String billingPeriod) {
        for (Customer customer : customers) {
            // SAFE: stable key computed here, outside the @Retry method.
            // Same value on initial attempt and every retry.
            String idempotencyKey = StableKeyHelper.billingKey(
                customer.id(), billingPeriod, "resteasy-billing"
            );

            try {
                billingService.chargeCustomerStable(customer.id(), customer.amountCents(),
                    billingPeriod, idempotencyKey);
            } catch (Exception e) {
                log.error("Billing failed for customer {} after retries: {}", customer.id(), e.getMessage());
            }
        }
    }
}

// SAFE: @Retry method receives pre-computed stable key as parameter.
// context.proceed() passes same idempotencyKey value on every retry — UUID.randomUUID() absent.
@ApplicationScoped
public class BillingService {

    @Inject
    @RestClient
    StripeRestClient stripeClient;

    // idempotencyKey parameter is pre-computed by the caller using sha256(customerId:billingPeriod:resteasy-billing)[:32].
    // @Retry interceptor passes the same parameter values on every context.proceed() call.
    @Retry(maxRetries = 3, delay = 1000, retryOn = WebApplicationException.class)
    public ChargeResponse chargeCustomerStable(String customerId, int amountCents,
                                               String billingPeriod, String idempotencyKey) {
        // No UUID.randomUUID() here — key arrives as a parameter, not computed locally.
        MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
        form.putSingle("amount", String.valueOf(amountCents));
        form.putSingle("currency", "usd");
        form.putSingle("customer", customerId);

        return stripeClient.createCharge(
            idempotencyKey,                        // SAFE: same value on initial and every retry
            "Bearer " + stripeSecretKey,
            form
        );
    }
}

The pre-flight database guard is a second, independent layer of protection. Add a billing_runs table with a unique constraint on (customer_id, billing_period) and execute an INSERT ... ON CONFLICT DO NOTHING before the @Retry method call. This check persists across JVM restarts, pod evictions, and Stripe’s 24-hour idempotency cache expiry, making it the authoritative source of truth for whether a given customer has been billed in the current period — not Stripe’s idempotency cache, which expires after 24 hours and does not persist across billing-period boundaries:

-- Pre-flight billing guard in PostgreSQL.
-- Insert-then-skip pattern: insert returns 0 rows affected when conflict found.
-- This is the authoritative billing mutex, not Stripe's 24h idempotency cache.

CREATE TABLE IF NOT EXISTS billing_runs (
    customer_id   TEXT        NOT NULL,
    billing_period TEXT       NOT NULL,
    idempotency_key TEXT      NOT NULL,
    stripe_charge_id TEXT,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT billing_runs_pkey PRIMARY KEY (customer_id, billing_period)
);

-- Java code in BillingOrchestrator before calling billingService.chargeCustomerStable():
int inserted = jdbcTemplate.update(
    "INSERT INTO billing_runs (customer_id, billing_period, idempotency_key) " +
    "VALUES (?, ?, ?) ON CONFLICT (customer_id, billing_period) DO NOTHING",
    customerId, billingPeriod, idempotencyKey
);

if (inserted == 0) {
    // Already billed or billing in progress — skip this customer.
    log.info("Billing already initiated for customer {} period {} — skipping", customerId, billingPeriod);
    return;
}

// Proceed with @Retry call only if this pod won the INSERT race.
billingService.chargeCustomerStable(customerId, amountCents, billingPeriod, idempotencyKey);

Failure mode 2: RESTEasy Reactive Mutiny Uni.createFrom().item() supplier re-called on onFailure().retry() re-subscription — UUID.randomUUID() inside the supplier re-evaluates per subscription — initial subscription creates ch_A before socket timeout — first retry re-subscription creates ch_B

RESTEasy Reactive uses Mutiny as its reactive programming model. The reactive REST client methods return Uni<Response> or typed response objects wrapped in Uni. When you add retry logic using Mutiny’s onFailure().retry() operator, you are telling Mutiny to re-subscribe the upstream Uni pipeline on each failure. The semantics of re-subscription in Mutiny are analogous to RxJava’s re-subscription: every operator in the pipeline between the retry operator and the source executes again, including any supplier lambdas passed to Uni.createFrom().item(() -> ...) or Uni.createFrom().completionStage(() -> ...).

Uni.createFrom().item(() -> ...)) accepts a Supplier<T> and calls the supplier on each subscription. This is by design — the supplier is the deferred computation that produces the item. When onFailure().retry() re-subscribes the Uni, the supplier runs again. If UUID.randomUUID() is inside the supplier, it evaluates again on the retry subscription. The initial subscription fires the supplier (UUID_A), the request goes to Stripe, Stripe commits ch_A before a socket timeout fires, onFailure().retry() re-subscribes, the supplier fires again (UUID_B), RESTEasy sends the request with the new idempotency key, and Stripe creates ch_B:

// UNSAFE: UUID.randomUUID() inside Uni.createFrom().item() supplier — re-evaluates per re-subscription.
// onFailure().retry() re-subscribes the upstream Uni — supplier re-called per retry.

import io.smallrye.mutiny.Uni;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import org.eclipse.microprofile.rest.client.inject.RestClient;

import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import java.time.Duration;
import java.util.UUID;

// RESTEasy Reactive @RegisterRestClient with Mutiny return type.
@RegisterRestClient(baseUri = "https://api.stripe.com")
@Path("/v1")
public interface StripeReactiveClient {

    @POST
    @Path("/charges")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    @Produces(MediaType.APPLICATION_JSON)
    Uni<ChargeResponse> createCharge(
        @HeaderParam("Idempotency-Key") String idempotencyKey,
        @HeaderParam("Authorization") String authorization,
        MultivaluedMap<String, String> formParams
    );
}

// UNSAFE: UUID computed inside Uni supplier — re-evaluates per Mutiny re-subscription.
@ApplicationScoped
public class ReactiveBillingService {

    @Inject
    @RestClient
    StripeReactiveClient stripeClient;

    public Uni<ChargeResponse> chargeCustomerUnsafe(String customerId, int amountCents) {
        // UNSAFE: Uni.createFrom().item() supplier runs per subscription.
        // onFailure().retry() triggers re-subscription — supplier re-runs — UUID_B generated.
        //
        // Subscription 1 (initial):
        //   Supplier runs → UUID_A = "7a3f1b2c-..."
        //   stripeClient.createCharge("7a3f1b2c-...", ...) → POST /v1/charges
        //   Stripe creates ch_A; network timeout before response returns
        //
        // Subscription 2 (retry attempt 1, after 1s):
        //   Supplier runs again → UUID_B = "c9d8e7f6-..."
        //   stripeClient.createCharge("c9d8e7f6-...", ...) → POST /v1/charges
        //   Stripe sees new key → creates ch_B  ← DUPLICATE CHARGE
        return Uni.createFrom().item(() -> {
            // UNSAFE: UUID inside supplier re-evaluates per subscription.
            String idempotencyKey = UUID.randomUUID().toString();

            MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
            form.putSingle("amount", String.valueOf(amountCents));
            form.putSingle("customer", customerId);
            form.putSingle("currency", "usd");

            // This returns a Uni — the supplier itself returns synchronously here,
            // but the pattern extends to Uni.createFrom().completionStage() for async calls.
            return stripeClient.createCharge(idempotencyKey, "Bearer " + stripeSecretKey, form);
        })
        .flatMap(uni -> uni) // unwrap the nested Uni returned by the supplier
        .onFailure(WebApplicationException.class)
            .retry().withBackOff(Duration.ofSeconds(1)).atMost(3);
    }
}

The more common pattern uses the @RestClient proxy directly in a reactive method body and wraps the call in onFailure().retry() without an explicit Uni.createFrom().item() supplier. This appears safe because the UUID is computed before the onFailure() chain. But there is a subtler variant: if the UUID is computed inside a .flatMap(), .chain(), or .transformToUni() lambda that is between the retry operator and the source, it also re-evaluates. The retry operator re-subscribes the entire upstream pipeline up to the subscription point — any lambda in the re-subscribed segment executes again:

// UNSAFE subtler variant: UUID inside .chain() lambda — re-evaluates when retry re-subscribes past the chain step.

@ApplicationScoped
public class ReactiveBillingService {

    @Inject
    @RestClient
    StripeReactiveClient stripeClient;

    public Uni<ChargeResponse> chargeWithChainUnsafe(String customerId, int amountCents) {
        // Pattern: load customer from database, then charge.
        // The retry wraps both steps — .chain() lambda re-executes on retry re-subscription.
        return customerRepository.findById(customerId)
            .chain(customer -> {
                // UNSAFE: UUID inside .chain() lambda — re-evaluates on every retry re-subscription.
                // If retry re-subscribes from before the .chain(), this lambda re-runs with UUID_B.
                String idempotencyKey = UUID.randomUUID().toString();

                MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
                form.putSingle("amount", String.valueOf(amountCents));
                form.putSingle("customer", customer.stripeId());
                form.putSingle("currency", "usd");

                return stripeClient.createCharge(idempotencyKey, "Bearer " + stripeSecretKey, form);
            })
            .onFailure(WebApplicationException.class)
                .retry().withBackOff(Duration.ofSeconds(1)).atMost(3);
    }
}

The fix is to compute the idempotency key before any Uni assembly and capture it as a final local variable in the surrounding scope. The key must be computed at the call site, in the Java frame that contains the Uni construction — not inside any lambda that the Uni pipeline will re-execute on subscription. Mutiny lambdas close over effectively-final variables; once the key is captured in the closure, the same value is used on every subscription including retries:

// SAFE: stable content-hash key computed before Uni assembly — captured as effectively-final.
// onFailure().retry() re-subscribes the Uni but the captured key variable never changes.

@ApplicationScoped
public class ReactiveBillingService {

    @Inject
    @RestClient
    StripeReactiveClient stripeClient;

    public Uni<ChargeResponse> chargeCustomerSafe(String customerId, int amountCents, String billingPeriod) {
        // SAFE: key computed here — outside all Uni lambdas — before Uni assembly.
        // sha256(customerId:billingPeriod:resteasy-billing)[:32] — deterministic, never UUID.
        final String idempotencyKey = StableKeyHelper.billingKey(customerId, billingPeriod, "resteasy-billing");

        MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
        form.putSingle("amount", String.valueOf(amountCents));
        form.putSingle("customer", customerId);
        form.putSingle("currency", "usd");

        // SAFE: idempotencyKey is a final variable captured by closure.
        // onFailure().retry() re-subscribes — stripeClient.createCharge() is called again
        // but with the same idempotencyKey — Stripe returns ch_A from idempotency cache
        // (within 24h window). Pre-flight ON CONFLICT DO NOTHING handles the beyond-24h case.
        return stripeClient.createCharge(idempotencyKey, "Bearer " + stripeSecretKey, form)
            .onFailure(WebApplicationException.class)
                .retry().withBackOff(Duration.ofSeconds(1)).atMost(3);
    }
}

The reactive variant of the pre-flight guard uses Mutiny’s Uni to run the INSERT ... ON CONFLICT DO NOTHING before the Stripe call, chaining them with .chain(). The database insert Uni executes first; if it returns 0 rows affected (conflict found), the chain short-circuits with a Uni.createFrom().failure() or a sentinel result that skips the Stripe call:

// Reactive pre-flight guard — Mutiny chain pattern.
// Database insert Uni executes first; Stripe Uni only fires if insert succeeded.

public Uni<ChargeResponse> chargeWithPreFlight(String customerId, int amountCents, String billingPeriod) {
    final String idempotencyKey = StableKeyHelper.billingKey(customerId, billingPeriod, "resteasy-billing");

    MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
    form.putSingle("amount", String.valueOf(amountCents));
    form.putSingle("customer", customerId);
    form.putSingle("currency", "usd");

    return billingRepository.insertBillingRunIfAbsent(customerId, billingPeriod, idempotencyKey)
        .chain(inserted -> {
            if (inserted == 0) {
                // Conflict: billing already initiated for this customer+period.
                // Return empty — no Stripe call.
                return Uni.createFrom().nullItem();
            }
            // Insert succeeded — this pod owns the billing run for this customer.
            return stripeClient.createCharge(idempotencyKey, "Bearer " + stripeSecretKey, form)
                .onFailure(WebApplicationException.class)
                    .retry().withBackOff(Duration.ofSeconds(1)).atMost(3);
        });
}

Failure mode 3: @Scheduled billing trigger fires on all Kubernetes replicas simultaneously — each pod’s RESTEasy @RestClient proxy generates its own UUID.randomUUID() per customer — no distributed coordination — ch_A, ch_B, and ch_C per customer per billing period

Quarkus @Scheduled (backed by Quartz) and Jakarta EE @Schedule (backed by the EJB timer service) are both JVM-local schedulers by default. When your Quarkus application runs as a Kubernetes Deployment with replicas: 3, all three pods start their schedulers independently. At the configured billing time (midnight UTC on the first of the month, for example), all three pods fire within milliseconds of each other. Each pod’s billing method executes independently — each pod calls UUID.randomUUID() per customer in its own JVM, producing different keys on each pod. Each pod calls the RESTEasy @RestClient proxy, which issues HTTP requests to Stripe. Stripe receives three requests for the same customer with three different idempotency keys within milliseconds and creates three charges: ch_A, ch_B, and ch_C per customer.

The key property that makes this dangerous is the timing. Stripe’s idempotency cache protects against retries of the same request arriving at Stripe with some time between them — after ch_A is fully committed to the idempotency cache, a subsequent request with the same key returns ch_A without creating ch_B. But for this guarantee to hold, the first request must be fully committed to the idempotency cache before the second request arrives. When three pods fire simultaneously and all three requests arrive at Stripe within milliseconds of each other, the first request may not be committed to the idempotency cache by the time the second and third arrive. Even if all three pods generate the same content-hash key (which is the correct fix for per-pod UUID divergence), Stripe may process all three as new charges before the idempotency cache entry from the first request propagates. The content-hash key eliminates the per-key divergence problem; the database pre-flight guard eliminates the simultaneous-arrival problem:

// UNSAFE: @Scheduled billing method runs on all Kubernetes replicas simultaneously.
// Each pod calls UUID.randomUUID() per customer — distinct keys across pods.
// Stripe receives concurrent requests with different idempotency keys — ch_A, ch_B, ch_C.

import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.List;
import java.util.UUID;

@ApplicationScoped
public class BillingScheduler {

    @Inject
    @RestClient
    StripeRestClient stripeClient;

    @Inject
    CustomerRepository customerRepository;

    // UNSAFE: fires on all 3 pods simultaneously.
    // Pod 1: UUID_A per customer → ch_A
    // Pod 2: UUID_B per customer → ch_B  ← duplicate
    // Pod 3: UUID_C per customer → ch_C  ← triplicate
    @Scheduled(cron = "0 0 1 * * ?")
    public void runMonthlyBilling() {
        String billingPeriod = currentBillingPeriod(); // e.g. "2026-11"
        List<Customer> customers = customerRepository.findAllActive();

        for (Customer customer : customers) {
            // UNSAFE: new UUID per customer per pod — no cross-pod coordination.
            String idempotencyKey = UUID.randomUUID().toString();

            MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
            form.putSingle("amount", String.valueOf(customer.amountCents()));
            form.putSingle("customer", customer.stripeId());
            form.putSingle("currency", "usd");

            stripeClient.createCharge(idempotencyKey, "Bearer " + stripeSecretKey, form);
        }
    }
}

The immediate fix is to replace per-pod UUID.randomUUID() with stable content-hash keys. This ensures that all three pods generate the same idempotency key for a given customer in a given billing period. Stripe’s idempotency cache then returns ch_A for pods 2 and 3 — provided that ch_A is fully committed to the cache before pods 2 and 3 arrive. For the simultaneous-arrival case, the database pre-flight guard is the only reliable fix:

// SAFE: content-hash key + pg_try_advisory_lock() for cross-pod serialization.
// Only the pod that acquires the advisory lock runs the billing loop.
// Other pods fail pg_try_advisory_lock() and return immediately.
// Content-hash key eliminates per-pod UUID divergence as a backstop.

@ApplicationScoped
public class BillingScheduler {

    @Inject
    @RestClient
    StripeRestClient stripeClient;

    @Inject
    CustomerRepository customerRepository;

    @Inject
    DataSource dataSource;

    // SAFE: @Scheduled fires on all pods but pg_try_advisory_lock() serializes to one.
    // The lock key must be stable and unique to the billing period:
    //   hashtext("resteasy-monthly-billing:" + billingPeriod) — PostgreSQL hashtext() returns bigint.
    @Scheduled(cron = "0 0 1 * * ?")
    public void runMonthlyBilling() {
        String billingPeriod = currentBillingPeriod();

        try (Connection conn = dataSource.getConnection()) {
            // pg_try_advisory_lock() returns true if this session acquired the lock.
            // Returns false immediately (non-blocking) if another session holds it.
            // Lock is automatically released when the PostgreSQL session ends.
            PreparedStatement lockStmt = conn.prepareStatement(
                "SELECT pg_try_advisory_lock(hashtext('resteasy-monthly-billing:' || ?))"
            );
            lockStmt.setString(1, billingPeriod);
            ResultSet rs = lockStmt.executeQuery();
            rs.next();
            boolean acquired = rs.getBoolean(1);

            if (!acquired) {
                // Another pod is running the billing loop for this period — exit.
                log.info("Billing lock not acquired for period {} — another pod is running", billingPeriod);
                return;
            }

            // This pod holds the advisory lock — run the billing loop.
            List<Customer> customers = customerRepository.findAllActive();

            for (Customer customer : customers) {
                // SAFE: content-hash key — same value on all pods for same customer+period.
                String idempotencyKey = StableKeyHelper.billingKey(
                    customer.id(), billingPeriod, "resteasy-billing"
                );

                // Pre-flight guard: INSERT ... ON CONFLICT DO NOTHING.
                // Even with the advisory lock, this per-customer guard handles edge cases:
                // the lock is pod-level, not customer-level — if this billing loop is interrupted
                // mid-run and restarted (e.g., pod eviction), some customers may already be billed.
                int inserted = insertBillingRunIfAbsent(conn, customer.id(), billingPeriod, idempotencyKey);
                if (inserted == 0) {
                    log.info("Customer {} already billed for period {} — skipping", customer.id(), billingPeriod);
                    continue;
                }

                // SAFE: stable key + pre-flight INSERT passed — call Stripe.
                MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
                form.putSingle("amount", String.valueOf(customer.amountCents()));
                form.putSingle("customer", customer.stripeId());
                form.putSingle("currency", "usd");

                try {
                    ChargeResponse charge = stripeClient.createCharge(
                        idempotencyKey, "Bearer " + stripeSecretKey, form
                    );
                    updateBillingRunChargeId(conn, customer.id(), billingPeriod, charge.id());
                } catch (WebApplicationException e) {
                    log.error("Stripe charge failed for customer {}: {}", customer.id(), e.getMessage());
                    // Do not retry here — let the next scheduled run or a manual run retry.
                    // The pre-flight INSERT ensures the next run skips this customer if already billed.
                }
            }

            // pg_try_advisory_lock() releases automatically at end of session.
            // Explicit release: pg_advisory_unlock(hashtext('...')) — not required but clean.
        } catch (SQLException e) {
            throw new RuntimeException("Database error during billing run", e);
        }
    }

    private int insertBillingRunIfAbsent(Connection conn, String customerId,
                                          String billingPeriod, String idempotencyKey)
            throws SQLException {
        PreparedStatement stmt = conn.prepareStatement(
            "INSERT INTO billing_runs (customer_id, billing_period, idempotency_key) " +
            "VALUES (?, ?, ?) ON CONFLICT (customer_id, billing_period) DO NOTHING"
        );
        stmt.setString(1, customerId);
        stmt.setString(2, billingPeriod);
        stmt.setString(3, idempotencyKey);
        return stmt.executeUpdate(); // 1 if inserted, 0 if conflict
    }
}

An alternative to pg_try_advisory_lock() is to configure Quarkus with Quartz clustering. Quartz clustering uses a shared database to elect one node as the scheduler leader for each job — only the leader fires the trigger. All other nodes participate in the cluster but do not fire the trigger if the leader is up. This is the correct long-term architecture for scheduled billing jobs in multi-replica deployments, but it requires configuring a Quartz datasource and is heavier-weight than a single advisory lock. The advisory lock approach is suitable for teams that want a lightweight fix without restructuring their Quartz setup:

# quarkus.properties — Quartz clustering (alternative to pg_try_advisory_lock()).
# Configure this to have only one pod fire the billing scheduler.

quarkus.quartz.clustered=true
quarkus.quartz.store-type=jdbc-cmt
quarkus.quartz.datasource=

# With clustered=true, Quartz creates a lock in the QRTZ_LOCKS table.
# Only the elected leader pod fires @Scheduled methods.
# Other pods are standby — they fire if the leader crashes.

Vault key spend caps as the financial backstop

Content-hash keys and database pre-flight guards are engineering controls at the application layer. They prevent duplicate charges when the code works as designed, but they do not protect against every failure mode: a billing bug in a code path not yet covered by tests, an unexpected interaction between a new library version and the retry logic, a database deadlock that causes the pre-flight guard to be skipped during exception handling, or an operator running the billing method manually in production during a debugging session. A hard financial backstop at the infrastructure layer catches what application-layer controls miss.

The approach: issue a Stripe restricted API key to the billing service with a monthly spend cap set to expected_monthly_total × 1.10. The 10% buffer covers legitimate billing volume fluctuations (new customers, plan upgrades) while creating a hard wall against a runaway billing loop charging all customers twice or three times in a period. Stripe honors the spend cap at the key level — once the key’s cumulative charges exceed the cap, further charge requests return a 402 error until the cap is reset. The 402 is loud, loggable, and alertable; a silent duplicate charge to 1,000 customers is not.

A Keybrake proxy key adds two more enforcement points that a raw Stripe restricted key does not provide: per-call audit logging (every POST /v1/charges with parsed amount, customer, and response code), and endpoint allowlisting (the billing service key can call /v1/charges but not /v1/refunds, /v1/payouts, or /v1/subscriptions). For an autonomous agent that calls Stripe across multiple code paths, the combination of a vault key, a spend cap, and an endpoint allowlist is the correct governance layer — not a replacement for application-layer idempotency, but a backstop that limits blast radius when idempotency fails:

# Keybrake proxy configuration for RESTEasy Client billing service.
# vault_key_xxx replaces the raw Stripe key in application config.
# Keybrake enforces the policy and forwards to Stripe with the real key.

POST https://proxy.keybrake.com/vault/keys
Authorization: Bearer kb_admin_key_xxx
Content-Type: application/json

{
  "vendor": "stripe",
  "label": "resteasy-billing-service-prod",
  "policy": {
    "daily_usd_cap": 12000,
    "monthly_usd_cap": 110000,
    "allowed_endpoints": [
      "POST /v1/charges",
      "GET /v1/charges/*"
    ],
    "expires_at": "2026-12-01T00:00:00Z"
  }
}

// Response: { "vault_key": "vault_key_billing_abc123", "vendor": "stripe", ... }

// In application.properties — replace Stripe key with vault key:
// stripe.secret.key=vault_key_billing_abc123
// stripe.api.base=https://proxy.keybrake.com/stripe

// RESTEasy @RegisterRestClient base URI:
// quarkus.rest-client.stripe.url=https://proxy.keybrake.com/stripe

// Keybrake transparent proxy: receives request with vault_key, enforces policy,
// forwards to api.stripe.com with the real Stripe secret key, logs response.
// If monthly_usd_cap exceeded: returns 402 before forwarding — Stripe never sees request.

Putting all three fixes together

The three failure modes operate at different levels and require fixes at different levels. @Retry CDI re-invocation is an application-code problem — fixed by moving UUID.randomUUID() out of the @Retry method body and into the caller. Mutiny Uni re-subscription is a reactive-semantics problem — fixed by computing the key before Uni assembly and capturing it as an effectively-final variable. Multi-pod scheduler races are an infrastructure-topology problem — fixed by pg_try_advisory_lock() or Quartz clustering. The pre-flight ON CONFLICT DO NOTHING guard and the vault key spend cap apply to all three and are defense in depth: they catch what the primary fix misses.

The complete, safe RESTEasy Client billing pattern combines all four layers:

// Complete safe pattern for RESTEasy Client + Stripe billing.
// Layer 1: Content-hash stable key — same value on all retry attempts and all pods.
// Layer 2: @Retry with stable key parameter — CDI interceptor passes same key per re-invocation.
// Layer 3: Pre-flight ON CONFLICT DO NOTHING — authoritative cluster-wide mutex per customer per period.
// Layer 4: pg_try_advisory_lock() — serializes billing loop to one pod per period.
// Layer 5: Vault key spend cap — financial backstop at infrastructure level.

@ApplicationScoped
public class CompleteBillingService {

    @Inject @RestClient StripeRestClient stripeClient;
    @Inject CustomerRepository customerRepository;
    @Inject DataSource dataSource;

    private final String stripeKey = System.getenv("STRIPE_VAULT_KEY"); // vault_key_xxx

    @Scheduled(cron = "0 0 1 * * ?")
    public void runMonthlyBilling() {
        String billingPeriod = currentBillingPeriod();

        // Layer 4: pg_try_advisory_lock() — only one pod runs the loop per period.
        if (!acquireAdvisoryLock(billingPeriod)) {
            return;
        }

        List<Customer> customers = customerRepository.findAllActive();
        for (Customer customer : customers) {
            // Layer 1: Stable content-hash key — never UUID.randomUUID().
            String key = StableKeyHelper.billingKey(customer.id(), billingPeriod, "resteasy-billing");

            // Layer 3: Pre-flight insert — skips customers already billed.
            if (!insertBillingRunIfAbsent(customer.id(), billingPeriod, key)) {
                continue;
            }

            // Layer 2: @Retry method receives pre-computed key as parameter.
            chargeWithRetry(customer.id(), customer.amountCents(), billingPeriod, key);
        }
    }

    // @Retry passes idempotencyKey parameter unchanged on every context.proceed() call.
    // No UUID.randomUUID() inside this method body — key arrives as parameter.
    @Retry(maxRetries = 3, delay = 1000, retryOn = WebApplicationException.class)
    void chargeWithRetry(String customerId, int amountCents, String billingPeriod, String idempotencyKey) {
        MultivaluedMap<String, String> form = new MultivaluedHashMap<>();
        form.putSingle("amount", String.valueOf(amountCents));
        form.putSingle("customer", customerId);
        form.putSingle("currency", "usd");

        // Layer 5: vault key proxied through Keybrake — spend cap enforced before Stripe.
        stripeClient.createCharge(idempotencyKey, "Bearer " + stripeKey, form);
    }

    private boolean acquireAdvisoryLock(String billingPeriod) { /* pg_try_advisory_lock */ }
    private boolean insertBillingRunIfAbsent(String customerId, String period, String key) { /* ON CONFLICT DO NOTHING, returns inserted == 1 */ }
    private String currentBillingPeriod() { /* e.g. "2026-11" */ }
}

Key differences from Jersey JAX-RS and Quarkus general posts

The Jersey JAX-RS and Stripe Integration post covers JAX-RS client retry patterns using Jersey’s own retry filters and the JAX-RS 3.x client API. Jersey and RESTEasy are both JAX-RS implementations but they differ in their reactive model and in how they integrate with MicroProfile Fault Tolerance. Jersey has no native MicroProfile Fault Tolerance integration — @Retry is not available on Jersey service methods without additional configuration. RESTEasy’s Quarkus integration provides full MicroProfile Fault Tolerance support out of the box, which is why the @Retry CDI interceptor pattern is specific to RESTEasy (and to SmallRye Fault Tolerance, the MicroProfile implementation that ships with Quarkus).

The Quarkus and Stripe Integration post covers Quarkus-specific patterns including Mutiny reactive streams, SmallRye Reactive Messaging, and Quarkus Scheduler. The RESTEasy Client post focuses specifically on the MicroProfile Rest Client layer — the @RegisterRestClient proxy, the interaction between @Retry and the proxy’s method delegation model, and the reactive client variant using Uni return types from the interface methods. These patterns are distinct from the general Quarkus Mutiny patterns: the key subtlety in the RESTEasy Client case is the extra delegation hop between the CDI bean and the HTTP request, which makes the @Retry re-invocation semantics less obvious than in a direct Mutiny pipeline.

The Spring Retry and Stripe Integration post covers the same CDI interceptor / AOP proxy pattern but for Spring’s @Retryable rather than MicroProfile’s @Retry. The mechanism is identical: AOP proxy wraps the annotated method, re-invokes it on failure, UUID inside the method body re-evaluates, ch_B. The fix is identical: pass the pre-computed key as a parameter. The difference is in how the interceptor chains are ordered (@Retry + @Timeout order in MicroProfile FT vs Spring’s separate @TimeLimiter or CompletableFuture.orTimeout()), and in the reactive model (Mutiny for RESTEasy Reactive vs WebFlux/Reactor for Spring WebFlux).

Summary

Failure mode Trigger Why UUID re-evaluates Fix
@Retry CDI interceptor re-invocation Socket timeout, WebApplicationException, TimeoutException context.proceed() re-runs method body from line 1 — UUID local variable evaluated fresh per invocation Pass pre-computed sha256(customerId:billingPeriod:resteasy-billing)[:32] as parameter; CDI interceptor passes same parameter values on every re-invocation
Mutiny Uni.createFrom().item() re-subscription onFailure().retry() re-subscribes upstream Uni Mutiny calls supplier lambda again on each subscription — UUID inside supplier evaluates per call Compute key before Uni assembly; capture as effectively-final variable in surrounding scope; lambda closes over stable value
@Scheduled on all Kubernetes replicas Cron trigger fires on all pods simultaneously Each pod’s JVM generates independent UUID per customer — distinct keys per pod — Stripe creates ch_A/ch_B/ch_C pg_try_advisory_lock(hashtext(‘resteasy-monthly-billing:’ + billingPeriod)) serializes to one pod; content-hash key as backstop; pre-flight ON CONFLICT DO NOTHING per customer

The three fixes compose: content-hash keys are always correct, regardless of whether @Retry, Uni.retry(), or a multi-pod scheduler is the failure trigger. Pre-flight ON CONFLICT DO NOTHING is always correct, regardless of key stability. pg_try_advisory_lock() is the additional layer needed when the billing loop itself — not just the individual charge call — must not run concurrently across pods. And the vault key spend cap is the financial backstop that catches what application-layer guards miss: operator error, unexpected code paths, and bugs introduced in future refactors.

For more on structuring Stripe calls in RESTEasy and Quarkus applications, see Give Your AI Agent a Stripe API Key It Can’t Abuse, Stripe Restricted API Key Examples, and Stripe Idempotency Keys for AI Agents.

Put the brakes on your agent’s Stripe key

Keybrake issues a scoped vault key for each billing service. Spend cap enforced before the request reaches Stripe. Full call-by-call audit log. Revoke in one click if a billing loop runs away.