Vert.x Future.compose() Retry and Stripe Integration: How UUID in executeBlocking Worker Body, compose() Mapper Recomposition, and setTimer() Backoff Retry Generate New Idempotency Keys on Each Attempt
Vert.x’s event-loop model forces the Stripe Java SDK into executeBlocking() worker tasks — and three idempotency-key failure modes live specifically in how Vert.x Future pipelines handle retries of those blocking tasks.
The Stripe Java SDK is entirely synchronous and blocking. Every call to Charge.create(), Subscription.create(), or PaymentIntent.create() blocks the calling thread until the HTTP response arrives. In Vert.x, blocking the event-loop thread for longer than a few milliseconds stalls all other handlers on that thread. The standard solution is vertx.executeBlocking(), which runs the Stripe SDK call on a worker thread from Vert.x’s worker thread pool and returns a Future<T> that completes when the blocking computation finishes.
This architecture creates a specific idempotency-key failure pattern. When UUID.randomUUID() is placed inside the executeBlocking() Callable or Supplier, it executes on the worker thread at the start of that blocking computation. If the computation fails and the caller retries by invoking the method again — as Future.recover() does implicitly when it calls the provided recovery function — a new executeBlocking() task is submitted to the worker pool, and the UUID inside it re-evaluates. The developer’s mental model (“the UUID is computed once per billing attempt, scoped to the worker task”) is accurate for the nominal path, but collapses under retry because each retry is a new worker task with a new Callable body invocation.
Two additional failure modes compound this. The first involves Future.compose() pipeline chains: when a billing pipeline consists of multiple async steps chained with .compose() and the UUID is placed inside a mapper function in the chain, retrying the outer pipeline by calling the method again rebuilds the entire chain from the first step, not just from the step that failed. Every .compose() mapper — including the one that generates the UUID — re-executes. The second involves vertx.setTimer()-based backoff retry: Vert.x’s non-blocking model prohibits Thread.sleep() on the event loop, so the canonical retry-with-backoff pattern schedules a delayed callback with vertx.setTimer(). The timer callback calls the billing method again — which is a new method invocation — and if the UUID lives inside the method body, it generates a new UUID for the retry attempt.
All three modes produce the same observable outcome: a Stripe charge ch_A is created and committed on the first attempt; the retry arrives with a different idempotency key UUID_B and Stripe creates ch_B for the same customer and billing period. The customer is double-charged. The Stripe audit trail shows two charges. The application database records only the retry’s charge ID.
Background: Vert.x Future semantics and how they differ from reactive streams
Before examining the failure modes, it helps to be precise about what Vert.x Future<T> is and how it differs from RxJava’s Single<T> or Mutiny’s Uni<T>.
RxJava Single and Mutiny Uni are cold (lazy) by default. The computation inside Single.fromCallable(callable) or Uni.createFrom().completionStage(supplier) does not start until something subscribes. Retry mechanisms in RxJava and Mutiny — Single.retry(), Uni.onFailure().retry() — work by re-subscribing to the upstream cold observable, which re-invokes the callable or supplier. This is why UUID inside a cold observable’s factory re-evaluates on each retry: the factory is a subscription handler, not a computed value.
Vert.x Future<T> is eager (hot). When you call vertx.executeBlocking(() -> computation()), the worker task is immediately submitted to the worker thread pool. There is no subscription step. The computation starts running as soon as executeBlocking() is called, regardless of whether any handler has been attached to the returned Future.
This distinction matters for retry semantics. In RxJava, re-subscribing to the same Single instance retries the computation without calling the factory again (if the factory is a defer-wrapped observable, re-subscription calls the factory again; if it is a direct fromCallable, the callable is called again per subscription). In Vert.x, you cannot “re-subscribe” to a completed or failed Future — a Future is a one-shot write-once result container. To retry, you must create a new Future: a new call to executeBlocking() with a new Callable. Future.recover() does this implicitly — the function you pass to recover() receives the failure and returns a new Future<T>, typically by calling the same method that produced the original Future.
The practical consequence: in Vert.x, every retry is unconditionally a new method invocation or a new executeBlocking() task. There is no mechanism to replay the same eager Future. This makes the UUID placement question equivalent to the question “is the UUID inside a method that gets called again on retry?” — which is the same framing as Spring @Retryable, Micronaut @Retryable, or Quarkus @Retry method re-invocation.
Mode 1: UUID.randomUUID() inside vertx.executeBlocking() Callable body — Future.recover() creates new Callable — UUID_B — ch_B
The most direct failure mode. The developer places UUID.randomUUID() inside the executeBlocking() Callable to associate the idempotency key with the blocking billing computation. The reasoning is sound for a single attempt: the UUID is created exactly once, in the worker thread, right before the Stripe SDK call, in the scope of the computation that will use it. Under retry, this reasoning fails.
// UNSAFE: UUID inside executeBlocking() Callable body
public Future<String> chargeCustomer(String customerId, int amountCents) {
return vertx.executeBlocking(() -> {
// Worker thread: safe to call blocking Stripe SDK here.
// UUID.randomUUID() is called on every new executeBlocking() invocation.
String idempotencyKey = UUID.randomUUID().toString();
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount((long) amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
Charge charge = Charge.create(params, opts);
return charge.getId();
});
}
// Caller with two recover() retries:
public Future<String> chargeWithRetry(String customerId, int amountCents) {
return chargeCustomer(customerId, amountCents) // UUID_A
.recover(err -> chargeCustomer(customerId, amountCents)) // UUID_B on first retry
.recover(err -> chargeCustomer(customerId, amountCents)); // UUID_C on second retry
}
The failure sequence: the event loop calls chargeWithRetry(), which calls chargeCustomer(). Vert.x submits the Callable to the worker pool. The worker thread executes the Callable: UUID.randomUUID() generates UUID_A, and the Stripe SDK call with UUID_A succeeds at Stripe’s server side — ch_A is created and committed to Stripe’s ledger. Then, at the network layer, the HTTP response from Stripe is lost in transit: a SocketTimeoutException or StripeException (code api_connection_error) is thrown inside the Callable. executeBlocking()’s Future completes as failed. The first .recover() fires, calling chargeCustomer() again. A new Callable is submitted to the worker pool. The new worker thread enters the Callable body, calls UUID.randomUUID(), generates UUID_B, and calls Charge.create() with UUID_B. Stripe has no record of UUID_B — it only knows UUID_A — and creates ch_B.
The developer’s mental model: “executeBlocking() runs the lambda once per attempt.” This is correct. The trap is that .recover() does not retry the same Callable — it calls the recovery function, which calls chargeCustomer(), which submits a brand new Callable. Each retry is a new Callable with its own fresh UUID.randomUUID() call.
Fix for mode 1
Compute the idempotency key outside the executeBlocking() boundary and capture it in the Callable’s closure. Since the key is computed before executeBlocking() is called, it is the same String value regardless of how many times executeBlocking() is called. The more robust approach is a content-hash key derived from immutable billing intent data, which is stable even if the method is called multiple times from different code paths:
// SAFE: UUID computed before executeBlocking() — captures stable value
public Future<String> chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// Content-hash key: same value for same (customerId, amountCents, billingPeriod)
// regardless of how many times this method is called.
String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
return vertx.executeBlocking(() -> {
// idempotencyKey is a final-effectively captured variable.
// The same String is used on every invocation of this Callable.
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount((long) amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
Charge charge = Charge.create(params, opts);
return charge.getId();
});
}
// Caller: pass stable billingPeriod as parameter to all invocations
public Future<String> chargeWithRetry(String customerId, int amountCents,
String billingPeriod) {
return chargeCustomer(customerId, amountCents, billingPeriod)
.recover(err -> chargeCustomer(customerId, amountCents, billingPeriod))
.recover(err -> chargeCustomer(customerId, amountCents, billingPeriod));
}
When chargeCustomer() is called the first time, idempotencyKey is computed as "charge:cus_abc:4999:2026-10". When .recover() calls chargeCustomer() again with the same arguments, idempotencyKey computes to the same string "charge:cus_abc:4999:2026-10" again. The Callable captures this string by closure — the same value every time. Stripe receives "charge:cus_abc:4999:2026-10" on both the original attempt and the retry, deduplicates them, and returns the existing ch_A response on the retry without creating a new charge.
One important nuance for content-hash keys in a billing period context: the key must be stable within the billing period but should vary across periods. The key "charge:cus_abc:4999:2026-10" (containing the YearMonth) is correct for October 2026. If the same customer is billed in November 2026 with the same amount, the key becomes "charge:cus_abc:4999:2026-11" — a different key — and Stripe correctly processes it as a new, independent charge.
Mode 2: UUID.randomUUID() inside .compose() mapper function — Future.recover() retries outer method — chain rebuilt from first step — mapper re-invoked — UUID_B — ch_B
Billing pipelines often involve multiple async steps before reaching the Stripe SDK call. In Vert.x, these steps are chained with .compose(), which applies a function to the result of the preceding Future and returns a new Future. A common billing pipeline: first, look up the customer record from a database (an async JDBC or reactive SQL query); then, using the customer data, call Stripe to create the charge (an executeBlocking() task). The developer places UUID.randomUUID() inside the second step’s .compose() mapper to “associate the idempotency key with the charge step.”
// UNSAFE: UUID inside .compose() mapper function
public Future<String> buildBillingFuture(String customerId, int amountCents) {
return fetchCustomer(customerId) // Step 1: DB lookup (async)
.compose(customer -> {
// Step 2: Stripe charge — UUID generated in mapper body
String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE
return vertx.executeBlocking(() -> {
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customer.getStripeCustomerId())
.setAmount((long) amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
return Charge.create(params, opts).getId();
});
});
}
// Caller with recover() retry:
public Future<String> chargeWithRetry(String customerId, int amountCents) {
return buildBillingFuture(customerId, amountCents) // UUID_A in compose mapper
.recover(err ->
buildBillingFuture(customerId, amountCents)) // UUID_B: mapper re-runs
.recover(err ->
buildBillingFuture(customerId, amountCents)); // UUID_C: mapper re-runs again
}
The failure sequence: buildBillingFuture() is called. fetchCustomer() starts and completes successfully, returning the customer record. The .compose() mapper runs: UUID.randomUUID() generates UUID_A. executeBlocking() submits the Stripe call to the worker pool. The worker calls Charge.create() with UUID_A. Stripe creates ch_A. Then the network response is lost — StripeException is thrown, the executeBlocking() Future fails, and the .compose()’s pipeline Future also fails. The outer .recover() fires. It calls buildBillingFuture() again. This starts a brand new Future pipeline: fetchCustomer() runs again (re-querying the database) and completes. The .compose() mapper runs again: UUID.randomUUID() generates UUID_B. executeBlocking() submits a new Stripe call with UUID_B. Stripe creates ch_B.
The developer’s reasoning — “the UUID is scoped to the charge step” — is correct in the sense that the UUID is computed in the .compose() mapper, not in fetchCustomer(). But the retry boundary is the outer method, not the compose step. .recover() rebuilds the pipeline from the first step. There is no mechanism in Vert.x to retry from an arbitrary step in a Future chain. Every .recover() retry re-executes the recovery function, which calls buildBillingFuture(), which starts the pipeline from fetchCustomer() again.
A secondary consequence: fetchCustomer() runs on every retry. This means an extra database query per retry attempt. In a high-volume billing job this adds up, and it also means the customer data loaded on attempt 1 (at time T) and the customer data loaded on retry attempt 2 (at time T + N seconds) could differ if another process modified the record between attempts. This is usually not a problem for billing (the Stripe customer ID and amount are stable), but it is worth noting as an operational cost.
The deeper trap: UUID placed “inside the charge step” is still inside the retried method
Developers who have read about reactive idempotency sometimes move the UUID from the method body to “inside the charge step” as a way to avoid the Mode 1 failure pattern (where UUID is at the top of the method body, obviously in the retried scope). The .compose() mapper feels more scoped — it is only called when the upstream Future succeeds, meaning it is only called when there is a customer to charge. But “inside the charge step” and “inside the retried method” are not mutually exclusive. The .compose() mapper is a function passed to a Future combinator — it is part of the method’s implementation. When the outer method is retried, the entire implementation re-executes, including the mapper.
Fix for mode 2
Compute the idempotency key before the pipeline is constructed — outside any .compose() mapper, and before any executeBlocking() call. Pass it as a parameter or capture it as a final local variable at method scope:
// SAFE: key computed at method scope — captured by closure in compose() mapper
public Future<String> buildBillingFuture(String customerId, int amountCents,
String billingPeriod) {
// Computed once, at method entry — same value on every call with same args.
final String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
return fetchCustomer(customerId)
.compose(customer -> {
// compose() mapper captures idempotencyKey from the enclosing method scope.
// Same String value on every invocation of this mapper.
return vertx.executeBlocking(() -> {
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customer.getStripeCustomerId())
.setAmount((long) amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
return Charge.create(params, opts).getId();
});
});
}
// Caller: same billingPeriod passed to all invocations
public Future<String> chargeWithRetry(String customerId, int amountCents,
String billingPeriod) {
return buildBillingFuture(customerId, amountCents, billingPeriod)
.recover(err -> buildBillingFuture(customerId, amountCents, billingPeriod))
.recover(err -> buildBillingFuture(customerId, amountCents, billingPeriod));
}
Now when buildBillingFuture() is called again by .recover(), the first line computes idempotencyKey as "charge:cus_abc:4999:2026-10" — exactly the same string as the first call, because the arguments are identical. The .compose() mapper captures this string from the enclosing method scope. executeBlocking()’s Callable captures it from the enclosing .compose() scope. Stripe receives the same key on the retry and returns the existing ch_A without creating a new charge.
The fetch of customer data from the database still re-runs on each retry (because fetchCustomer() is still inside the retried pipeline). This is acceptable: the customer lookup is read-only and the result is stable for billing purposes. If the extra query is expensive, consider caching the result or passing the customer data as a parameter.
Mode 3: UUID.randomUUID() in method body — vertx.setTimer() backoff retry calls method again — UUID_B — ch_B
The third failure mode involves vertx.setTimer(), which is Vert.x’s standard mechanism for scheduling delayed work. In other JVM frameworks you might use Thread.sleep(N) between retry attempts, but Thread.sleep() on a Vert.x event-loop thread stalls the entire event loop for all other handlers. The Vert.x-idiomatic way to introduce backoff between retries is vertx.setTimer(delayMs, timerId -> retryAttempt()): this schedules a callback on the event loop after delayMs milliseconds without blocking the event loop during the wait.
Developers who switch from a simple Future.recover()-based retry to a setTimer()-based retry-with-backoff often introduce mode 3 during the refactor. The original code might have had the UUID at method scope (either from mode 1 being fixed, or from mode 2 being fixed) and the refactored code moves the UUID back inside the method body to “keep the key fresh per attempt” — at which point the backoff retry creates the UUID regeneration bug.
// UNSAFE: UUID inside method body + setTimer() backoff retry
public Future<String> chargeCustomer(String customerId, int amountCents) {
// Developer thought: "generate a fresh UUID per charge attempt for cleanliness"
String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE: new UUID per call
return vertx.executeBlocking(() -> {
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount((long) amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
return Charge.create(params, opts).getId();
});
}
// Caller with setTimer() exponential backoff:
public Future<String> chargeWithBackoff(String customerId, int amountCents,
int attempt, Promise<String> result) {
chargeCustomer(customerId, amountCents) // UUID_A on attempt 0
.onSuccess(chargeId -> result.complete(chargeId))
.onFailure(err -> {
if (attempt < 3) {
long delayMs = (long) Math.pow(2, attempt) * 1000L; // 1s, 2s, 4s
// setTimer() schedules the retry callback on the event loop after delayMs.
// The callback calls chargeCustomer() again — UUID_B on attempt 1.
vertx.setTimer(delayMs, timerId ->
chargeWithBackoff(customerId, amountCents, attempt + 1, result));
} else {
result.fail(err);
}
});
return result.future();
}
The failure sequence: chargeWithBackoff() is called with attempt=0. chargeCustomer() is called. idempotencyKey in chargeCustomer()’s first line is UUID_A. The executeBlocking() task runs: Stripe creates ch_A with UUID_A. The network response is lost. The .onFailure() handler fires. setTimer(1000L, ...) schedules a retry after 1 second. The event loop continues processing other handlers. After 1 second, the timer fires and the callback calls chargeWithBackoff() with attempt=1. Inside chargeWithBackoff(), chargeCustomer() is called again. The first line of chargeCustomer() executes: UUID.randomUUID() generates UUID_B. Charge.create() runs with UUID_B. Stripe has no record of UUID_B and creates ch_B.
The developer’s reasoning: “I want a fresh idempotency key per attempt.” This appears reasonable — if an attempt succeeds, you don’t want a stale key. But the Stripe idempotency key’s purpose is precisely to make retries of the same billing intent safe. A fresh key per attempt defeats this purpose entirely: it tells Stripe “this is a new, independent charge request” rather than “this is a retry of the same charge I already requested.”
The setTimer() backoff and the stale-key misconception
The “fresh key per attempt” reasoning often comes from a misunderstanding of what Stripe’s idempotency key deduplication window means in practice. Stripe’s idempotency keys are valid for 24 hours. A retry within seconds or minutes of the first attempt is well within this window. Using the same key on a retry does not risk an “expired key” error — it returns the result of the previous successful attempt, which is exactly what you want.
The only scenario where reusing an idempotency key causes a Stripe error is if the request payload changes while the key stays the same — for example, retrying with a different amount value under the same key. Stripe returns HTTP 422 with code idempotency_key_in_use or idempotency_key_reuse in this case. Content-hash idempotency keys (which include the amount and customer ID in the key derivation) are inherently safe against this: a changed amount produces a different key, so Stripe processes it as a new request and charges the updated amount. No payload change means the key is identical and Stripe deduplicates correctly.
Fix for mode 3
Compute the idempotency key in the caller, before any charge attempt, and pass it as a parameter to every attempt. The key is stable across all retry attempts because it is derived from the stable billing intent parameters:
// SAFE: idempotency key computed once by caller — passed as parameter
public Future<String> chargeCustomer(String customerId, int amountCents,
String idempotencyKey) {
return vertx.executeBlocking(() -> {
ChargeCreateParams params = ChargeCreateParams.builder()
.setCustomer(customerId)
.setAmount((long) amountCents)
.setCurrency("usd")
.build();
RequestOptions opts = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
return Charge.create(params, opts).getId();
});
}
public Future<String> chargeWithBackoff(String customerId, int amountCents,
String billingPeriod) {
// Key computed once, before the first attempt.
// Passed to every attempt — including all retries scheduled by setTimer().
final String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
Promise<String> result = Promise.promise();
scheduleAttempt(customerId, amountCents, idempotencyKey, 0, result);
return result.future();
}
private void scheduleAttempt(String customerId, int amountCents,
String idempotencyKey, int attempt,
Promise<String> result) {
chargeCustomer(customerId, amountCents, idempotencyKey) // same key every attempt
.onSuccess(chargeId -> result.tryComplete(chargeId))
.onFailure(err -> {
if (attempt < 3) {
long delayMs = (long) Math.pow(2, attempt) * 1000L;
vertx.setTimer(delayMs, timerId ->
scheduleAttempt(customerId, amountCents,
idempotencyKey, attempt + 1, result));
} else {
result.tryFail(err);
}
});
}
The idempotency key is computed once in chargeWithBackoff(), outside any retry loop or timer callback. It is passed as a parameter to every call to chargeCustomer(). The setTimer() callback captures it by closure from the enclosing scheduleAttempt() scope. On attempt 0: chargeCustomer(customerId, amountCents, "charge:cus_abc:4999:2026-10"). If it fails and a retry fires after 1 second: chargeCustomer(customerId, amountCents, "charge:cus_abc:4999:2026-10") — same key. Stripe deduplicates and returns ch_A.
Notice the use of result.tryComplete() and result.tryFail() instead of result.complete() and result.fail(). A Promise can only be completed once; calling complete() on an already-completed Promise throws an IllegalStateException. If the timer fires a retry but the original attempt’s success handler fires first (race condition between the timer and a late-arriving success response), tryComplete() is a no-op on the second completion, while complete() would throw. This is a separate correctness concern from idempotency but worth addressing in the same fix.
Cross-mode analysis: how these three modes relate to each other and to the broader retry-idempotency pattern
All three failure modes share the same root structure: UUID.randomUUID() is placed inside a scope that is re-executed when a retry mechanism creates a new computation unit. The scope differs by mode:
- Mode 1: UUID inside the
executeBlocking()Callable — the “new computation unit” is a new Callable submitted to the worker pool by a newexecuteBlocking()call. - Mode 2: UUID inside the
.compose()mapper — the “new computation unit” is a new Future pipeline rebuilt from the first step, which re-invokes the compose mapper. - Mode 3: UUID inside the method body — the “new computation unit” is a new method invocation triggered by a
setTimer()callback.
Mode 1 vs. Vert.x Web Client (Future.recover() calling doCharge() again): The Web Client post noted in passing that Future.recover() calling a method again generates a new UUID. Mode 1 in this post covers the same mechanism but specifically for the blocking Stripe Java SDK via executeBlocking(). The Web Client post focused on HTTP client calls (RxJava Single.defer() + retryWhen()); this post focuses on the synchronous Stripe SDK that requires worker thread isolation. The mechanism of UUID regeneration is the same, but the context and the required fix path differ: Web Client callers often move to RxJava Single.defer(() -> {final String key = UUID.randomUUID(); ...}) to stabilize the key within the cold observable — which does not apply to executeBlocking(), where there is no cold-observable subscription to stabilize.
Mode 2 vs. Quarkus Mutiny transformToUni() mapper (session 284): The Quarkus post covered UUID.randomUUID() inside a Mutiny transformToUni(mapper) with an outer onFailure().retry(). The mechanism is structurally identical: an intermediate mapper function in a reactive pipeline is re-invoked when the outer pipeline is retried. The key difference is the cold vs. hot eagerness model. Mutiny’s Uni is cold — retry re-subscribes to the Uni, which re-invokes the mapper on the re-subscription path. Vert.x Future is hot — retry calls the outer method again, which rebuilds the pipeline from scratch, re-invoking the mapper as part of pipeline construction. The observable outcome is the same (UUID regenerates on retry), but the precise mechanism differs. The fix is identical: compute the UUID before the pipeline, pass it into the pipeline as a closure-captured variable.
Mode 3 vs. vertx.executeBlocking() mode 1: Both modes involve UUID inside a method body, and both involve retry by calling the method again. The difference is the retry trigger: mode 1 uses synchronous Future.recover() (fires immediately on failure), while mode 3 uses vertx.setTimer() (fires after a delay). The delay does not affect the UUID regeneration — it just changes when the new method call happens. A developer who fixes mode 1 correctly (moving UUID out of the method to a billingPeriod-derived content-hash) and then adds setTimer() backoff without changing the key derivation remains safe. A developer who fixes mode 1 incorrectly by moving UUID to “just before executeBlocking()” (still inside the method body, just not inside the Callable) is still vulnerable to mode 3 when setTimer() retries call the method again.
Vert.x vs. annotation-driven AOP retry (@Retryable, @Retry): Vert.x has no equivalent of Spring’s @Retryable, Micronaut’s @Retryable, or Quarkus’s SmallRye FT @Retry. All retry logic is explicit: the developer writes the retry handler, whether via .recover(), setTimer(), or a recursive method. This means there is no AOP proxy interception that “silently” calls the method body again — in Vert.x, the retry is always visible in the code. This makes the failure modes more traceable: you can read the code and identify exactly where the method is called again. It does not, however, prevent the UUID regeneration: even explicit, visible retry calls create a new method invocation with a new UUID if the UUID is inside the method body.
Vert.x Future eagerness and “one-shot” semantics: A Vert.x Future can only complete once. Once it transitions to succeeded or failed, it stays in that terminal state. You cannot re-subscribe to it. This means you cannot build a retry mechanism that replays the same Future — you must always create a new Future for each retry. This is architecturally cleaner than RxJava (where a cold Single can be retried by re-subscribing without calling the outer method) but it means that any Vert.x retry mechanism is a new method invocation. The UUID must therefore be computed outside the retried scope in every case.
Testing: detecting UUID regeneration in Vert.x Future pipelines
Integration tests for Vert.x billing services use @ExtendWith(VertxExtension.class) from vertx-junit5 and WireMock for simulating Stripe’s API. The test strategy is identical across all three modes: configure WireMock to return a 503 on the first attempt and a 200 on the second, capture all Idempotency-Key headers sent to the simulated Stripe endpoint, and assert that all captured keys are equal. A test that passes only with the safe content-hash key and fails with any UUID.randomUUID()-based key confirms the fix.
@ExtendWith(VertxExtension.class)
class BillingServiceIdempotencyTest {
private BillingService billingService;
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@BeforeEach
void setUp(Vertx vertx) {
// Point the billing service at WireMock's port.
Stripe.overrideApiBase("http://localhost:" + wireMock.getPort());
billingService = new BillingService(vertx);
}
@Test
void chargeWithRetry_carries_same_idempotency_key_on_all_attempts(
Vertx vertx, VertxTestContext testCtx) {
List<String> capturedKeys = Collections.synchronizedList(new ArrayList<>());
// First attempt: 503 — Stripe processed the charge server-side but
// the response was lost (simulating ch_A committed before timeout).
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("idempotency-test")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse()
.withStatus(503)
.withBody("{\"error\":{\"type\":\"api_error\",\"message\":\"service unavailable\"}}"))
.willSetStateTo("first-retry"));
// Second attempt: 200 — Stripe returns ch_A (deduplicated by same key).
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("idempotency-test")
.whenScenarioStateIs("first-retry")
.willReturn(aResponse()
.withStatus(200)
.withBody(buildChargeResponseJson("ch_test123"))));
// Capture every Idempotency-Key header sent to /v1/charges.
wireMock.addMockServiceRequestListener((request, response) -> {
if ("/v1/charges".equals(request.getUrl())) {
String key = request.getHeader("Idempotency-Key");
if (key != null) capturedKeys.add(key);
}
});
billingService.chargeWithRetry("cus_test", 4999, "2026-10")
.onSuccess(chargeId -> testCtx.verify(() -> {
// Two attempts were made (1 failure + 1 success).
assertThat(capturedKeys).hasSize(2);
// Both attempts must carry the same idempotency key.
// If UUID regenerated on retry, new HashSet(capturedKeys).size() == 2
// and this assertion fails — exposing the unsafe UUID placement.
assertThat(new HashSet<>(capturedKeys)).hasSize(1);
testCtx.completeNow();
}))
.onFailure(testCtx::failNow);
}
}
The assertion assertThat(new HashSet<>(capturedKeys)).hasSize(1) is the key line. For the unsafe UUID.randomUUID()-based implementations (modes 1, 2, and 3), capturedKeys contains two different UUID strings — the first attempt’s UUID and the retry’s UUID. The HashSet deduplication produces two distinct elements, and the assertion fails. For the safe content-hash key implementation, capturedKeys contains the same string twice — "charge:cus_test:4999:2026-10" on both attempts — the HashSet deduplicates to one element, and the assertion passes.
Testing mode 2 specifically: the compose() mapper re-invocation
Mode 2 requires an additional verification: confirming that the database lookup step (fetchCustomer()) is indeed re-executed on retry and that the UUID inside the mapper re-generates. The test for mode 2 uses an in-memory mock for fetchCustomer() and counts its invocations:
@Test
void buildBillingFuture_compose_mapper_receives_new_uuid_on_retry(
Vertx vertx, VertxTestContext testCtx) {
List<String> capturedKeys = Collections.synchronizedList(new ArrayList<>());
AtomicInteger fetchCustomerCallCount = new AtomicInteger(0);
// Configure the billing service to use an instrumented customer fetch.
BillingService service = new BillingService(vertx,
customerId -> {
fetchCustomerCallCount.incrementAndGet();
return Future.succeededFuture(
new Customer(customerId, "cus_stripe_abc"));
});
// WireMock: fail on first, succeed on second.
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("compose-retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503)
.withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("first-retry"));
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("compose-retry")
.whenScenarioStateIs("first-retry")
.willReturn(aResponse().withStatus(200)
.withBody(buildChargeResponseJson("ch_compose_test"))));
wireMock.addMockServiceRequestListener((request, response) -> {
if ("/v1/charges".equals(request.getUrl())) {
String key = request.getHeader("Idempotency-Key");
if (key != null) capturedKeys.add(key);
}
});
service.chargeWithRetry("cus_test", 4999, "2026-10")
.onSuccess(id -> testCtx.verify(() -> {
// Both the idempotency key stability assertion...
assertThat(new HashSet<>(capturedKeys)).hasSize(1);
// ...and the fetchCustomer() call count assertion.
// Unsafe code: fetchCustomerCallCount == 2 (once per retry).
// Safe code: still 2 (fetchCustomer always re-runs in this pipeline).
// Key assertion is what differs between safe and unsafe.
assertThat(capturedKeys).hasSize(2);
assertThat(capturedKeys.get(0)).isEqualTo(capturedKeys.get(1));
testCtx.completeNow();
}))
.onFailure(testCtx::failNow);
}
Testing mode 3: the setTimer() backoff retry
Mode 3 is trickier to test because vertx.setTimer() is asynchronous and time-dependent. The standard approach is to use a test Vert.x instance with a fake timer or to configure the backoff delay to be very short (1 millisecond) in tests to avoid slowing down the test suite. The VertxTestContext timeout controls the maximum test duration:
@Test
@Timeout(value = 10, timeUnit = TimeUnit.SECONDS)
void chargeWithBackoff_carries_same_key_across_timer_retry(
Vertx vertx, VertxTestContext testCtx) {
List<String> capturedKeys = Collections.synchronizedList(new ArrayList<>());
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("backoff-retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503)
.withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("first-retry"));
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("backoff-retry")
.whenScenarioStateIs("first-retry")
.willReturn(aResponse().withStatus(200)
.withBody(buildChargeResponseJson("ch_backoff_test"))));
wireMock.addMockServiceRequestListener((request, response) -> {
if ("/v1/charges".equals(request.getUrl())) {
String key = request.getHeader("Idempotency-Key");
if (key != null) capturedKeys.add(key);
}
});
// Use 1ms backoff in tests to avoid slow test suites.
billingService.chargeWithBackoff("cus_test", 4999, "2026-10",
/* minBackoffMs= */ 1L)
.onSuccess(id -> testCtx.verify(() -> {
assertThat(capturedKeys).hasSize(2);
assertThat(new HashSet<>(capturedKeys)).hasSize(1);
testCtx.completeNow();
}))
.onFailure(testCtx::failNow);
}
The relationship between Vert.x Future and the Keybrake proxy model
All three failure modes produce a ch_A / ch_B double-charge that is invisible to the application. The application database records only the charge ID from the successful retry attempt. ch_A remains in Stripe’s ledger as an unreferenced charge — no database row points to it, no application flow references it, but it has taken money from the customer.
Standard reconciliation catches this eventually: a nightly job that compares Stripe charge records against application billing records will surface the orphaned ch_A. But the reconciliation window (typically 24–48 hours) means the customer may notice first, via a credit card notification, before your reconciliation job runs.
The upstream governance layer pattern — issuing a per-billing-period vault key with a USD spend cap equal to customerCount × maxChargeAmount × 1.10 (10% buffer for legitimate retries) — provides a second line of defense. If mode 1, 2, or 3 fires at scale (a billing job retrying hundreds of customers due to a transient Stripe outage), the spend cap limits the blast radius to the expected billing total plus a 10% buffer. The proxy rejects any charge that would exceed the cap. This doesn’t prevent a single duplicate charge from occurring, but it prevents a runaway retry loop from creating thousands of duplicate charges before anyone notices.
The combination of content-hash idempotency keys (prevents duplicates at the Stripe level) and per-period spend caps (limits blast radius when the idempotency key is wrong) provides two independent layers of protection. Neither layer alone is sufficient for a high-volume billing job: a correctly-keyed request that Stripe deduplicates correctly still benefits from a spend cap (protection against algorithm bugs in the amount calculation), and a spend cap without correct keys leaves individual customers exposed to duplicate charges up to the moment the cap triggers.
Summary
Three Vert.x-native Future failure modes produce Stripe duplicate charges:
- UUID inside
vertx.executeBlocking()Callable body:Future.recover()retry calls the method again, submitting a new Callable to the worker pool. The new Callable’s body invokesUUID.randomUUID()again. UUID_B. ch_B. Fix: compute the key beforeexecuteBlocking(), outside the Callable, as a stable content-hash from immutable billing intent data. Pass it as a parameter or capture it as a final local variable at method scope. - UUID inside
.compose()mapper function:Future.recover()on the outer Future calls the method again, rebuilding the entire pipeline from the first step. Every.compose()mapper is re-invoked, including the one that generates the UUID. UUID_B. ch_B. Fix: compute the key at method scope, before any.compose()chain construction. Capture it by closure in the mapper. The mapper receives the same stable string on every invocation of the pipeline. - UUID inside method body with
vertx.setTimer()backoff: The timer fires and calls the billing method again after the delay. The method body executesUUID.randomUUID()again. UUID_B. ch_B. Fix: compute the key in the caller, before the first attempt, and pass it as a parameter to all retry attempts, including those scheduled bysetTimer(). Capture it in thesetTimer()callback closure.
In all three cases the fix is the same logical operation: move the idempotency key computation to a scope that is outside the retry boundary. The retry boundary in Vert.x is always a new method invocation or a new executeBlocking() task. Anything inside that boundary re-evaluates on retry. Content-hash keys derived from immutable billing intent parameters (customerId, amountCents, billingPeriod) are inherently stable because calling the same method with the same arguments produces the same key. The key does not need to be generated, stored, or looked up — it is computed on demand from inputs that are already stable.
Keybrake: per-vendor spend caps for the APIs your agents call
A scoped API-key proxy for Stripe, Twilio, and Resend. Issue a vault key with a daily USD cap, allowlisted endpoints, and a kill switch. The proxy sits between your agent and the vendor — enforcing the cap pre-call, logging every request with parsed cost, and letting you revoke in one click without rotating the upstream secret.