Helidon MicroProfile Fault Tolerance and Stripe Integration: How CDI Interceptor Priority, @Timeout+@Retry Ordering, and JPA OptimisticLockException Retries Generate New Idempotency Keys on Retry
Helidon MicroProfile 4.x introduces three Stripe billing failure modes that are structurally distinct from the Quarkus MicroProfile FT post and the Micronaut Data post. All three stem from the same root: UUID.randomUUID() in a method body that is re-entered on every retry attempt, each time with a new 128-bit random value that Stripe treats as a distinct charge request. The three modes are: in Helidon MP 4.x (Weld CDI + Narayana JTA + SmallRye FT), the CDI @Transactional interceptor (priority 200) is the outer interceptor and the SmallRye FT composite interceptor (priority 1000) is the inner interceptor — @Retry retries the method body within the same open transaction, but UUID.randomUUID() at method entry regenerates on every retry attempt regardless of the surrounding transaction context, so a Stripe network timeout on attempt 1 commits ch_A and a retry with UUID_B commits ch_B; the MicroProfile Fault Tolerance specification section 7.2 defines a canonical execution order for combined annotations where @Retry wraps @Timeout on a per-attempt basis, so @Timeout can fire before Stripe delivers its full response — Stripe committed ch_A before the client-side @Timeout interrupted the blocking HTTP call — @Retry retries the method body with UUID_B and Stripe creates ch_B — a developer who tests with @HelidonTest and an instant-responding WireMock stub never triggers @Timeout and never sees this path; and @Retry(retryOn = OptimisticLockException.class) with a JPA @Version-annotated entity retries the entire method body on version conflict — if the method body calls Stripe before the @Version-checked EntityManager.merge(), the retry generates UUID_B and commits ch_B alongside the committed ch_A — single-threaded @HelidonTest CDI containers never produce concurrent update contention, so the double-charge path is untested until concurrent production load.
Background: CDI interceptor priority in Helidon MP 4.x — same stack as Quarkus
Helidon MP 4.x builds on Weld CDI, Narayana JTA, and SmallRye Fault Tolerance — the same three components as Quarkus MP. As a result, the CDI interceptor priority ordering is identical when the same annotations are applied to the same method.
The two interceptors relevant to a Helidon MP billing service annotated with both @Transactional and @Retry:
- Narayana JTA
@Transactional:io.narayana.jta.jaxrs.TransactionalInterceptorRequired(and its siblings) is registered at@Priority(Interceptor.Priority.PLATFORM_BEFORE + 200).Interceptor.Priority.PLATFORM_BEFOREis 0, so the effective priority is 200. - SmallRye FT composite interceptor:
io.smallrye.faulttolerance.FaultToleranceInterceptorhandles all MicroProfile FT annotations through a single interceptor registered at@Priority(Interceptor.Priority.LIBRARY_BEFORE).Interceptor.Priority.LIBRARY_BEFOREis 1000, so the effective priority is 1000.
CDI invokes interceptors in ascending priority order — lower number = outer wrapper, higher number = inner. With 200 < 1000:
[Caller]
→ [TransactionalInterceptor (@Transactional, priority 200, OUTER)] ← opens T1 once
→ [FaultToleranceInterceptor (@Retry, priority 1000, INNER)]
→ [method body] ← re-entered on every @Retry attempt, all within T1
This is structurally identical to the Quarkus ordering and the opposite of Micronaut 4.x’s default, where RecoveryInterceptor precedes TransactionalInterceptor and each @Retryable retry opens a fresh transaction.
What is distinct about Helidon MP 4.x is the programming model and test infrastructure. Helidon 4.x introduced virtual thread–based request handling (Project Loom) as the default for all server-side request processing, replacing the reactive Single/Multi model from Helidon 3.x. Helidon MP 4.x billing services are written in a straightforward imperative style — blocking Stripe SDK calls on virtual threads — without the reactive chain assembly that created additional failure modes in the Quarkus Mutiny post. This simplicity makes the underlying CDI interceptor priority issue easier to hit accidentally, because developers do not need to think about reactive subscription mechanics to trigger the double-charge.
Failure mode 1: @Transactional outer (priority 200), SmallRye FT @Retry inner (priority 1000) — UUID.randomUUID() at method entry regenerates per @Retry attempt within the same transaction — Stripe commits ch_B
A developer builds a Helidon MP 4.x billing endpoint. Following the standard Helidon MP documentation pattern — which places business logic and JAX-RS annotations together on a single CDI bean — they write a billing service annotated with both @Retry (for Stripe network resilience) and @Transactional (to keep the Stripe charge and local billing record atomic):
// BillingService.java — UNSAFE: @Retry (priority 1000) is INNER to @Transactional (priority 200).
// Transaction T1 opens once per caller invocation. @Retry retries the method body inside T1.
// UUID.randomUUID() at method entry produces UUID_A on attempt 1.
// On @Retry retry: method body re-enters — UUID.randomUUID() produces UUID_B.
// Stripe network timeout on attempt 1 may commit ch_A. UUID_B on retry creates ch_B.
@ApplicationScoped
public class BillingService {
@Inject
EntityManager em;
@Retry(
maxRetries = 3,
delay = 500,
delayUnit = ChronoUnit.MILLIS,
retryOn = { StripeNetworkException.class, PersistenceException.class }
)
@Transactional
public BillingResult chargeAndRecord(String customerId, long amountCents, String billingPeriod) {
// UNSAFE: this expression executes on EVERY @Retry attempt.
// CDI priority ordering: @Transactional (200) opened T1 before @Retry (1000) loop started.
// Each retry re-enters this method body within the still-open T1.
// UUID.randomUUID() is a Java expression in the method body — it is not transaction-scoped.
// There is no CDI or JTA mechanism that memoizes a method-body expression for a transaction.
String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();
Charge charge;
try {
charge = StripeClient.create(apiKey).charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
} catch (StripeException e) {
throw new StripeNetworkException("Stripe error on attempt", e);
}
BillingRecord record = new BillingRecord();
record.setCustomerId(customerId);
record.setChargeId(charge.getId());
record.setBillingPeriod(billingPeriod);
record.setAmountCents(amountCents);
em.persist(record);
return new BillingResult(charge.getId(), idempotencyKey);
}
}
The failure sequence when a Stripe network read timeout occurs on attempt 1:
- Caller invokes
billingService.chargeAndRecord("cus_A", 5000, "2026-Q4"). TransactionalInterceptor(outer, priority 200) opens transaction T1. Control passes toFaultToleranceInterceptor(inner, priority 1000), which starts the@Retryloop.@Retrycalls the method body for attempt 1.- Method body:
UUID.randomUUID()produces UUID_A.idempotencyKey = "cus_A:2026-Q4:UUID_A". - Stripe API call with
UUID_A. Stripe processes the charge and commitsch_A($50) before the network response arrives. A TCP read timeout fires. The Stripe Java SDK throwsApiConnectionException(aStripeExceptionsubclass). The catch block wraps it inStripeNetworkException. StripeNetworkExceptionpropagates toFaultToleranceInterceptor. It is in theretryOnlist.@Retrywaits 500 ms.@Retrycalls the method body again for attempt 2. Transaction T1 is still open —TransactionalInterceptorhas not seen any exception yet and has not committed or rolled back.- Method body:
UUID.randomUUID()produces UUID_B.idempotencyKey = "cus_A:2026-Q4:UUID_B". - Stripe API call with
UUID_B. Stripe has never seenUUID_B— it cannot return the cachedch_A. Stripe processes and commitsch_B. HTTP 200 returned. em.persist(record)writes aBillingRecordforch_Bwithin T1. Method body returnsBillingResult(ch_B, UUID_B).FaultToleranceInterceptorsees a successful return. Control passes back toTransactionalInterceptor, which commits T1. One local record forch_B.ch_Ais committed at Stripe with no local record.
The customer is charged $100 for a $50 billing period. The local database has a single record for ch_B. ch_A is an orphan charge visible in the Stripe Dashboard but absent from the local audit table. The discrepancy appears only during Stripe payout reconciliation.
Why developers deploying from Helidon 3.x hit this immediately
Helidon 3.x used a reactive programming model where billing operations were typically expressed as Single<BillingResult> chains. In Helidon 3.x’s reactive model, @Retry from SmallRye FT applied to reactive return types by re-subscribing the returned reactive type — not by re-invoking the method body. Developers who placed UUID.randomUUID() inside a Single.fromCallable(() -> { ... }) could observe it re-executing on re-subscription in 3.x and took care to extract it as a stable variable outside the reactive chain. When migrating to Helidon 4.x’s imperative virtual-thread model, those same developers moved the UUID computation to “method entry” to keep it outside any reactive chain — but in Helidon 4.x’s blocking model, @Retry re-enters the method body on every retry, not re-subscribes a reactive type. The UUID at method entry is more exposed to retry re-evaluation in Helidon 4.x than in the reactive 3.x version, because the re-entry point is the method signature itself rather than a reactive subscription callback.
The transaction-scope reasoning trap
A developer who understands CDI interceptor priority and confirms that @Transactional is outer in Helidon MP may reason: “T1 opens once for the entire chargeAndRecord call. All @Retry retries execute inside T1. Transaction T1 is a single logical unit of work — therefore expressions at method entry are evaluated once per logical transaction unit.”
This reasoning conflates two separate concepts. A CDI transaction is a resource coordination scope: it coordinates JPA EntityManager flush/rollback and JTA enlisted resources. It does not memoize or cache Java expressions in the method body. UUID.randomUUID() is a method call in the Java method body. The method body is re-entered on every @Retry attempt — that is what @Retry does at the CDI interceptor level. There is no JTA extension point, no CDI scope, and no Java language feature that caches a UUID.randomUUID() call result across re-entries of the same method body within the same transaction. The transaction simply remains open; the method body code runs fresh on each entry.
The fix: compute the idempotency key outside both interceptors
// BillingService.java — SAFE: idempotency key passed as method parameter.
// Key is computed by the caller before any CDI interceptor wraps the method.
// @Retry (inner, priority 1000) re-enters the method body with the same key on every attempt.
// @Transactional (outer, priority 200) provides the transaction context.
@ApplicationScoped
public class BillingService {
@Inject
EntityManager em;
@Retry(
maxRetries = 3,
delay = 500,
delayUnit = ChronoUnit.MILLIS,
retryOn = { StripeNetworkException.class, PersistenceException.class }
)
@Transactional
public BillingResult chargeAndRecord(String customerId, long amountCents,
String billingPeriod, String idempotencyKey) {
// idempotencyKey is a stable parameter. Same value on attempt 1 and all @Retry retries.
// Stripe idempotency: if ch_A was committed with idempotencyKey on attempt 1 and the
// network timed out, attempt 2 with the same key returns cached ch_A, not ch_B.
Charge charge;
try {
charge = StripeClient.create(apiKey).charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
} catch (StripeException e) {
throw new StripeNetworkException("Stripe error", e);
}
BillingRecord record = new BillingRecord();
record.setCustomerId(customerId);
record.setChargeId(charge.getId());
record.setBillingPeriod(billingPeriod);
record.setAmountCents(amountCents);
em.persist(record);
return new BillingResult(charge.getId(), idempotencyKey);
}
}
// BillingResource.java — JAX-RS resource computes the stable key before service call.
@Path("/billing")
@ApplicationScoped
public class BillingResource {
@Inject
BillingService billingService;
@POST
@Path("/charge")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response charge(ChargeRequest req) {
// Content-hash key: deterministic from request inputs, no UUID.randomUUID() involved.
// Same customer + billing period + namespace always produces the same 32-char hex key.
// This expression runs once per HTTP request, before any CDI interceptor on BillingService.
String idempotencyKey = sha256Hex(
req.getCustomerId() + ":" + req.getBillingPeriod() + ":helidon-billing"
).substring(0, 32);
BillingResult result = billingService.chargeAndRecord(
req.getCustomerId(), req.getAmountCents(), req.getBillingPeriod(), idempotencyKey);
return Response.ok(result).build();
}
}
The caller computes the idempotency key once, before the CDI proxy wraps chargeAndRecord. The key is a parameter — stable across all @Retry re-entries of the method body. Stripe’s idempotency guarantee then handles a network-timeout retry correctly: the cached response for ch_A is returned instead of a new ch_B.
Failure mode 2: MicroProfile FT spec–defined @Retry outer / @Timeout inner — @Timeout fires before Stripe response — Stripe committed ch_A — @Retry sends UUID_B — Stripe commits ch_B
SmallRye FT’s single FaultToleranceInterceptor (priority 1000) handles all MicroProfile FT annotations (@Retry, @Timeout, @CircuitBreaker, @Bulkhead, @Fallback) through a composite execution strategy. When multiple annotations appear on the same method, the MicroProfile Fault Tolerance specification section 7.2 defines their execution order:
When multiple MicroProfile FT annotations are applied to a method, they are layered as follows (outermost first):
@Retry/@Fallback→@CircuitBreaker→@Bulkhead→@Timeout→ method body.
This means @Retry is the outer loop and @Timeout is applied per attempt, inside the retry loop. Each @Retry attempt runs the method body subject to a fresh @Timeout countdown. If the method body does not complete within the @Timeout value for that attempt, SmallRye FT throws org.eclipse.microprofile.faulttolerance.exceptions.TimeoutException for that attempt. If @Retry is configured with retryOn that includes TimeoutException (or with the default which includes it), @Retry retries the method body.
This is the second Helidon-specific failure mode. A developer adds @Timeout to a billing service method as a safeguard against slow Stripe API responses:
// BillingService.java — UNSAFE: @Timeout + @Retry on the same method.
// MicroProfile FT spec section 7.2 execution order: @Retry (outer) wraps @Timeout (per attempt).
// @Timeout fires before Stripe delivers the full HTTP response.
// TimeoutException is in @Retry's default retryOn set.
// @Retry retries the method body. UUID.randomUUID() at method entry generates UUID_B.
// If Stripe committed ch_A before @Timeout fired, UUID_B on retry creates ch_B.
@ApplicationScoped
public class BillingService {
@Retry(
maxRetries = 2,
delay = 300,
delayUnit = ChronoUnit.MILLIS
// retryOn not set explicitly — default includes Exception (which includes TimeoutException)
)
@Timeout(value = 3, unit = ChronoUnit.SECONDS)
public BillingResult charge(String customerId, long amountCents, String billingPeriod) {
// UNSAFE: UUID.randomUUID() here re-executes on every @Retry re-invocation,
// including @Retry attempts triggered by a @Timeout TimeoutException.
String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();
Charge charge;
try {
// Stripe Java SDK: stripe.charges().create() is a synchronous blocking HTTP call.
// On a virtual thread (Helidon 4.x default), blocking in a HTTP read blocks the
// virtual thread only — the platform thread underneath continues scheduling
// other virtual threads. SmallRye FT @Timeout on a virtual thread uses
// Thread.interrupt() on the virtual thread to interrupt the blocking I/O.
charge = StripeClient.create(apiKey).charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
} catch (StripeException e) {
throw new RuntimeException("Stripe error", e);
}
return new BillingResult(charge.getId(), idempotencyKey);
}
}
The failure sequence when the Stripe API is slow (response takes 3.8 seconds, above the 3-second @Timeout):
- Caller invokes
billingService.charge("cus_B", 9900, "2026-OCT"). FaultToleranceInterceptorbegins the@Retryouter loop. Attempt 1 starts. A 3-second@Timeoutcountdown begins for attempt 1.- Method body:
UUID.randomUUID()produces UUID_A.idempotencyKey = "cus_B:2026-OCT:UUID_A". - Stripe API call with
UUID_Abegins. Stripe’s servers receive the request and begin processing. At t=2.5s, Stripe commitsch_A($99) and begins writing the HTTP response. The network is slow — the response headers and body arrive at t=3.8s on the client side. - At t=3.0s, SmallRye FT’s
@Timeoutwatcher fires. In Helidon 4.x, the billing method is running on a virtual thread. SmallRye FT callsThread.interrupt()on the virtual thread. The blocking HTTP read in the Stripe Java SDK’s underlyingjava.net.http.HttpClient(or OkHttp) receives the interrupt and throwsInterruptedIOExceptionor wraps it inApiConnectionException. - The exception propagates up the method body.
FaultToleranceInterceptor’s@Timeoutlayer catches it and throwsorg.eclipse.microprofile.faulttolerance.exceptions.TimeoutException. @Retry(outer to@Timeout) catches theTimeoutException. The defaultretryOnincludesException, soTimeoutExceptionqualifies for retry.@Retrywaits 300 ms.@Retrycalls the method body again for attempt 2. A new 3-second@Timeoutcountdown begins for attempt 2.- Method body:
UUID.randomUUID()produces UUID_B.idempotencyKey = "cus_B:2026-OCT:UUID_B". - Stripe API call with
UUID_B. Stripe has never seenUUID_B— it cannot return the cachedch_A. Stripe processes and commitsch_B($99). Response arrives within 3 seconds.@Timeoutdoes not fire for attempt 2. - Method body returns
BillingResult(ch_B, UUID_B).@Retryloop exits successfully. The customer has been charged $198 for a $99 billing period.
Why @Timeout on a virtual thread is different from @Timeout on a platform thread
In Helidon 3.x (reactive model), MicroProfile FT @Timeout was implemented differently because method bodies were non-blocking — blocking calls should not have appeared inside a reactive pipeline. In Helidon 4.x with virtual threads, blocking HTTP calls like the Stripe Java SDK’s synchronous charges().create() are idiomatic and expected. SmallRye FT’s @Timeout implementation for blocking methods starts a watcher thread that calls Thread.interrupt() on the worker thread at the timeout deadline.
On a virtual thread, Thread.interrupt() interrupts any blocking I/O operation that the virtual thread is parked in. The Java virtual thread scheduler translates the interrupt into an InterruptedIOException (or equivalent) on the blocking socket read. This is the correct, expected behavior — but it means that the Stripe API call can be interrupted at any point during the TCP receive, including after Stripe has fully committed the charge on its side and is in the process of sending the HTTP response. The interrupt fires based on elapsed wall time from the client’s perspective, completely unaware of what Stripe’s servers have already committed.
The subtle variant: retryOn that explicitly excludes TimeoutException but catches the wrapped cause
A developer who reads the MicroProfile FT documentation and understands that @Retry wraps @Timeout may attempt to prevent the double-charge by explicitly excluding TimeoutException from retryOn:
@Retry(
maxRetries = 2,
retryOn = { StripeNetworkException.class }
// abortOn not set — default abortOn is empty
)
@Timeout(value = 3, unit = ChronoUnit.SECONDS)
public BillingResult charge(String customerId, long amountCents, String billingPeriod) {
// attempt: by removing TimeoutException from retryOn, @Retry should not retry on @Timeout
...
}
If the Stripe SDK catches the InterruptedIOException from the virtual thread interrupt, wraps it in ApiConnectionException (a StripeException), and the service layer catches it and rethrows as StripeNetworkException before the @Timeout layer can throw its TimeoutException — then the exception that reaches FaultToleranceInterceptor’s @Retry layer is StripeNetworkException, not TimeoutException. StripeNetworkException is in retryOn. The @Retry retries. The @Timeout fired first (the interrupt was sent), but the caught-and-rethrown exception bypasses the TimeoutException exclusion. UUID_B on retry — ch_B.
The ordering of exception handling at the SmallRye FT interceptor boundary is subtle: @Timeout’s watcher fires asynchronously at the 3-second mark. Whether the interrupted thread has already caught the interrupt and rethrown as a domain exception before the FaultToleranceInterceptor’s @Timeout handler inspects the thrown exception depends on thread scheduling. The same buggy code can fail to retry (if TimeoutException is thrown by FT before the domain exception propagates) or succeed with UUID_B (if the domain exception propagates and @Retry catches it) in different runs of the same scenario.
The fix: stable key and explicit @Timeout abort
// BillingService.java — SAFE: stable key parameter + explicit @Timeout abort.
// @Retry does not retry on TimeoutException — @Timeout interruptions terminate the call.
// A vault key spend cap at expected_daily_revenue * 1.10 provides a proxy-layer backstop
// for any timeout scenario where the client cannot determine whether Stripe committed.
@ApplicationScoped
public class BillingService {
@Retry(
maxRetries = 2,
delay = 300,
delayUnit = ChronoUnit.MILLIS,
retryOn = { StripeNetworkException.class },
abortOn = { org.eclipse.microprofile.faulttolerance.exceptions.TimeoutException.class }
)
@Timeout(value = 3, unit = ChronoUnit.SECONDS)
public BillingResult charge(String customerId, long amountCents,
String billingPeriod, String idempotencyKey) {
// idempotencyKey is a stable parameter. On a @Timeout abort, no retry occurs.
// On a StripeNetworkException retry, the same idempotencyKey is passed to Stripe.
Charge charge;
try {
charge = StripeClient.create(apiKey).charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
} catch (StripeException e) {
throw new StripeNetworkException("Stripe error", e);
}
return new BillingResult(charge.getId(), idempotencyKey);
}
}
The key combination is: (1) pass the idempotency key as a parameter so it cannot regenerate on retry; and (2) abortOn = { TimeoutException.class } so that if a @Timeout fires, the exception is not retried and propagates to the caller, who can decide whether to query the Stripe Dashboard for a pending charge with the known idempotencyKey rather than blindly submitting a new one. Stripe’s idempotency keys are queryable: stripe.idempotencyKeys().retrieve(idempotencyKey) returns whether a request with that key committed, letting callers implement a safe “query before retry” path after a timeout.
Why @HelidonTest hides this failure mode
@HelidonTest (Helidon 4.x’s CDI integration test annotation) starts the full CDI container and embedded Helidon server. When billing tests use WireMock as the Stripe backend:
// WireMock stub — returns instant HTTP 200 with charge body
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_A\",\"amount\":9900,\"status\":\"succeeded\"}")));
WireMock’s in-process HTTP server returns the response in microseconds — far below the 3-second @Timeout threshold. @Timeout never fires. The double-charge path through @Retry-on-TimeoutException is never exercised. The test suite passes green. The failure appears only when the real Stripe API is slow — high traffic, transient network degradation, or a Stripe infrastructure event that causes responses to take longer than the configured @Timeout value.
To expose the failure mode in tests:
// WireMock stub with fixed delay exceeding @Timeout threshold
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(4000) // 4s > @Timeout(3s)
.withBody("{\"id\":\"ch_A\",\"amount\":9900,\"status\":\"succeeded\"}")));
// Test: verify only one Stripe request fires (no retry on @Timeout)
// With correct abortOn = { TimeoutException.class }:
verify(1, postRequestedFor(urlEqualTo("/v1/charges")));
// Without abortOn: verify catches UUID instability
List<ServeEvent> events = getAllServeEvents();
// second request (if any) must carry same key as first
String key0 = events.get(0).getRequest().getHeader("Idempotency-Key");
String key1 = events.get(1).getRequest().getHeader("Idempotency-Key");
assertThat(key1).isEqualTo(key0);
Failure mode 3: @Retry(retryOn = OptimisticLockException.class) + JPA @Version — entire method body retried on version conflict — Stripe call precedes @Version-checked merge — UUID_B per contention retry — single-threaded @HelidonTest never triggers version conflicts
A Helidon MP 4.x billing service uses JPA optimistic locking via @Version-annotated entities to handle concurrent billing period updates. The developer annotates the billing method with @Retry(retryOn = OptimisticLockException.class) to automatically retry on version conflict:
// CustomerAccount.java — JPA entity with @Version for optimistic locking
@Entity
@Table(name = "customer_accounts")
public class CustomerAccount {
@Id
@Column(name = "customer_id")
private String customerId;
@Version
@Column(name = "version")
private long version;
@Column(name = "last_billed_period")
private String lastBilledPeriod;
@Column(name = "total_charged_cents")
private long totalChargedCents;
// getters and setters
}
// BillingService.java — UNSAFE: @Retry(retryOn = OptimisticLockException.class)
// retries the ENTIRE method body, including the Stripe call before EntityManager.merge().
// UUID.randomUUID() at method entry regenerates on every contention-triggered retry.
// Stripe commits ch_A on attempt 1. @Version conflict fires. @Retry retries with UUID_B.
// Stripe creates ch_B. Customer billed twice for the same billing period.
@ApplicationScoped
public class BillingService {
@Inject
EntityManager em;
@Retry(
maxRetries = 3,
delay = 50,
delayUnit = ChronoUnit.MILLIS,
retryOn = { OptimisticLockException.class }
)
@Transactional
public BillingResult chargeAndUpdateAccount(String customerId, long amountCents,
String billingPeriod) {
// UNSAFE: UUID.randomUUID() at method entry.
// @Retry (inner, priority 1000) retries the method body on OptimisticLockException.
// @Transactional (outer, priority 200) wraps both the @Retry loop and the method body.
// Transaction T1 opens once. Each @Retry attempt re-executes the method body within T1.
// But: T1 is marked rollback-only when OptimisticLockException fires from em.merge().
// SmallRye FT @Retry with @Transactional OUTER means the @Retry loop runs within T1.
// When T1 is marked rollback-only, subsequent JPA calls throw RollbackException.
// The developer does not see this in practice because @HelidonTest never triggers
// OptimisticLockException — single-threaded test scenarios have no version conflicts.
String idempotencyKey = customerId + ":" + billingPeriod + ":" + UUID.randomUUID();
// Step 1: Stripe charge — external call, not transactional.
Charge charge;
try {
charge = StripeClient.create(apiKey).charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
} catch (StripeException e) {
throw new RuntimeException("Stripe error", e);
}
// Step 2: @Version-checked JPA update — throws OptimisticLockException on conflict.
// If two concurrent requests both read CustomerAccount at version=5 and both try
// to merge at version=5, the second merge throws OptimisticLockException from JPA.
CustomerAccount account = em.find(CustomerAccount.class, customerId);
account.setLastBilledPeriod(billingPeriod);
account.setTotalChargedCents(account.getTotalChargedCents() + amountCents);
em.merge(account); // ← throws OptimisticLockException on version conflict
return new BillingResult(charge.getId(), idempotencyKey);
}
}
The failure sequence when two concurrent billing requests for the same customer arrive simultaneously:
- Request A and Request B both invoke
billingService.chargeAndUpdateAccount("cus_C", 7500, "2026-NOV")concurrently. - Both enter
chargeAndUpdateAccount(within their respective CDI proxy invocations). - Request A, attempt 1:
UUID.randomUUID()produces UUID_A1. Stripe API call withUUID_A1. Stripe commitsch_A($75).em.find()readsCustomerAccount(version=5).em.merge(account)with version=5 succeeds (Request B hasn’t merged yet). T1_A commits. Request A returnsBillingResult(ch_A, UUID_A1). - Request B, attempt 1:
UUID.randomUUID()produces UUID_B1. Stripe API call withUUID_B1. Stripe commitsch_B($75).em.find()readsCustomerAccount(version=5)(stale read — Request A committed version=6 between B’s find and merge).em.merge(account)attempts version=5 → JPA detects version mismatch (DB has version=6) → throwsOptimisticLockException. T1_B is marked rollback-only. OptimisticLockExceptionis in Request B’s@RetryretryOnlist.@Retrywaits 50 ms.@Retrycalls the method body again for attempt 2.- Request B, attempt 2:
UUID.randomUUID()produces UUID_B2. Stripe API call withUUID_B2. Stripe has never seenUUID_B2. Stripe commitsch_C($75).em.merge(account)at version=6 succeeds. T1_B commits. - Customer
cus_Chas been charged $225 for a single $75 billing period.
Note that this failure mode has two distinct problems: the double charge from ch_B (Request B, attempt 1) alongside ch_A (Request A, attempt 1) already represents concurrent double billing even without the @Retry interaction. The @Retry issue adds a third charge (ch_C from Request B attempt 2). The correct pre-flight duplicate guard — INSERT INTO billing_log(customer_id, billing_period) ... ON CONFLICT DO NOTHING — prevents both problems at the database level. But in code that lacks the pre-flight guard, @Retry(OptimisticLockException) makes a concurrent double-billing into a concurrent triple-billing.
Why @HelidonTest hides the @Version contention path
@HelidonTest starts a Helidon MP server in the test JVM and uses the CDI container with a real JPA EntityManager (backed by H2 in-memory or an embedded database). Test methods are invoked sequentially by JUnit — there is no test-level parallelism. Within a single test method, only one thread is calling billingService.chargeAndUpdateAccount(). A single-threaded call reads CustomerAccount at version N, merges at version N, increments to version N+1, and commits — no conflict. OptimisticLockException never fires. @Retry never executes. The UUID regeneration on retry is structurally invisible in the test suite.
The production failure requires at least two concurrent HTTP requests for the same customerId — which is exactly what happens in a multi-user SaaS product when two separate processes (a scheduled billing job and a manual retry from a support dashboard) both attempt to bill the same customer in the same billing period at the same moment.
The fix: separate Stripe call from @Version-checked DB write with REQUIRES_NEW
// BillingService.java — SAFE: Stripe call once (outside @Retry boundary).
// @Version-checked DB write is in a separate REQUIRES_NEW inner method with @Retry.
// chargeId is passed as a stable parameter — same value on all version-conflict retries.
@ApplicationScoped
public class BillingService {
@Inject
BillingService self; // CDI self-injection for @Transactional(REQUIRES_NEW) to apply
// Outer method: calls Stripe once, then delegates DB update to the @Retry inner method.
// NOT annotated with @Retry or @Transactional directly.
public BillingResult chargeAndUpdateAccount(String customerId, long amountCents,
String billingPeriod) {
// Key computed once. Same value throughout this billing request.
// sha256 of stable inputs: same customer + billing period + namespace always produces
// the same 32-char hex. No UUID.randomUUID() involved.
String idempotencyKey = sha256Hex(
customerId + ":" + billingPeriod + ":helidon-billing-v3"
).substring(0, 32);
// Stripe call: exactly once per billing request.
// If this throws, no @Retry wraps it here — the caller handles it.
Charge charge;
try {
charge = StripeClient.create(apiKey).charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
} catch (StripeException e) {
throw new RuntimeException("Stripe error", e);
}
// DB update: @Retry handles OptimisticLockException here only.
// chargeId is stable — same Stripe charge referenced on all @Retry attempts.
self.updateAccountWithRetry(customerId, charge.getId(), amountCents, billingPeriod);
return new BillingResult(charge.getId(), idempotencyKey);
}
// Inner method: @Retry for version contention only.
// Does NOT call Stripe — receives chargeId as a stable parameter.
// REQUIRES_NEW: each retry attempt opens a new transaction so the EntityManager
// is not marked rollback-only from the previous attempt's @Version failure.
@Retry(
maxRetries = 5,
delay = 20,
delayUnit = ChronoUnit.MILLIS,
retryOn = { OptimisticLockException.class }
)
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void updateAccountWithRetry(String customerId, String chargeId,
long amountCents, String billingPeriod) {
// No Stripe call here. chargeId is already committed at Stripe.
// @Retry retries only the JPA update, not the Stripe charge.
CustomerAccount account = em.find(CustomerAccount.class, customerId);
account.setLastBilledPeriod(billingPeriod);
account.setTotalChargedCents(account.getTotalChargedCents() + amountCents);
account.setLastChargeId(chargeId);
em.merge(account);
}
}
With REQUIRES_NEW, each @Retry attempt on updateAccountWithRetry opens a completely new transaction. The previous transaction that failed with OptimisticLockException is rolled back. The new transaction reads a fresh CustomerAccount at the current version and performs a clean merge. The chargeId parameter is stable across all retry attempts — no additional Stripe call is made on version-conflict retry.
Why @Transactional(REQUIRES_NEW) is needed alongside @Retry in Helidon MP
When @Transactional is outer (priority 200) and @Retry is inner (priority 1000) — which is the case in Helidon MP (and Quarkus), not in Micronaut — @Retry retries the method body within the same open transaction. When em.merge() throws OptimisticLockException within that transaction, JPA marks the EntityManager as rollback-only. Subsequent JPA calls within the same transaction will throw javax.persistence.RollbackException. So a @Retry attempt that follows within the same transaction will fail on em.find() (or on the next em.merge() at the latest) with a RollbackException — which is not in retryOn = { OptimisticLockException.class } — causing the retry to abort and rethrow the RollbackException instead of the original OptimisticLockException.
In other words: without REQUIRES_NEW, @Retry(retryOn = OptimisticLockException) with outer @Transactional in Helidon MP does not actually retry successfully in the same transaction — the second attempt throws RollbackException which is not in retryOn. The developer observes in tests that @Retry doesn’t seem to retry on OptimisticLockException and adds the UUID-regenerating Stripe call in desperation. The correct fix is REQUIRES_NEW on the inner method annotated with @Retry, which gives each retry attempt a clean transaction context and a non-marked-rollback-only EntityManager.
Integration test patterns for all three failure modes
The @HelidonTest annotation starts the Helidon MP CDI container with the full SmallRye FT interceptor chain active. Combining it with WireMock as the Stripe backend lets you exercise all three failure modes:
@HelidonTest
class BillingServiceIT {
@Inject
BillingService billingService;
WireMockServer wireMock;
@BeforeEach
void setUp() {
wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
wireMock.start();
// Point Stripe client at WireMock port via system property or config
System.setProperty("stripe.base-url", "http://localhost:" + wireMock.port());
}
@AfterEach
void tearDown() {
wireMock.stop();
}
// Mode 1: @Retry + @Transactional CDI priority — UUID stability on retry
@Test
void mode1_stableIdempotencyKeyAcrossRetries() {
// WireMock: fail attempt 1 with 503 (simulates Stripe 503, not timeout)
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry-scenario")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("second-attempt"));
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry-scenario")
.whenScenarioStateIs("second-attempt")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_test\",\"amount\":5000,\"status\":\"succeeded\"}")));
// Compute stable key in test — same formula as caller in production
String key = sha256Hex("cus_test:2026-Q4:helidon-billing").substring(0, 32);
billingService.chargeAndRecord("cus_test", 5000, "2026-Q4", key);
// Capture all Stripe requests and assert idempotency key is identical on all attempts
List<ServeEvent> events = wireMock.getAllServeEvents();
assertThat(events).hasSize(2);
String key0 = events.get(0).getRequest().getHeader("Idempotency-Key");
String key1 = events.get(1).getRequest().getHeader("Idempotency-Key");
assertThat(key1).isEqualTo(key0);
assertThat(key0).isEqualTo(key); // matches the pre-computed expected value
}
// Mode 2: @Timeout + @Retry — @Timeout fires, no retry (abortOn = TimeoutException)
@Test
void mode2_timeoutAbortsDoesNotRetry() {
// WireMock: return delayed response exceeding @Timeout(3s)
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withFixedDelay(4500)
.withBody("{\"id\":\"ch_timeout\",\"amount\":9900,\"status\":\"succeeded\"}")));
String key = sha256Hex("cus_timeout:2026-OCT:helidon-billing").substring(0, 32);
assertThatThrownBy(() -> billingService.charge("cus_timeout", 9900, "2026-OCT", key))
.isInstanceOf(TimeoutException.class);
// With abortOn = { TimeoutException.class }: exactly 1 Stripe request (no retry)
List<ServeEvent> events = wireMock.getAllServeEvents();
assertThat(events).hasSize(1);
}
// Mode 3: @Retry + @Version — Stripe called once, chargeId stable on version-conflict retry
@Test
void mode3_stripeCalledOnceOnVersionConflict() {
// WireMock: always succeed immediately (Stripe is not the source of the conflict)
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_version\",\"amount\":7500,\"status\":\"succeeded\"}")));
// Simulate version conflict: first call to updateAccountWithRetry will throw
// OptimisticLockException (requires a concurrent test setup or a mock EntityManager).
// For integration tests, verify that WireMock received exactly 1 Stripe request
// regardless of how many times @Retry fires on the DB update path.
billingService.chargeAndUpdateAccount("cus_version", 7500, "2026-NOV");
// Safe: Stripe called exactly once (charge is done before @Retry on DB)
verify(1, postRequestedFor(urlEqualTo("/v1/charges")));
}
}
Summary: what makes these three failure modes Helidon-specific
| Mode | Root mechanism | Helidon-specific aspect | Hidden by test |
|---|---|---|---|
1: @Transactional outer, @Retry inner |
CDI priority 200 vs 1000; UUID at method entry regenerates per @Retry re-entry |
Helidon 4.x migration from reactive 3.x model moves UUID to method entry; same CDI stack as Quarkus, different from Micronaut | @HelidonTest with WireMock: 503→200 shows retry fires but does not assert key stability |
2: @Timeout per attempt, @Retry outer |
MicroProfile FT spec section 7.2 ordering; virtual thread interrupt fires after Stripe commits | Helidon 4.x virtual threads make blocking Stripe calls idiomatic; @Timeout interrupts blocking I/O on virtual thread via Thread.interrupt() |
@HelidonTest WireMock returns instant 200; @Timeout threshold never reached in tests |
3: @Retry(OptimisticLockException) + JPA @Version |
SmallRye FT retries entire method body including Stripe call before @Version-checked merge |
Helidon MP uses JPA directly (no Panache); @Transactional outer means T1 rollback-only after OLE — REQUIRES_NEW required on inner retry method |
@HelidonTest single-threaded: no concurrent writes, OptimisticLockException never fires |
All three fix to the same structural pattern: compute a stable, deterministic idempotency key outside the @Retry boundary and pass it as a method parameter. For Mode 2, add abortOn = { TimeoutException.class } to prevent retrying on a @Timeout interruption where the Stripe committed status is unknown. For Mode 3, separate the Stripe call (once, outside @Retry) from the @Version-checked JPA update (inside @Retry with REQUIRES_NEW to give each retry attempt a clean transaction).
Vault key spend cap as a proxy-layer backstop
Application-layer fixes are necessary but not sufficient as a sole safeguard. A CDI interceptor priority mistake, an unexpected @Timeout/@Retry interaction, or a runtime condition that exercises the version-conflict retry path all produce double or triple Stripe charges before any of the above fixes apply — the fix takes a deploy cycle while the charges are happening in real time.
A Keybrake vault key scoped to the Helidon MP billing service provides a configurable daily USD cap. Set the cap to expected_daily_revenue × 1.10: a 10% buffer above normal volume. Any double-charge loop — from any of the three modes above, or from a future fourth mode you haven’t encountered yet — causes the vault key to reject Stripe calls once the daily cap is hit. The policy enforcement is at the proxy layer, not in the application code, so it applies regardless of which interceptor combination triggered the loop and regardless of whether the application is deployed yet. The audit log records which vault key made which Stripe call at which timestamp, making the root cause investigation a log query rather than a Stripe Dashboard reconstruction.
The three-line Helidon MP configuration:
# microprofile-config.properties
stripe.base-url=https://proxy.keybrake.com/stripe
stripe.api-key=vault_key_xxx # scoped to this service, daily cap $550, allowlist /v1/charges only
The vault key policy on the Keybrake side:
{
"vendor": "stripe",
"daily_usd_cap": 550,
"allowed_endpoints": ["/v1/charges"],
"merchant_allowlist": ["acct_XXXXXXXXXXXXXXXX"],
"expires_at": null
}
When the vault key’s daily cap is hit, subsequent calls return HTTP 429 from proxy.keybrake.com with a X-Keybrake-Reason: daily_cap_exceeded header. The Stripe Java SDK treats this as an ApiException (HTTP 429) — not a StripeNetworkException — so it does not trigger @Retry (assuming ApiException is not in retryOn). The double-charge loop stops. The audit log shows the sequence of calls that hit the cap, identifying the exact service method, deployment, and time of the failure.
Cap your Helidon MP service’s Stripe spend before the next MicroProfile FT retry fires
One vault key, one daily cap, one audit log. Works with any Stripe Java SDK call your Helidon MP service makes — no code changes beyond the base URL and key name. Early access open.