SmallRye REST Client and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
SmallRye REST Client introduces three Stripe billing failure modes that are structurally distinct from the RESTEasy Client and Micronaut HTTP Client patterns covered in earlier posts. The failure modes arise from SmallRye-specific mechanisms: @ClientHeaderParam with a dynamic method reference calls the generator method on every outbound request — including each @Retry attempt — so UUID.randomUUID() inside the generator produces a new idempotency key per retry and Stripe creates ch_B; @Retry placed directly on a @RegisterRestClient interface method retries the REST call at the SmallRye CDI proxy level with no natural insertion point for stable key computation before the retry boundary, and a Stripe 408 Request Timeout response that arrives after ch_A was committed triggers a retry that creates ch_B; and Quarkus Panache’s @Transactional combined with SmallRye’s @Retry on the same service method causes a PersistenceException from a unique-constraint violation (the developer’s intended idempotency guard) to roll back the local transaction and trigger @Retry with a new UUID while the Stripe charge from the previous attempt is already committed and unaffected by the rollback.
This post covers all three failure modes with Java code (Quarkus 3.x, SmallRye REST Client, MicroProfile Fault Tolerance 4.x, Quarkus Panache, RESTEasy Reactive), the @ClientHeaderParam dynamic method invocation model and why the referenced method is called on every outbound request, the difference between placing @Retry at the interface method level vs the service method level and why the retry boundary placement determines whether stable key computation is possible, the JTA interceptor ordering between SmallRye Fault Tolerance and Quarkus Panache @Transactional and why transaction rollback does not undo a committed Stripe charge, content-hash idempotency keys stable across all three failure patterns, pre-flight PostgreSQL INSERT ... ON CONFLICT DO NOTHING as the authoritative billing mutex, and per-billing-period vault keys via a spend-cap proxy as the financial backstop. For the MicroProfile Fault Tolerance @Retry + RESTEasy Reactive Mutiny patterns, see the RESTEasy Client and Stripe Integration post. For the Micronaut HTTP Client @ClientFilter and ReactorHttpClient reactive retry patterns, see the Micronaut HTTP Client and Stripe Integration post. The SmallRye REST Client failure modes documented here are structurally different from both: they arise from SmallRye’s @ClientHeaderParam dynamic dispatch, the retry boundary of interface-level vs service-level @Retry, and the interaction between Quarkus’s Panache JPA layer and SmallRye’s fault tolerance interceptor stack.
Failure mode 1: @ClientHeaderParam with a dynamic method reference calls UUID.randomUUID() on every outbound request including each @Retry attempt — initial attempt creates ch_A before socket timeout — retry attempt creates ch_B via fresh UUID from the generator method
SmallRye REST Client extends MicroProfile REST Client with a header injection feature: @ClientHeaderParam(name = "Header-Name", value = "{fully.qualified.ClassName.methodName}"). When SmallRye builds the CDI proxy for a @RegisterRestClient interface, it reads the curly-brace syntax in the value attribute and resolves it to a method call. On every outbound HTTP request made through the REST client proxy, SmallRye invokes the referenced method and uses the returned String as the header value. The method can be a static method on any class, or an instance method on a CDI-managed bean that SmallRye resolves via the CDI container.
A developer who wants to automatically inject Idempotency-Key on every Stripe request without requiring every call site to supply the header will reach for @ClientHeaderParam with a static generator method. The pattern is ergonomic: annotate the interface method once, define a generator that returns a fresh UUID, and every call gets its own idempotency key automatically. The failure arises when @Retry from MicroProfile Fault Tolerance is stacked on the same interface method (or on a service method that calls the interface). Each @Retry attempt re-invokes the interface method, SmallRye creates a new outbound HTTP request, and the @ClientHeaderParam generator is called again to produce the header value for that request. If the generator calls UUID.randomUUID(), it returns a different UUID on the retry. Stripe sees a new idempotency key and creates a new charge:
// UNSAFE: @ClientHeaderParam with dynamic method generates UUID.randomUUID() per call.
// SmallRye calls the referenced method on every outbound request — including each @Retry attempt.
// Initial attempt creates ch_A before SocketTimeoutException.
// First retry: KeyHelper.generate() called again → UUID_B → Stripe creates ch_B.
import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam;
import org.eclipse.microprofile.rest.client.annotation.RegisterRestClient;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
@RegisterRestClient(configKey = "stripe")
@Path("/v1")
public interface StripeRestClient {
// UNSAFE: KeyHelper.generate() is called by SmallRye on every outbound request.
// @Retry on the calling service method re-invokes chargeCustomer() per retry attempt.
// Each chargeCustomer() call → new outbound request → SmallRye calls KeyHelper.generate()
// → UUID.randomUUID() → UUID_B on retry 1.
@POST
@Path("/charges")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
@ClientHeaderParam(name = "Idempotency-Key", value = "{io.example.KeyHelper.generate}")
ChargeResponse chargeCustomer(MultivaluedMap<String, String> formParams);
}
// UNSAFE static generator: UUID.randomUUID() per invocation.
// SmallRye calls this per outbound request — per @Retry attempt.
public class KeyHelper {
public static String generate() {
// UNSAFE: called fresh per outbound request.
// Timeline (with @Retry on calling service method):
// Attempt 1: generate() → UUID_A = "7a3f1b2c-..."
// SmallRye builds request with Idempotency-Key: 7a3f1b2c-...
// Stripe receives POST /v1/charges — commits ch_A — socket timeout before response
// SocketTimeoutException propagates to @Retry
//
// Attempt 2 (retry 1, backoff 1s):
// generate() → UUID_B = "c9d8e7f6-..." ← new invocation, new UUID
// SmallRye builds request with Idempotency-Key: c9d8e7f6-...
// Stripe creates ch_B ← DUPLICATE CHARGE
return java.util.UUID.randomUUID().toString();
}
}
// Calling service with @Retry — re-invokes the REST client proxy on each retry attempt.
import org.eclipse.microprofile.faulttolerance.Retry;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.ProcessingException;
@ApplicationScoped
public class BillingService {
@Inject
@RestClient
StripeRestClient stripeRestClient;
// @Retry re-invokes chargeWithAutoKey() on each retry attempt.
// Each chargeWithAutoKey() call → StripeRestClient.chargeCustomer() via SmallRye proxy
// → new HTTP request → SmallRye calls KeyHelper.generate() → new UUID.
@Retry(maxRetries = 3, delay = 1000,
retryOn = { ProcessingException.class })
public ChargeResponse chargeWithAutoKey(String customerId, int amountCents,
String billingPeriod) {
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
params.add("amount", String.valueOf(amountCents));
params.add("currency", "usd");
params.add("customer", customerId);
// stripeRestClient.chargeCustomer() triggers SmallRye to call KeyHelper.generate()
// to populate the Idempotency-Key header. On @Retry, this executes again → UUID_B.
return stripeRestClient.chargeCustomer(params);
}
}
There is a subtler variant involving an instance method on a CDI bean. Some developers move the generator to an @ApplicationScoped CDI bean so they can inject dependencies (a logger, a metrics counter, etc.) into the generator. SmallRye supports instance method references in @ClientHeaderParam by resolving the bean from the CDI container. The CDI bean is a singleton (one instance), but the instance method is called per outbound request — the CDI proxy does not cache method return values. If the method calls UUID.randomUUID(), each method invocation returns a different value regardless of the singleton scope of the bean. The behavior is identical to the static method variant from the perspective of per-request re-evaluation:
// UNSAFE instance method variant: @ApplicationScoped CDI bean resolved per request,
// but the method body executes per outbound request — UUID still re-evaluates per @Retry.
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class IdempotencyKeyProvider {
// UNSAFE: method is called per outbound request regardless of @ApplicationScoped scope.
// @ApplicationScoped means ONE instance — it does NOT mean one UUID per lifetime.
// SmallRye calls this method on each new outbound HTTP request.
public String generate() {
return java.util.UUID.randomUUID().toString(); // re-evaluates per call → ch_B on @Retry
}
}
// Interface with instance method reference — syntax: "{beanType.methodName}" not supported
// directly by all SmallRye versions; common pattern uses a static method on the provider.
// SmallRye resolves instance methods for @ClientHeaderParam via CDI lookup per request.
@RegisterRestClient(configKey = "stripe")
@Path("/v1")
public interface StripeRestClient {
@POST
@Path("/charges")
@ClientHeaderParam(name = "Idempotency-Key",
value = "{io.example.IdempotencyKeyProvider.generate}")
ChargeResponse chargeCustomer(MultivaluedMap<String, String> formParams);
}
The fix eliminates UUID.randomUUID() from the @ClientHeaderParam generator entirely and replaces it with one of two patterns. The first is to have the generator method read a stable key from a @RequestScoped CDI bean that was populated by the calling service before entering the @Retry boundary. The @RequestScoped bean holds the stable key for the duration of the JAX-RS request context (or the manually managed context in a non-JAX-RS service call). The generator reads the field rather than generating a new UUID. The second, simpler pattern is to remove @ClientHeaderParam from the interface method and instead add @HeaderParam("Idempotency-Key") String idempotencyKey as an explicit parameter on the interface method. The calling service computes the stable key once before the @Retry boundary and passes it as an argument. @Retry passes the same argument values on every retry re-invocation of the service method body, so the interface method receives the same key on every attempt:
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
// Stable content-hash key: sha256(customerId:billingPeriod:smallrye-billing)[:32].
// No UUID.randomUUID() — derived deterministically from billing fields that do not
// change between @Retry re-invocations.
public class StableKeyHelper {
public static String billingKey(String customerId, String billingPeriod, String service) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest((customerId + ":" + billingPeriod + ":" + service)
.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash).substring(0, 32);
} catch (Exception e) {
throw new RuntimeException("SHA-256 unavailable", e);
}
}
}
// SAFE: @ClientHeaderParam removed. Key passed as explicit @HeaderParam.
// SmallRye reads the @HeaderParam from the method argument — stable across @Retry attempts.
@RegisterRestClient(configKey = "stripe")
@Path("/v1")
public interface StripeRestClient {
@POST
@Path("/charges")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
ChargeResponse chargeCustomer(
@HeaderParam("Idempotency-Key") String idempotencyKey,
MultivaluedMap<String, String> formParams
);
}
// SAFE: calling service computes stable key before @Retry method, passes as parameter.
// @Retry passes the same idempotencyKey argument on every retry re-invocation.
@ApplicationScoped
public class BillingService {
@Inject
@RestClient
StripeRestClient stripeRestClient;
@Inject
BillingRunRepository billingRunRepository;
// SAFE: key computed outside the @Retry method boundary by the orchestrator.
// @Retry re-invokes this method with the same idempotencyKey argument per retry.
@Retry(maxRetries = 3, delay = 1000,
retryOn = { ProcessingException.class })
public ChargeResponse chargeCustomer(String customerId, int amountCents,
String billingPeriod, String idempotencyKey) {
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
params.add("amount", String.valueOf(amountCents));
params.add("currency", "usd");
params.add("customer", customerId);
// SAFE: idempotencyKey is a stable parameter — same value on every @Retry attempt.
return stripeRestClient.chargeCustomer(idempotencyKey, params);
}
}
// Calling orchestrator: compute stable key once, insert pre-flight guard, call @Retry method.
@ApplicationScoped
public class BillingOrchestrator {
@Inject
BillingService billingService;
@Inject
BillingRunRepository billingRunRepository;
public void runMonthlyBilling(List<Customer> customers, String billingPeriod) {
for (Customer customer : customers) {
// SAFE: stable key computed once outside the @Retry method.
String idempotencyKey = StableKeyHelper.billingKey(
customer.id(), billingPeriod, "smallrye-billing"
);
// Pre-flight: INSERT ... ON CONFLICT DO NOTHING.
// If this pod or another pod already started billing this customer for this period,
// the INSERT returns 0 rows and we skip — no Stripe call, no retry race.
int inserted = billingRunRepository.insertIfAbsent(
customer.id(), billingPeriod, idempotencyKey
);
if (inserted == 0) {
continue; // already billed or billing in progress — skip
}
try {
billingService.chargeCustomer(
customer.id(), customer.amountCents(), billingPeriod, idempotencyKey
);
} catch (Exception e) {
log.error("Billing failed for customer {} after retries", customer.id(), e);
}
}
}
}
The BillingRunRepository.insertIfAbsent() executes INSERT INTO billing_runs (customer_id, billing_period, idempotency_key) VALUES ($1, $2, $3) ON CONFLICT (customer_id, billing_period) DO NOTHING and returns the count of rows inserted. If the row already exists (another pod ran billing first, or this pod already ran billing and is re-attempting the orchestration loop), the count is zero and the orchestrator skips to the next customer without calling Stripe. The pre-flight guard is outside the @Retry boundary — it runs once before the retryable method, not once per retry attempt. Even if the @Retry method is retried three times, the Stripe call uses the same stable key each time, and Stripe’s own idempotency cache returns the same charge object on the second and third attempts if ch_A already exists.
Failure mode 2: @Retry at @RegisterRestClient interface method level — SmallRye CDI proxy retries the REST call directly — no stable key computation point before the retry boundary — Stripe 408 Request Timeout triggers @Retry while ch_A is already committed
MicroProfile Fault Tolerance @Retry can be placed on a @RegisterRestClient interface method directly, not only on a service method that calls the interface. When SmallRye builds the CDI proxy for the REST client interface, it applies fault tolerance interceptors from the annotations on the interface method, making the generated CDI stub itself retry on failure. The consequence is that the retry boundary lives at the REST-client-proxy level, not at a service-method level where the developer could compute a stable key before the retry fires.
This differs from RESTEasy Client’s MicroProfile Fault Tolerance @Retry pattern in an important structural way: the RESTEasy post covers @Retry on a service method that wraps a @RestClient call, where the service method body includes both the key computation (UUID.randomUUID()) and the REST client call. Moving the stable key computation before the @Retry boundary in the service wrapper is straightforward. With interface-level @Retry, the interface method is the REST call — the method body from the developer’s perspective is the HTTP request itself. Any key that changes per request (from a @ClientHeaderParam generator or a registered ClientRequestFilter) re-evaluates on each retry without any developer-controlled insertion point before the retry loop:
// UNSAFE: @Retry on the @RegisterRestClient interface method itself.
// SmallRye applies @Retry at the CDI proxy level for the REST client interface.
// Each retry re-invokes the interface method → new HTTP request → @ClientHeaderParam
// generator called again → UUID_B.
// Subtler failure: Stripe 408 Response Timeout triggers @Retry
// while ch_A may already be committed in Stripe's ledger.
import org.eclipse.microprofile.faulttolerance.Retry;
import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam;
import org.eclipse.microprofile.rest.client.annotation.RegisterRestClient;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
@RegisterRestClient(configKey = "stripe")
@Path("/v1")
public interface StripeRestClient {
// UNSAFE: @Retry on the interface method — retry boundary is at REST client proxy level.
// There is no service method wrapper where a stable key can be computed before @Retry fires.
// @ClientHeaderParam calls KeyHelper.generate() on every outbound request including retries.
//
// The 408 scenario:
// Attempt 1: generate() → UUID_A
// Stripe receives POST /v1/charges — processes payment — commits ch_A
// Network stalls returning the 200 response — Stripe sends 408 Request Timeout
// SmallRye REST Client throws WebApplicationException(408) to @Retry interceptor
// WebApplicationException is in retryOn={WebApplicationException.class}
// @Retry fires attempt 2
//
// Attempt 2 (retry 1): generate() → UUID_B
// Stripe sees new key — creates ch_B ← DUPLICATE CHARGE
// ch_A is already in Stripe's ledger and was not reversed by the timeout
@POST
@Path("/charges")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
@ClientHeaderParam(name = "Idempotency-Key", value = "{io.example.KeyHelper.generate}")
@Retry(maxRetries = 3, delay = 1000,
retryOn = { jakarta.ws.rs.ProcessingException.class,
jakarta.ws.rs.WebApplicationException.class })
ChargeResponse chargeCustomer(MultivaluedMap<String, String> formParams);
}
// Service that calls the REST client — the @Retry is on the interface, not here.
// The service has no retry logic of its own, but retries still fire at the proxy level.
@ApplicationScoped
public class BillingService {
@Inject
@RestClient
StripeRestClient stripeRestClient;
// No @Retry here — but retries still fire because @Retry is on the interface method.
// The service has no opportunity to compute a stable key before the retry boundary.
public ChargeResponse chargeCustomer(String customerId, int amountCents) {
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
params.add("amount", String.valueOf(amountCents));
params.add("currency", "usd");
params.add("customer", customerId);
// stripeRestClient.chargeCustomer() internally retries with new UUID per attempt.
// A 408 from Stripe after ch_A was committed triggers @Retry → ch_B.
return stripeRestClient.chargeCustomer(params);
}
}
The 408 Request Timeout scenario is particularly dangerous because it is easy to misclassify as a safe-to-retry error. A 408 means the server timed out waiting for the client request body — in an HTTP proxy or load balancer context, it can also mean the upstream (Stripe) timed out sending the response after successfully processing the request. From Stripe’s perspective, the charge was committed and ch_A exists in their ledger. The timeout was a network event on the return path, not a failure to process. When MicroProfile REST Client receives a 408 response code and throws WebApplicationException, the application sees a failure. Including WebApplicationException.class in retryOn is common practice for making REST calls resilient, but it makes the retry fire on a response code that indicates Stripe completed the operation — not that it failed to start it. The retry with UUID_B creates ch_B regardless of the fact that ch_A was committed successfully.
The fix is to move @Retry from the interface method to a service method wrapper, and to remove @ClientHeaderParam from the interface. The service method wrapper has a method body where stable key computation can happen before the @Retry boundary. The interface method receives the stable key as an explicit @HeaderParam argument, making it retry-transparent — the same key value is passed on every retry attempt of the service method:
// SAFE: @Retry removed from interface. Key passed as explicit @HeaderParam.
// @Retry moved to service method where stable key is computed before retry boundary.
// retryOn narrowed to ProcessingException (network-level) only —
// WebApplicationException (4xx/5xx from Stripe) is excluded so a 408 does not retry
// blindly over a committed charge.
@RegisterRestClient(configKey = "stripe")
@Path("/v1")
public interface StripeRestClient {
@POST
@Path("/charges")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
// No @Retry here — retry boundary is at the service level, not the proxy level.
// No @ClientHeaderParam — key is passed explicitly as @HeaderParam.
ChargeResponse chargeCustomer(
@HeaderParam("Idempotency-Key") String idempotencyKey,
MultivaluedMap<String, String> formParams
);
}
// SAFE: @Retry on service method — stable key computed in method body before the Stripe call.
// @Retry passes the same idempotencyKey parameter on every retry re-invocation.
// retryOn narrowed: only network-level ProcessingException retries; 4xx/5xx excluded.
@ApplicationScoped
public class BillingService {
@Inject
@RestClient
StripeRestClient stripeRestClient;
// SAFE: idempotencyKey is a stable pre-computed parameter.
// @Retry passes the same value on every re-invocation of this method.
// retryOn = {ProcessingException.class} only — does NOT include WebApplicationException,
// so a 408 or 500 from Stripe does not trigger a retry that would create ch_B.
// Handle non-retryable Stripe errors in the caller with compensation logic.
@Retry(maxRetries = 3, delay = 1000,
retryOn = { jakarta.ws.rs.ProcessingException.class })
public ChargeResponse chargeCustomer(String customerId, int amountCents,
String billingPeriod, String idempotencyKey) {
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
params.add("amount", String.valueOf(amountCents));
params.add("currency", "usd");
params.add("customer", customerId);
// SAFE: idempotencyKey is the same stable value on initial attempt and all retries.
return stripeRestClient.chargeCustomer(idempotencyKey, params);
}
}
Narrowing retryOn to ProcessingException.class only (network-level failures where the request did not reach Stripe — DNS failures, connection refused, connection reset mid-send before Stripe received the full request) and excluding WebApplicationException.class (HTTP-level responses including 408, 429, 5xx) is a separate important correction. For Stripe responses that indicate a committed charge (4xx, 5xx, or 408), the correct response is to query the Idempotency-Key on the Stripe API to retrieve the existing charge object — not to retry with a new key. For network-level failures where the request demonstrably did not reach Stripe (connection refused, TLS handshake failure), the stable key makes it safe to retry because Stripe never received the initial request and no ch_A exists.
A ClientRequestFilter registered globally via @Provider presents the same failure mode as @ClientHeaderParam with a dynamic generator when @Retry is at interface level. If a ClientRequestFilter implements filter(ClientRequestContext) and injects UUID.randomUUID() into the Idempotency-Key header, it is called for every outbound request — including each retry from an interface-level @Retry. The fix is the same: pass the stable key as an explicit @HeaderParam and have the filter validate that the header is already present rather than generate it.
Failure mode 3: Quarkus Panache @Transactional + SmallRye @Retry stacked — PersistenceException from unique constraint after committed Stripe charge triggers @Retry with UUID_B — ch_B created while ch_A is in Stripe’s ledger
Quarkus developers use Panache for JPA persistence. A common Panache pattern for billing services combines @Transactional (Quarkus CDI interceptor that begins a JTA transaction before the method and commits or rolls back after it) with SmallRye Fault Tolerance @Retry (CDI interceptor that re-invokes the method on exception) on the same service method. The developer’s intent is to make the billing operation resilient to transient failures while also ensuring the billing record is committed atomically with any other local database state.
The failure arises from a misunderstanding of the interaction between @Transactional, @Retry, and the Stripe charge. Stripe is not a JTA resource — the HTTP POST to Stripe’s API is not part of the local JTA transaction. When @Transactional rolls back the transaction because an exception escaped the method body, it undoes only local database writes. The Stripe charge that was committed in a previous execution of the method body is not rolled back. If @Retry catches the exception that caused the rollback and re-invokes the method body with a new UUID.randomUUID(), the Stripe charge fires again with UUID_B and Stripe creates ch_B:
// UNSAFE: @Transactional + @Retry stacked on a method that calls Stripe AND Panache persist.
// @Transactional rolls back the local DB transaction on PersistenceException.
// @Retry catches PersistenceException and re-invokes the method with UUID_B.
// Stripe charge from the previous attempt (ch_A) is NOT rolled back — it's already committed.
// Stripe creates ch_B on the retry invocation.
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.PersistenceException;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.faulttolerance.Retry;
import jakarta.ws.rs.ProcessingException;
@ApplicationScoped
public class BillingService {
@Inject
@RestClient
StripeRestClient stripeRestClient;
@Inject
BillingRecordRepository billingRecordRepository;
// UNSAFE: @Transactional (inner interceptor) + @Retry (outer interceptor) stacked.
// SmallRye FT interceptors bind at jakarta.interceptor.Interceptor.Priority = 1000.
// @Transactional binds at Interceptor.Priority.PLATFORM_BEFORE + 200 = 500.
// @Retry (priority 1000) is OUTER, @Transactional (priority 500) is INNER.
// Each @Retry attempt starts a fresh @Transactional scope.
//
// Failure sequence:
// Attempt 1 (@Transactional scope A begins):
// idempotencyKey = UUID.randomUUID() → UUID_A
// stripeRestClient.chargeCustomer(UUID_A, ...) → Stripe commits ch_A
// BillingRecord.persist() → INSERT INTO billing_records (customer_id, billing_period)
// If a concurrent pod ALSO inserted (customer_id, billing_period) within microseconds,
// PostgreSQL throws: ERROR: duplicate key value violates unique constraint
// PersistenceException propagates from Panache
// @Transactional scope A marks for rollback → rolls back local DB only (not Stripe)
// PersistenceException propagates to @Retry outer interceptor
//
// Attempt 2 (retry 1, @Transactional scope B begins):
// idempotencyKey = UUID.randomUUID() → UUID_B ← NEW UUID
// stripeRestClient.chargeCustomer(UUID_B, ...) → Stripe creates ch_B ← DUPLICATE
@Transactional
@Retry(maxRetries = 2, delay = 500,
retryOn = { ProcessingException.class, PersistenceException.class })
public ChargeResponse chargeAndRecord(String customerId, int amountCents,
String billingPeriod) {
// UNSAFE: UUID computed inside the @Retry method body — re-evaluates per retry.
String idempotencyKey = java.util.UUID.randomUUID().toString();
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
params.add("amount", String.valueOf(amountCents));
params.add("currency", "usd");
params.add("customer", customerId);
// Step 1: Call Stripe. ch_A committed here on attempt 1.
// Stripe HTTP call is NOT part of the JTA transaction — @Transactional rollback
// does NOT undo this charge.
ChargeResponse charge = stripeRestClient.chargeCustomer(idempotencyKey, params);
// Step 2: Persist billing record via Panache.
// PersistenceException (unique constraint) here triggers @Retry with UUID_B.
BillingRecord record = new BillingRecord();
record.customerId = customerId;
record.billingPeriod = billingPeriod;
record.chargeId = charge.getId();
billingRecordRepository.persist(record); // UNSAFE: throws → @Retry → ch_B
return charge;
}
}
The interceptor ordering is critical to understanding why each retry starts with a fresh transaction scope. SmallRye Fault Tolerance interceptors are registered at jakarta.interceptor.Interceptor.Priority value 1000. Quarkus’s @Transactional interceptor is registered at Interceptor.Priority.PLATFORM_BEFORE + 200 = 500. Lower priority values bind the interceptor as an inner (closer-to-implementation) wrapper. Higher priority values bind as outer wrappers. Therefore @Retry (priority 1000, outer) wraps @Transactional (priority 500, inner). The call stack from outermost to innermost is: @Retry interceptor → @Transactional interceptor → method body. When @Transactional receives a PersistenceException that escaped the method body, it marks the transaction for rollback and rolls it back before propagating the exception upward. @Retry receives the exception, sees PersistenceException.class in its retryOn list, and re-invokes the method body. Because @Retry is outer, the re-invocation goes through @Transactional again — a fresh transaction begins. This is why each retry attempt gets a fresh, independent transaction scope, and why the rollback of attempt 1’s transaction does not suppress the retry or signal to @Retry that the Stripe charge succeeded.
There is a subtler variant involving @Transactional(rollbackOn = PersistenceException.class) where the developer explicitly marks PersistenceException as a rollback trigger, intending the unique-constraint exception to be the signal that the billing record already exists and the customer is already billed. But because the Stripe charge happens before the persist, and the rollback does not touch the Stripe charge, this attempt at using the unique constraint as a billing idempotency guard actually inverts the expected behavior: the constraint protects the database from double-writes but does nothing to prevent the Stripe double-charge that @Retry causes on re-invocation.
The fix has three components. First, remove PersistenceException.class from @Retry’s retryOn list. A unique-constraint violation is not a transient failure that warrants retry — it is a signal that the billing already occurred. @Retry should only fire on network-level transient errors (ProcessingException) where the Stripe request demonstrably did not reach Stripe. Second, replace the Panache persist() call that can throw a constraint exception with a pre-flight INSERT ... ON CONFLICT DO NOTHING executed before the Stripe call, outside the @Retry boundary entirely. If the pre-flight INSERT returns zero rows, skip the customer. Third, compute the stable content-hash key before passing it to the @Retry method:
// SAFE: @Transactional + @Retry separated in scope.
// Pre-flight ON CONFLICT DO NOTHING runs BEFORE @Retry boundary — not inside it.
// PersistenceException excluded from retryOn — unique constraint is a signal, not a retry cause.
// Stable content-hash idempotency key computed by orchestrator before passing to @Retry method.
@ApplicationScoped
public class BillingOrchestrator {
@Inject
BillingRunRepository billingRunRepository;
@Inject
BillingService billingService;
// Orchestrator runs outside any @Retry or @Transactional boundary.
// Pre-flight INSERT happens here — one transaction that either succeeds or is skipped.
public void runMonthlyBilling(List<Customer> customers, String billingPeriod) {
for (Customer customer : customers) {
String idempotencyKey = StableKeyHelper.billingKey(
customer.id(), billingPeriod, "smallrye-billing"
);
// Pre-flight: INSERT ... ON CONFLICT (customer_id, billing_period) DO NOTHING.
// Returns 1 if this pod won the race (billing not yet started for this customer).
// Returns 0 if another pod already inserted — skip this customer.
// This INSERT is in its own @Transactional boundary (the repository method),
// separate from the @Retry billing method below.
int inserted = billingRunRepository.insertIfAbsent(
customer.id(), billingPeriod, idempotencyKey
);
if (inserted == 0) {
continue; // already billed or in progress
}
try {
// billingService.chargeAndRecordSafe() has @Retry only on ProcessingException.
// The stable idempotencyKey is the same value on all @Retry attempts.
// No PersistenceException can escape from billingService — persist uses
// MERGE or updateChargeId (on conflict do update) to avoid throwing on
// a pre-existing row.
billingService.chargeAndRecordSafe(
customer.id(), customer.amountCents(), billingPeriod, idempotencyKey
);
} catch (Exception e) {
log.error("Billing failed for customer {} after retries — manual review needed",
customer.id(), e);
}
}
}
}
@ApplicationScoped
public class BillingService {
@Inject
@RestClient
StripeRestClient stripeRestClient;
@Inject
BillingRecordRepository billingRecordRepository;
// SAFE: @Retry only on ProcessingException (network-level, Stripe did not receive request).
// PersistenceException excluded — unique constraint is a prior-success signal, not retry cause.
// idempotencyKey is a stable parameter — same value on all @Retry attempts.
// @Transactional scope covers only the persist() after the Stripe call succeeds.
@Retry(maxRetries = 3, delay = 1000,
retryOn = { jakarta.ws.rs.ProcessingException.class })
public ChargeResponse chargeAndRecordSafe(String customerId, int amountCents,
String billingPeriod, String idempotencyKey) {
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
params.add("amount", String.valueOf(amountCents));
params.add("currency", "usd");
params.add("customer", customerId);
// SAFE: idempotencyKey is the same stable value on all @Retry re-invocations.
// If ch_A was created on attempt 1 and a ProcessingException fired before the response
// arrived, attempt 2 sends the same idempotency key and Stripe returns ch_A — no ch_B.
ChargeResponse charge = stripeRestClient.chargeCustomer(idempotencyKey, params);
// SAFE: persist inside a separate @Transactional boundary.
// Uses INSERT ... ON CONFLICT (customer_id, billing_period) DO UPDATE SET charge_id = $3
// so it never throws PersistenceException — it upserts instead.
persistBillingRecord(customerId, billingPeriod, charge.getId(), idempotencyKey);
return charge;
}
@Transactional
void persistBillingRecord(String customerId, String billingPeriod,
String chargeId, String idempotencyKey) {
// INSERT ... ON CONFLICT DO UPDATE — no unique-constraint exception.
// This is safe to call on every @Retry attempt:
// if ch_A was already persisted by a prior attempt's successful persist call,
// this upserts charge_id = chargeId (same value) — idempotent.
billingRecordRepository.upsert(customerId, billingPeriod, chargeId, idempotencyKey);
}
}
// Repository: pre-flight INSERT (returns count) and upsert (never throws on conflict).
@ApplicationScoped
public class BillingRecordRepository implements PanacheRepository<BillingRecord> {
@Inject
EntityManager em;
// Pre-flight: returns 1 if inserted, 0 if conflict (already exists).
@Transactional
public int insertIfAbsent(String customerId, String billingPeriod, String idempotencyKey) {
return em.createNativeQuery(
"INSERT INTO billing_runs (customer_id, billing_period, idempotency_key) " +
"VALUES (:cid, :bp, :key) ON CONFLICT (customer_id, billing_period) DO NOTHING"
)
.setParameter("cid", customerId)
.setParameter("bp", billingPeriod)
.setParameter("key", idempotencyKey)
.executeUpdate();
}
// Upsert: safe to call multiple times — last writer wins but all writers have same chargeId.
@Transactional
public void upsert(String customerId, String billingPeriod,
String chargeId, String idempotencyKey) {
em.createNativeQuery(
"INSERT INTO billing_records (customer_id, billing_period, charge_id, idempotency_key) " +
"VALUES (:cid, :bp, :chid, :key) " +
"ON CONFLICT (customer_id, billing_period) DO UPDATE SET charge_id = EXCLUDED.charge_id"
)
.setParameter("cid", customerId)
.setParameter("bp", billingPeriod)
.setParameter("chid", chargeId)
.setParameter("key", idempotencyKey)
.executeUpdate();
}
}
The upsert in persistBillingRecord() is important on its own. If a ProcessingException fires after the Stripe call succeeded but before persistBillingRecord() was called, @Retry fires and the second attempt succeeds at both the Stripe call (which returns ch_A from the idempotency cache) and the persist call (which upserts — equivalent to a second INSERT of the same data). The billing record ends up with the correct charge ID without any exception. The stable idempotency key is the thread that connects all three layers: the pre-flight billing run guard uses it as a lookup key, the Stripe call uses it as the idempotency key, and the billing record upsert stores it for audit and deduplication queries.
Keybrake: spend caps as the financial backstop for all three failure modes
Content-hash stable idempotency keys, pre-flight ON CONFLICT DO NOTHING guards, and carefully scoped @Retry boundaries provide defense-in-depth at the application layer. They close each of the three failure modes under the failure conditions described. They do not protect against undiscovered failure modes — a new developer who adds a UUID to a @ClientHeaderParam generator, a deployment that adds interface-level @Retry for observability, or a future Quarkus version that changes interceptor ordering. A financial backstop that is independent of the application layer covers the cases the application layer misses.
A per-billing-period vault key with a spend cap at expected_total_charges × 1.10 limits the blast radius. The vault key is issued for the billing run: vault_key_period_2026_09, scoped to the Stripe create-charge endpoint, capped at 110% of the expected total. If the billing service creates ch_B alongside ch_A for even one customer, the cap absorbs the overage but prevents a runaway retry loop from charging the entire customer list twice. The audit log records every charge by idempotency key and vault key, making post-run reconciliation — comparing the vault log against Stripe’s event stream — a lookup operation rather than a manual investigation.
The vault key also covers a scenario that none of the three failure modes address: a bug in the content-hash key function itself. If two different (customerId, billingPeriod) pairs hash to the same 32-character key (SHA-256 truncated to 32 hex characters has 2¹²&sup8; possible values — collision is astronomically unlikely in a billing list but not impossible at very large scale), Stripe would return ch_A on the second charge and the second customer would not be billed. The spend cap does not prevent this class of bug, but the audit log makes it detectable: a missing charge in the billing record table alongside a successful vault log entry with the colliding idempotency key surfaces the collision immediately.
Summary
| Failure mode | Root cause | Fix |
|---|---|---|
@ClientHeaderParam dynamic method generates UUID.randomUUID() per request |
SmallRye calls generator method on every outbound request including @Retry attempts |
Remove @ClientHeaderParam; pass stable content-hash key as @HeaderParam method parameter computed before @Retry boundary |
@Retry at @RegisterRestClient interface method level |
Retry boundary at REST proxy level; no insertion point for stable key; Stripe 408 triggers retry while ch_A committed | Move @Retry to service wrapper method; narrow retryOn to ProcessingException only; pass stable key as @HeaderParam |
Quarkus Panache @Transactional + SmallRye @Retry stacked |
PersistenceException from unique constraint after committed ch_A triggers @Retry with UUID_B; @Transactional rollback does not undo Stripe charge |
Pre-flight ON CONFLICT DO NOTHING before @Retry boundary; exclude PersistenceException from retryOn; upsert billing record; stable content-hash key |
| All three | Financial blast radius from any surviving duplicate charge path | Per-billing-period vault key capped at expected_total × 1.10 via spend-cap proxy |
SmallRye REST Client’s @ClientHeaderParam dynamic method invocation, the retry boundary placement difference between interface-level and service-level @Retry, and the Quarkus Panache + SmallRye Fault Tolerance interceptor stack are each structurally distinct from the failure modes covered in the RESTEasy Client and Micronaut HTTP Client posts. The common thread across all three SmallRye failure modes is that UUID.randomUUID() evaluated inside a framework-managed invocation point (a @ClientHeaderParam generator, a retried interface proxy method, or a @Retry-retried service method body) is evaluated again on each framework-managed re-invocation — and the application layer has no visibility into when those re-invocations happen. Content-hash keys derived from stable billing fields remove the dependency on per-invocation randomness entirely.
Protect Stripe billing from retry duplicates
Keybrake issues a per-billing-period vault key with a spend cap and audit log. Every charge is logged with its idempotency key and policy verdict — duplicates surface immediately in the run report.