Spring Integration Google PubSub Stripe Integration: How PubSubInboundChannelAdapter ackMode=AUTO Lets ackDeadlineSeconds=10 Expire During Stripe 3DS Delivering M1 to a Second Adapter Instance Generating UUID_B Committing ch_B, How BasicAcknowledgeablePubsubMessage.ack() Inside @Transactional Fires Immediately Unaffected by Transaction Rollback Leaving No BillingRecord Enabling ch_B on Reconciliation Republish, and How RequestHandlerRetryAdvice Re-Invokes the Billing ServiceActivator with UUID.randomUUID() Generating UUID_B Through UUID_C Committing ch_B Through ch_C

Spring Integration’s PubSubInboundChannelAdapter introduces three acknowledgement and retry behaviors that differ structurally from both the raw Google Cloud Pub/Sub Subscriber client and the Spring Cloud Stream GCP PubSub Binder: an ackMode=AUTO default that holds the subscriber thread without extending the GCP ackDeadline, a MANUAL acknowledgement API that issues an immediate HTTP call to GCP rather than participating in Spring’s transaction lifecycle, and a RequestHandlerRetryAdvice integration pattern that re-invokes the ServiceActivator’s handleMessage() method fresh on each retry — each of which produces duplicate Stripe charges when the billing handler calls UUID.randomUUID() per invocation rather than a stable content-derived key.

This post covers all three failure modes end to end: the exact PubSubInboundChannelAdapter and RequestHandlerRetryAdvice configuration that enables each one, the step-by-step timeline showing precisely when ch_A commits and when UUID_B is generated, the specific misconception each mode exploits about how Spring Integration GCP PubSub acknowledgement and retry work, and the idempotency fix that makes the billing ServiceActivator safe regardless of ack deadline expiry, transactional rollback, or retry re-invocation. Code samples target spring-cloud-gcp-starter-pubsub 5.x (Spring Cloud GCP), Spring Integration 6.x, Spring Boot 3.x, WireMock, Testcontainers with the Google Cloud PubSub emulator, and JUnit 5.

Background: how PubSubInboundChannelAdapter differs from the raw Subscriber client and the SCS GCP PubSub Binder at the acknowledgement and retry layers

To understand why these three failure modes are specific to Spring Integration GCP PubSub rather than to the raw com.google.cloud:google-cloud-pubsub client or the SCS GCP PubSub Binder (covered in the previous post), you need to understand three structural differences introduced by PubSubInboundChannelAdapter.

PubSubInboundChannelAdapter with ackMode=AUTO (the default) does not configure ack deadline extension on the underlying GCP subscription subscriber. The raw com.google.cloud.pubsub.v1.Subscriber client defaults to setMaxAckExtensionPeriod(Duration.ofMinutes(60)), meaning it automatically sends ModifyAckDeadline requests to GCP to extend the ack window while the message receiver is processing. PubSubInboundChannelAdapter is built on top of PubSubTemplate and PubSubSubscriberOperations, which in turn use a GCP Subscriber with configuration supplied via SubscriberFactory. Unless the application explicitly configures spring.cloud.gcp.pubsub.subscriber.max-ack-extension-period in the application properties, the period defaults to 0 in many Spring Cloud GCP 5.x setups — the same default that causes the SCS GCP PubSub Binder issue in the previous post. With ackMode=AUTO and no ack deadline extension, the clock starts ticking when GCP delivers the message to the adapter subscriber thread: if the subscription has ackDeadlineSeconds=10 (the GCP default at subscription creation) and the billing handler takes 12 seconds due to Stripe 3DS SCA, GCP redelivers the message at t=10.001 seconds without waiting for the adapter to call ack().

The three ackMode values on PubSubInboundChannelAdapter have subtly different timing semantics that do not map cleanly onto JMS acknowledgement modes. AUTO_ACK acknowledges the message immediately when the adapter’s subscriber thread picks it up — before the Spring Integration channel receives it and before the ServiceActivator runs. AUTO acknowledges after the downstream handler returns successfully (equivalent to at-least-once with retry on exception). MANUAL injects a BasicAcknowledgeablePubsubMessage into the Spring Integration Message headers and delegates the ack() and nack() calls to application code. The critical difference from JMS is that in all three modes, the GCP ack() is an HTTP call to the GCP Pub/Sub REST API — it is not a local transaction resource managed by Spring’s PlatformTransactionManager. JmsTransactionManager defers session.commit() until the outermost @Transactional boundary commits. BasicAcknowledgeablePubsubMessage.ack() fires immediately, unconditionally, on the calling thread, regardless of whether an enclosing Spring transaction is open or will later roll back.

RequestHandlerRetryAdvice is Spring Integration’s method-level retry interceptor for MessageHandler beans, and it re-invokes handleMessage() as a fresh method call on each retry attempt. The SCS GCP PubSub Binder exposes retry configuration via spring.cloud.stream.bindings.<binding>.consumer.maxAttempts and related properties, which control SCS-internal retry before the binder decides to nack(). Spring Integration does not use those properties; instead, developers who want to retry on transient exceptions wrap their ServiceActivator beans with RequestHandlerRetryAdvice using a RetryTemplate. Each retry is a fresh call to handleMessage(msg) with the same Spring Integration Message object — same headers, same payload — but the method body runs from scratch. Any value computed inside handleMessage(), including an idempotency key generated with UUID.randomUUID(), is recomputed fresh on each retry. If the first attempt committed a Stripe charge and then threw on the DB save, the second retry generates a new UUID and commits a second Stripe charge.

With these three structural differences established, the failure modes follow directly from specific configurations and operational decisions.

Failure mode 1: ackMode=AUTO (default) + ackDeadlineSeconds=10 + Stripe 3DS takes 12.8s — GCP redelivers M1 to second adapter instance; UUID_B → ch_B

The setup

A team migrating from SQS to Google Cloud Pub/Sub configures Spring Integration for message consumption. They add spring-cloud-gcp-starter-pubsub and wire a PubSubInboundChannelAdapter to a DirectChannel feeding a billing ServiceActivator. They leave ackMode at its default (AUTO), which they interpret as “Spring Integration will automatically acknowledge the message once the ServiceActivator returns without exception — exactly like JMS AUTO_ACKNOWLEDGE.”

The GCP subscription was created with the GCP Console defaults: ackDeadlineSeconds=10, no retry policy configured, no dead-letter topic. The team does not set spring.cloud.gcp.pubsub.subscriber.max-ack-extension-period in their application properties because they are not aware it defaults to 0.

The billing consumer generates its Stripe idempotency key with UUID.randomUUID() at the start of each handleMessage() invocation:

@ServiceActivator(inputChannel = "billingChannel")
public void handleBillingEvent(Message<BillingEvent> message) {
    BillingEvent event = message.getPayload();
    String idempotencyKey = UUID.randomUUID().toString(); // new key every call

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

    ChargeCreateParams params = ChargeCreateParams.builder()
        .setAmount(event.getAmountCents())
        .setCurrency("eur")
        .setCustomer(event.getCustomerId())
        .build();

    Charge charge = Charge.create(params, options);
    billingRecordRepo.save(new BillingRecord(event, idempotencyKey, charge.getId()));
}

The Spring Integration configuration registers the adapter with the default ackMode=AUTO:

@Bean
public PubSubInboundChannelAdapter billingAdapter(
        @Qualifier("billingChannel") MessageChannel outputChannel,
        PubSubTemplate pubSubTemplate) {
    PubSubInboundChannelAdapter adapter =
        new PubSubInboundChannelAdapter(pubSubTemplate, "billing-processor");
    adapter.setOutputChannel(outputChannel);
    // ackMode defaults to AcknowledgementMode.AUTO
    return adapter;
}

@Bean
public MessageChannel billingChannel() {
    return new DirectChannel();
}

Application properties contain no max-ack-extension-period override:

spring:
  cloud:
    gcp:
      pubsub:
        project-id: my-project
        # max-ack-extension-period not set → defaults to 0 in Spring Cloud GCP 5.x
        # → no ModifyAckDeadline requests sent while the subscriber thread is processing

The failure timeline

A billing event M1 arrives for customer cust_EU_001 with an EU Maestro card requiring 3DS2 authentication. The production p99 for Stripe 3DS SCA on EU Maestro is 12.8 seconds due to card issuer authentication round-trips.

The customer’s card is charged twice: ch_A (amount from Pod-A) and ch_B (same amount from Pod-B). The database contains two BillingRecord rows for the same BillingEvent. Neither pod logged an error. The only observable signal is the duplicate charge in Stripe’s dashboard and the duplicate row in the billing table.

Developer misconception

“ackMode=AUTO in Spring Integration means the ack deadline timer doesn’t start until after the ServiceActivator returns — it’s equivalent to JMS AUTO_ACKNOWLEDGE which fires after the MessageListener.onMessage() call returns. GCP PubSub must work the same way; the timer can’t start until Spring Integration finishes processing.”

This misconception conflates two different things: when Spring Integration calls ack() (controlled by ackMode=AUTO) and when GCP PubSub’s broker-side ack deadline timer fires (controlled by the subscription’s ackDeadlineSeconds and by ModifyAckDeadline requests). GCP’s timer starts when the message is delivered to the subscriber client, not when the application layer acknowledges it. ackMode=AUTO only governs when the adapter calls ack() after successful processing — it does nothing to pause or reset the broker-side deadline. JMS AUTO_ACKNOWLEDGE fires after onMessage() returns because JMS brokers (ActiveMQ, RabbitMQ via AMQP) wait for the client acknowledgement within a configurable timeout, and many JMS brokers have much longer default acknowledgement windows than GCP’s 10-second default. GCP PubSub’s 10-second default ackDeadlineSeconds is unusually short by messaging-system standards; the only mechanism to extend it is ModifyAckDeadline, which must be sent proactively by the subscriber client while processing.

The fix

Two independent fixes, both required:

Fix 1: Enable ack deadline extension. Configure spring.cloud.gcp.pubsub.subscriber.max-ack-extension-period to a value longer than your Stripe processing p99 (including 3DS SCA), and increase the subscription’s ackDeadlineSeconds so GCP’s initial deadline is long enough that it doesn’t expire before the first extension request arrives:

spring:
  cloud:
    gcp:
      pubsub:
        subscriber:
          max-ack-extension-period: 600  # seconds; extends ack deadline up to 10 minutes
          # GCP also supports setAckExtensionDefaultDeadline, setMaxOutstandingMessages, etc.
# Terraform: set subscription ackDeadlineSeconds higher than the default 10s
resource "google_pubsub_subscription" "billing_processor" {
  name  = "billing-processor"
  topic = google_pubsub_topic.billing_events.id

  ack_deadline_seconds = 120  # 2 minutes — longer than Stripe 3DS p99=90s

  retry_policy {
    minimum_backoff = "30s"
    maximum_backoff = "600s"
  }
}

Fix 2: Use a stable idempotency key. Even with ack deadline extension, GCP delivers messages at-least-once. Concurrent delivery is possible during restarts, failovers, and network partitions. A stable content-derived key collapses concurrent duplicate deliveries into a single Stripe charge:

@ServiceActivator(inputChannel = "billingChannel")
public void handleBillingEvent(Message<BillingEvent> message) {
    BillingEvent event = message.getPayload();

    // Stable key derived from business identity — same value on every re-delivery
    String stableKey = "billing:" + sha256(
        event.getAccountId() + ":" + event.getBillingPeriod() + ":" + event.getAmountCents()
    );

    // Idempotency guard: if BillingRecord already exists for this key, skip
    if (billingRecordRepo.existsByIdempotencyKey(stableKey)) {
        return;  // ch_A already committed; this is a re-delivery
    }

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

    ChargeCreateParams params = ChargeCreateParams.builder()
        .setAmount(event.getAmountCents())
        .setCurrency("eur")
        .setCustomer(event.getCustomerId())
        .build();

    Charge charge = Charge.create(params, options);
    billingRecordRepo.save(new BillingRecord(event, stableKey, charge.getId()));
}

private String sha256(String input) {
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(hash);
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException(e);
    }
}

With both fixes applied: ack deadline extension prevents concurrent redelivery from occurring in normal operation; the stable key and BillingRecord guard handle the case where concurrent delivery does occur (restart, GCP replication lag, or a delivery that arrived before the first ModifyAckDeadline request). The Stripe idempotency cache returns ch_A for any call with stableKey, and the existsByIdempotencyKey guard short-circuits the second adapter’s handleMessage() before it even calls Stripe.

Failure mode 2: ackMode=MANUAL + BasicAcknowledgeablePubsubMessage.ack() inside @Transactional — GCP ACK fires as immediate HTTP; @Transactional rollback leaves no BillingRecord; reconciliation republish → UUID_B → ch_B

The setup

A senior developer on the platform team, experienced with Spring JMS and JmsTransactionManager, migrates a billing consumer from an ActiveMQ queue to Google Cloud Pub/Sub. In the JMS version, the consumer uses JmsTransactionManager, which ties the JMS session commit (and therefore the message acknowledgement) to the Spring @Transactional boundary: if the database transaction rolls back, the JMS session rolls back too, and the message is redelivered. The developer wants the same guarantee on GCP Pub/Sub and chooses ackMode=MANUAL to call ack() explicitly at the end of a successful billing transaction.

The developer writes the following pattern, calling ack() inside the @Transactional billing method after confirming the Stripe charge succeeded:

@Bean
public PubSubInboundChannelAdapter billingAdapter(
        @Qualifier("billingChannel") MessageChannel outputChannel,
        PubSubTemplate pubSubTemplate) {
    PubSubInboundChannelAdapter adapter =
        new PubSubInboundChannelAdapter(pubSubTemplate, "billing-processor");
    adapter.setOutputChannel(outputChannel);
    adapter.setAckMode(AcknowledgementMode.MANUAL);
    return adapter;
}

// ServiceActivator receives the raw Spring Integration Message including
// the GcpPubSubHeaders.ORIGINAL_MESSAGE header
@ServiceActivator(inputChannel = "billingChannel")
public void handleBillingEvent(Message<BillingEvent> message) {
    BasicAcknowledgeablePubsubMessage pubsubMsg =
        message.getHeaders().get(GcpPubSubHeaders.ORIGINAL_MESSAGE,
                                  BasicAcknowledgeablePubsubMessage.class);
    billingService.chargeAndAck(message.getPayload(), pubsubMsg);
}

// Developer intends: if @Transactional rolls back, ack() is also undone
@Transactional
public void chargeAndAck(BillingEvent event,
                          BasicAcknowledgeablePubsubMessage pubsubMsg) {
    String idempotencyKey = UUID.randomUUID().toString();

    ChargeCreateParams params = ChargeCreateParams.builder()
        .setAmount(event.getAmountCents())
        .setCurrency("eur")
        .setCustomer(event.getCustomerId())
        .build();
    Charge charge = Charge.create(params,
        RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());

    // Developer believes: ack() here is transactional — if @Transactional
    // rolls back after this line, GCP will redeliver the message
    pubsubMsg.ack();  // <-- fires IMMEDIATELY as HTTP POST to GCP API

    outboxTable.insertBillingEvent(event, idempotencyKey, charge.getId()); // throws DIVE
    billingRecordRepo.save(new BillingRecord(event, idempotencyKey, charge.getId()));
}

The outboxTable.insertBillingEvent() call writes to a transactional outbox table that has a unique constraint on (account_id, billing_period). A previous partial-failure scenario left a stale row in this table for the same account and period. The insert throws DataIntegrityViolationException.

The failure timeline

Developer misconception

“BasicAcknowledgeablePubsubMessage.ack() participates in the enclosing Spring @Transactional just like JmsTransactionManager defers session.commit() until the outermost @Transactional boundary commits. If I call ack() inside a @Transactional method and the transaction rolls back, GCP will redeliver the message — the same guarantee I had with JMS.”

This is a category error between two fundamentally different resource types. JmsTransactionManager implements Spring’s PlatformTransactionManager interface and manages a JMS Session as a Spring-aware transactional resource. When you configure JmsTransactionManager, Spring’s TransactionSynchronizationManager binds the JMS Session to the current thread; session.commit() and session.rollback() are called by Spring as part of the two-phase transaction lifecycle. The JMS session ACK is deferred because the transaction manager explicitly calls it at commit time. BasicAcknowledgeablePubsubMessage.ack() is not implemented this way. It is a call to GrpcSubscriberStub.acknowledgeCallable().futureCall(request): an asynchronous HTTP/2 call to the GCP Pub/Sub API that completes independently of Spring’s transaction lifecycle. There is no Google Cloud Pub/Sub PlatformTransactionManager implementation in Spring Cloud GCP that would make ack() transactional in the Spring sense. The guarantee that JMS CLIENT_ACKNOWLEDGE + JmsTransactionManager provides does not exist at the GCP Pub/Sub API level.

A secondary misconception is that calling ack() after the Stripe call but before the DB save is a safe ordering. In fact, once ack() fires, the message is gone from GCP permanently. Any exception after that point leaves the system with a committed Stripe charge and no database record — the worst possible inconsistency mode, because there is no automatic GCP redelivery to retry.

The fix

Two separate fixes are needed:

Fix 1: Call ack() only after the database transaction commits successfully. Use Spring’s TransactionSynchronizationManager.registerSynchronization() to defer the ack() call to the afterCommit() callback, which fires only if the transaction commits:

@Transactional
public void chargeAndRecord(BillingEvent event,
                              BasicAcknowledgeablePubsubMessage pubsubMsg) {
    String stableKey = "billing:" + sha256(
        event.getAccountId() + ":" + event.getBillingPeriod() + ":" + event.getAmountCents()
    );

    if (billingRecordRepo.existsByIdempotencyKey(stableKey)) {
        // Already processed — safe to ack and return
        pubsubMsg.ack();
        return;
    }

    Charge charge = Charge.create(
        ChargeCreateParams.builder()
            .setAmount(event.getAmountCents())
            .setCurrency("eur")
            .setCustomer(event.getCustomerId())
            .build(),
        RequestOptions.builder().setIdempotencyKey(stableKey).build()
    );

    billingRecordRepo.save(new BillingRecord(event, stableKey, charge.getId()));

    // Register GCP ack to fire AFTER the DB transaction commits.
    // If the transaction rolls back, afterCommit() is never called;
    // GCP will redeliver the message after the ackDeadline expires.
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                pubsubMsg.ack();
            }
        }
    );
}

// ServiceActivator just delegates — no ack() here
@ServiceActivator(inputChannel = "billingChannel")
public void handleBillingEvent(Message<BillingEvent> message) {
    BasicAcknowledgeablePubsubMessage pubsubMsg =
        message.getHeaders().get(GcpPubSubHeaders.ORIGINAL_MESSAGE,
                                  BasicAcknowledgeablePubsubMessage.class);
    billingService.chargeAndRecord(message.getPayload(), pubsubMsg);
}

Fix 2: Use a stable idempotency key so that if a reconciliation job or any other redelivery does occur, the Stripe call returns the original ch_A from the idempotency cache rather than creating ch_B. The stable key in Fix 1 above handles this: even if reconciliation republishes 72 hours later, sha256(accountId+billingPeriod+amountCents) generates the same key that was used in the first attempt. If Stripe’s 24-hour cache has expired, Stripe returns a 409 Conflict with an idempotency_error indicating that the original charge attempt for this key succeeded. The billing handler should treat that 409 as success (the charge exists) and skip the duplicate. If the 24-hour window has not expired, Stripe returns the original ch_A directly. Either way, no new charge is created.

Failure mode 3: RequestHandlerRetryAdvice wrapping the billing ServiceActivator — each retry re-invokes handleMessage() with UUID.randomUUID(); ch_A committed on attempt 1; ch_B and ch_C committed on retries 2 and 3

The setup

A developer adds RequestHandlerRetryAdvice to the billing ServiceActivator to handle transient database connection pool exhaustion. The production database occasionally spikes to 100% connection pool utilization during peak billing windows, causing billingRecordRepo.save() to throw CannotGetJdbcConnectionException. Without retry, the message would go to the error channel and require manual reprocessing. The developer wraps the ServiceActivator with a retry that backs off and retries up to 3 times.

@Bean
@ServiceActivator(inputChannel = "billingChannel",
                   adviceChain = "billingRetryAdvice")
public MessageHandler billingServiceActivator() {
    return new BillingMessageHandler(stripeClient, billingRecordRepo);
}

@Bean
public RequestHandlerRetryAdvice billingRetryAdvice() {
    RequestHandlerRetryAdvice advice = new RequestHandlerRetryAdvice();
    RetryTemplate retryTemplate = RetryTemplate.builder()
        .maxAttempts(3)
        .exponentialBackoff(500, 2.0, 4000)
        .retryOn(CannotGetJdbcConnectionException.class)
        .build();
    advice.setRetryTemplate(retryTemplate);
    return advice;
}

The billing message handler generates its Stripe idempotency key at the top of handleMessage():

public class BillingMessageHandler implements MessageHandler {

    @Override
    public void handleMessage(Message<?> message) throws MessagingException {
        BillingEvent event = (BillingEvent) message.getPayload();

        // Developer rationale: "UUID.randomUUID() is called once per message delivery.
        // The retry advice passes the same Message object to each retry attempt,
        // so the key is consistent across retries."
        String idempotencyKey = UUID.randomUUID().toString(); // NEW key on every call

        try {
            RequestOptions options = RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build();
            ChargeCreateParams params = ChargeCreateParams.builder()
                .setAmount(event.getAmountCents())
                .setCurrency("eur")
                .setCustomer(event.getCustomerId())
                .build();
            Charge charge = Charge.create(params, options);
            billingRecordRepo.save(new BillingRecord(event, idempotencyKey, charge.getId()));
        } catch (StripeException e) {
            throw new MessagingException(message, "Stripe call failed", e);
        }
        // CannotGetJdbcConnectionException propagates uncaught; caught by RetryTemplate
    }
}

The developer’s reasoning: “The RetryTemplate retries the same Message<?> object through the same MessageHandler. Since we’re calling UUID.randomUUID() once at the top of handleMessage(), and the Message object is the same on every retry attempt, the key should be the same across retries. The retry advice doesn’t create a new Message; it just re-invokes the handler with the original Message.”

The failure timeline

The database connection pool hits 100% during the month-end billing run. billingRecordRepo.save() throws CannotGetJdbcConnectionException for approximately 8 seconds.

Developer misconception

“RequestHandlerRetryAdvice retries the same operation with the same input. Since the same Message<BillingEvent> object is passed to every retry attempt and we derive the idempotency key at the top of handleMessage(), the key is computed from the same Message every time and therefore has the same value on every retry.”

The misconception is conflating “the same Message object” with “the same values computed from that object.” UUID.randomUUID() does not read from the Message object at all; it calls java.security.SecureRandom and returns a cryptographically random 128-bit value. The same Message object produces a different UUID on every call because randomUUID() has no inputs — it is a pure random number generator, not a function of the Message payload. The Message object being “the same” across retries is true but irrelevant: UUID.randomUUID() ignores the Message entirely.

A related misconception: “the Stripe idempotency cache will deduplicate these calls because Stripe can detect that the same customer, amount, and currency were charged.” Stripe’s idempotency key mechanism matches only on the exact key string. Stripe does not inspect the charge parameters for semantic equivalence. UUID_A, UUID_B, and UUID_C are three distinct idempotency keys; Stripe creates three separate charges even if the ChargeCreateParams are byte-for-byte identical.

The secondary misconception is about the scope of RequestHandlerRetryAdvice’s retry: “retry advice wraps just the database save call — Stripe is called before the exception point, so Stripe is outside the retry scope.” RequestHandlerRetryAdvice wraps the entire handleMessage() method, not individual calls within it. Every line of handleMessage(), including the Stripe API call, is re-executed on each retry.

The fix

Fix 1: Compute the stable key before the retry loop, not inside handleMessage(). There are two patterns for this in Spring Integration:

Option A — derive the key from the message payload before calling the handler and store it as a Message header:

// Add a transformer upstream that enriches the message with a stable key
@Transformer(inputChannel = "rawBillingChannel", outputChannel = "billingChannel")
public Message<BillingEvent> enrichWithIdempotencyKey(Message<BillingEvent> message) {
    BillingEvent event = message.getPayload();
    String stableKey = "billing:" + sha256(
        event.getAccountId() + ":" + event.getBillingPeriod() + ":" + event.getAmountCents()
    );
    return MessageBuilder.fromMessage(message)
        .setHeader("stripeIdempotencyKey", stableKey)
        .build();
}

// Handler reads the stable key from headers — same value on every retry
public class BillingMessageHandler implements MessageHandler {
    @Override
    public void handleMessage(Message<?> message) throws MessagingException {
        BillingEvent event = (BillingEvent) message.getPayload();
        String stableKey = (String) message.getHeaders().get("stripeIdempotencyKey");
        // stableKey is now identical on every RequestHandlerRetryAdvice retry
        // because it was computed once upstream and stored in the Message headers

        if (billingRecordRepo.existsByIdempotencyKey(stableKey)) {
            return;  // ch_A already committed and saved — idempotent return
        }

        Charge charge = Charge.create(
            ChargeCreateParams.builder()
                .setAmount(event.getAmountCents())
                .setCurrency("eur")
                .setCustomer(event.getCustomerId())
                .build(),
            RequestOptions.builder().setIdempotencyKey(stableKey).build()
        );
        billingRecordRepo.save(new BillingRecord(event, stableKey, charge.getId()));
    }
}

Option B — compute the stable key directly inside handleMessage() from the payload (no transformer needed):

public class BillingMessageHandler implements MessageHandler {
    @Override
    public void handleMessage(Message<?> message) throws MessagingException {
        BillingEvent event = (BillingEvent) message.getPayload();

        // Deterministic function of payload fields — same value on every retry
        // because the Message payload does not change between retry attempts
        String stableKey = "billing:" + sha256(
            event.getAccountId() + ":" + event.getBillingPeriod() + ":" + event.getAmountCents()
        );

        if (billingRecordRepo.existsByIdempotencyKey(stableKey)) {
            return;
        }

        Charge charge = Charge.create(
            ChargeCreateParams.builder()
                .setAmount(event.getAmountCents())
                .setCurrency("eur")
                .setCustomer(event.getCustomerId())
                .build(),
            RequestOptions.builder().setIdempotencyKey(stableKey).build()
        );
        billingRecordRepo.save(new BillingRecord(event, stableKey, charge.getId()));
    }
}

Option B is simpler and equally correct. With a stable key, attempts 1, 2, and 3 all send stableKey to Stripe. Attempt 1 creates ch_A. Attempts 2 and 3 hit Stripe’s idempotency cache and receive ch_A back. The billingRecordRepo.save() call on attempts 2 and 3, after DB connections recover, stores the same stableKey and ch_A (or is skipped by the existsByIdempotencyKey guard). One charge, one billing record, three retry attempts.

Fix 2: Limit RequestHandlerRetryAdvice scope to avoid wrapping the Stripe call. If your architecture allows it, move the Stripe call to a separate service method that is not inside the handleMessage() retry scope. This requires splitting the handler into a Stripe-calling component and a DB-saving component connected by a channel. The retry advice then wraps only the DB save component. This is architecturally more complex and is usually not warranted when Fix 1 (stable key) is applied — Fix 1 makes the retry idempotent across all components. Use Fix 1 as the primary defense; Fix 2 is an optional defense-in-depth measure.

Cross-layer comparison: Spring Integration Google PubSub vs SCS GCP PubSub Binder vs raw GCP Subscriber vs Spring Integration Azure Service Bus

Layer Ack deadline extension default Transactional ack mechanism Application-layer retry Double-charge trigger
Raw GCP Subscriber setMaxAckExtensionPeriod(60 min) auto-extends None — AckReplyConsumer.ack() is immediate Manual (in MessageReceiver) Rare: requires >60min processing or manual nack
SCS GCP PubSub Binder max-ack-extension-period=0 (disabled by default) None — nack() called by binder after maxAttempts maxAttempts + backOffInitialInterval nackOnError loop; ackDeadline expiry; DLT multiplier
Spring Integration GCP PubSub max-ack-extension-period=0 (same GCP subscriber default) None — BasicAcknowledgeablePubsubMessage.ack() is immediate HTTP RequestHandlerRetryAdvice (SI-specific) AckDeadline expiry; MANUAL ack transactional misuse; retry advice fresh UUID
Spring Integration Azure Service Bus maxAutoLockRenewDuration (default 5 min, configurable) None — AcknowledgmentCallback.complete() is immediate HTTP RequestHandlerRetryAdvice (SI-specific) AUTO_COMPLETE abandonment; ack inside @Transactional; lock renewal deadline
JMS (JmsTransactionManager) N/A — broker holds message until session.commit() Yes — session.commit() deferred to @Transactional commit @Retryable or manual UUID.randomUUID() per retry if stable key not used

AcknowledgementMode behavior table: PubSubInboundChannelAdapter

AcknowledgementMode When GCP ack() fires On exception in ServiceActivator Redelivery on exception? Double-charge risk
AUTO_ACK When adapter receives message (before channel send) Message already ack’d — no GCP redelivery No (message is gone from GCP) Low from GCP side; errorChannel loop if configured
AUTO (default) After downstream handler returns successfully ack() not called — GCP redelivers after ackDeadline expiry Yes (via ackDeadline expiry) High if ackDeadline < Stripe processing time
MANUAL Whenever application calls pubsubMsg.ack() Application controls — nack() causes redelivery If application calls nack() or lets deadline expire High if ack() called inside @Transactional before DB save

RequestHandlerRetryAdvice vs SCS maxAttempts vs @Retryable: stable key scope comparison

Retry mechanism Scope of retry Message object across retries UUID.randomUUID() per retry? Stable key required?
SCS maxAttempts Consumer function (SCS binding) Same deserialized payload object Yes, if called inside consumer Yes
RequestHandlerRetryAdvice Entire MessageHandler.handleMessage() Same Spring Integration Message object Yes, if called inside handler Yes
@Retryable on service method The annotated method Same arguments passed through Yes, if called inside the method Yes
GCP PubSub redelivery (ackDeadline expiry) New handler invocation (may be different pod) New Spring Integration Message from redelivered GCP message Yes Yes

Eight-item diagnostic checklist for Spring Integration Google PubSub Stripe billing

  1. What is ackMode on your PubSubInboundChannelAdapter? If AUTO (default) or not set, check the next item.
  2. Is spring.cloud.gcp.pubsub.subscriber.max-ack-extension-period configured? If missing or 0, the ack deadline is not extended. Check your GCP subscription’s ackDeadlineSeconds value. If 10 (the GCP default), any Stripe call taking more than 10 seconds (including 3DS SCA) risks concurrent redelivery. Set max-ack-extension-period=600 and ackDeadlineSeconds=120 minimum.
  3. Does your billing ServiceActivator or MessageHandler call UUID.randomUUID() at the point of the Stripe API call? If yes, every invocation — whether from GCP redelivery, RequestHandlerRetryAdvice, or errorChannel rerouting — generates a new Stripe idempotency key and commits a new charge. Replace with a stable content-derived key.
  4. Is BasicAcknowledgeablePubsubMessage.ack() called inside a @Transactional method? If yes, the GCP ACK fires immediately and cannot be rolled back by Spring’s transaction manager. Move ack() to TransactionSynchronizationManager.registerSynchronization().afterCommit().
  5. Does the billing handler use RequestHandlerRetryAdvice? If yes, confirm the stable key is computed from the Message payload (not from UUID.randomUUID()) and is consistent across retry invocations. Add a billingRecordRepo.existsByIdempotencyKey(stableKey) guard as early return.
  6. Is there an errorChannel handler that republishes failed messages back to the same or a retry GCP topic? If yes, trace whether the billing consumer will call UUID.randomUUID() on the republished message. Each republish creates a new GCP message with new delivery context; UUID.randomUUID() generates a new key on every receipt.
  7. Does the GCP subscription have a dead-letter topic configured? If yes with maxDeliveryAttempts, know the GCP delivery multiplier: each GCP delivery triggers the full RequestHandlerRetryAdvice retry cycle. 5 GCP deliveries × 3 RetryTemplate attempts = 15 handleMessage() calls without a stable key. Check the DLT message’s x-goog-pubsub-delivery-attempt attribute before concluding how many Stripe calls ran.
  8. Is the billing event idempotency key stored in the database and used to fast-return on re-delivery? A stable Stripe idempotency key alone is not sufficient if the Stripe 24-hour idempotency cache has expired at re-delivery time. A BillingRecord row keyed on the stable business key provides an application-layer cache with configurable retention that survives Stripe’s expiry window.

Test suite: JUnit 5 + WireMock + Testcontainers (GCP PubSub emulator)

Each failure mode can be verified in isolation with a test that controls: (a) the ack deadline or retry behavior, (b) the Stripe response via WireMock, and (c) the database behavior via mocking or Testcontainers.

@SpringBootTest
@Testcontainers
class SpringIntegrationGcpPubSubStripeIdempotencyTest {

    @Container
    static final PubSubEmulatorContainer pubSubEmulator =
        new PubSubEmulatorContainer("gcr.io/google.com/cloudsdktool/cloud-sdk:emulators");

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();

    @Autowired PubSubTemplate pubSubTemplate;
    @Autowired BillingRecordRepository billingRecordRepo;

    static final String BILLING_TOPIC = "billing-events";
    static final String BILLING_SUBSCRIPTION = "billing-processor";

    // ── Mode 1: ackMode=AUTO + ackDeadline expiry → concurrent delivery ─────────

    @Test
    @DisplayName("Mode 1: concurrent delivery after ackDeadline expiry → UUID_B → ch_B (without stable key)")
    void ackDeadlineExpiry_withoutStableKey_twoChargesCommitted() throws Exception {
        // Arrange: first Stripe call takes 12s (longer than ackDeadline=10s)
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("3ds")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse()
                .withFixedDelay(12_000)
                .withStatus(200)
                .withBody(chargeJson("ch_A")))
            .willSetStateTo("second"));

        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("3ds")
            .whenScenarioStateIs("second")
            .willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_B"))));

        pubSubTemplate.publish(BILLING_TOPIC,
            new BillingEvent("acct_EU_001", "2026-10", 4900L));

        // Wait for both deliveries to complete
        await().atMost(Duration.ofSeconds(30))
               .until(() -> wireMock.findAll(
                   postRequestedFor(urlEqualTo("/v1/charges"))).size() >= 2);

        // Two distinct idempotency keys → two Stripe charges
        List<LoggedRequest> stripeRequests = wireMock.findAll(
            postRequestedFor(urlEqualTo("/v1/charges")));
        Set<String> keys = stripeRequests.stream()
            .map(r -> r.getHeader("Idempotency-Key"))
            .collect(Collectors.toSet());
        assertThat(keys).hasSize(2); // UUID_A and UUID_B — two charges
    }

    @Test
    @DisplayName("Mode 1: concurrent delivery with stable key → one charge (with fix)")
    void ackDeadlineExpiry_withStableKey_oneCharge() throws Exception {
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_A"))));

        pubSubTemplate.publish(BILLING_TOPIC,
            new BillingEvent("acct_EU_002", "2026-10", 4900L));

        await().atMost(Duration.ofSeconds(30))
               .until(() -> billingRecordRepo.count() == 1);

        // All Stripe calls used the same stable key → Stripe idempotency cache
        List<LoggedRequest> stripeRequests = wireMock.findAll(
            postRequestedFor(urlEqualTo("/v1/charges")));
        Set<String> keys = stripeRequests.stream()
            .map(r -> r.getHeader("Idempotency-Key"))
            .collect(Collectors.toSet());
        assertThat(keys).hasSize(1); // one stable key — ch_A returned every time
    }

    // ── Mode 2: ack() inside @Transactional → rollback doesn't un-ack ───────────

    @Test
    @DisplayName("Mode 2: ack() inside @Transactional → rollback leaves no BillingRecord (without fix)")
    void manualAck_insideTransactional_rollbackLeavesNoRecord() throws Exception {
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_A"))));

        // Configure outbox table to throw DataIntegrityViolationException
        when(outboxTable.insertBillingEvent(any(), any(), any()))
            .thenThrow(new DataIntegrityViolationException("unique constraint violation"));

        pubSubTemplate.publish(BILLING_TOPIC,
            new BillingEvent("acct_EU_003", "2026-10", 4900L));

        // Wait for the handler to complete (with exception)
        await().atMost(Duration.ofSeconds(5))
               .until(() -> wireMock.findAll(
                   postRequestedFor(urlEqualTo("/v1/charges"))).size() == 1);

        // Stripe was charged (ch_A committed) but BillingRecord was not saved
        assertThat(billingRecordRepo.count()).isZero();
        // GCP message was ack'd (ack() fired before outbox insert threw)
        // — verified by asserting no redelivery occurs within test timeout
    }

    @Test
    @DisplayName("Mode 2: ack() in afterCommit() → rollback prevents ack → GCP redelivers → stable key → one charge (with fix)")
    void manualAck_afterCommit_rollbackPreventsAck_stableKeyDeduplicates() throws Exception {
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_A"))));

        AtomicInteger outboxCallCount = new AtomicInteger(0);
        when(outboxTable.insertBillingEvent(any(), any(), any())).thenAnswer(inv -> {
            // Fail on first attempt, succeed on second (redelivery)
            if (outboxCallCount.incrementAndGet() == 1) {
                throw new DataIntegrityViolationException("constraint violation");
            }
            return null;
        });

        pubSubTemplate.publish(BILLING_TOPIC,
            new BillingEvent("acct_EU_004", "2026-10", 4900L));

        // Wait for successful second delivery
        await().atMost(Duration.ofSeconds(20))
               .until(() -> billingRecordRepo.count() == 1);

        // All Stripe calls used the stable key → only ch_A charged
        List<LoggedRequest> stripeRequests = wireMock.findAll(
            postRequestedFor(urlEqualTo("/v1/charges")));
        Set<String> keys = stripeRequests.stream()
            .map(r -> r.getHeader("Idempotency-Key"))
            .collect(Collectors.toSet());
        assertThat(keys).hasSize(1); // stable key — ch_A on all attempts
    }

    // ── Mode 3: RequestHandlerRetryAdvice + UUID.randomUUID() per retry ──────────

    @Test
    @DisplayName("Mode 3: RequestHandlerRetryAdvice + UUID.randomUUID() → ch_A, ch_B, ch_C on 3 retries (without stable key)")
    void retryAdvice_withoutStableKey_threeChargesCommitted() throws Exception {
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_NEW"))));

        // DB throws on first two attempts, succeeds on third
        AtomicInteger saveAttempt = new AtomicInteger(0);
        when(billingRecordRepo.save(any())).thenAnswer(inv -> {
            if (saveAttempt.incrementAndGet() < 3) {
                throw new CannotGetJdbcConnectionException("pool exhausted");
            }
            return inv.getArgument(0);
        });

        pubSubTemplate.publish(BILLING_TOPIC,
            new BillingEvent("acct_EU_005", "2026-10", 4900L));

        await().atMost(Duration.ofSeconds(15))
               .until(() -> billingRecordRepo.count() == 1);

        // Three Stripe calls with three different idempotency keys
        List<LoggedRequest> stripeRequests = wireMock.findAll(
            postRequestedFor(urlEqualTo("/v1/charges")));
        assertThat(stripeRequests).hasSize(3);
        Set<String> keys = stripeRequests.stream()
            .map(r -> r.getHeader("Idempotency-Key"))
            .collect(Collectors.toSet());
        assertThat(keys).hasSize(3); // UUID_A, UUID_B, UUID_C — three distinct charges
    }

    @Test
    @DisplayName("Mode 3: RequestHandlerRetryAdvice + stable key → one Stripe charge across 3 retries (with fix)")
    void retryAdvice_withStableKey_oneCharge() throws Exception {
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_A"))));

        AtomicInteger saveAttempt = new AtomicInteger(0);
        when(billingRecordRepo.save(any())).thenAnswer(inv -> {
            if (saveAttempt.incrementAndGet() < 3) {
                throw new CannotGetJdbcConnectionException("pool exhausted");
            }
            return inv.getArgument(0);
        });

        pubSubTemplate.publish(BILLING_TOPIC,
            new BillingEvent("acct_EU_006", "2026-10", 4900L));

        await().atMost(Duration.ofSeconds(15))
               .until(() -> billingRecordRepo.count() == 1);

        // Three Stripe calls, all with the same stable key → Stripe cache returns ch_A
        List<LoggedRequest> stripeRequests = wireMock.findAll(
            postRequestedFor(urlEqualTo("/v1/charges")));
        assertThat(stripeRequests).hasSize(3); // three attempts
        Set<String> keys = stripeRequests.stream()
            .map(r -> r.getHeader("Idempotency-Key"))
            .collect(Collectors.toSet());
        assertThat(keys).hasSize(1); // one stable key → one Stripe charge ch_A
    }

    private String chargeJson(String chargeId) {
        return """
            {
              "id": "%s",
              "object": "charge",
              "amount": 4900,
              "currency": "eur",
              "status": "succeeded"
            }
            """.formatted(chargeId);
    }
}

Run with Testcontainers and the GCP PubSub emulator to verify the ack deadline and redelivery behaviors in a real Pub/Sub environment without a GCP project. The PubSubEmulatorContainer supports configuring ackDeadlineSeconds on subscriptions via the emulator’s admin API, enabling Mode 1 tests to run without waiting for real GCP timeouts.

How this relates to Keybrake

The three failure modes in this post share a common shape: the billing consumer reaches Stripe and commits a charge, then something goes wrong at the infrastructure or application layer (ack deadline expiry, transaction rollback, retry re-invocation), and a second Stripe call is made with a different idempotency key. The fix in every case is the same: a stable, content-derived Stripe idempotency key that collapses multiple calls for the same business event into a single Stripe response from the idempotency cache.

Keybrake adds a second layer of protection at the proxy layer. When your Spring Integration consumer sends a billing request through proxy.keybrake.com/stripe/v1/charges, Keybrake’s audit log records the idempotency key, the Stripe charge ID, and the timestamp for every proxied call. If you see two audit log entries with two different idempotency keys for the same customer and billing period — the signature of every failure mode in this series — you have an actionable alert before the customer sees the double charge. Keybrake can also enforce a spend cap per customer per day: a runaway RequestHandlerRetryAdvice loop that generates 100 Stripe charges before someone notices hits the daily cap, and all subsequent charge attempts are rejected by the proxy before they reach Stripe.

Never let a retry loop drain your customers’ cards

Keybrake proxies your agent’s Stripe calls with per-customer spend caps, stable-key enforcement, and a full audit log. One line of config change — no Stripe account changes needed.

Summary

Spring Integration’s PubSubInboundChannelAdapter introduces three failure modes that produce duplicate Stripe charges, each arising from a behavior that differs from the raw GCP Subscriber client, the SCS GCP PubSub Binder, and from JMS-based messaging systems that developers commonly migrate from:

The stable idempotency key is the fix for all three modes: a deterministic function of the billing event’s business fields (SHA-256(accountId + billingPeriod + amountCents)) that produces the same string on every re-delivery, retry, or reconciliation republish. Combined with a BillingRecord guard in the application layer, it makes the billing ServiceActivator safe under any re-invocation scenario that Spring Integration, GCP PubSub, and Stripe’s 24-hour idempotency cache can produce.

The next post in this series covers Spring Integration Solace + Stripe idempotency failure modes — how JCSMPInboundChannelAdapter settlement semantics, Solace DMQ routing, and guaranteed message replay interact with UUID.randomUUID() to produce duplicate charges in ways specific to the Solace JCSMP adapter layer.