Spring Cloud Contract and Stripe Integration: How a WireMock Consumer Contract Stub That Matches Any idempotencyKey Masks That the Producer Generates UUID.randomUUID() Per Call Committing ch_B After @Retryable Fires, a Producer Contract Verification Test That Exercises Only the Happy-Path Stripe Request Leaves the @Retryable Retry Branch Untested Allowing UUID_B to Produce ch_B in Production, and a Messaging Contract @KafkaListener That Generates UUID_B Per Delivery Commits ch_B When Kafka Re-Delivers the Unacknowledged BillingRequestedEvent

This post is part of the ongoing series on Stripe idempotency failure modes in JVM frameworks. Earlier entries covered Spring Data JPA (JDBC transaction boundary and Stripe commit ordering), Spring Batch (step retry, chunk rollback, and JobRepository state divergence), Spring Data Redis (TTL expiry before retry window, setIfAbsent connection timeout null return, Sentinel failover replication lag), Spring Cloud Kubernetes (ConfigMap Watch @RefreshScope cluster-wide refresh, Fabric8 GC pause dual-active leader, rolling deployment key-formula split-brain), Spring Integration (RequestHandlerRetryAdvice UUID regeneration, JdbcChannelMessageStore transactional poller rollback requeue, JdbcMessageGroupStore aggregator crash double-release), and Spring Cloud Task (SCDF relaunch new executionId, TaskExecutionListener.onTaskFailed() compensation post-commit, TASK_EXECUTION end_time IS NULL guard misread). This post covers Spring Cloud Contract — the consumer-driven contract testing framework — and specifically the three failure modes that arise from the trust developers place in contract tests as proxies for production correctness, the limited scope of auto-generated producer verification tests, and the inability of stateless WireMock stubs to enforce idempotency-key stability across repeated Stripe invocations.

The three failure modes are: a developer who writes a Spring Cloud Contract consumer contract whose WireMock stub matches any non-blank string for the idempotencyKey request field, where the stub passes regardless of whether the producer generates a stable key or calls UUID.randomUUID() on every invocation, and where the consuming service’s @Retryable annotation fires after a SocketTimeoutException on the Stripe response path, causing the producer to be called twice with two different keys and Stripe to commit ch_B alongside the already-committed ch_A; a developer who relies on the auto-generated Spring Cloud Contract producer verification test to validate the Stripe integration, where the generated test makes a single HTTP request to the producer endpoint and verifies the response shape against the contract, and where the WireMock stub configured in the test base class accepts any Idempotency-Key header value on every Stripe call, so the test passes regardless of whether the producer calls UUID.randomUUID() per request, and where the @Retryable retry branch is never exercised by the single-request verification test, so in production a SocketTimeoutException fires @Retryable, UUID_B is generated, and Stripe commits ch_B; and a developer who writes a Spring Cloud Contract messaging contract for a @KafkaListener billing handler, verifies the contract with a double-trigger StubTrigger test that sends the same event twice and observes two successful responses from the WireMock Stripe stub, concludes the handler is idempotent because both invocations “succeeded”, but in production Kafka re-delivers the unacknowledged BillingRequestedEvent after the handler throws a DataIntegrityViolationException following a successful Stripe commit, UUID.randomUUID() generates UUID_B on the second delivery, and Stripe commits ch_B.

Stack for examples: Spring Boot 3.x, Spring Cloud Contract 4.x (spring-cloud-starter-contract-verifier, spring-cloud-starter-contract-stub-runner), Spring Kafka 3.x, Spring Retry 2.x (@Retryable), Stripe Java SDK v24+, WireMock 3.x, Apache Kafka 3.x, Testcontainers 1.20+, JUnit 5.

Background: what Spring Cloud Contract adds and why it interacts with billing idempotency differently

Spring Cloud Contract is a consumer-driven contract testing framework that lets teams define API contracts between microservices and verify both sides automatically. A contract is a Groovy DSL (or YAML) file that describes a request and its expected response. The framework generates two things from each contract: a WireMock stub (a JSON mapping file) that consumer tests load via @AutoConfigureStubRunner to simulate the producing service, and a JUnit verification test that runs against the real producing service to confirm it honors the contract. When both pass, consumer and producer agree on the API surface.

The first distinct idempotency surface in Spring Cloud Contract is the WireMock stub’s matching semantics. Contracts routinely use wildcards for fields that vary by test run: anyNonBlankString(), anyUuid(), byRegex("[a-z0-9-]+"). This is intentional and useful — a contract should not hard-code generated identifiers. The problem arises when an idempotency key field is treated as “just another generated identifier” and matched with a wildcard. The stub then accepts any value for that field on any number of calls. If the producer generates a fresh UUID.randomUUID() on every invocation, the stub accepts both the first call and the retry with equal success. The consumer test passes, the producer verification test passes, and the team gains false confidence that the integration is idempotent when it is not.

The second distinct surface is the producer contract verification test’s happy-path scope. Spring Cloud Contract auto-generates a JUnit test class from each contract. The generated test makes exactly the requests described in the contract — typically one request per contract file — and verifies the response. The test does not simulate network failures, timeouts, or retries. A producer that wraps its Stripe call in @Retryable will have that annotation exercised only if the test causes a retryable exception, which a well-configured WireMock stub will not do. The retry branch — the branch where UUID.randomUUID() is called a second time with a different result — is invisible to the contract verification framework.

The third distinct surface is the messaging contract’s observability gap. When Spring Cloud Contract is used for messaging contracts (Kafka, RabbitMQ, ActiveMQ), the @AutoConfigureMessageVerifier and StubTrigger APIs allow tests to send contractually-defined messages and verify the downstream effects. The contract verifies message shape: field names, field types, presence of required fields. It does not verify Stripe API behavior, idempotency key values, or the sequence of HTTP calls made to Stripe during message processing. A developer who uses StubTrigger twice to simulate re-delivery and observes two successful WireMock responses is watching two independent, stateless WireMock interactions — not two Stripe deduplication checks. The WireMock Stripe stub returns {"id":"ch_test_123","status":"succeeded"} for any valid-looking charge request, whether the Idempotency-Key header is UUID_A or UUID_B.

All three failure modes share the same structural cause: the contract testing layer validates the shape of service interactions, not the behavioral invariants of those interactions under failure conditions. This is not a deficiency in Spring Cloud Contract — it is precisely the tool’s intended scope. The failure arises when developers treat passing contract tests as evidence of idempotency safety, a claim the framework was never designed to make.

Mode 1: WireMock consumer contract stub matches any idempotencyKey — producer generates UUID.randomUUID() per call — @Retryable in order service fires — UUID_B — ch_B

Setup

The developer is building a microservices billing pipeline. OrderService is a Spring Boot application responsible for processing completed orders. When an order is finalized, OrderService calls PaymentService (a separate microservice) to charge the customer’s subscription. PaymentService internally calls Stripe to create the charge and returns a charge ID to OrderService.

The team has adopted Spring Cloud Contract for consumer-driven contract testing between OrderService (consumer) and PaymentService (producer). The developer writes the following contract DSL in the OrderService repository under src/test/resources/contracts/payment/charge_subscription.groovy:

// src/test/resources/contracts/payment/charge_subscription.groovy
// Consumer (OrderService) defines what it sends and what it expects back.
// WireMock stubs are generated from this file for consumer tests.
// PaymentService auto-generates a verification test from this file.

import org.springframework.cloud.contract.spec.Contract

Contract.make {
    description "POST /api/payments/charge — charge subscription for billing period"
    request {
        method POST()
        url "/api/payments/charge"
        headers {
            contentType applicationJson()
        }
        body([
            subscriptionId : $(consumer(regex("[a-z0-9_]+")),
                              producer("sub_001")),
            billingPeriod  : $(consumer(regex("\\d{4}-\\d{2}")),
                              producer("2026-10")),
            amountCents    : $(consumer(positiveInt()),
                              producer(8900)),
            customerId     : $(consumer(anyNonBlankString()),
                              producer("cus_ABC123")),
            // BUG: anyNonBlankString() on both sides — WireMock stub will
            // match any value sent for idempotencyKey, including UUID_A on
            // the first call and UUID_B on the retry. The test cannot
            // detect that the producer generates a new UUID on each invocation.
            idempotencyKey : $(consumer(anyNonBlankString()),
                              producer(anyNonBlankString()))
        ])
    }
    response {
        status 200
        headers { contentType applicationJson() }
        body([
            chargeId : $(producer(regex("ch_[a-zA-Z0-9]+")),
                        consumer("ch_test_BcD456")),
            status   : "succeeded"
        ])
    }
}

The contract looks reasonable. The developer knows that idempotencyKey will be a UUID generated by the caller, so anyNonBlankString() captures the shape correctly — it is a non-blank string, and its exact value is not something the contract should fix to a constant. This reasoning is sound for a shape-only contract. The problem is not the contract definition; it is what the developer infers from the contract test passing.

The buggy PaymentService implementation:

// BUGGY — UUID generated inside the @Retryable method.
// @Retryable re-invokes the entire method from the top on each retry attempt.
// If Stripe commits ch_A with UUID_A on attempt 1 and a SocketTimeoutException
// fires on the response path, attempt 2 generates UUID_B. Stripe has no record
// of UUID_B and commits ch_B alongside ch_A.

@Service
public class PaymentService {

    @Retryable(
        retryFor = { StripeException.class, ResourceAccessException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public PaymentResult charge(ChargeRequest request) {

        // BUG: UUID_A on attempt 1. @Retryable re-enters here on retry.
        // UUID_B on attempt 2. UUID_C on attempt 3. Each attempt is a
        // different Stripe idempotency key — no deduplication across retries.
        String idempotencyKey = UUID.randomUUID().toString();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(request.getAmountCents())
            .setCurrency("usd")
            .setCustomer(request.getCustomerId())
            .setDescription("Sub " + request.getSubscriptionId()
                + " " + request.getBillingPeriod())
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        Charge charge = Charge.create(params, options);

        return new PaymentResult(charge.getId(), charge.getStatus());
    }
}

public record ChargeRequest(
    String subscriptionId,
    String billingPeriod,
    long amountCents,
    String customerId,
    String idempotencyKey  // received from OrderService but ignored
) {}

The OrderService that calls PaymentService also has @Retryable:

// OrderService — retries HTTP calls to PaymentService on transient failures.
// Each retry sends a new HTTP request to PaymentService.
// PaymentService generates a new UUID on each request it receives.

@Service
public class OrderService {

    private final PaymentServiceClient paymentServiceClient; // Feign / RestTemplate

    @Retryable(
        retryFor = { FeignException.ServiceUnavailable.class,
                     ResourceAccessException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 500)
    )
    public void processPayment(Order order) {
        // OrderService generates the idempotencyKey here and includes it in
        // the request body — the contract says it should be anyNonBlankString().
        // But PaymentService IGNORES this field and generates its own UUID.
        // OrderService's retry sends the same idempotencyKey each time,
        // but PaymentService generates a new UUID on each invocation.
        ChargeRequest chargeRequest = new ChargeRequest(
            order.getSubscriptionId(),
            order.getBillingPeriod(),
            order.getAmountCents(),
            order.getStripeCustomerId(),
            computeStableKey(order.getSubscriptionId(),
                             order.getBillingPeriod(),
                             order.getAmountCents())
        );

        paymentServiceClient.charge(chargeRequest);
    }

    private String computeStableKey(String subscriptionId,
                                     String billingPeriod,
                                     long amountCents) {
        // OrderService correctly computes a stable key — but PaymentService
        // ignores the key it receives and generates its own UUID.
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            String input = subscriptionId + "|" + billingPeriod + "|" + amountCents;
            byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hash).substring(0, 32);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}

The consumer contract test

The consumer test in OrderService loads the WireMock stub generated from the contract and verifies that OrderService can successfully call the stub:

@SpringBootTest
@AutoConfigureStubRunner(
    ids = "com.example:payment-service:+:stubs:8090",
    stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class OrderServiceContractTest {

    @Autowired
    private OrderService orderService;

    @Test
    void processPayment_callsPaymentService_andReceivesChargeId() {
        Order order = new Order("order_001", "sub_001",
            "2026-10", 8900L, "cus_ABC123");

        // No exception thrown — the WireMock stub matched the request.
        // The stub matched ANY value for idempotencyKey (anyNonBlankString).
        // The test cannot tell whether PaymentService uses a stable key
        // or generates UUID.randomUUID() on every invocation.
        assertThatNoException().isThrownBy(
            () -> orderService.processPayment(order)
        );
    }
}

The test passes cleanly. The WireMock stub generated from the contract accepts any non-blank idempotencyKey in the request body. It returns {"chargeId":"ch_test_BcD456","status":"succeeded"} without consulting any state about what key was sent on a previous call.

The failure path

In production, OrderService processes order order_001 for subscription sub_001, billing period 2026-10, amount 8900 cents. OrderService.processPayment() computes the stable key k_stable from SHA-256("sub_001|2026-10|8900") and sends a POST request to PaymentService with idempotencyKey = k_stable.

PaymentService.charge() receives the request. It immediately discards request.idempotencyKey() and calls UUID.randomUUID(), generating UUID_A. It calls Stripe’s /v1/charges endpoint with Idempotency-Key: UUID_A. Stripe validates the charge, commits ch_A, and begins sending the 200 response. The TCP stack transmits the response bytes toward PaymentService. A network hiccup drops the connection after Stripe has committed ch_A but before PaymentService reads the response body. The Stripe Java SDK’s HTTP client throws a SocketTimeoutException (wrapped in ApiConnectionException, a subclass of StripeException).

Spring Retry’s @Retryable catches the exception. Because ApiConnectionException is a StripeException, it matches retryFor = { StripeException.class }. After the configured backoff, Spring Retry re-invokes PaymentService.charge() from the top. The method body runs again from line 1. UUID.randomUUID() is called again — it returns UUID_B, a different value. PaymentService calls Stripe with Idempotency-Key: UUID_B. Stripe has a cached result for UUID_A (ch_A) but no record for UUID_B. It processes the second request as a new, independent charge and commits ch_B. The customer is charged twice.

Developer misconception

The developer’s mental model has two parts. First: “The consumer contract test verified that OrderService correctly calls PaymentService — the contract test represents the production behavior.” Second: “I pass an idempotencyKey from OrderService to PaymentService — the same key on every retry — so Stripe deduplication is guaranteed.”

The first part is wrong in scope: the contract test validated the API shape (OrderService sends a body with the right fields, PaymentService returns the right fields). It did not validate that the idempotencyKey field received by PaymentService is the same key that reaches Stripe. The contract stub’s anyNonBlankString() match made this invisible — the stub would have passed whether PaymentService forwarded the received key or generated a new one.

The second part is wrong about the data path: OrderService does send a stable key, but PaymentService ignores the idempotencyKey field in ChargeRequest and generates its own UUID.randomUUID(). The stable key computed in OrderService is included in the request body and transmitted across the wire, but it is never read by PaymentService.charge(). The contract says the field should be present (as anyNonBlankString()) but says nothing about whether the producer must use it.

The compound misconception: “contract tests verify behavior, so passing contract tests mean the service behaves correctly end-to-end including retry safety.” Contract tests verify the contract (request/response shape agreement). They do not verify internal implementation choices: which field values the producer uses, how it derives keys, or how it behaves under retries. These invariants must be specified and tested separately.

The fix

PaymentService must use the idempotencyKey it receives from the caller. The caller (OrderService) must derive that key stably from the billing intent fields and pass the same value on every retry attempt.

// FIXED PaymentService — uses the idempotencyKey provided by the caller.
// The caller (OrderService) derives a stable key from billing intent fields.
// @Retryable retries are safe: the same stable key is passed on every attempt.

@Service
public class PaymentService {

    private final BillingRecordRepository billingRecordRepo;

    @Retryable(
        retryFor = { StripeException.class, ResourceAccessException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public PaymentResult charge(ChargeRequest request) {

        // FIXED: use the key provided by the caller — same key on every @Retryable attempt
        // because the caller derives it stably from billing intent fields.
        String idempotencyKey = request.idempotencyKey();

        if (idempotencyKey == null || idempotencyKey.isBlank()) {
            throw new IllegalArgumentException(
                "idempotencyKey is required — derive it from subscriptionId + billingPeriod + amountCents");
        }

        // Guard: if BillingRecord already exists, ch_A was committed on a prior attempt.
        // This handles the case where Stripe committed ch_A but the @Retryable retry
        // fires because the response was not received. Stripe deduplicates via the stable
        // key; this guard is a belt-and-suspenders defense.
        Optional<BillingRecord> existing =
            billingRecordRepo.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return new PaymentResult(existing.get().getStripeChargeId(), "succeeded");
        }

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(request.amountCents())
            .setCurrency("usd")
            .setCustomer(request.customerId())
            .setDescription("Sub " + request.subscriptionId()
                + " " + request.billingPeriod())
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        Charge charge = Charge.create(params, options);

        billingRecordRepo.saveIfAbsent(new BillingRecord(
            request.subscriptionId(), request.billingPeriod(),
            idempotencyKey, charge.getId(), request.amountCents()
        ));

        return new PaymentResult(charge.getId(), charge.getStatus());
    }
}

The contract should be updated to reflect that the idempotencyKey is a required, content-bearing field that the producer must use, and the consumer-side test should verify that the same key is sent on both the initial request and the retry:

// FIXED contract — idempotencyKey is explicitly required, non-blank, on both sides.
// The producer verification test base class should additionally configure a
// WireMock Stripe stub that uses stateful scenarios: first call returns 200,
// second call with the SAME key returns the cached 200, second call with a
// DIFFERENT key returns an error — so the producer test catches key instability.

Contract.make {
    description "POST /api/payments/charge — idempotencyKey required, derived by caller"
    request {
        method POST()
        url "/api/payments/charge"
        headers { contentType applicationJson() }
        body([
            subscriptionId : $(consumer(regex("[a-z0-9_]+")), producer("sub_001")),
            billingPeriod  : $(consumer(regex("\\d{4}-\\d{2}")), producer("2026-10")),
            amountCents    : $(consumer(positiveInt()), producer(8900)),
            customerId     : $(consumer(anyNonBlankString()), producer("cus_ABC123")),
            // FIXED: still anyNonBlankString for shape, but producer test base class
            // verifies that the key received is the same key sent to Stripe.
            idempotencyKey : $(consumer(regex("[a-f0-9]{32,64}")),
                              producer("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"))
        ])
    }
    response {
        status 200
        headers { contentType applicationJson() }
        body([
            chargeId : $(producer(regex("ch_[a-zA-Z0-9]+")), consumer("ch_test_BcD456")),
            status   : "succeeded"
        ])
    }
}

Mode 2: Producer contract verification test exercises only the happy-path Stripe call — @Retryable retry branch untested — UUID_B — ch_B in production

Setup

The developer has now fixed Mode 1 — PaymentService generates the idempotency key internally using UUID.randomUUID() before any fixes were applied in Mode 1, but in a different section of the codebase they have a new microservice BillingService that calls Stripe directly (not via a downstream microservice). BillingService has a Spring Cloud Contract consumer in SubscriptionService, and the auto-generated producer contract verification test in BillingService verifies the contract.

The developer has configured @Retryable on BillingService.charge() to handle transient Stripe connectivity issues. The implementation looks correct at first glance: the developer knows about idempotency keys and generates one using UUID.randomUUID() at the top of the method. The @Retryable annotation is on the same method. What the developer has missed is that @Retryable re-invokes the annotated method body from the top on each retry, including the UUID.randomUUID() call.

// BillingService — BUGGY — UUID.randomUUID() inside @Retryable method.
// The producer contract verification test makes one successful Stripe call
// and passes. The @Retryable retry path is never exercised by the test.
// In production, SocketTimeoutException fires @Retryable — UUID_B — ch_B.

@Service
public class BillingService {

    @Retryable(
        retryFor = { ApiConnectionException.class, ApiException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 2000, multiplier = 1.5)
    )
    public BillingResult chargeSubscription(String subscriptionId,
                                             String billingPeriod,
                                             long amountCents,
                                             String stripeCustomerId) {

        // BUG: UUID generated at the top of the @Retryable method.
        // Attempt 1: UUID_A  → Stripe commits ch_A → SocketTimeoutException
        // Attempt 2: UUID_B  → Stripe has no record of UUID_B → ch_B committed
        // The producer contract verification test makes exactly one attempt —
        // it never sees this retry path.
        String idempotencyKey = UUID.randomUUID().toString();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(amountCents)
            .setCurrency("usd")
            .setCustomer(stripeCustomerId)
            .setDescription("Sub " + subscriptionId + " " + billingPeriod)
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        Charge charge = Charge.create(params, options);

        return new BillingResult(charge.getId(), idempotencyKey, amountCents);
    }
}

The auto-generated producer verification test

Spring Cloud Contract generates a base test class setup and a concrete test class. The developer writes a base test class that configures the application context and the WireMock Stripe stub:

// BillingBaseTest.java — developer-written base class for auto-generated tests
// The developer configures a WireMock stub for the Stripe /v1/charges endpoint.
// The stub accepts any Idempotency-Key header and always returns 200.
// This is the standard pattern for Stripe WireMock stubs.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 9090)
public abstract class BillingBaseTest {

    @Autowired
    protected WebApplicationContext webApplicationContext;

    @BeforeEach
    public void setup() {
        RestAssuredMockMvc.webAppContextSetup(webApplicationContext);

        // WireMock stub for Stripe POST /v1/charges.
        // matching(".*") accepts ANY value for the Idempotency-Key header.
        // The stub is stateless: it returns the same 200 response regardless
        // of how many times it is called, and regardless of whether UUID_A
        // and UUID_B differ between calls. The contract test never detects
        // that @Retryable would call this stub twice with different keys.
        stubFor(post(urlEqualTo("/v1/charges"))
            .withHeader("Idempotency-Key", matching(".*"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {
                      "id": "ch_test_XyZ789",
                      "object": "charge",
                      "amount": 8900,
                      "currency": "usd",
                      "status": "succeeded",
                      "paid": true
                    }
                    """)));
    }
}

The auto-generated producer contract verification test (do not modify):

// Auto-generated by Spring Cloud Contract — do not modify directly
// Generated from: contracts/billing/charge_subscription.groovy

public class ContractVerifierTest extends BillingBaseTest {

    @Test
    public void validate_charge_subscription() throws Exception {
        // given — the contract specifies this request body
        MockMvcRequestSpecification request = given()
            .header("Content-Type", "application/json")
            .body("{" +
                "\"subscriptionId\":\"sub_001\"," +
                "\"billingPeriod\":\"2026-10\"," +
                "\"amountCents\":8900," +
                "\"stripeCustomerId\":\"cus_ABC123\"" +
                "}");

        // when — one HTTP request to the real BillingService
        ResponseOptions response = given().spec(request)
            .post("/api/billing/charge");

        // then — verify the response matches the contract
        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.header("Content-Type"))
            .matches("application/json.*");
        DocumentContext parsedJson =
            JsonPath.parse(response.getBody().asString());
        assertThatJson(parsedJson).field("['chargeId']")
            .matches("ch_[a-zA-Z0-9]+");
        assertThatJson(parsedJson).field("['status']")
            .isEqualTo("succeeded");

        // The test makes EXACTLY ONE request. @Retryable is never triggered.
        // The WireMock Stripe stub receives exactly one call with UUID_A.
        // The test passes. The developer has verified the contract.
        // What the test has NOT verified:
        //   - that the @Retryable retry path uses the same UUID_A
        //   - that the Stripe stub would be called a second time with UUID_B
        //   - that two Stripe charges would result from one billing operation under retry
    }
}

The failure path

In production, SubscriptionService triggers a billing run for subscription sub_001, billing period 2026-10. The request reaches BillingService.chargeSubscription(). At the top of the method, UUID.randomUUID() generates UUID_A = "f4c2a1b8-8d12-4e77-9f3a-c1b2d3e4f5a6". The method proceeds, constructs ChargeCreateParams, and calls Stripe’s /v1/charges with Idempotency-Key: f4c2a1b8-8d12-4e77-9f3a-c1b2d3e4f5a6. Stripe validates the charge, debits the customer, commits ch_A = "ch_3P1mN...", and begins writing the HTTP response.

Between the Stripe server writing the response and the Stripe Java SDK reading it, an intermediate load balancer resets the connection (idle timeout on the LB exceeded while Stripe was processing). The Stripe Java SDK receives a SocketException: Connection reset and throws ApiConnectionException. Spring Retry’s @Retryable catches the exception — ApiConnectionException extends StripeException, which is in retryFor. After a 2-second backoff, Spring Retry calls BillingService.chargeSubscription() again with the same arguments.

The method body begins again from line 1. UUID.randomUUID() is called again and returns UUID_B = "a9b1c2d3-e4f5-6789-abcd-ef1234567890". chargeSubscription() constructs new ChargeCreateParams (identical values — same subscription, same billing period, same amount) and calls Stripe with Idempotency-Key: a9b1c2d3-e4f5-6789-abcd-ef1234567890. Stripe looks up this key in its idempotency store: no record. It processes the request as a new, independent charge and commits ch_B = "ch_3Q2nP...". The customer is charged 8900 cents twice.

The producer contract verification test never observed this path because it made exactly one HTTP request to BillingService. That one request caused one Stripe call. @Retryable was not triggered. The WireMock stub received one call with UUID_A. The test passed. The developer’s confidence in the Stripe integration — derived from the passing contract test — was unearned for the retry path.

Developer misconception

The developer’s mental model: “The Spring Cloud Contract producer verification test is an integration test that exercises the full BillingService.chargeSubscription() method, including the Stripe call through the WireMock stub. If the test passes, the integration is correct. I have @Retryable on the method and I generate an idempotency key at the top — Stripe’s deduplication will handle any retries.”

This mental model contains three wrong claims. First: the producer verification test exercises the full method once, not including retry paths. Spring Cloud Contract generates tests that send the described requests and verify the described responses. It does not generate tests that simulate network failures or exercise @Retryable retry paths. The test is an integration test for the API contract, not a resilience test.

Second: “I generate an idempotency key at the top” is true, but “@Retryable retries use the same key” is false. @Retryable works by catching the exception thrown by the annotated method and re-invoking the method. The method starts fresh from line 1. All local variables — including idempotencyKey computed by UUID.randomUUID() — are recomputed on each invocation. The key generated on attempt 1 does not survive to attempt 2 in any form.

Third: “Stripe’s deduplication will handle any retries.” Stripe deduplicates within a 24-hour window for requests that present the same idempotency key. A retry that presents a different key is not a retry from Stripe’s perspective — it is a new, independent request. Stripe’s deduplication cannot protect against a client that presents a different key on each attempt.

The fix

The idempotency key must be computed before the @Retryable method is entered, so that all retry attempts share the same pre-computed value. The key can be computed by the caller and passed in as a parameter, or it can be derived deterministically from the method arguments (which do not change between retry attempts) at the very top of the method before any variable-result operations.

// FIXED BillingService — stable key derived from method arguments.
// Method arguments do not change between @Retryable retry attempts.
// SHA-256(subscriptionId + "|" + billingPeriod + "|" + amountCents)
// produces the same value on every attempt for the same billing intent.

@Service
public class BillingService {

    private final BillingRecordRepository billingRecordRepo;

    @Retryable(
        retryFor = { ApiConnectionException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 2000, multiplier = 1.5)
    )
    public BillingResult chargeSubscription(String subscriptionId,
                                             String billingPeriod,
                                             long amountCents,
                                             String stripeCustomerId) {

        // FIXED: key derived from billing intent fields — same on every @Retryable attempt.
        // subscriptionId, billingPeriod, amountCents do not change between attempts.
        String idempotencyKey = computeStableKey(
            subscriptionId, billingPeriod, amountCents
        );

        // Belt-and-suspenders guard: if Stripe committed ch_A but we received
        // a network error before reading the response, the BillingRecord check
        // catches the case where the BillingRecord was written. Stripe also
        // deduplicates: sending the same stable key returns ch_A from cache.
        Optional<BillingRecord> existing =
            billingRecordRepo.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return new BillingResult(existing.get().getStripeChargeId(),
                idempotencyKey, amountCents);
        }

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(amountCents)
            .setCurrency("usd")
            .setCustomer(stripeCustomerId)
            .setDescription("Sub " + subscriptionId + " " + billingPeriod)
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        Charge charge = Charge.create(params, options);

        billingRecordRepo.saveIfAbsent(new BillingRecord(
            subscriptionId, billingPeriod,
            idempotencyKey, charge.getId(), amountCents
        ));

        return new BillingResult(charge.getId(), idempotencyKey, amountCents);
    }

    private static String computeStableKey(String subscriptionId,
                                            String billingPeriod,
                                            long amountCents) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            String input = subscriptionId + "|" + billingPeriod + "|" + amountCents;
            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}

To catch key instability in the producer contract verification test, the BillingBaseTest WireMock stub should use stateful scenarios that detect if two calls arrive with different keys for the same billing intent:

// IMPROVED BillingBaseTest — WireMock scenario that detects key instability.
// This is not part of Spring Cloud Contract's auto-generation; it is
// additional verification logic the developer adds to the test base class.
// The scenario: first call succeeds; if @Retryable fires and sends a
// DIFFERENT key, the stub records a second call — the test asserts only
// one Stripe call was made.

@BeforeEach
public void setup() {
    RestAssuredMockMvc.webAppContextSetup(webApplicationContext);

    // Configure WireMock to record all calls to /v1/charges
    stubFor(post(urlEqualTo("/v1/charges"))
        .withHeader("Idempotency-Key", matching("[a-f0-9]{64}"))
        .willReturn(aResponse()
            .withStatus(200)
            .withHeader("Content-Type", "application/json")
            .withBody("""
                {"id":"ch_test_XyZ789","status":"succeeded","amount":8900}
                """)));
}

@AfterEach
public void verifyStripeCalledExactlyOnce() {
    // Assert that Stripe was called exactly once — @Retryable should not
    // have produced a second Stripe call (stable key means Stripe deduplicates,
    // but ideally @Retryable should not even reach Stripe a second time
    // after the first call succeeds).
    verify(exactly(1), postRequestedFor(urlEqualTo("/v1/charges")));
}

Mode 3: Messaging contract @KafkaListener generates UUID_B on Kafka re-delivery after post-commit exception — StubTrigger double-trigger test passes because WireMock is stateless — ch_B

Setup

The developer is building an event-driven billing pipeline. OrderService publishes a BillingRequestedEvent to a Kafka topic billing-requests whenever a subscription payment is due. BillingService consumes that topic, calls Stripe, and publishes a BillingCompletedEvent to billing-completions. The team uses Spring Cloud Contract messaging contracts to verify the event interface between the two services.

The messaging contract (defined in OrderService’s contract resources, since OrderService is the producer of BillingRequestedEvent):

// src/test/resources/contracts/billing/billing_requested.groovy
// OrderService produces this contract — it describes the message OrderService
// publishes to the "billing-requests" Kafka topic.
// BillingService consumes this contract stub via @AutoConfigureStubRunner
// to simulate receiving the event.

Contract.make {
    description "OrderService publishes BillingRequestedEvent to billing-requests"
    label "billing_request_event"
    input {
        triggeredBy("orderService.triggerBillingRequest('order_001')")
    }
    outputMessage {
        sentTo "billing-requests"
        body([
            eventId        : $(producer(uuid()), consumer("f47ac10b-58cc-4372-a567-0e02b2c3d479")),
            orderId        : $(producer(anyNonBlankString()), consumer("order_001")),
            subscriptionId : $(producer(regex("[a-z0-9_]+")), consumer("sub_001")),
            billingPeriod  : $(producer(regex("\\d{4}-\\d{2}")), consumer("2026-10")),
            amountCents    : $(producer(positiveInt()), consumer(8900)),
            customerId     : $(producer(anyNonBlankString()), consumer("cus_ABC123"))
        ])
        headers {
            header("contentType", applicationJson())
        }
    }
}

The BillingService consumer test uses @AutoConfigureStubRunner to load the stubs and StubTrigger to fire events at the @KafkaListener:

// BillingService consumer-side messaging contract test
@SpringBootTest
@AutoConfigureStubRunner(
    ids = "com.example:order-service:+:stubs:8090",
    stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
@WireMockTest(httpPort = 9091)
class BillingServiceMessagingContractTest {

    @Autowired
    private StubTrigger stubTrigger;

    @Autowired
    private BillingRecordRepository billingRecordRepo;

    @BeforeEach
    void setupStripeStub() {
        // WireMock Stripe stub — stateless, accepts any Idempotency-Key,
        // returns ch_test_123 for any valid charge request.
        stubFor(post(urlEqualTo("/v1/charges"))
            .withHeader("Idempotency-Key", matching(".*"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {"id":"ch_test_Abc123","status":"succeeded","amount":8900}
                    """)));
    }

    @Test
    void billingHandler_processesEvent_andCreatesChargeRecord() throws Exception {
        // Single delivery — baseline test. Passes trivially.
        stubTrigger.trigger("billing_request_event");
        Thread.sleep(500);

        assertThat(billingRecordRepo.findAll()).hasSize(1);
        assertThat(billingRecordRepo.findAll().get(0).getStripeChargeId())
            .isEqualTo("ch_test_Abc123");
    }

    @Test
    void billingHandler_isIdempotent_onDuplicateDelivery() throws Exception {
        // Developer simulates re-delivery by triggering the event twice.
        stubTrigger.trigger("billing_request_event");
        Thread.sleep(300);
        stubTrigger.trigger("billing_request_event");  // "re-delivery"
        Thread.sleep(500);

        // The developer observes: no exception, no crash. The WireMock Stripe
        // stub returned ch_test_Abc123 for BOTH calls. The developer concludes:
        // "both deliveries produced a successful charge result — the handler
        // handles duplicate messages correctly."
        //
        // What actually happened: the Stripe WireMock stub accepted BOTH calls
        // with different UUID_A and UUID_B — it is stateless and does not know
        // that two different idempotency keys represent a double charge.
        // In production, two distinct Stripe charges (ch_A and ch_B) would be
        // created. This test cannot detect that difference.
        assertThat(billingRecordRepo.count()).isLessThanOrEqualTo(2L);
        // The developer intended this to assert "only 1 record — idempotent".
        // But they wrote isLessThanOrEqualTo(2) "just in case" the double
        // delivery is expected to write two records during development.
        // This weak assertion does not detect the double-charge bug.
    }
}

The buggy @KafkaListener handler:

// BillingEventHandler — BUGGY — UUID.randomUUID() inside @KafkaListener.
// Kafka re-delivers the message if the listener throws before ack.
// Second delivery generates UUID_B. Stripe commits ch_B alongside ch_A.

@Component
public class BillingEventHandler {

    private final StripeClient stripeClient;
    private final BillingRecordRepository billingRecordRepo;

    @KafkaListener(
        topics = "billing-requests",
        groupId = "billing-service",
        containerFactory = "billingKafkaListenerContainerFactory"
    )
    public void handleBillingRequest(
            @Payload BillingRequestedEvent event,
            Acknowledgment ack) {

        // BUG: UUID generated on every message delivery.
        // Kafka re-delivers if listener throws before ack.acknowledge().
        // Re-delivery generates UUID_B — Stripe commits ch_B.
        String idempotencyKey = UUID.randomUUID().toString();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(event.getAmountCents())
            .setCurrency("usd")
            .setCustomer(event.getCustomerId())
            .setDescription("Order " + event.getOrderId()
                + " " + event.getBillingPeriod())
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        // Stripe commits ch_A with UUID_A on first delivery.
        Charge charge = Charge.create(params, options);

        // DataIntegrityViolationException: unique constraint on
        // (order_id, billing_period) already violated by a concurrent
        // billing attempt. Exception thrown AFTER Stripe commits ch_A.
        billingRecordRepo.save(new BillingRecord(
            event.getOrderId(),
            event.getBillingPeriod(),
            idempotencyKey,
            charge.getId(),
            event.getAmountCents()
        ));

        // ack.acknowledge() is never reached when billingRecordRepo.save() throws.
        // Kafka does not commit the offset. The message is re-delivered.
        ack.acknowledge();
    }
}

The failure path

In production, OrderService publishes a BillingRequestedEvent for order order_001, subscription sub_001, billing period 2026-10, amount 8900 cents. Kafka delivers this message to the BillingService consumer group. One BillingService pod (Pod A) receives the message and its handleBillingRequest() listener fires.

At the top of the listener, UUID.randomUUID() generates UUID_A = "7e3a5f91-2b4c-8d6e-1a9f-0c3b4d5e6f7a". The listener calls Stripe with Idempotency-Key: 7e3a5f91-2b4c-8d6e-1a9f-0c3b4d5e6f7a. Stripe commits ch_A = "ch_3R4sT..." and returns 200. The listener receives the charge response and calls billingRecordRepo.save(new BillingRecord(...)).

The save() call throws a DataIntegrityViolationException: a concurrent billing job (a monthly billing sweep running in a separate thread) has already written a BillingRecord for (order_001, 2026-10). The unique constraint on (order_id, billing_period) is violated. The exception propagates out of handleBillingRequest(). Because the exception propagates before ack.acknowledge() is called, Kafka does not commit the consumer group offset for this message partition. After the Kafka container’s default error recovery interval, the message is re-delivered to the same consumer group.

Pod A (or another Pod in the group) receives the re-delivered BillingRequestedEvent. handleBillingRequest() fires again. UUID.randomUUID() generates UUID_B = "2a4f6c8e-1b3d-5e7f-9a0b-c2d4e6f8a0b2". The listener calls Stripe with Idempotency-Key: 2a4f6c8e-1b3d-5e7f-9a0b-c2d4e6f8a0b2. Stripe has no record of this key (Stripe’s cache is keyed by the exact string; UUID_B is a different string from UUID_A). Stripe processes the request as a new charge and commits ch_B = "ch_3S5tU...". The customer is charged twice.

Note that the billingRecordRepo.save() on this second delivery also throws DataIntegrityViolationException (the concurrent billing job wrote the record on the first delivery’s failure). If the Kafka container keeps re-delivering, every re-delivery produces a new charge. The total charge is proportional to the number of re-deliveries before the dead-letter topic sink catches the message.

Developer misconception: what the StubTrigger test actually verified

The developer ran the billingHandler_isIdempotent_onDuplicateDelivery() test and saw it pass. Two calls to stubTrigger.trigger("billing_request_event"), two successful WireMock Stripe responses, no exceptions, no test failure. The developer concluded: “My handler processes duplicate messages correctly — the Spring Cloud Contract messaging test confirms this.”

What the test actually verified was narrower than the developer believed. The two StubTrigger.trigger() calls sent two events to the @KafkaListener. Each invocation of the listener generated a fresh UUID.randomUUID() and called the WireMock Stripe stub. The WireMock stub accepted both calls because its matching(".*") header matcher accepts any Idempotency-Key value. The stub returned ch_test_Abc123 for both calls. The test observed two “successful” charge results and passed.

What the test did not verify: whether the two calls sent the same or different Idempotency-Key values to Stripe. A stateless WireMock stub that accepts any key has no way to express Stripe’s actual deduplication behavior. Real Stripe, receiving two calls for the same customer and amount but with different keys, commits two charges. The test’s WireMock stub accepted both with equal success — making the test look like a positive idempotency verification when it was, in practice, a test that would have passed regardless of whether the handler was idempotent or not.

The secondary misconception: “The assertion isLessThanOrEqualTo(2L) on the BillingRecord count catches any problems.” This assertion allows 1 or 2 billing records to exist after two event deliveries. An idempotent handler would produce exactly 1 record. A buggy handler producing two records (and two Stripe charges) satisfies the assertion ≤ 2. The loose assertion was written “just in case” but it made the test incapable of detecting the double-charge behavior it was intended to guard against.

The fix

The @KafkaListener handler must derive the idempotency key from message payload fields that are stable across re-deliveries. The Kafka message itself carries the same payload bytes on re-delivery — orderId, subscriptionId, billingPeriod, and amountCents are invariant. SHA-256(orderId + billingPeriod + amountCents) produces the same string on every delivery of the same event.

// FIXED BillingEventHandler — stable key from payload fields.
// Same key on every Kafka re-delivery of the same BillingRequestedEvent.
// Guard check before Stripe call handles the case where ch_A was committed
// but billingRecordRepo.save() failed on the first delivery.

@Component
public class BillingEventHandler {

    private final BillingRecordRepository billingRecordRepo;

    @KafkaListener(
        topics = "billing-requests",
        groupId = "billing-service",
        containerFactory = "billingKafkaListenerContainerFactory"
    )
    public void handleBillingRequest(
            @Payload BillingRequestedEvent event,
            Acknowledgment ack) {

        // FIXED: stable key from payload fields — invariant across re-deliveries
        String idempotencyKey = computeStableKey(
            event.getOrderId(),
            event.getBillingPeriod(),
            event.getAmountCents()
        );

        // Guard check: if BillingRecord exists, Stripe committed ch_A on a
        // prior delivery. Acknowledge and skip — ch_A is the canonical charge.
        Optional<BillingRecord> existing =
            billingRecordRepo.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            log.info("Duplicate delivery for key {} — charge {} already committed — acking",
                idempotencyKey, existing.get().getStripeChargeId());
            ack.acknowledge();
            return;
        }

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(event.getAmountCents())
            .setCurrency("usd")
            .setCustomer(event.getCustomerId())
            .setDescription("Order " + event.getOrderId()
                + " " + event.getBillingPeriod())
            .build();

        RequestOptions options = RequestOptions.builder()
            .setIdempotencyKey(idempotencyKey)
            .build();

        Charge charge = Charge.create(params, options);

        // INSERT ON CONFLICT DO NOTHING (or findByIdempotencyKey check above).
        // If this save() throws because the concurrent job wrote the record
        // between our guard check and this call: on re-delivery, the guard
        // will now find the record and ack without calling Stripe.
        // Stripe deduplicates if the guard is missed: same key → same ch_A.
        billingRecordRepo.saveIfAbsent(new BillingRecord(
            event.getOrderId(),
            event.getBillingPeriod(),
            idempotencyKey,
            charge.getId(),
            event.getAmountCents()
        ));

        ack.acknowledge();
    }

    private static String computeStableKey(String orderId,
                                            String billingPeriod,
                                            long amountCents) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            String input = orderId + "|" + billingPeriod + "|" + amountCents;
            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}

The consumer messaging contract test should also be strengthened with a stateful WireMock Stripe stub that enforces idempotency semantics:

// IMPROVED messaging contract test — stateful WireMock Stripe stub.
// Two triggers with the same payload should produce exactly ONE Stripe call
// (guard returns on second delivery). If guard is bypassed, the stateful
// WireMock scenario detects that two calls arrived with different keys.

@BeforeEach
void setupStripeStub() {
    // Stateful scenario: first call (state STARTED) returns 200 and records key.
    // If a second call arrives with a DIFFERENT key, WireMock is in state
    // "already_charged" and returns 409 — making the test fail and surfacing
    // the double-charge bug.
    stubFor(post(urlEqualTo("/v1/charges"))
        .inScenario("stripe-idempotency")
        .whenScenarioStateIs(STARTED)
        .withHeader("Idempotency-Key", matching("[a-f0-9]{64}"))
        .willReturn(aResponse().withStatus(200)
            .withHeader("Content-Type", "application/json")
            .withBody("{\"id\":\"ch_test_Abc123\",\"status\":\"succeeded\",\"amount\":8900}"))
        .willSetStateTo("already_charged"));

    stubFor(post(urlEqualTo("/v1/charges"))
        .inScenario("stripe-idempotency")
        .whenScenarioStateIs("already_charged")
        .willReturn(aResponse().withStatus(409)
            .withHeader("Content-Type", "application/json")
            .withBody("{\"error\":{\"type\":\"idempotency_error\",\"message\":\"duplicate request with different parameters\"}}")));
}

@Test
void billingHandler_isIdempotent_onDuplicateDelivery() throws Exception {
    stubTrigger.trigger("billing_request_event");
    Thread.sleep(300);
    stubTrigger.trigger("billing_request_event");  // re-delivery with same payload
    Thread.sleep(500);

    // FIXED assertion: exactly 1 BillingRecord. Idempotent handler acks
    // on second delivery without calling Stripe.
    assertThat(billingRecordRepo.count()).isEqualTo(1L);

    // Assert Stripe was called exactly once (guard prevents second call)
    verify(exactly(1), postRequestedFor(urlEqualTo("/v1/charges")));
}

Diagnostic checklist

Check Safe state Dangerous state
Contract stub idempotencyKey matching: Does the consumer contract WireMock stub use a wildcard matcher (anyNonBlankString(), matching(".*"), anyUuid()) for the idempotency key field or header? Wildcard matcher is used for the key shape, AND the producer verification test base class asserts that only one Stripe call was made per billing operation AND that the same key was sent on any simulated retries. Wildcard matcher is used AND no additional assertion verifies key stability across calls. The stub accepts UUID_A and UUID_B with equal success, masking a producer that generates a new UUID on every invocation.
Producer contract test retry path: Does the producer contract verification test exercise the @Retryable retry path for the Stripe-calling method? The producer verification test base class configures the Stripe WireMock stub to fail (with a simulated ApiConnectionException) on the first call and succeed on the second, AND the test asserts that the same Idempotency-Key was sent on both calls. The producer verification test makes exactly one Stripe call through a stub that always succeeds. @Retryable is never triggered. Key stability on retry is never tested. The test passes regardless of whether the method generates UUID.randomUUID() or a stable key.
Stripe WireMock stub statefulness: Is the Stripe WireMock stub used in messaging contract tests (and producer verification tests) stateful, enforcing that two calls with different idempotency keys for the same billing intent produce an error? Stripe WireMock stub uses WireMock stateful scenarios: first call succeeds (transitions state to already_charged); second call with a different key returns a 409 or error response that fails the test. This surfaces double-charge bugs in test, not in production. Stripe WireMock stub is stateless: accepts any number of calls with any Idempotency-Key value and returns 200 for all. The stub cannot distinguish between a Stripe retry (same key, expected) and a double-charge attempt (different key, dangerous).
Messaging contract test StubTrigger assertion strength: When the StubTrigger is called twice to simulate re-delivery in a messaging contract test, does the assertion verify that exactly one Stripe call was made (not two)? Test asserts verify(exactly(1), postRequestedFor(urlEqualTo("/v1/charges"))) after two StubTrigger.trigger() calls. An idempotent handler acks on second delivery without calling Stripe. Test fails if Stripe is called twice, surfacing the double-charge bug. Test uses a weak assertion (assertThat(count).isLessThanOrEqualTo(2)) or asserts only that no exception was thrown. Two Stripe calls satisfy both assertions. The double-charge is invisible to the test.
@KafkaListener idempotency key source: Does the @KafkaListener billing handler derive its Stripe idempotency key from message payload fields, or from UUID.randomUUID()? Key is derived from stable payload fields: SHA-256(orderId + billingPeriod + amountCents). The same key is produced on every delivery of the same message payload, including Kafka re-deliveries after a non-ack exception. Key is derived from UUID.randomUUID() inside the listener method body. Every Kafka delivery generates a new UUID. Kafka re-delivery after a post-commit exception generates UUID_B. Stripe commits ch_B.
Guard check before Stripe call in @KafkaListener: Does the billing event handler query a durable guard record before calling Stripe? Handler queries BillingRecord by stable idempotency key before calling Stripe. If record exists, handler acks and returns without calling Stripe. Guard handles the case where Stripe committed ch_A but billingRecordRepo.save() failed on the first delivery. Handler calls Stripe unconditionally and writes a BillingRecord after the Stripe call. A post-commit exception causes Kafka re-delivery. Guard is absent — the re-delivered handler calls Stripe with a new UUID, producing ch_B.
Producer idempotency key forwarding: When the consumer contract specifies an idempotencyKey field in the request body (intended to be forwarded to Stripe), does the producer actually use the received key for the Stripe call? Producer reads request.idempotencyKey() (or the equivalent field) and passes it to RequestOptions.setIdempotencyKey(). The consumer and producer contract test base class verifies that the key in the request body matches the key sent to Stripe in the Idempotency-Key HTTP header. Producer receives the idempotencyKey field but calls UUID.randomUUID() internally, ignoring the received value. The contract test passes because the stub only checks field presence (anyNonBlankString), not whether the received key is forwarded to Stripe. In production, the stable key from the consumer never reaches Stripe.
Contract scope awareness: Does the team understand that Spring Cloud Contract verifies API contract shape (field names, types, presence), not behavioral invariants (idempotency, retry safety, deduplication correctness)? Team maintains a separate suite of resilience and idempotency tests (using Testcontainers with real Stripe Sandbox, or stateful WireMock scenarios) to verify retry and re-delivery behavior. Contract tests are used for their intended purpose: API shape agreement between consumer and producer. Team treats passing contract tests as comprehensive validation of the billing integration including retry safety. Additional resilience tests do not exist. Idempotency bugs surface in production when @Retryable fires or Kafka re-delivers, not in CI.

Summary

The three Spring Cloud Contract failure modes described in this post arise from a mismatch between what contract tests verify (API shape agreement) and what developers sometimes believe they verify (end-to-end behavioral correctness including retry safety). The WireMock consumer contract stub failure arises because a wildcard matcher on the idempotencyKey field accepts any string — including UUID_A on the first call and UUID_B on the retry — so a producer that generates a new UUID on every invocation passes the consumer test without the test detecting the instability; when @Retryable fires in production, the producer generates UUID_B, and Stripe commits ch_B. The producer contract verification test failure arises because the auto-generated test makes exactly one request to the producer endpoint and verifies the response shape; it does not simulate network failures, does not exercise @Retryable retry paths, and does not assert key stability across calls; the test passes regardless of whether the producer generates a stable key or a fresh UUID, so the retry-path double-charge is invisible until production. The messaging contract @KafkaListener failure arises because the StubTrigger double-trigger test sends two events and observes two successful WireMock Stripe responses, but the WireMock stub is stateless — it accepts UUID_A and UUID_B with equal success — so the developer concludes the handler is idempotent when in fact two real Stripe charges would result from two Kafka deliveries with different keys.

All three modes share the same root cause and the same root fix. The root cause: the contract testing layer is not designed to enforce behavioral invariants like idempotency key stability. The root fix: derive idempotency keys deterministically from billing intent fields (SHA-256(subscriptionId + billingPeriod + amountCents) or SHA-256(orderId + billingPeriod + amountCents)) before any variable-result operation; use a durable application-level guard record to short-circuit Stripe calls on re-delivery; complement contract tests with stateful WireMock Stripe scenarios that enforce key stability; and add a verify(exactly(1), postRequestedFor(...)) assertion after any test that simulates re-delivery. These patterns appear throughout this series in different guises: in Spring Integration, the RequestHandlerRetryAdvice re-invokes the handler including the UUID generation step, just as @Retryable re-invokes the annotated method; in Spring Cloud Task, the SCDF relaunch re-starts the task from the top including the key derivation step; in all cases, the framework’s retry or re-execution mechanism restarts computation at a scope that includes key generation, and the fix is always to move key derivation to a scope that is invariant to that restart.

The Spring Cloud Contract failure modes carry an additional lesson specific to contract testing: the tools that give teams confidence about service integrations may themselves create blind spots if their output is over-interpreted. A green contract test suite means consumer and producer agree on the API shape. It does not mean the integration is safe under partial failures, network interruptions, message re-deliveries, or @Retryable retry storms. Keybrake’s proxy layer provides a durable audit record of every Stripe call keyed by the presented idempotency key, independent of what contract tests run in CI, so that incident response teams can answer the question “did two different idempotency keys reach Stripe for the same billing intent?” even when the answer is buried in retries generated by WireMock-tested code paths that were never validated for key stability.

Stop Spring Cloud Contract → Stripe double charges at the proxy layer

Keybrake is an API key proxy for the SaaS APIs your agents and services call — Stripe, Twilio, Resend. Every proxied request gets a stable, durable idempotency key derived from the request fingerprint and stored in the audit log. @Retryable retry UUID rotation, messaging contract @KafkaListener re-delivery key regeneration, and producer contract test happy-path gaps cannot produce a second charge through Keybrake. Early access is open.