MicroProfile REST Client and Quarkus Testing: How @InjectMock, WireMock Assertions, and Fault Tolerance Test Profiles Mask Stripe Idempotency Bugs
The SmallRye REST Client post covered three production failure modes: @ClientHeaderParam with a dynamic generator method calls UUID.randomUUID() on every outbound request including each @Retry retry; @Retry at the @RegisterRestClient interface level creates a retry boundary with no natural stable-key insertion point; and Quarkus Panache @Transactional stacked with @Retry causes a PersistenceException from a duplicate billing record to trigger a retry with UUID_B. This post covers a different layer of the same problem: how the three most common @QuarkusTest patterns give those bugs a clean bill of health before they reach production. First: @InjectMock on a @RegisterRestClient interface replaces the entire SmallRye REST Client CDI proxy, so @ClientHeaderParam header generation never runs in the test — UUID re-evaluation bugs are structurally invisible. Second: WireMock retry scenarios that verify Idempotency-Key is present on retried requests but not that it is identical across retry attempts — distinct UUID values both match matching(".+"). Third: %test.quarkus.smallrye-fault-tolerance.enabled=false in the test application properties disables @Retry interceptors — UUID re-evaluation inside the annotated method body is never triggered in tests, ships unchecked to production.
The SmallRye REST Client layer that @InjectMock discards
When you annotate a Java interface with @RegisterRestClient and inject it in a CDI bean via @Inject @RestClient, Quarkus wires up a SmallRye REST Client CDI proxy at startup. That proxy is a dynamically generated class that implements your interface. When a method is called on the proxy, SmallRye does the following before the HTTP request goes out: it processes any @ClientHeaderParam annotations on the method or interface, calling the referenced method or evaluating the expression to produce header values; it applies any registered ClientRequestFilter instances; it serializes the method parameter(s) to the request body; and it sends the HTTP request to the configured base URI. If MicroProfile Fault Tolerance annotations (@Retry, @Timeout, @Fallback) are present on the interface method, the CDI interceptors for those annotations fire around the entire proxy method invocation — meaning each interceptor re-invocation also re-triggers @ClientHeaderParam evaluation.
This is the mechanism that causes the UUID re-evaluation bug. @ClientHeaderParam(name = "Idempotency-Key", value = "{io.example.IdempotencyHelper.generate}") tells SmallRye to call IdempotencyHelper.generate() on every invocation of the annotated interface method. @Retry on the same method re-invokes the proxy method on each retry attempt — and each proxy method invocation triggers a new @ClientHeaderParam evaluation — and IdempotencyHelper.generate() calls UUID.randomUUID() — UUID_B on the first retry.
When @InjectMock from quarkus-junit5-mockito is used to mock a @RegisterRestClient interface in a @QuarkusTest, Quarkus registers a Mockito mock of the interface type as the CDI bean satisfying the @RestClient injection qualifier. The SmallRye REST Client CDI proxy described above is replaced entirely. No HTTP requests are sent. No @ClientHeaderParam annotations are processed. No ClientRequestFilter instances run. The CDI interceptors for @Retry on the interface method are also bypassed (since the proxy that would trigger them is gone). What remains is a plain Mockito mock that records method calls and returns configured stubs.
Failure mode 1: @InjectMock on @RegisterRestClient replaces the SmallRye CDI proxy — @ClientHeaderParam generator never fires — UUID re-evaluation bug structurally invisible in tests
Consider the following production code. The REST client interface uses @ClientHeaderParam with a static method reference as the idempotency key generator:
// StripeClient.java — @RegisterRestClient interface with @ClientHeaderParam idempotency key generator.
// IdempotencyHelper.generate() calls UUID.randomUUID().toString() on every invocation.
// SmallRye REST Client calls this method on every outbound HTTP request including @Retry retries.
import org.eclipse.microprofile.rest.client.annotation.RegisterRestClient;
import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam;
import org.eclipse.microprofile.faulttolerance.Retry;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
@RegisterRestClient(baseUri = "https://api.stripe.com")
public interface StripeClient {
@POST
@Path("/v1/charges")
@ClientHeaderParam(name = "Idempotency-Key", value = "{io.example.IdempotencyHelper.generate}")
@Retry(maxRetries = 3, delay = 500, jitter = 100)
ChargeResponse createCharge(ChargeRequest request);
}
// IdempotencyHelper.java — static generator method called by SmallRye on every interface method invocation.
public class IdempotencyHelper {
public static String generate() {
return UUID.randomUUID().toString(); // WRONG: new UUID per invocation, including per @Retry attempt
}
}
In production this causes ch_B: @Retry re-invokes the SmallRye proxy method on each retry attempt; each proxy invocation calls IdempotencyHelper.generate(); UUID_B is sent to Stripe on the first retry after Stripe committed ch_A during the initial attempt. A developer who covers this code with a @QuarkusTest using @InjectMock will never see this failure:
// BillingServiceTest.java — UNSAFE: @InjectMock replaces SmallRye REST Client CDI proxy.
// @ClientHeaderParam generator IdempotencyHelper.generate() is never called in this test.
// The mock does not validate idempotency key values, presence, or stability across retry attempts.
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.mockito.InjectMock;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.junit.jupiter.api.Test;
import jakarta.inject.Inject;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@QuarkusTest
class BillingServiceTest {
@InjectMock
@RestClient
StripeClient stripeMock; // SmallRye REST Client CDI proxy replaced by Mockito mock
@Inject
BillingService billingService;
@Test
void testRetryOnTransientError() {
// Mock: fail first attempt, succeed on second
when(stripeMock.createCharge(any()))
.thenThrow(new jakarta.ws.rs.ProcessingException("connection reset"))
.thenReturn(new ChargeResponse("ch_ok", "succeeded"));
billingService.chargeCustomer("cus_123", 2999L, "2026-09");
// Verifying retry invocation count — this passes: mock called twice
verify(stripeMock, times(2)).createCharge(any());
// But @ClientHeaderParam was never evaluated.
// IdempotencyHelper.generate() was never called.
// No idempotency key was ever generated, checked, or compared.
// The test has no information about whether UUID_A == UUID_B on retry.
}
}
The test passes because the mock records two calls to createCharge(any()), which is exactly what the developer expected. verify(stripeMock, times(2)) confirms retry fired. Nothing in the test can observe idempotency key behavior because @InjectMock replaced the layer that generates the key.
A developer who tries to improve this test by asserting on the idempotency key hits a different wall. The idempotency key is an HTTP header, not a Java method parameter. The createCharge(ChargeRequest request) method signature passes the request body as the argument; the @ClientHeaderParam header is applied by the SmallRye proxy layer on the outbound HTTP request. The mock intercepts the Java method call, not the HTTP request. There is no Mockito argument captor that captures HTTP headers from a @ClientHeaderParam annotation because the annotation processing layer doesn’t exist in the test — it was discarded when the CDI proxy was replaced.
Subtler variant: @ApplicationScoped CDI bean used as @ClientHeaderParam provider — bean is one instance, method executes per invocation — mock replaces proxy before bean is consulted
The SmallRye REST Client documentation notes that the @ClientHeaderParam value can reference an instance method on a CDI bean: @ClientHeaderParam(name = "Idempotency-Key", value = "{billingKeyProvider.generate}") where billingKeyProvider is an @ApplicationScoped CDI bean. A developer who sees “ApplicationScoped” might assume the key is computed once per application instance and shared. It is not — ApplicationScoped means the bean instance is one, but the method generate() is called per request. If generate() calls UUID.randomUUID(), it produces UUID_B per @Retry attempt just as the static method variant does. In tests with @InjectMock, neither the static nor the instance-method variant of @ClientHeaderParam processing runs — the CDI proxy that calls them is discarded. The developer who tests billingKeyProvider.generate() in isolation (a unit test that calls the method directly and asserts it returns a UUID) gets a false sense of correctness: the method works in isolation; the bug is in its position relative to the @Retry boundary, which no @InjectMock-based test exercises.
Fix: use WireMock instead of @InjectMock for REST client tests — actual SmallRye proxy runs — @ClientHeaderParam fires on every HTTP request
WireMock (via the quarkus-junit5-wiremock community extension or a manually configured WireMockServer listening on a test port) serves as the Stripe API endpoint in tests. Quarkus configures the @RegisterRestClient base URI to point at the WireMock server via %test.io.example.StripeClient/mp-rest/url=http://localhost:${quarkus.wiremock.server-port}. The SmallRye REST Client CDI proxy is wired up normally at test startup — @ClientHeaderParam, @Retry, and all CDI interceptors run exactly as they would in production. The HTTP request goes to WireMock’s port, not to Stripe’s API. WireMock records every request it receives, including all headers. You can capture the Idempotency-Key header value from each retry attempt and assert that they are identical:
// BillingServiceWireMockTest.java — SAFE: WireMock runs actual SmallRye REST Client proxy.
// @ClientHeaderParam fires on every HTTP request sent by the proxy.
// WireMock captures the actual Idempotency-Key header from each retry attempt.
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.verification.LoggedRequest;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import jakarta.inject.Inject;
import java.util.List;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;
@QuarkusTest
class BillingServiceWireMockTest {
@Inject
WireMockServer wireMock; // configured to receive requests from StripeClient base URI
@Inject
BillingService billingService;
@Test
void idempotencyKeyMustBeStableAcrossRetries() {
// Scenario: Stripe returns 503 on attempt 1, 200 on attempt 2
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("stripe-retry")
.whenScenarioStateIs(STARTED)
.willReturn(serviceUnavailable())
.willSetStateTo("first-failed"));
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("stripe-retry")
.whenScenarioStateIs("first-failed")
.willReturn(okJson("{\"id\":\"ch_ok\",\"status\":\"succeeded\"}")));
billingService.chargeCustomer("cus_123", 2999L, "2026-09");
// Two HTTP requests reached WireMock: attempt 1 (503) and attempt 2 (200)
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests).hasSize(2);
// THE CRITICAL ASSERTION: both attempts must carry the same Idempotency-Key
String keyOnAttempt1 = requests.get(0).getHeader("Idempotency-Key");
String keyOnAttempt2 = requests.get(1).getHeader("Idempotency-Key");
assertThat(keyOnAttempt1).isNotNull();
assertThat(keyOnAttempt2)
.as("Idempotency-Key must be identical on retry to prevent ch_B")
.isEqualTo(keyOnAttempt1); // FAILS when @ClientHeaderParam calls UUID.randomUUID()
}
}
When IdempotencyHelper.generate() calls UUID.randomUUID(), keyOnAttempt1 and keyOnAttempt2 will be different UUID strings and the assertion fails. This is the first test in the codebase that actually catches the bug. The fix — computing a stable content-hash key before the @Retry boundary and passing it as a @HeaderParam to the interface method rather than using a @ClientHeaderParam dynamic generator — makes keyOnAttempt1.equals(keyOnAttempt2) true and the assertion green.
Failure mode 2: WireMock retry scenario verifies Idempotency-Key is present — matching(".+") passes for both UUID_A and UUID_B — distinct keys both satisfy the assertion
A developer who switches from @InjectMock to WireMock (correctly) and writes a retry test is now exercising the actual SmallRye REST Client proxy and @ClientHeaderParam evaluation. But the idempotency assertion they reach for first is the wrong one: verify that the header is present on retried requests.
// BillingServiceWireMockTest.java — UNSAFE: assertion verifies presence, not stability.
// Both UUID_A and UUID_B satisfy matching(".+") — distinct keys both pass this test.
@Test
void retryMustSendIdempotencyKey() {
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("stripe-retry")
.whenScenarioStateIs(STARTED)
.willReturn(serviceUnavailable())
.willSetStateTo("first-failed"));
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("stripe-retry")
.whenScenarioStateIs("first-failed")
.willReturn(okJson("{\"id\":\"ch_ok\",\"status\":\"succeeded\"}")));
billingService.chargeCustomer("cus_123", 2999L, "2026-09");
// Verifies that both requests had the Idempotency-Key header present and non-empty.
// UUID_A on attempt 1 satisfies matching(".+").
// UUID_B on attempt 2 also satisfies matching(".+").
// The test passes whether or not UUID_A == UUID_B.
wireMock.verify(2, postRequestedFor(urlEqualTo("/v1/charges"))
.withHeader("Idempotency-Key", matching(".+"))); // WRONG assertion — too weak
}
The test documents a real requirement — every Stripe charge request must carry an idempotency key — but does not document the stronger requirement that is necessary for correctness: the idempotency key must be identical across all retry attempts for the same logical billing operation. Without the identity assertion, both Idempotency-Key: uuid-A and Idempotency-Key: uuid-B pass. The test is not wrong; it is incomplete. It certifies presence but not safety.
Subtler variant: format check is also insufficient — matching("[0-9a-f\\-]{36}") passes for any UUID string including UUID_B
A developer who recognizes that matching(".+") is too broad might upgrade to a UUID format check:
// STILL UNSAFE: UUID format assertion does not check key identity across retries.
// UUID_A = "550e8400-e29b-41d4-a716-446655440000" — matches the pattern
// UUID_B = "7f000001-0000-4000-8000-000000000001" — also matches the pattern
wireMock.verify(2, postRequestedFor(urlEqualTo("/v1/charges"))
.withHeader("Idempotency-Key", matching("[0-9a-f\\-]{36}")));
This verifies that SmallRye is sending a correctly formatted UUID as the idempotency key on both attempts. It does not verify that the two UUIDs are the same. From Stripe’s perspective, ch_A was created with UUID_A on attempt 1. Attempt 2 arrives with UUID_B — a different idempotency key — and Stripe creates ch_B. The format-check assertion is green. The customer has been charged twice.
Subtler variant: @Retry at interface level — service layer calls interface method once — WireMock retry scenario verifies 2 requests — but both are separate CDI proxy invocations from @Retry on the interface method
When @Retry is placed on the @RegisterRestClient interface method (not the service method), the retry boundary is inside the SmallRye CDI proxy for the REST client. The service layer calls stripeClient.createCharge(request) once; the SmallRye proxy internally retries the HTTP call when the 503 arrives. From the service layer’s perspective, the call either eventually succeeds or throws after all retries exhaust. WireMock receives multiple HTTP requests corresponding to each retry attempt from the SmallRye proxy.
In this configuration, a developer might write a WireMock verify that checks the two requests have the same value, but only checks the first and last:
// Partial check — only compares first and last request.
// With maxRetries=3, WireMock receives 4 requests.
// Assertion checks requests.get(0) vs requests.get(3) but not attempts 1, 2, 3.
// If UUID is generated per @ClientHeaderParam invocation, all 4 are distinct.
// The test still passes if requests.get(0) happens to equal requests.get(3) — which it won't,
// but the point is: check all requests against the first, not just the last.
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests.get(requests.size() - 1).getHeader("Idempotency-Key"))
.isEqualTo(requests.get(0).getHeader("Idempotency-Key"));
The complete assertion iterates all requests:
// SAFE: checks every retry attempt carries the identical key.
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests).hasSizeGreaterThanOrEqualTo(2); // at least one retry occurred
String expectedKey = requests.get(0).getHeader("Idempotency-Key");
assertThat(expectedKey).isNotNull().isNotBlank();
for (int i = 1; i < requests.size(); i++) {
assertThat(requests.get(i).getHeader("Idempotency-Key"))
.as("Retry attempt %d must carry the same Idempotency-Key as attempt 0", i)
.isEqualTo(expectedKey);
}
Failure mode 3: %test.quarkus.smallrye-fault-tolerance.enabled=false disables @Retry in test profile — UUID inside annotated method body never re-evaluates — ch_B ships unchecked to production
Quarkus’s SmallRye Fault Tolerance extension respects two MicroProfile Config properties that control whether fault tolerance interceptors are active. MP_Fault_Tolerance_NonFallback_Enabled controls @Retry, @CircuitBreaker, @Bulkhead, and @Timeout. Enabled (or quarkus.smallrye-fault-tolerance.enabled) controls all annotations including @Fallback. Setting either to false via the %test profile disables the corresponding CDI interceptors for all @QuarkusTest runs in that profile:
# src/test/resources/application.properties
# Disables @Retry, @CircuitBreaker, @Bulkhead, @Timeout in test JVM.
# Each annotated method executes once without interception — retries never fire in tests.
%test.MP_Fault_Tolerance_NonFallback_Enabled=false
The motivation is legitimate: fault tolerance annotations add jitter, backoff delays, and conditional retry paths that make tests non-deterministic and slow. A billing test with @Retry(maxRetries = 3, delay = 500, jitter = 200) could take up to 2.1 seconds per retry-path test case. Disabling fault tolerance makes every annotated method execute synchronously and once — tests complete in milliseconds.
The hidden cost is that the UUID re-evaluation bug is never triggered. Consider:
// BillingService.java — @Retry with UUID.randomUUID() inside the annotated method body.
// In production: @Retry interceptor re-invokes the method body on each retry attempt.
// UUID.randomUUID() at method entry re-evaluates per invocation — UUID_B on first retry.
// In test with %test.MP_Fault_Tolerance_NonFallback_Enabled=false:
// @Retry interceptor disabled — method executes once — UUID generated once — no retry — no ch_B.
@ApplicationScoped
public class BillingService {
@Inject @RestClient
StripeClient stripeClient;
@Retry(maxRetries = 3, delay = 500, jitter = 100)
public ChargeResponse chargeCustomer(String customerId, long amountCents, String billingPeriod) {
String idempotencyKey = UUID.randomUUID().toString(); // WRONG: inside @Retry boundary
ChargeRequest request = new ChargeRequest(customerId, amountCents, idempotencyKey);
ChargeResponse response = stripeClient.createCharge(request);
billingRepo.recordCharge(customerId, billingPeriod, response.id());
return response;
}
}
// BillingServiceTest.java — UNSAFE: fault tolerance disabled — @Retry never fires.
// UUID.randomUUID() at method entry is evaluated once — single invocation.
// Test passes. Bug ships to production where @Retry is enabled.
@QuarkusTest
class BillingServiceTest {
@InjectMock @RestClient
StripeClient stripeMock;
@Inject
BillingService billingService;
@Test
void testChargeCustomer() {
when(stripeMock.createCharge(any()))
.thenReturn(new ChargeResponse("ch_ok", "succeeded"));
ChargeResponse result = billingService.chargeCustomer("cus_123", 2999L, "2026-09");
assertThat(result.id()).isEqualTo("ch_ok");
verify(stripeMock, times(1)).createCharge(any());
// @Retry was disabled — mock was called once as expected.
// In production, mock is replaced by real SmallRye REST Client.
// @Retry is enabled. 503 from Stripe fires @Retry. Method body re-invokes.
// UUID.randomUUID() at entry produces UUID_B. Stripe creates ch_B.
}
}
The test is correct for the behavior it exercises. The problem is that the behavior it exercises is not the production behavior. In production, @Retry is enabled; in the test JVM, @Retry is disabled. The test covers the single-invocation happy path. No test in the suite covers the retry path at all, because fault tolerance is globally disabled.
Subtler variant: @QuarkusTestProfile with selective fault tolerance disabling — some test classes have @Retry enabled, others do not — coverage gap from profile-switching
Teams that have multiple test profiles often disable fault tolerance only for unit-style tests and enable it for integration-style tests. The problem arises when the test class that covers the billing service uses the unit-style profile (fault tolerance disabled) and the test class that covers the fault tolerance configuration uses a different profile (enabled) but doesn’t test the billing service. Neither test class exercises both: fault tolerance enabled AND the billing service’s idempotency key placement:
// UnitTestProfile.java — disables fault tolerance for fast unit tests
public class UnitTestProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("MP_Fault_Tolerance_NonFallback_Enabled", "false");
}
}
// BillingServiceTest.java — uses UnitTestProfile (FT disabled)
@QuarkusTest
@TestProfile(UnitTestProfile.class)
class BillingServiceTest { /* ... covers billing logic, but @Retry never fires */ }
// FaultToleranceTest.java — uses default profile (FT enabled)
@QuarkusTest
class FaultToleranceTest {
// Tests that @Retry fires on 503 — but exercises a different service, not BillingService
@Test
void retryFiresOnServiceUnavailable() { /* ... */ }
}
The gap: no test class exercises BillingService.chargeCustomer() with @Retry enabled. The UUID re-evaluation bug is in BillingService.chargeCustomer(). No test triggers it.
Subtler variant: quarkus.smallrye-fault-tolerance.timeout.enabled=false disables only @Timeout — developer assumes @Retry is active — but reads wrong property documentation
The SmallRye Fault Tolerance configuration has per-annotation enable flags: quarkus.smallrye-fault-tolerance.retry.enabled, quarkus.smallrye-fault-tolerance.timeout.enabled, and so on. A developer who wants to disable only @Timeout (to prevent time-sensitive tests from failing on a slow CI machine) might incorrectly set quarkus.smallrye-fault-tolerance.enabled=false (disabling everything) instead of the more targeted quarkus.smallrye-fault-tolerance.timeout.enabled=false. The result is that @Retry is also disabled in the test JVM, silently. The developer does not know that the retry-path UUID re-evaluation bug is now untested. The mismatch shows up in production: CI is green, first production 503 creates ch_B.
Fix: keep @Retry active in at least one integration test class — write an explicit retry idempotency test with WireMock scenario
The correct configuration separates concerns: unit tests may use @InjectMock and disable fault tolerance for fast execution. But a dedicated integration test class must exist with fault tolerance enabled and WireMock as the HTTP backend, containing at least one test that:
- Configures a WireMock scenario with failure on attempt 1 and success on attempt 2
- Invokes the billing service method (not the REST client directly)
- Captures all HTTP requests that WireMock received
- Asserts that every request carries the identical
Idempotency-Keyvalue
# src/test/resources/application-integration-test.properties
# Keep @Retry, @CircuitBreaker active — matches production behavior.
# @Timeout may be increased to prevent CI flakiness without disabling @Retry.
quarkus.smallrye-fault-tolerance.enabled=true
quarkus.smallrye-fault-tolerance.timeout.enabled=false # disable only @Timeout for CI
# Point StripeClient at WireMock server
io.example.StripeClient/mp-rest/url=http://localhost:${wiremock.server.port}
// BillingIdempotencyIntegrationTest.java — fault tolerance enabled, WireMock HTTP backend.
// This is the test that catches the @ClientHeaderParam UUID re-evaluation bug.
@QuarkusTest
@TestProfile(IntegrationTestProfile.class)
class BillingIdempotencyIntegrationTest {
@Inject
WireMockServer wireMock;
@Inject
BillingService billingService;
@BeforeEach
void reset() {
wireMock.resetAll();
}
@Test
void idempotencyKeyMustBeIdenticalOnAllRetryAttempts() {
// Stripe returns 503 twice, then 200 — exercises maxRetries=2 path
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry-idempotency")
.whenScenarioStateIs(STARTED)
.willReturn(serviceUnavailable())
.willSetStateTo("attempt-1-failed"));
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry-idempotency")
.whenScenarioStateIs("attempt-1-failed")
.willReturn(serviceUnavailable())
.willSetStateTo("attempt-2-failed"));
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry-idempotency")
.whenScenarioStateIs("attempt-2-failed")
.willReturn(okJson("{\"id\":\"ch_ok\",\"status\":\"succeeded\"}")));
billingService.chargeCustomer("cus_123", 2999L, "2026-09");
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests).hasSize(3); // initial + 2 retries
String baseKey = requests.get(0).getHeader("Idempotency-Key");
assertThat(baseKey).isNotNull().isNotBlank();
for (int i = 1; i < requests.size(); i++) {
assertThat(requests.get(i).getHeader("Idempotency-Key"))
.as("Retry attempt %d must carry the same Idempotency-Key as attempt 0 to prevent ch_B", i)
.isEqualTo(baseKey);
}
}
}
If @ClientHeaderParam calls UUID.randomUUID(), the three requests will have three different values and the loop assertion fails on i=1. The fix — removing the dynamic @ClientHeaderParam generator and replacing it with a @HeaderParam that receives a stable content-hash key computed by the calling service before any retry boundary — makes all three requests carry the same key, and the assertion passes.
Producing a stable idempotency key that is safe under all three test anti-patterns
The fix that makes the production code safe and all three test patterns either catch the bug or no longer apply is to remove the idempotency key from the REST client interface layer entirely. Instead of @ClientHeaderParam calling a generator method per outbound request, the caller (the service layer) computes a stable content-hash key before invoking the REST client interface method and passes it as an explicit method parameter:
// StripeClient.java — idempotency key passed as @HeaderParam, not generated by @ClientHeaderParam.
// No dynamic generator method — no UUID re-evaluation risk.
// The caller is responsible for computing and passing a stable key.
@RegisterRestClient(baseUri = "https://api.stripe.com")
public interface StripeClient {
@POST
@Path("/v1/charges")
ChargeResponse createCharge(
@HeaderParam("Idempotency-Key") String idempotencyKey,
ChargeRequest request
);
}
// BillingService.java — stable content-hash key computed once before @Retry boundary.
// @Retry re-invokes the method body — idempotencyKey parameter is passed from the caller,
// which computed it once before the @Retry annotation was entered.
// All retry attempts pass the same idempotencyKey to stripeClient.createCharge().
@ApplicationScoped
public class BillingService {
@Inject @RestClient
StripeClient stripeClient;
public ChargeResponse chargeCustomer(String customerId, long amountCents, String billingPeriod) {
// Stable key computed once, outside the @Retry boundary.
// sha256(customerId:billingPeriod:mp-rest-client-billing)[:32] is deterministic
// for the same (customerId, billingPeriod) pair across JVM restarts.
String idempotencyKey = stableKey(customerId, billingPeriod);
return chargeWithRetry(customerId, amountCents, billingPeriod, idempotencyKey);
}
@Retry(maxRetries = 3, delay = 500, jitter = 100)
ChargeResponse chargeWithRetry(
String customerId, long amountCents, String billingPeriod, String idempotencyKey) {
ChargeRequest request = new ChargeRequest(customerId, amountCents);
// idempotencyKey parameter carries the same value on every @Retry re-invocation.
// It was passed in from chargeCustomer(), which computed it before this method was entered.
ChargeResponse response = stripeClient.createCharge(idempotencyKey, request);
billingRepo.recordCharge(customerId, billingPeriod, response.id());
return response;
}
private static String stableKey(String customerId, String billingPeriod) {
String input = customerId + ":" + billingPeriod + ":mp-rest-client-billing";
byte[] hash = MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8));
return HexFormat.of().formatHex(hash).substring(0, 32);
}
}
With this structure:
@InjectMocktests can now assert on theidempotencyKeyparameter passed to the mockedcreateCharge(idempotencyKey, request)method directly — Mockito can capture it. The stable key is a method argument, not a side-effect of a SmallRye proxy annotation.verify(mock).createCharge(eq(expectedKey), any())verifies key identity on every mock invocation including retried ones.- WireMock tests will see the same
Idempotency-Keyheader value on every retry attempt, because the stable key is passed as the same@HeaderParamvalue to every invocation ofchargeWithRetry()by the@Retryinterceptor. - Tests with fault tolerance disabled don’t need to exercise the retry path to validate correctness — the stable key computation is in
chargeCustomer(), which executes once regardless of fault tolerance settings. A test that verifiesstableKey("cus_123", "2026-09")returns the same value on multiple calls covers the determinism property without needing@Retryto fire.
Pre-flight database guard as authoritative billing mutex
Even with a stable idempotency key at the Stripe layer, concurrent billing scenarios — two pods running the same billing job simultaneously — require a server-side guard. INSERT INTO billing_charges (customer_id, billing_period, idempotency_key, charge_id) VALUES (?, ?, ?, ?) ON CONFLICT (customer_id, billing_period) DO NOTHING before calling Stripe ensures that only one pod proceeds to the Stripe API call for a given (customer_id, billing_period) pair. The pod that wins the insert calls Stripe and writes the charge_id; the pod that loses (0 rows inserted) skips the Stripe call entirely. Even if both pods somehow proceed to Stripe (network partition between the guard and the Stripe call), the stable idempotency key ensures Stripe returns the cached ch_A to both — no ch_B is created.
// BillingRepo.java — pre-flight guard returns true if this pod won the insert.
// False means another pod already started billing for this (customer, period).
@ApplicationScoped
public class BillingRepo {
@Inject
EntityManager em;
@Transactional(Transactional.TxType.REQUIRES_NEW)
public boolean claimBillingSlot(String customerId, String billingPeriod, String idempotencyKey) {
int rows = em.createNativeQuery("""
INSERT INTO billing_charges (customer_id, billing_period, idempotency_key, status)
VALUES (:cid, :period, :key, 'pending')
ON CONFLICT (customer_id, billing_period) DO NOTHING
""")
.setParameter("cid", customerId)
.setParameter("period", billingPeriod)
.setParameter("key", idempotencyKey)
.executeUpdate();
return rows == 1; // true: this pod won; false: another pod already claimed
}
}
The test for this guard is straightforward and does not require fault tolerance to be active or SmallRye REST Client to be involved. It is a data-layer test that can use @QuarkusTest with a real database (or @QuarkusTestResource with a PostgreSQL DevService) and is unaffected by the three test anti-patterns described above.
Vault key spend cap as financial backstop
Keybrake’s vault keys add a financial-level guardrail that operates independently of idempotency key correctness and test coverage. A vault key issued for a billing run carries a daily_usd_cap policy: {"vendor": "stripe", "daily_usd_cap": expected_total * 1.10, "allowed_endpoints": ["/v1/charges"]}. If a bug causes ch_B to be created alongside ch_A — whether from a UUID re-evaluation issue that slipped through testing, a concurrent billing race, or an unexpected edge case — Keybrake tracks every proxied charge against the cap and rejects requests that would exceed it. The 1.10× multiplier absorbs legitimate variance (currency rounding, one-off plan adjustments) without blocking normal billing. A runaway retry loop or concurrent double-billing that would create N× ch_B charges across all customers is stopped at the network proxy layer before the financial damage scales.
The combination of correct idempotency keys (stable content-hash, computed before @Retry boundary), a pre-flight database guard (ON CONFLICT DO NOTHING on (customer_id, billing_period)), and a vault key spend cap provides three independent layers of protection. Any one of the three is individually capable of preventing duplicate charges from the failure modes described in this post and the SmallRye REST Client production failure modes post.
Summary: what each test approach validates
| Test approach | @ClientHeaderParam fires? |
Can detect UUID re-evaluation? | Can assert key stability? |
|---|---|---|---|
@InjectMock on @RegisterRestClient |
No — SmallRye CDI proxy discarded | No | No (header not a method arg) |
@InjectMock + @HeaderParam key (fixed code) |
N/A (key is a method arg) | Yes — Mockito captures the arg | Yes — verify(mock).createCharge(eq(expectedKey), any()) |
WireMock + matching(".+") assertion |
Yes — actual SmallRye proxy runs | No — assertion too weak | No — must compare captured values |
| WireMock + explicit key-equality assertion | Yes | Yes — assertion fails on UUID_B | Yes |
| FT disabled + WireMock | Yes (WireMock active) | No — @Retry never fires |
No — only one request reaches WireMock |
| FT enabled + WireMock + key-equality assertion | Yes | Yes | Yes — full coverage |
The only configuration that provides full coverage is: WireMock as the HTTP backend (not @InjectMock), fault tolerance enabled in the test JVM (not disabled via profile), and an explicit assertion that all retry requests carry the identical Idempotency-Key value (not just that the key is present or correctly formatted).
Keybrake enforces spend caps even when tests say the code is correct
Tests verify what you thought to test. Vault key spend caps enforce what you care about regardless. A Keybrake vault key with daily_usd_cap: total_monthly_arr * 1.10 / 30 stops a billing run that exceeds expected daily spend — whether the excess comes from a UUID re-evaluation bug, a concurrent scheduling race, or an edge case no test anticipated.