Helidon SE FaultTolerance.builder() and Stripe Integration: How Programmatic Retry Chain Composition, Per-Attempt Virtual Thread Timeout Interrupts, and Async CompletionStage Re-invocation Generate New Idempotency Keys on Retry
Helidon SE 4.x introduces three Stripe billing failure modes that are structurally distinct from the CDI-based failure modes in the Helidon MicroProfile Fault Tolerance post. Helidon SE has no CDI container, no annotation scanning, and no SmallRye Fault Tolerance interceptor priority ordering. Fault tolerance is entirely programmatic: Retry, Timeout, and CircuitBreaker instances are built via their own fluent builders and composed by FaultTolerance.builder(). The three SE-specific failure modes are: UUID.randomUUID() inside an FtSupplier lambda body regenerates on every Retry re-invocation because the lambda is a functional interface that Retry.invoke() calls again from the top on each attempt; FaultTolerance.builder().addRetry().addTimeout() creates a per-attempt Timeout that fires a ScheduledExecutorService-based virtual thread interrupt — when Stripe commits ch_A at second 2.9 of a 3.0-second timeout deadline and the interrupt fires before the client reads the response, Retry retries with UUID_B — ch_B; and Retry.invokeCompletionStage() re-invokes the Supplier<CompletionStage<T>> on exceptional stage completion, and UUID.randomUUID() inside the stage supplier generates UUID_B for every retry subscription.
Background: Helidon SE vs Helidon MicroProfile — why the failure modes differ
Helidon ships two programming models. Helidon MicroProfile (MP) is a CDI-based model where fault tolerance behavior is declared with @Retry, @Timeout, @CircuitBreaker, and @Fallback annotations from the MicroProfile Fault Tolerance specification. The runtime (Weld CDI + SmallRye Fault Tolerance) applies interceptors whose CDI priority values determine the interception chain order. The failure modes in Helidon MP — including the @Transactional (priority 200) outer / SmallRye FT (priority 1000) inner ordering and the @Timeout virtual thread interrupt — are described in the Helidon MicroProfile post.
Helidon SE is a different programming model entirely. There is no CDI container. There is no SmallRye Fault Tolerance. Applications are structured as plain Java programs where developers wire dependencies manually. Fault tolerance is provided by Helidon SE’s io.helidon.faulttolerance module, which exposes a purely programmatic API. The key types are:
Retry— built viaRetry.builder().retryOn(...).maxAttempts(...).delay(...).build(); invoked by callingretry.invoke(supplier)orretry.invokeCompletionStage(supplier).Timeout— built viaTimeout.builder().timeout(Duration.ofSeconds(3)).build(); applied per invocation in aFaultTolerancechain.CircuitBreaker— built viaCircuitBreaker.builder().build(); tracks failure rates over a rolling window.Bulkhead— built viaBulkhead.builder().limit(10).build(); enforces concurrency limits via a semaphore.FaultTolerance— the composition builder:FaultTolerance.builder().addRetry(retry).addTimeout(timeout).build()produces a typed handler that composes the listed handlers into a chain.
The FtSupplier<T> functional interface is the abstraction that each handler invokes. It is equivalent to Callable<T> but throws Throwable. When Retry.invoke(supplier) catches a retryable exception, it calls the supplier again — it does not call the supplier once and replay a cached invocation. This is the root of all three failure modes: any side-effecting computation inside the supplier body, including UUID.randomUUID(), re-executes on every retry attempt.
This distinction from Helidon MP matters for billing code. In Helidon MP, the @Retry interceptor wraps the CDI proxy method invocation. Developers who understand CDI proxies know that a proxy re-dispatches to the bean instance on each call and therefore understand that method-entry code re-executes on retry. In Helidon SE, the framing is different: developers call retry.invoke(() -> { ... }) and the lambda looks like a block of code that “describes” the operation. The lambda does not look like a function that will be called multiple times. Developers who have not internalized that lambdas are functional interfaces called anew per retry invocation write UUID.randomUUID() inside the lambda body and expect it to be evaluated once, when the lambda is first submitted.
Failure mode 1: UUID.randomUUID() inside the FtSupplier lambda body — Retry re-invokes the lambda from the top — UUID_B — ch_B
A Helidon SE billing service creates a Retry instance at construction time and calls retry.invoke() in the billing method, passing a lambda that builds the Stripe request and calls the Stripe SDK:
// BillingService.java — UNSAFE: UUID.randomUUID() inside the FtSupplier
// lambda body re-evaluates on every Retry re-invocation.
// Developer mental model: invoke() calls the lambda once; on StripeException,
// Retry transparently repeats the "same call."
// Actual behavior: each Retry re-invocation calls the lambda from the top —
// UUID.randomUUID() generates UUID_B on the first retry — ch_B.
public class BillingService {
private final StripeClient stripeClient;
private final Retry retry;
public BillingService(StripeClient stripeClient) {
this.stripeClient = stripeClient;
this.retry = Retry.builder()
.retryOn(StripeException.class)
.maxAttempts(3)
.delay(Duration.ofMillis(1000))
.delayFactor(2.0)
.build();
}
public ChargeResult createCharge(String userId, String billingPeriod, long amountCents)
throws Exception {
// The lambda is the FtSupplier. It is called once per attempt.
// On Retry re-invocation (attempt 2, attempt 3), the entire lambda body
// executes from the opening brace — including UUID.randomUUID().
return retry.invoke(() -> {
// UUID_A on attempt 1.
// UUID_B on Retry re-invocation (attempt 2) — Stripe has never seen
// this key — ch_B alongside committed ch_A.
String idempotencyKey = billingPeriod + ":" + userId + ":" + UUID.randomUUID();
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw e; // rethrown directly — Retry catches and re-invokes the supplier
}
});
}
}
The failure sequence when Stripe commits ch_A but a transient 503 prevents the client from receiving the response:
- Caller invokes
createCharge("user-007", "2026-Q4", 4900L). retry.invoke(supplier)calls theFtSupplierfor attempt 1.- Lambda executes.
UUID.randomUUID()→UUID_A = "b3d1-...". Stripe receivesIdempotency-Key: 2026-Q4:user-007:b3d1-.... Stripe processes the charge and commitsch_A = "ch_111". Before delivering the 200 response, a transient network error returns HTTP 503. StripeExceptionis thrown from inside the lambda. The exception propagates out of the lambda body toretry.invoke().Retrychecks whetherStripeExceptionis in itsretryOnset. It is. Retry waits 1 second (configured delay). Retry calls the supplier again — re-invoking the lambda from its opening brace.- Lambda executes again for attempt 2.
UUID.randomUUID()→UUID_B = "c7a2-...". Stripe receivesIdempotency-Key: 2026-Q4:user-007:c7a2-.... Stripe has never seen this key. Stripe createsch_B = "ch_222". - Both
ch_Aandch_Bare committed in Stripe foruser-007/2026-Q4. The application records onlych_B.ch_Ais billed to the customer with no application record.
Why the lambda mental model breaks for retry
Java lambdas are instances of functional interfaces. When you write retry.invoke(() -> { ... }), you are passing a FtSupplier<ChargeResult> object whose single abstract method contains the lambda body. Every time Retry calls supplier.get(), the lambda body executes in full from the opening brace. There is no caching, memoization, or “replay” of the previous invocation. The lambda body is not a description of the operation that Retry executes once and re-uses — it is a function that Retry calls as many times as maxAttempts requires.
This is no different, mechanically, from calling a method:
// Equivalent to the lambda form — makes the re-invocation obvious:
public ChargeResult createChargeAttempt(String userId, String billingPeriod, long amountCents)
throws StripeException {
String idempotencyKey = billingPeriod + ":" + userId + ":" + UUID.randomUUID(); // re-runs per call
...
}
// Retry calls createChargeAttempt() on each attempt — UUID_B on attempt 2.
return retry.invoke(() -> createChargeAttempt(userId, billingPeriod, amountCents));
Written as a method reference, it is immediately apparent that the method body (including UUID.randomUUID()) runs on every call. The lambda form obscures this because the lambda body appears to be a self-contained block that “the retry mechanism” handles. Both forms are identical at the bytecode level.
The test gap: Helidon SE test harness succeeds on first attempt
Helidon SE 4.x uses HelidonTest (or @HelidonTest in the JUnit 5 extension) to start a Helidon SE server for integration tests. WireMock can be configured as the Stripe endpoint. A typical test configures WireMock to return HTTP 200 on the first call:
@HelidonTest
class BillingServiceTest {
@Test
void createCharge_returnsChargeId() {
// WireMock stub: always succeed on first call
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"id\":\"ch_111\",\"amount\":4900}")));
ChargeResult result = billingService.createCharge("user-007", "2026-Q4", 4900L);
assertThat(result.chargeId()).isEqualTo("ch_111");
}
}
This test confirms that the billing method returns the correct charge ID on success. It does not exercise the Retry path. The lambda body with UUID.randomUUID() is called exactly once; the UUID is never compared across invocations; the retry re-invocation path is structurally invisible to this test configuration.
The developer concludes from this test that the billing service is correct, including the Retry configuration. The double-charge path is first encountered in production when Stripe returns a transient 5xx response.
The fix: compute the idempotency key before the lambda, capture it as a final variable
The correct fix is to compute the idempotency key outside the FtSupplier lambda. Because the key is computed before retry.invoke() is called, it is evaluated exactly once per createCharge() call. The lambda captures the key as a final (or effectively final) variable and never calls UUID.randomUUID() internally:
// BillingService.java — SAFE: idempotency key computed BEFORE retry.invoke().
// The lambda captures it as a final variable — same key on every Retry re-invocation.
public ChargeResult createCharge(String userId, String billingPeriod, long amountCents)
throws Exception {
// Computed once per createCharge() call — before the Retry boundary.
// Content-hash from user-invariant inputs: no UUID.randomUUID().
final String idempotencyKey = sha256Hex(
userId + ":" + billingPeriod + ":helidon-se-billing"
).substring(0, 32);
return retry.invoke(() -> {
// idempotencyKey is captured from the outer scope — always the same value
// regardless of which attempt this lambda invocation is.
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw e;
}
});
}
private static String sha256Hex(String input) {
try {
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();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
The test that would have caught the original bug:
@Test
void createCharge_retryPreservesIdempotencyKey() {
// Attempt 1: Stripe returns 503 (transient error).
// Attempt 2: Stripe returns 200 (success).
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("transient-failure")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("attempt-2"));
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("transient-failure")
.whenScenarioStateIs("attempt-2")
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"id\":\"ch_111\",\"amount\":4900}")));
billingService.createCharge("user-007", "2026-Q4", 4900L);
// Capture Idempotency-Key headers from all WireMock requests.
List<LoggedRequest> allRequests = findAll(postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(allRequests).hasSize(2);
String keyAttempt1 = allRequests.get(0).getHeader("Idempotency-Key");
String keyAttempt2 = allRequests.get(1).getHeader("Idempotency-Key");
// CRITICAL: both keys must be IDENTICAL — not just non-null.
assertThat(keyAttempt1).isEqualTo(keyAttempt2);
}
Failure mode 2: FaultTolerance.builder().addRetry().addTimeout() — per-attempt virtual thread timeout interrupt fires after Stripe commits ch_A — Retry retries with UUID_B — ch_B
The second failure mode involves the FaultTolerance.builder() chain composition feature and Helidon SE’s Timeout interrupt mechanism. A developer wants both per-attempt timeouts and retry-on-timeout behavior and uses FaultTolerance.builder() to compose them:
// BillingService.java — UNSAFE: UUID.randomUUID() inside the FtSupplier lambda.
// The FaultTolerance chain applies Timeout per attempt (innermost).
// When Timeout fires Thread.interrupt() on the virtual thread after Stripe
// committed ch_A, TimeoutException propagates to Retry (outermost).
// Retry re-invokes the supplier — UUID_B — ch_B.
public class BillingService {
private final StripeClient stripeClient;
// Retry is the first added to the builder → Retry is the OUTERMOST handler.
// Timeout is the second added → Timeout is the INNERMOST handler (per attempt).
// Invocation chain: Retry.invoke → Timeout.invoke → supplier
private final FtHandler<ChargeResult> ftHandler;
public BillingService(StripeClient stripeClient) {
this.stripeClient = stripeClient;
Retry retry = Retry.builder()
.retryOn(TimeoutException.class, StripeException.class)
.maxAttempts(3)
.delay(Duration.ofMillis(500))
.build();
Timeout timeout = Timeout.builder()
.timeout(Duration.ofSeconds(3)) // per-attempt deadline
.build();
// FaultTolerance.builder() chain: first added = outermost.
// addRetry first → Retry is outermost (retries the Timeout+supplier chain).
// addTimeout second → Timeout is innermost (applied per attempt).
this.ftHandler = FaultTolerance.builder()
.addRetry(retry)
.addTimeout(timeout)
.build();
}
public ChargeResult createCharge(String userId, String billingPeriod, long amountCents)
throws Exception {
return ftHandler.invoke(() -> {
// UUID.randomUUID() at lambda entry — UUID_A on attempt 1.
// On Timeout interrupt + Retry re-invocation — UUID_B — ch_B.
String idempotencyKey = billingPeriod + ":" + userId + ":" + UUID.randomUUID();
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw e;
}
});
}
}
How Helidon SE Timeout fires on virtual threads
Helidon SE 4.x is built on Project Loom virtual threads. Blocking I/O calls — including the Stripe Java SDK’s OkHttpClient-based HTTP calls — are idiomatic on virtual threads; they block the virtual thread without occupying a carrier (platform) thread. Timeout in Helidon SE is implemented with a ScheduledExecutorService. When Timeout.invoke(supplier) runs:
- The supplier is executed on the current thread (the calling virtual thread).
- A timeout task is scheduled: after the deadline duration, call
Thread.interrupt()on the executing thread. - If the supplier returns before the deadline, the timeout task is cancelled.
- If the deadline passes first, the scheduled task fires
Thread.interrupt()on the virtual thread. - The interrupted virtual thread’s blocking call (e.g., the blocking read waiting for the Stripe HTTP response) throws
InterruptedIOException. - The Stripe SDK wraps this as a
StripeTimeoutException(a subclass ofStripeException) or, depending on the SDK version and error wrapping, anIOException. Timeout’s invoke catches this and throwsTimeoutException.TimeoutExceptionpropagates to the outerRetryhandler.Retrychecks whetherTimeoutExceptionis in itsretryOnset. If yes, it re-invokes the supplier.
This mechanism has a precise timing window that produces a double charge. The Stripe network round-trip for a POST /v1/charges call typically takes 200–800 milliseconds under normal conditions. On occasional slow days or during Stripe infrastructure events, it may take 2–5 seconds. The 3-second per-attempt timeout fires Thread.interrupt() at exactly second 3.0.
The critical window: Stripe receives the request and begins processing. Stripe commits the charge internally and begins composing the HTTP 200 response at second 2.9. The network response is in transit at second 2.95. The Helidon SE application is still waiting on the blocking read. At second 3.0, Thread.interrupt() fires. The blocking read is interrupted before the HTTP 200 response bytes arrive. The application never reads the charge ID. ch_A is committed in Stripe; the application has no record of it.
The failure sequence with per-attempt timeout
- Caller invokes
createCharge("user-007", "2026-Q4", 4900L). ftHandler.invoke(supplier)dispatches through the chain:Retry→Timeout→ supplier.Timeoutschedules the interrupt task for 3.0 seconds from now.Timeoutcalls the supplier.- Lambda body executes.
UUID.randomUUID()→UUID_A = "b3d1-...". Stripe call begins withIdempotency-Key: 2026-Q4:user-007:b3d1-.... - At second 2.9, Stripe commits
ch_A = "ch_111"internally and begins sending the 200 response. - At second 3.0, the
ScheduledExecutorServicefiresThread.interrupt()on the virtual thread. The blocking HTTP read is interrupted.InterruptedIOExceptionis thrown insideOkHttpClient. The Stripe SDK wraps this as aStripeTimeoutException.Timeoutcatches this and throwsTimeoutException. TimeoutExceptionreaches the outerRetryhandler.RetryhasretryOn(TimeoutException.class)configured. Retry waits 500 ms.- Retry re-invokes the supplier. Lambda body executes for attempt 2.
UUID.randomUUID()→UUID_B = "c7a2-...". Stripe call made withIdempotency-Key: 2026-Q4:user-007:c7a2-.... Stripe has never seen this key. Stripe createsch_B = "ch_222". This request returns in 400 ms.Timeoutcancels its interrupt task for this attempt.RetryreturnsChargeResult("ch_222", 4900). - The application records
ch_222.ch_111is billed to the customer with no application record.
How FaultTolerance.builder() ordering determines the chain
The Helidon SE FaultTolerance.builder() ordering rule is: the first added handler is the outermost. Each subsequently added handler wraps the previous inner chain. The supplier passed to the built handler is the innermost element. The invocation flow is strictly linear: the caller invokes the outermost handler, which calls the next handler in the chain as its own supplier, and so on until the original supplier is called at the innermost position.
| Builder call order | Chain position | Scope |
|---|---|---|
addRetry(retry) (first) | Outermost | Retries the entire inner chain on exception |
addTimeout(timeout) (second) | Innermost (before supplier) | Per-attempt deadline; interrupts the virtual thread |
Supplier (passed to invoke()) | Core | The actual billing logic; re-invoked per Retry attempt |
A developer who reverses the order — addTimeout(timeout) first, then addRetry(retry) — creates an overall timeout that wraps the entire Retry execution. This means: if all three retry attempts (including backoff delays) collectively take longer than 3 seconds, the entire chain is aborted. Individual attempts do not each get their own 3-second deadline. This is a different semantics from per-attempt timeout and is usually not what the developer intends. The ordering rule is a non-obvious distinction from Helidon MP, where interceptor priorities are numerical and easy to reason about relative to a spec-defined canonical order.
Why this failure mode is distinct from Helidon MP’s @Timeout
In Helidon MP (and Quarkus MP), @Timeout is implemented by SmallRye Fault Tolerance’s composite CDI interceptor, which also handles @Retry. The MP Fault Tolerance spec (section 7.2) defines the canonical execution order: Retry wraps Timeout for per-attempt timeout semantics. The interrupt mechanism is the same — SmallRye FT also uses Thread.interrupt() on the executing virtual thread. However, the trigger path differs: in MP, the composite interceptor fires Thread.interrupt() from inside the SmallRye FT implementation, which wraps the CDI method invocation. In SE, Timeout’s ScheduledExecutorService fires the interrupt from a scheduler thread external to the call stack.
The failure mode outcome is the same — commit-before-interrupt, retry, UUID_B, ch_B — but the developer reaches it by a different path. In SE, the developer explicitly composed the chain via FaultTolerance.builder() and chose to include TimeoutException.class in retryOn. The double-charge path depends on both that explicit choice and the UUID placement inside the lambda.
The fix: stable key outside the chain + abortOn for TimeoutException
Two complementary fixes. First, compute the key outside the lambda (same as Mode 1). Second, add TimeoutException to the Retry’s abortOn list. A per-attempt timeout expiring means Stripe may have already processed the charge — retrying with a new key is the wrong response:
// BillingService.java — SAFE: key outside lambda + TimeoutException in abortOn.
public class BillingService {
private final StripeClient stripeClient;
private final FtHandler<ChargeResult> ftHandler;
public BillingService(StripeClient stripeClient) {
this.stripeClient = stripeClient;
Retry retry = Retry.builder()
.retryOn(StripeException.class)
// TimeoutException in abortOn: if the per-attempt Timeout fires,
// we cannot safely retry — Stripe may have committed the charge.
// abortOn causes Retry to rethrow immediately without further attempts.
.abortOn(TimeoutException.class)
.maxAttempts(3)
.delay(Duration.ofMillis(500))
.build();
Timeout timeout = Timeout.builder()
.timeout(Duration.ofSeconds(3))
.build();
this.ftHandler = FaultTolerance.builder()
.addRetry(retry)
.addTimeout(timeout)
.build();
}
public ChargeResult createCharge(String userId, String billingPeriod, long amountCents)
throws Exception {
// Key computed ONCE before the FaultTolerance chain — stable across all
// Retry re-invocations. Not regenerated inside the lambda.
final String idempotencyKey = sha256Hex(
userId + ":" + billingPeriod + ":helidon-se-billing"
).substring(0, 32);
return ftHandler.invoke(() -> {
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw e;
}
});
}
}
With abortOn(TimeoutException.class), if the per-attempt timeout fires, Retry rethrows TimeoutException immediately rather than retrying. The caller receives the exception and can apply application-level logic: query the Stripe API with the original stable key to determine whether the charge was committed, or present the user with a “please check your billing” message. Retrying blindly with the same key using a re-query is safe because the stable key means Stripe’s idempotency layer will return the original charge result if it was committed.
Test that exposes the timing race
WireMock’s withFixedDelay() simulates a slow Stripe response that exceeds the per-attempt timeout:
@Test
void createCharge_perAttemptTimeoutAbortsRetry() {
// Stripe takes 4 seconds — exceeds the 3-second per-attempt Timeout.
// With abortOn(TimeoutException.class), Retry should NOT fire.
// WireMock should receive exactly 1 request.
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"id\":\"ch_111\",\"amount\":4900}")
.withFixedDelay(4000))); // 4 seconds > 3-second per-attempt Timeout
assertThrows(TimeoutException.class,
() -> billingService.createCharge("user-007", "2026-Q4", 4900L));
// If Retry had fired, WireMock would have received 2+ requests.
// With abortOn(TimeoutException.class), only 1 request ever reaches WireMock.
verify(1, postRequestedFor(urlEqualTo("/v1/charges")));
}
Failure mode 3: Retry.invokeCompletionStage() — stage supplier re-invoked on exceptional completion — UUID_B inside the stage supplier lambda
Helidon SE 4.x supports non-blocking HTTP clients that return CompletionStage<T>. A developer building a billing service on top of Helidon SE’s WebClient (Helidon SE’s reactive HTTP client) or the Java 11+ HttpClient receives a CompletableFuture<T> from the client and wants to retry on failure. Retry provides invokeCompletionStage(Supplier<CompletionStage<T>>) for this case:
// BillingService.java — UNSAFE: UUID.randomUUID() inside the CompletionStage
// supplier lambda. When the stage completes exceptionally with a StripeException,
// Retry invokes the supplier again — UUID_B — ch_B.
public class BillingService {
private final HttpClient httpClient; // Java 11+ non-blocking client
private final String stripeChargesUrl;
private final String stripeApiKey;
private final Retry retry;
public BillingService(HttpClient httpClient, String stripeApiKey) {
this.httpClient = httpClient;
this.stripeChargesUrl = "https://api.stripe.com/v1/charges";
this.stripeApiKey = stripeApiKey;
this.retry = Retry.builder()
.retryOn(StripeException.class, IOException.class)
.maxAttempts(3)
.delay(Duration.ofMillis(1000))
.build();
}
public CompletionStage<ChargeResult> createCharge(
String userId, String billingPeriod, long amountCents) {
// invokeCompletionStage takes a Supplier<CompletionStage<ChargeResult>>.
// The supplier is called once per attempt to produce a new CompletionStage.
// When the stage completes exceptionally, Retry calls the supplier again —
// re-executing the supplier lambda body from the top — UUID_B on re-invocation.
return retry.invokeCompletionStage(() -> {
// UUID.randomUUID() is called here — inside the supplier lambda.
// UUID_A on attempt 1, UUID_B when the supplier is re-invoked on retry.
String idempotencyKey = billingPeriod + ":" + userId + ":" + UUID.randomUUID();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(stripeChargesUrl))
.header("Authorization", "Bearer " + stripeApiKey)
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(
"amount=" + amountCents + "¤cy=usd&source=tok_visa"))
.build();
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(response -> {
if (response.statusCode() == 503) {
throw new RuntimeException(new IOException("Stripe 503"));
}
return parseChargeResult(response.body());
});
});
}
}
How invokeCompletionStage triggers re-invocation
Retry.invokeCompletionStage(Supplier<CompletionStage<T>>) works by calling the supplier to obtain an initial CompletionStage<T>, then attaching a completion handler to it. The completion handler inspects the outcome:
- If the stage completes normally:
Retrypropagates the result through the returnedCompletionStage. - If the stage completes exceptionally with a retryable exception:
Retrywaits for the backoff delay and then calls the supplier again to obtain a newCompletionStagefor the next attempt.
The supplier is not stored as a “pending computation” to be resumed — it is called again as a new invocation. There is no concept of “replaying the first stage” because a CompletionStage in Java represents a completed or in-progress computation; once completed (even exceptionally), it cannot be re-run. To retry, Retry must invoke the supplier again to produce a fresh CompletionStage.
This means UUID.randomUUID() inside the supplier body regenerates on every retry call to the supplier:
// Invocation trace:
// Attempt 1: retry.invokeCompletionStage(supplier)
// → supplier() called → UUID_A generated → httpClient.sendAsync() returns stage_A
// → stage_A completes exceptionally (503) → IOException wrapped
// → Retry: IOException is in retryOn → wait 1 second
// Attempt 2: supplier() called again
// → UUID_B generated ← UUID.randomUUID() re-evaluates in the lambda body
// → httpClient.sendAsync() returns stage_B → stage_B completes normally
// → Retry returns ChargeResult from stage_B
// ch_A committed at attempt 1 (Stripe processed before 503).
// ch_B committed at attempt 2 (new key, Stripe treats as new request).
The developer assumption: “invokeCompletionStage retries the same async operation”
A developer familiar with Reactor’s Mono.retryWhen() or RxJava’s retryWhen() operators knows that those operators re-subscribe to the upstream publisher on retry. In Reactor, re-subscribing to a Mono.fromCallable(() -> { UUID.randomUUID() ... }) would also re-evaluate UUID.randomUUID(). A developer who has already hit and fixed this Reactor-specific UUID regeneration bug in a Spring WebFlux project might expect Helidon SE’s invokeCompletionStage to behave differently because CompletionStage semantics are different from Mono semantics. The reasoning: “CompletionStage is not a cold publisher; it’s a handle to a computation that is already running or completed. Retrying a completed CompletionStage is not the same as re-subscribing to a cold publisher.”
This reasoning is correct about CompletionStage semantics in isolation. But it misidentifies what invokeCompletionStage retries. The function does not retry the CompletionStage — it retries the supplier that produces the CompletionStage. The supplier is a function. Calling a function again re-evaluates its body. UUID.randomUUID() in the supplier body re-evaluates on every supplier call. The CompletionStage is a result, not a computation description; the supplier is the computation description, and it is called once per attempt.
Why integration tests miss this
The standard integration test for async billing configures the mock HTTP backend to return 200 on all calls:
// This test confirms successful async billing. It does NOT test retry.
// The supplier is called exactly once — UUID.randomUUID() evaluated once —
// invokeCompletionStage() never re-invokes the supplier in this test.
@Test
void createCharge_async_returnsChargeId() throws Exception {
mockServer.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"id\":\"ch_111\",\"amount\":4900}")));
ChargeResult result = billingService
.createCharge("user-007", "2026-Q4", 4900L)
.toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertThat(result.chargeId()).isEqualTo("ch_111");
}
The test passes. The Retry path is never exercised. UUID.randomUUID() is called once. The double-charge path first appears in production when the mock HTTP backend is replaced by the real Stripe API over a network that occasionally returns 503.
The fix: stable key computed outside the stage supplier
// BillingService.java — SAFE: idempotency key computed before
// invokeCompletionStage(). Supplier captures it as a final variable.
// UUID.randomUUID() never called inside the supplier body.
public CompletionStage<ChargeResult> createCharge(
String userId, String billingPeriod, long amountCents) {
// Content-hash computed once per createCharge() call.
// Stable across all invokeCompletionStage() supplier re-invocations.
final String idempotencyKey = sha256Hex(
userId + ":" + billingPeriod + ":helidon-se-billing"
).substring(0, 32);
return retry.invokeCompletionStage(() -> {
// idempotencyKey captured from outer scope — same on every supplier call.
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(stripeChargesUrl))
.header("Authorization", "Bearer " + stripeApiKey)
.header("Idempotency-Key", idempotencyKey) // stable
.POST(HttpRequest.BodyPublishers.ofString(
"amount=" + amountCents + "¤cy=usd&source=tok_visa"))
.build();
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(response -> {
if (response.statusCode() != 200) {
throw new RuntimeException(new IOException(
"Stripe returned " + response.statusCode()));
}
return parseChargeResult(response.body());
});
});
}
The test that catches the original bug:
@Test
void createCharge_async_retryPreservesIdempotencyKey() throws Exception {
// Attempt 1: 503. Attempt 2: 200.
mockServer.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("async-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("attempt-2"));
mockServer.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("async-retry")
.whenScenarioStateIs("attempt-2")
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"id\":\"ch_111\",\"amount\":4900}")));
billingService.createCharge("user-007", "2026-Q4", 4900L)
.toCompletableFuture()
.get(10, TimeUnit.SECONDS);
// Both requests must carry the SAME Idempotency-Key.
List<ServeEvent> events = mockServer.getAllServeEvents();
assertThat(events).hasSize(2);
String keyAttempt1 = events.get(1).getRequest().getHeader("Idempotency-Key");
String keyAttempt2 = events.get(0).getRequest().getHeader("Idempotency-Key");
assertThat(keyAttempt1).isEqualTo(keyAttempt2);
}
Cross-mode comparison: Helidon SE vs Helidon MP vs CDI-based frameworks
| Dimension | Helidon SE | Helidon MP / Quarkus MP / Micronaut AOP |
|---|---|---|
| Fault tolerance wiring | Programmatic: Retry.builder().build(), FaultTolerance.builder().addRetry().addTimeout().build() |
Annotation-driven: @Retry, @Timeout on CDI or Micronaut AOP beans |
| Retry trigger mechanism | retry.invoke(FtSupplier) re-calls FtSupplier.get(); retry.invokeCompletionStage(Supplier) re-calls the stage supplier |
CDI interceptor re-calls the annotated method via the proxy dispatch chain |
| UUID regeneration root cause | UUID.randomUUID() inside FtSupplier or stage supplier lambda body |
UUID.randomUUID() at method entry — re-evaluates per CDI proxy re-dispatch |
| Timeout interrupt mechanism | ScheduledExecutorService fires Thread.interrupt() from a scheduler thread external to the call stack |
SmallRye FT composite interceptor fires Thread.interrupt() from inside the CDI interception execution |
| Chain ordering control | Builder insertion order: first added = outermost | CDI interceptor priority values: lower number = outermost |
| Async retry abstraction | Retry.invokeCompletionStage(Supplier<CompletionStage<T>>) |
@Retry on a method returning Uni<T> (Quarkus Mutiny) or CompletionStage<T> |
| Self-invocation bypass | Not applicable (no proxy object — direct method calls do not bypass anything) | Present in CDI beans: calling annotated methods on this bypasses the CDI proxy and all interceptors |
Helidon SE’s absence of a CDI container eliminates one common Helidon MP failure mode: the self-invocation proxy bypass. In Helidon MP and Quarkus, if a CDI bean calls its own @Retry-annotated method via this.method(), the CDI proxy is bypassed, @Retry does not fire, and transient errors propagate directly to the caller. In Helidon SE, there is no proxy. retry.invoke(supplier) always wraps the supplier regardless of whether the supplier is a lambda, a method reference, or an inline anonymous class; there is no bypass mechanism.
However, Helidon SE introduces a different structural trap: the developer is responsible for the explicit composition of handlers in FaultTolerance.builder(). The MicroProfile FT spec defines a canonical execution order (Retry → CircuitBreaker → Bulkhead → Timeout → Fallback) that MP implementations follow by default; developers who use @Retry + @Timeout on the same method in MP get the spec-correct per-attempt timeout behavior without thinking about ordering. In SE, there is no canonical order — the developer’s call sequence to addRetry() and addTimeout() fully determines the semantics. A developer who adds addTimeout(timeout) before addRetry(retry) gets an overall timeout (wrapping all retries), not a per-attempt timeout. This difference in intent and outcome is not surfaced by compile-time checks or runtime warnings.
Unified fix pattern for all three Helidon SE failure modes
All three failure modes share the same root cause: UUID.randomUUID() is placed inside a supplier body that Retry calls on every retry attempt. The fix is the same in structure regardless of whether the supplier is a synchronous FtSupplier, a CompletionStage stage supplier, or a supplier inside a FaultTolerance chain:
- Never call
UUID.randomUUID()inside any supplier, lambda, or method body that is or could be passed to aRetryhandler. The test to apply: if this expression re-evaluated, would a new value be wrong? For idempotency keys, yes. - Compute the idempotency key as a deterministic content-hash of the request’s stable inputs before invoking any
Retryhandler. Inputs that are stable across all retry attempts: the authenticated user’s identifier, the billing period, the amount, the product SKU. Inputs that are not stable:UUID.randomUUID(),System.currentTimeMillis(),Instant.now(). - Capture the key as a final variable and close over it in the supplier lambda. Java’s lambda capture semantics guarantee that the captured variable’s value does not change between captures.
- For
FaultTolerance.builder()chains withTimeout, addTimeoutExceptiontoRetry’sabortOnlist. Retrying after a per-attempt timeout risks a double charge. The correct recovery afterTimeoutExceptionis application-level: query Stripe with the stable key to determine if the charge was committed.
// Canonical safe pattern for all three Helidon SE failure modes.
public class BillingService {
private final StripeClient stripeClient;
private final FtHandler<ChargeResult> syncHandler;
private final Retry asyncRetry;
public BillingService(StripeClient stripeClient) {
this.stripeClient = stripeClient;
Retry retry = Retry.builder()
.retryOn(StripeException.class)
.abortOn(TimeoutException.class) // abort on per-attempt timeout
.maxAttempts(3)
.delay(Duration.ofMillis(1000))
.build();
Timeout timeout = Timeout.builder()
.timeout(Duration.ofSeconds(3))
.build();
// Retry (outermost) → Timeout (per-attempt) → supplier
this.syncHandler = FaultTolerance.builder()
.addRetry(retry)
.addTimeout(timeout)
.build();
// Separate Retry for async (no per-attempt Timeout in async chain here)
this.asyncRetry = Retry.builder()
.retryOn(IOException.class)
.maxAttempts(3)
.delay(Duration.ofMillis(1000))
.build();
}
// SAFE synchronous billing
public ChargeResult createCharge(String userId, String billingPeriod, long amountCents)
throws Exception {
// Key computed ONCE before the FaultTolerance chain.
final String key = sha256Hex(userId + ":" + billingPeriod + ":billing").substring(0, 32);
return syncHandler.invoke(() -> doStripeCall(key, amountCents));
}
// SAFE async billing
public CompletionStage<ChargeResult> createChargeAsync(
String userId, String billingPeriod, long amountCents) {
// Key computed ONCE before invokeCompletionStage.
final String key = sha256Hex(userId + ":" + billingPeriod + ":billing").substring(0, 32);
return asyncRetry.invokeCompletionStage(() -> doStripeCallAsync(key, amountCents));
}
private ChargeResult doStripeCall(String idempotencyKey, long amountCents)
throws StripeException {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents).setCurrency("usd").setSource("tok_visa").build(),
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return new ChargeResult(charge.getId(), amountCents);
}
private CompletionStage<ChargeResult> doStripeCallAsync(
String idempotencyKey, long amountCents) {
// idempotencyKey is a parameter passed from the outer scope — stable.
// No UUID.randomUUID() anywhere in this method.
...
}
}
Keybrake: a spend cap for Stripe API keys
The failure modes described in this post have a common consequence: Stripe commits a charge your application does not intend. The double charge appears in Stripe’s dashboard, gets billed to your customer, and if no reconciliation job catches it, it stays. The application’s own records show only the retry’s charge ID — the original charge is orphaned with no application reference.
Auditing for this class of bug requires correlating Stripe charge timestamps with application-side retry log timestamps and checking for charges with no matching application record for the same (userId, billingPeriod) tuple. That audit is retroactive. By the time you run it, customers have already been double-charged.
Keybrake adds a preventive layer: a proxy that sits between your Helidon SE billing service and the Stripe API and enforces a configurable spend cap per (key, time window). When the billing service generates a second distinct idempotency key for the same logical billing operation — because UUID.randomUUID() ran twice inside a retry supplier — Keybrake counts it as a new charge against the spend cap. A per-day cap of $N per customer blocks the second charge before it reaches Stripe. The double-charge bug is caught at the proxy boundary, not in a post-facto reconciliation job.
The proxy also logs every idempotency key it sees alongside the Stripe response status. A query for “same (userId, billingPeriod) with two distinct idempotency keys in the same time window” immediately surfaces every retry-induced key regeneration event. The log tells you when the bug fired, which customer was affected, and which charge ID to refund — without waiting for a customer complaint or monthly reconciliation cycle.
Prevent retry-induced double charges
Keybrake sits between your billing service and Stripe: spend caps, per-customer charge limits, and idempotency key audit logs. Catch UUID.randomUUID()-on-retry bugs before they reach customers.
Related posts in this series
- Helidon MicroProfile Fault Tolerance and Stripe Integration — CDI interceptor priority ordering,
@Timeout+@Retryvirtual thread interrupts, and JPAOptimisticLockExceptionretries - Quarkus MicroProfile Fault Tolerance and Stripe Integration — SmallRye FT interceptor priority, Mutiny
Uniretry subscriptions, and PanacheOptimisticLockExceptionretries - Micronaut Data
@Transactionaland Stripe Integration —@Retryableinterceptor ordering, compile-time-generated repository transactional behavior - Spring Boot
@Cacheableand@RetryableStripe Integration — AOP advisor ordering, cache miss on exception, and SpEL UUID key expression evaluation - Spring WebMVC Async and Stripe Integration —
DeferredResult,MODE_INHERITABLETHREADLOCAL, and@Async/@Retryableinteraction