Vert.x Web Client and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Vert.x Web Client’s reactive model introduces Stripe billing failure modes that are structurally different from blocking HTTP client retry loops. Three places where duplicate charges appear: Single.defer() with retryWhen() — the RxJava 3 deferred factory re-executes on every retry re-subscription including UUID.randomUUID() inside the lambda; Future.recover() retry that calls doCharge() again — a new method invocation generates a new UUID and Stripe sees a new idempotency key; and vertx.setPeriodic() firing on all Kubernetes replicas simultaneously — with no cluster-wide advisory lock, all pods charge the same customers concurrently.
This post covers all three failure modes with Java code (Vert.x 4.x, RxJava 3 binding), Single.defer() subscription semantics and how retryWhen() differs from error handling in non-deferred pipelines, the attempt-counter anti-pattern that guarantees a distinct key per retry by construction, Future.recover() retry patterns and how HttpRequest.copy() interacts with idempotency headers, pg_try_advisory_lock() for cross-pod billing serialization, clustered Vert.x EventBus publish() vs send() delivery semantics, and pre-flight PostgreSQL ON CONFLICT DO NOTHING as a durable billing mutex — plus per-billing-period vault keys via a spend-cap proxy as a hard financial backstop. For the general Vert.x reactive stream and EventBus patterns, see the Eclipse Vert.x and Stripe Integration post. For Ktor’s coroutine-based retry patterns, see the Ktor and Stripe Integration post. The Vert.x Web Client failure modes share a common root — UUID.randomUUID() evaluated at subscription or method-invocation time rather than at request-construction time outside the reactive pipeline — but the trigger mechanisms differ at each layer of the client API.
Failure mode 1: Single.defer() + retryWhen() — the deferred factory re-executes on every retry re-subscription including UUID.randomUUID() — initial subscription creates ch_A before socket timeout — first retry re-subscription creates ch_B
Vert.x Web Client’s RxJava 3 binding exposes rxSendJsonObject() and related methods that return Single<HttpResponse<Buffer>>. A common pattern wraps the request construction inside Single.defer() so that the HttpRequest is only built and sent on subscription rather than at assembly time. This laziness is correct for most purposes, but it interacts fatally with retryWhen(): because Single.defer() re-executes the factory lambda on every subscription, and because retryWhen() re-subscribes the upstream Single on each retry signal, the entire factory body — including any UUID.randomUUID() call inside it — runs fresh on every retry attempt.
The failure is not specific to any particular placement of UUID.randomUUID() within the factory: whether the UUID is passed as a putHeader() argument, stored in a local variable before building the JSON body, or computed inside the JsonObject put chain, it evaluates at the moment the factory runs — which is once per subscription. The initial subscription sends the request; Stripe commits ch_A before the TCP connection drops; the socket timeout propagates as an exception; retryWhen() re-subscribes the deferred Single; the factory runs again; a new UUID is computed; Stripe sees a new idempotency key and creates ch_B:
// UNSAFE: UUID.randomUUID() inside Single.defer() factory re-evaluates on every retry re-subscription.
// retryWhen() re-subscribes the deferred Single on failure — factory lambda re-runs per re-subscription.
import io.reactivex.rxjava3.core.Single;
import io.vertx.rxjava3.ext.web.client.WebClient;
import io.vertx.core.json.JsonObject;
import java.util.UUID;
// WebClient is a shared instance — do not create inside defer() per request.
WebClient webClient = WebClient.create(vertx);
// UNSAFE: defer() factory executes once per subscription.
// retryWhen() re-subscribes on failure → factory re-executes → new UUID per attempt.
//
// Subscription 1 (initial):
// UUID_A = "7a3f1b2c-..." ← generated when factory runs
// POST /v1/charges with Idempotency-Key: UUID_A
// Stripe commits ch_A before TCP drops
// SocketTimeoutException wrapped in HttpException → retryWhen() receives error signal
//
// Subscription 2 (retry attempt 1):
// UUID_B = "c9d8e7f6-..." ← factory runs again, new UUID
// POST /v1/charges with Idempotency-Key: UUID_B ← Stripe sees new key
// Stripe creates ch_B ← duplicate charge
//
// Subscription 3 (retry attempt 2):
// UUID_C = "b2a1c3d4-..." ← yet another UUID
// POST /v1/charges → ch_C ← triplicate
Single<String> chargeResult = Single.defer(() -> {
// UNSAFE: everything inside defer() re-executes on each re-subscription.
String idempotencyKey = UUID.randomUUID().toString(); // re-evaluates per subscription
JsonObject body = new JsonObject()
.put("amount", amountCents)
.put("currency", "usd")
.put("customer", customerId);
return webClient.post(443, "api.stripe.com", "/v1/charges")
.ssl(true)
.putHeader("Authorization", "Bearer " + secretKey)
.putHeader("Idempotency-Key", idempotencyKey) // different value per retry
.rxSendJsonObject(body)
.map(resp -> resp.bodyAsJsonObject().getString("id"));
}).retryWhen(errors -> errors
.zipWith(Flowable.range(1, 3), (err, attempt) -> attempt)
.flatMapSingle(attempt -> Single.timer(attempt, TimeUnit.SECONDS))
);
A subtler variant occurs when the developer moves UUID.randomUUID() outside the defer() lambda, believing this makes it stable across retries. It does — if the Single is assembled once and subscribed multiple times. But if the Single pipeline itself is re-assembled inside a retry loop or called from a method that is invoked on each retry attempt, the UUID is still effectively regenerated per attempt. The key property is where in the call stack UUID.randomUUID() is evaluated relative to the retry re-subscription, not just whether it appears inside the defer() literal.
A second subtler variant is the attempt-counter anti-pattern. Some developers embed a retry counter in the idempotency key string to track which attempt created the charge: UUID.randomUUID().toString() + "-attempt-" + attempt. This is not a workaround — it is a guarantee of distinct keys per attempt. Each attempt has a different key by construction; ch_A on attempt 0, ch_B on attempt 1, ch_C on attempt 2. The intent to track attempts does not change the fact that Stripe treats each distinct key as a new charge request:
// UNSAFE anti-pattern: attempt counter embedded in idempotency key.
// Each attempt produces a structurally distinct key — guaranteed ch_B on retry 1.
//
// Attempt 0 key: "7a3f1b2c-...-attempt-0"
// Attempt 1 key: "7a3f1b2c-...-attempt-0" does NOT appear — new UUID per defer() call:
// UUID_B + "-attempt-1" ← two sources of distinctness: new UUID and different counter
// Even if UUID were fixed (same UUID across attempts), attempt counter alone guarantees distinct keys:
// Attempt 0 key: "FIXED-UUID-attempt-0"
// Attempt 1 key: "FIXED-UUID-attempt-1" ← Stripe creates ch_B
// Attempt 2 key: "FIXED-UUID-attempt-2" ← ch_C
Single<String> chargeResult = Single.defer(() -> {
// Double anti-pattern: new UUID per defer() AND attempt counter per retry.
// Both factors independently guarantee distinct keys across attempts.
String baseKey = UUID.randomUUID().toString(); // different UUID per defer()
return webClient.post(443, "api.stripe.com", "/v1/charges")
.ssl(true)
.putHeader("Authorization", "Bearer " + secretKey)
.rxSendJsonObject(body)
.map(resp -> resp.bodyAsJsonObject().getString("id"));
}).retryWhen(errors -> errors.zipWith(
Flowable.range(1, 3),
(err, attempt) -> {
// Attempt-tracking key built in retryWhen — also evaluated per retry signal.
// Even if placed outside defer(), the key embedded here differs per attempt.
String keyWithAttempt = UUID.randomUUID() + "-attempt-" + attempt;
// keyWithAttempt is computed but never used — this is illustrating the logic flaw.
return attempt;
}
).flatMapSingle(attempt -> Single.timer(attempt, TimeUnit.SECONDS)));
The fix is to compute the idempotency key from stable inputs before assembling the Single pipeline, capture it as a final local variable, and reference it inside defer(). The key must be derived from fields that do not change between retry attempts: customerId, billingPeriod, and a fixed service identifier. No UUID.randomUUID(), no System.currentTimeMillis(), no attempt counter, no thread ID:
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
// SAFE: stable key computed BEFORE Single.defer() assembly.
// Captured as final variable — defer() factory lambda closes over the pre-computed value.
// The same key is used on every re-subscription regardless of retry attempt number.
String stableKey = sha256Hex(customerId + ":" + billingPeriod + ":vertx-billing");
JsonObject body = new JsonObject()
.put("amount", amountCents)
.put("currency", "usd")
.put("customer", customerId);
Single<String> chargeResult = Single.defer(() -> {
// stableKey is a captured final variable — same value on every defer() re-execution.
return webClient.post(443, "api.stripe.com", "/v1/charges")
.ssl(true)
.putHeader("Authorization", "Bearer " + secretKey)
.putHeader("Idempotency-Key", stableKey) // same value on every retry attempt
.rxSendJsonObject(body) // body JsonObject is also pre-built and safe to reuse
.map(resp -> resp.bodyAsJsonObject().getString("id"));
}).retryWhen(errors -> errors
.zipWith(Flowable.range(1, 3), (err, attempt) -> attempt)
.flatMapSingle(attempt -> Single.timer(attempt, TimeUnit.SECONDS))
);
// Helper — truncate to 32 chars to stay within Stripe's 255-byte limit with room to spare.
private static String sha256Hex(String input) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) sb.append(String.format("%02x", b));
return sb.toString().substring(0, 32);
}
With stableKey captured outside Single.defer(), every re-subscription of the deferred factory reads the same pre-computed key. Stripe’s idempotency cache returns the ch_A result on the second and third subscription without creating new charges. Pre-flight ON CONFLICT DO NOTHING (described in failure mode 3) handles the scenario where the 24-hour cache window has expired.
Failure mode 2: Future.recover() retry calls doCharge() again — new method invocation generates new UUID — initial call creates ch_A before HttpException — recover() callback creates ch_B
Vert.x’s Future<T> API provides recover(Function<Throwable, Future<T>>) as a way to substitute an alternative Future when the original fails. A common retry pattern passes a failure handler that calls the same charging method again, producing a new Future on each failure. If the charging method computes UUID.randomUUID() as a local variable inside the method body, each invocation generates a fresh UUID. The recover() callback does not reuse any state from the original call — it invokes a fresh method execution with fresh local variables.
The failure arises because UUID.randomUUID() inside a method body evaluates once per method invocation, not once per request construction. The first call to doCharge() computes UUID_A, constructs an HttpRequest with that key, sends it, and fails with a socket timeout after Stripe commits ch_A. The recover() callback calls doCharge() again: a new method invocation, a new stack frame, a new UUID.randomUUID() evaluation producing UUID_B. Stripe sees a new key and creates ch_B:
// UNSAFE: doCharge() computes UUID.randomUUID() as a local variable inside the method body.
// Every call to doCharge() generates a new UUID, regardless of whether it is an initial attempt or a retry.
// recover() calls doCharge() again → new method invocation → new UUID → ch_B.
private Future<String> doCharge(WebClient webClient,
String customerId,
String billingPeriod,
long amountCents) {
// UNSAFE: UUID generated at method invocation time, not at request assembly time.
// recover() callback calls doCharge() again → new invocation → UUID_B.
//
// Call 1 (initial attempt):
// UUID_A = "7a3f1b2c-..."
// HttpRequest constructed with Idempotency-Key: UUID_A
// POST /v1/charges → ch_A committed before SocketTimeoutException
// Future fails with HttpException
//
// Call 2 (recover() callback):
// doCharge() invoked fresh → UUID_B = "c9d8e7f6-..." ← new UUID
// HttpRequest constructed with Idempotency-Key: UUID_B
// POST /v1/charges → Stripe sees new key → ch_B ← duplicate
String idempotencyKey = UUID.randomUUID().toString(); // fresh per invocation
JsonObject body = new JsonObject()
.put("amount", amountCents)
.put("currency", "usd")
.put("customer", customerId);
return webClient.post(443, "api.stripe.com", "/v1/charges")
.ssl(true)
.putHeader("Authorization", "Bearer " + secretKey)
.putHeader("Idempotency-Key", idempotencyKey)
.sendJsonObject(body)
.map(resp -> resp.bodyAsJsonObject().getString("id"));
}
// Caller — recover() calls doCharge() again on failure:
doCharge(webClient, customerId, billingPeriod, amountCents)
.recover(err -> {
log.warn("Charge attempt 1 failed: {}", err.getMessage());
return doCharge(webClient, customerId, billingPeriod, amountCents); // NEW UUID
})
.recover(err -> {
log.warn("Charge attempt 2 failed: {}", err.getMessage());
return doCharge(webClient, customerId, billingPeriod, amountCents); // another NEW UUID
});
A subtler variant of this pattern uses HttpRequest.copy() to clone the request before each retry, believing the copy preserves the idempotency header set on the original request. HttpRequest.copy() in Vert.x Web Client 4.x does copy request headers, so if the original request was constructed with a stable UUID header outside the retry loop, the copy does carry the same idempotency key. The bug in this variant is not in copy() itself but in where the original request is built relative to the retry scope:
// Subtler variant: HttpRequest.copy() is used for retry — but original request is rebuilt
// per doCharge() invocation, so copy() copies the per-invocation UUID, not a stable one.
// UNSAFE: original request built fresh per doCharge() call — copy() copies a different UUID each time.
private Future<String> doCharge(WebClient webClient, String customerId, long amountCents) {
// HttpRequest built fresh per method invocation — putHeader() UUID is fresh per invocation.
HttpRequest<Buffer> originalRequest = webClient.post(443, "api.stripe.com", "/v1/charges")
.ssl(true)
.putHeader("Authorization", "Bearer " + secretKey)
.putHeader("Idempotency-Key", UUID.randomUUID().toString()); // fresh UUID here
JsonObject body = new JsonObject()
.put("amount", amountCents)
.put("currency", "usd")
.put("customer", customerId);
// copy() copies the request including the per-invocation UUID header — still a fresh UUID per call.
return originalRequest.copy().sendJsonObject(body)
.map(resp -> resp.bodyAsJsonObject().getString("id"));
}
// recover() calls doCharge() again → new HttpRequest built → new UUID in copy() → ch_B.
doCharge(webClient, customerId, amountCents)
.recover(err -> doCharge(webClient, customerId, amountCents));
The copy() call only helps if the HttpRequest with the stable idempotency header is built once outside all retry scope and the same copy is used on each attempt. To make copy() useful for retry, the base request must be assembled at a scope that outlives the retry loop:
// SAFE: HttpRequest built once outside retry scope — stable idempotency key computed once.
// copy() creates a fresh sendable request from the stable base — same key on every attempt.
String stableKey = sha256Hex(customerId + ":" + billingPeriod + ":vertx-billing");
// Base request constructed once — stable key in header.
HttpRequest<Buffer> baseRequest = webClient.post(443, "api.stripe.com", "/v1/charges")
.ssl(true)
.putHeader("Authorization", "Bearer " + secretKey)
.putHeader("Idempotency-Key", stableKey); // same on all copies
JsonObject body = new JsonObject()
.put("amount", amountCents)
.put("currency", "usd")
.put("customer", customerId);
// Helper method takes the pre-built base request — does not rebuild it per invocation.
private Future<String> sendCharge(HttpRequest<Buffer> baseReq, JsonObject body) {
// copy() creates a fresh request object safe for a single send() call.
// The Idempotency-Key header copied from baseReq is the stable content-hash key.
return baseReq.copy()
.sendJsonObject(body)
.map(resp -> resp.bodyAsJsonObject().getString("id"));
}
// Retry chain with stable base request:
sendCharge(baseRequest, body)
.recover(err -> {
log.warn("Attempt 1 failed: {}", err.getMessage());
return sendCharge(baseRequest, body); // copy() of same base → same stable key
})
.recover(err -> {
log.warn("Attempt 2 failed: {}", err.getMessage());
return sendCharge(baseRequest, body); // same key again
});
With the base request assembled outside the retry scope and copy() used to create a per-send request object, the idempotency key is stable across all retry attempts. Stripe’s 24-hour cache returns ch_A on the second and third attempt without creating new charges. For monthly billing where the retry interval may extend past the 24-hour window, the pre-flight ON CONFLICT DO NOTHING guard described in failure mode 3 is the backstop.
Failure mode 3: vertx.setPeriodic() fires on all Kubernetes replicas — clustered EventBus publish() compounds the race — TOCTOU race with no distributed lock — ch_A, ch_B, ch_C per customer per billing period
Vert.x’s vertx.setPeriodic(delay, handler) registers a JVM-local timer that fires on the Vert.x event loop of the instance that called setPeriodic(). In a Kubernetes Deployment with replicas:3, three separate JVM processes each create a Vert.x instance and each call setPeriodic() at application startup. After the configured interval — 30 days for monthly billing — all three timers fire within milliseconds of each other. Each Vert.x instance has its own event loop, its own memory, and its own database connection pool. None of them are aware of each other’s timer state.
The sequence that produces duplicate charges is a TOCTOU (time-of-check, time-of-use) race on the billing_completed flag. All three pods read the same customer table before any pod writes a billing_started record. All three find billing_completed = false for all 500 customers. All three begin iterating the customer list. Even if idempotency keys are stable content-hash values (same key per customer per billing period across all pods), Stripe’s idempotency cache does not guarantee protection for concurrent simultaneous requests that arrive within the same cache-write window. Stripe’s documentation specifies that idempotency applies to sequential requests — if two requests with the same key arrive at Stripe’s infrastructure before the first has been fully committed to the idempotency cache, Stripe may treat both as new requests and create ch_A and ch_B:
// UNSAFE: vertx.setPeriodic() is JVM-local — fires on every pod in a replicas:3 Deployment.
// No distributed lock — all three pods enter chargeAllCustomers() simultaneously.
// Even with stable content-hash keys, concurrent Stripe requests bypass the idempotency cache.
import io.vertx.core.Vertx;
import java.util.concurrent.TimeUnit;
public class BillingVerticle extends AbstractVerticle {
@Override
public void start() {
// UNSAFE: setPeriodic() creates a JVM-local timer.
// With replicas:3, three timers fire at the same wall-clock time.
vertx.setPeriodic(TimeUnit.DAYS.toMillis(30), timerId -> {
// All three pods enter this block simultaneously.
chargeAllCustomers()
.onFailure(err -> log.error("Billing run failed on {}: {}", podHostname(), err));
});
}
private Future<Void> chargeAllCustomers() {
// All three pods execute this concurrently.
// Even with stable keys: three simultaneous POST /v1/charges requests per customer
// may all create new charges before Stripe commits the first to its idempotency cache.
return loadActiveCustomers()
.compose(customers -> {
List<Future> futures = customers.stream()
.map(customer -> {
// Stable key — same value on all three pods per customer per period.
String key = sha256Hex(customer.id() + ":" + billingMonth() + ":vertx-billing");
return chargeCustomer(customer, key); // concurrent across 3 pods
})
.collect(Collectors.toList());
return Future.all(futures).mapEmpty();
});
}
}
A subtler compounding failure occurs with clustered Vert.x EventBus and publish(). Some architectures use an EventBus message to trigger billing from a scheduler pod to worker pods. With Vert.x clustering (Hazelcast, Infinispan, or ZooKeeper cluster manager), vertx.eventBus().publish("billing.trigger", customerId) delivers the message to all registered consumers across all pods in the cluster — not to one consumer round-robin like send(). If a cron-style trigger uses publish() and three pods each have a consumer registered on "billing.trigger", all three consumers receive every message and all three call Stripe for the same customer with their own idempotency keys:
// UNSAFE: clustered EventBus publish() delivers to ALL consumers on ALL pods.
// Three consumers (one per pod) each receive the billing trigger for the same customer.
public class BillingWorker extends AbstractVerticle {
@Override
public void start() {
// Three pods each register a consumer on the same address.
// With clustered EventBus and publish(), all three receive every message.
vertx.eventBus().consumer("billing.trigger", message -> {
String customerId = (String) message.body();
String billingPeriod = getCurrentBillingPeriod();
// Stable key — but all three consumer handlers run concurrently for the same message.
String key = sha256Hex(customerId + ":" + billingPeriod + ":vertx-billing");
chargeCustomer(customerId, billingPeriod, key)
.onSuccess(chargeId -> log.info("Charged {} → {}", customerId, chargeId))
.onFailure(err -> log.error("Charge failed for {}: {}", customerId, err));
});
}
}
// Scheduler pod triggers billing — using publish() instead of send():
public class BillingScheduler extends AbstractVerticle {
@Override
public void start() {
vertx.setPeriodic(TimeUnit.DAYS.toMillis(30), id -> {
loadActiveCustomers().onSuccess(customers -> {
for (String customerId : customers) {
// publish() delivers to ALL consumers across the cluster.
// All 3 BillingWorker instances on 3 pods receive this for each customerId.
vertx.eventBus().publish("billing.trigger", customerId); // ALL consumers
}
});
});
}
}
// SAFE alternative: use send() for point-to-point delivery (round-robin to ONE consumer).
// vertx.eventBus().send("billing.trigger", customerId); // ONE consumer receives, not all three
Even with send(), the underlying issue persists: the scheduler on every pod also fires setPeriodic(), so the trigger is sent three times (once per pod), and even with round-robin consumer selection, two of those three sends reach a consumer. The only reliable fix at the architecture level is a distributed advisory lock that serializes the billing run across all pods to one executor.
The correct fix combines three layers. First, a PostgreSQL advisory lock acquired at billing-run entry inside vertx.executeBlocking(). pg_try_advisory_lock() is a non-blocking session-level lock: it returns true if this connection acquired the lock and false if another session already holds it. The lock is held for the duration of the billing run and released explicitly in a finally block before the JDBC connection is returned to the pool. Second, a pre-flight INSERT ... ON CONFLICT (customer_id, billing_period) DO NOTHING on a billing_records table as the durable per-customer guard. Third, clustered EventBus send() (not publish()) for any message-based billing triggers:
// SAFE: three-layer fix for vertx.setPeriodic() multi-pod billing race.
public class BillingVerticle extends AbstractVerticle {
@Override
public void start() {
vertx.setPeriodic(TimeUnit.DAYS.toMillis(30), timerId -> {
// Layer 1: pg_try_advisory_lock() — only one pod runs the billing loop.
vertx.executeBlocking(() -> {
try (Connection conn = dataSource.getConnection()) {
// Non-blocking advisory lock — returns false if another pod holds it.
boolean acquired = tryAdvisoryLock(conn, "vertx-monthly-billing", billingMonth());
if (!acquired) {
log.info("Advisory lock not acquired — another pod is running billing");
return null; // exit without billing — other pod has it
}
try {
runBillingLoop(conn);
} finally {
releaseAdvisoryLock(conn, "vertx-monthly-billing", billingMonth());
}
}
return null;
}, false).onFailure(err -> log.error("Billing run failed: {}", err));
});
}
private void runBillingLoop(Connection conn) throws Exception {
List<Customer> customers = loadActiveCustomers(conn);
for (Customer customer : customers) {
String billingPeriod = billingMonth();
String stableKey = sha256Hex(customer.id() + ":" + billingPeriod + ":vertx-billing");
// Layer 2: pre-flight ON CONFLICT DO NOTHING — skip customers already billed.
// Handles concurrent pods that both acquired lock (timing edge case)
// and handles Stripe idempotency cache expiry after 24 hours.
boolean inserted = insertBillingStarted(conn, customer.id(), billingPeriod, stableKey);
if (!inserted) {
log.info("Customer {} already billed for {} — skipping", customer.id(), billingPeriod);
continue;
}
// Layer 3: stable idempotency key — same across all pods and all retry attempts.
// Stripe cache returns ch_A on duplicate requests within 24h; pre-flight guard
// handles post-24h scenario where Stripe cache has expired.
chargeCustomer(customer, stableKey, conn);
}
}
private boolean tryAdvisoryLock(Connection conn, String lockName, String period) throws Exception {
long lockId = (long) (lockName + ":" + period).hashCode() & 0x7FFFFFFFL;
try (PreparedStatement ps = conn.prepareStatement(
"SELECT pg_try_advisory_lock(?)")) {
ps.setLong(1, lockId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getBoolean(1);
}
}
}
private boolean insertBillingStarted(Connection conn, String customerId,
String billingPeriod, String idempotencyKey) throws Exception {
// ON CONFLICT DO NOTHING returns 0 rows inserted if the record already exists.
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO billing_records (customer_id, billing_period, idempotency_key, started_at) " +
"VALUES (?, ?, ?, NOW()) ON CONFLICT (customer_id, billing_period) DO NOTHING")) {
ps.setString(1, customerId);
ps.setString(2, billingPeriod);
ps.setString(3, idempotencyKey);
return ps.executeUpdate() == 1; // true if inserted, false if already existed
}
}
}
With the three-layer fix in place: pg_try_advisory_lock() ensures only one pod executes the billing loop per billing period; ON CONFLICT DO NOTHING ensures only one charge attempt per customer per period even if two pods somehow both acquire the lock (impossible with a correct advisory lock, but belt-and-suspenders); and the stable content-hash idempotency key ensures Stripe’s cache handles any partial retry within the 24-hour window.
The spend-cap vault key as financial backstop
Code fixes resolve the duplicate-charge failure modes at the application layer, but they do not provide a financial circuit breaker if the code has a bug or if an unexpected failure mode emerges. A vault key issued by a scoped API-key proxy provides that backstop: a vault_key_xxx is issued with a policy that caps the total spend against the underlying Stripe key for the billing period. Even if all three failure modes fire simultaneously — duplicate defer subscriptions, recover retries, and all three pods running the billing loop — the vault key policy enforces a hard ceiling at expected_total × 1.10. Charges above that ceiling are rejected at the proxy layer before they reach Stripe.
The proxy also provides the audit trail that distinguishes a billing run that charged exactly the right customers from one that charged some customers twice. Every proxied request is logged with the vault key ID, the request body (including idempotency key), the Stripe response (including charge ID and amount), and the wall-clock timestamp. A billing run that produced 500 charges appears in the audit log as 500 distinct charge IDs with 500 distinct idempotency keys. A run that produced 501 charges — one duplicate — appears as 501 entries where two entries share a customer ID but have different charge IDs and different idempotency keys. The audit log surfaces the bug; the spend cap limits the financial damage.
Per-vendor Vert.x Web Client configuration for the proxy
To route all Stripe calls through a spend-cap proxy, configure the Vert.x Web Client to target the proxy host instead of api.stripe.com. The proxy accepts the same Stripe API request format and forwards to api.stripe.com after validating the vault key policy. The only change required in the application is the WebClient.post() target host and the Authorization header format:
// Before: direct Stripe call.
webClient.post(443, "api.stripe.com", "/v1/charges")
.ssl(true)
.putHeader("Authorization", "Bearer " + stripeSecretKey)
.putHeader("Idempotency-Key", stableKey)
.sendJsonObject(body);
// After: route through Keybrake spend-cap proxy.
// vault_key_xxx is issued per billing period with spend cap = expected_total * 1.10.
// stripeSecretKey never leaves the proxy — only vault_key_xxx is in the application.
webClient.post(443, "proxy.keybrake.com", "/stripe/v1/charges")
.ssl(true)
.putHeader("Authorization", "Bearer " + vaultKey) // vault_key_xxx, not sk_live_xxx
.putHeader("Idempotency-Key", stableKey) // still required — proxy forwards it
.sendJsonObject(body);
// Vault key policy (set at issuance, not in application code):
// {
// "vendor": "stripe",
// "daily_usd_cap": 55000, // expected_total * 1.10 for this billing period
// "allowed_endpoints": ["/v1/charges"], // no refunds, no subscriptions — charges only
// "expires_at": "2026-12-01T00:00:00Z" // expires after the billing window closes
// }
The vault key is issued per billing period and expires after the billing window closes. If a bug causes more than expected_total × 1.10 in charges, the proxy rejects subsequent requests with a 429 policy-exceeded response. The application sees a failed Future, logs the error, and the billing run stops. No further charges are created. The audit log records the exact point where the cap was hit and which customer was being processed, giving the operations team a precise recovery scope.
Summary: what to check in any Vert.x Web Client billing path
| Pattern | What goes wrong | Fix |
|---|---|---|
Single.defer(() -> { UUID.randomUUID() }).retryWhen() |
Defer factory re-executes per retry re-subscription — new UUID per retry — ch_B on retry 1 | Compute stable key before defer(); capture as final variable; reference inside lambda |
| Attempt counter in idempotency key string | Distinct key per attempt by construction — guaranteed ch_B on retry 1 | Remove attempt counter; use content-hash of stable fields only |
doCharge() called from recover() callback |
Fresh method invocation — fresh UUID local variable — ch_B on recovery | Pass stable key as parameter to doCharge(); compute key at call site outside retry scope |
HttpRequest.copy() inside retry loop where base request is rebuilt per call |
copy() copies the per-invocation UUID — different per-call UUID on each copy — ch_B on retry | Build base request once outside all retry scope; copy() it on each attempt |
vertx.setPeriodic() on all Kubernetes replicas |
JVM-local timer fires on all pods simultaneously — TOCTOU race — ch_A/ch_B/ch_C per customer | pg_try_advisory_lock() at billing-run entry + pre-flight ON CONFLICT DO NOTHING |
Clustered EventBus publish() for billing trigger |
All consumers on all pods receive every message — all pods charge the same customer | Use send() for point-to-point delivery; combine with advisory lock in the consumer handler |
The idempotency key rule for Vert.x Web Client is the same as for every other reactive HTTP client: the key must be a deterministic function of stable inputs (customerId, billingPeriod, service identifier), computed once before the reactive pipeline is assembled, and referenced as a captured final variable inside any deferred or lazy factory lambda. sha256(customerId:billingPeriod:vertx-billing)[:32] satisfies all these requirements and produces the same 32-character hex string on every computation regardless of how many times the Single.defer() factory re-executes or how many times doCharge() is called. The pre-flight ON CONFLICT DO NOTHING record handles the scenario where the 24-hour Stripe idempotency cache has expired and the advisory lock handles the scenario where the application is deployed on multiple JVM replicas.
Put a spend cap on your Vert.x billing path
Keybrake issues per-billing-period vault keys with configurable daily USD caps, endpoint allowlists, and an audit log of every proxied Stripe call. Route your WebClient.post() to proxy.keybrake.com/stripe/v1/charges — one header change, no SDK swap.