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.
- t=0.000s — GCP PubSub delivers M1 to the billing-processor subscription subscriber on Pod-A. The ack deadline timer starts immediately in the GCP broker: if no
ack()or extendedModifyAckDeadlinearrives within 10 seconds, GCP marks M1 as unacknowledged and redelivers. - t=0.001s —
PubSubInboundChannelAdapteron Pod-A receives M1 from the subscriber callback. It places M1 ontobillingChannel(aDirectChannel).DirectChannel.send()is synchronous: the adapter thread is blocked insidesend()while theServiceActivatorruns. - t=0.002s —
handleBillingEvent()starts.UUID.randomUUID()generatesUUID_A. The Stripe API call begins (Charge.create()). - t=0.500s — Stripe receives the charge creation request and begins 3DS SCA. The Stripe API call is blocking inside
handleBillingEvent(). No thread is sendingModifyAckDeadlineto GCP because the adapter never configured ack deadline extension. - t=10.001s — GCP PubSub’s ack deadline timer fires. GCP marks M1 as unacknowledged and schedules redelivery. GCP does NOT notify Pod-A. M1 is redelivered to any subscriber on the billing-processor subscription. Pod-B’s
PubSubInboundChannelAdapterreceives M1. - t=10.002s — Pod-B’s
handleBillingEvent()starts.UUID.randomUUID()generatesUUID_B. A separate Stripe API call begins withidempotencyKey=UUID_B. - t=11.200s — Pod-B’s Stripe call succeeds. Stripe creates charge
ch_Band returns200 OK. Pod-B writesBillingRecord(UUID_B, ch_B)to the database and returns fromhandleBillingEvent(). Pod-B’s adapter callsack()for M1. - t=12.800s — Pod-A’s Stripe 3DS call completes. Stripe creates charge
ch_Aand returns200 OK. Pod-A writesBillingRecord(UUID_A, ch_A)to the database. Pod-A’s adapter callsack()for M1 (GCP ignores this; M1 was already ack’d by Pod-B at t=11.200s).
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=AUTOin Spring Integration means the ack deadline timer doesn’t start until after theServiceActivatorreturns — it’s equivalent to JMSAUTO_ACKNOWLEDGEwhich fires after theMessageListener.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
- t=0ms — M1 delivered to Pod-A’s
PubSubInboundChannelAdapter(ackMode=MANUAL). Adapter places M1 intobillingChannel.handleBillingEvent()extractspubsubMsgfrom the message headers and callsbillingService.chargeAndAck(). - t=1ms — Spring opens a database transaction for
chargeAndAck()(annotated with@Transactional).UUID.randomUUID()generatesUUID_A. The Stripe API call begins. - t=430ms — Stripe returns
200 OKwithch_A. The charge is committed server-side in Stripe’s systems. - t=431ms —
pubsubMsg.ack()is called. Internally, Spring Cloud GCP callsPubSubTemplate.ack(), which callssubscriberStub.acknowledge(AcknowledgeRequest.newBuilder()...). This is an HTTP(S) gRPC call topubsub.googleapis.com. The call completes successfully in ~50ms. - t=481ms — GCP Pub/Sub broker records M1 as acknowledged. M1 is removed from the billing-processor subscription permanently. From GCP’s perspective, M1 is done.
- t=482ms —
outboxTable.insertBillingEvent()throwsDataIntegrityViolationException(unique constraint violation on the stale row). Spring’s transaction interceptor catches the runtime exception and callsTransactionManager.rollback(). - t=483ms — Spring’s
@Transactionalrolls back the database transaction. All database writes inside the transaction are reverted: theBillingRecordrow (which had not yet been written because the exception occurred beforebillingRecordRepo.save()) does not exist. TheoutboxTableinsert is also rolled back. - t=483ms — The GCP
ack()call at t=431ms is NOT affected by the database rollback. It is an independent HTTP call that completed 52ms ago. Spring’sDataSourceTransactionManager(orJpaTransactionManager) only manages JDBC connections — it has no knowledge of the GCP Pub/Sub API call.BasicAcknowledgeablePubsubMessage.ack()does not register itself as a SpringTransactionSynchronizationorTransactionResource; it fires unconditionally. - t=483ms — The system is in an inconsistent state: Stripe has committed
ch_A(real money out the door), GCP Pub/Sub has acknowledged M1 (no redelivery will occur), but the database has noBillingRecordrow for this billing event. The inconsistency is invisible in real time: no exception is surfaced to the caller, no alert fires. - t+72h — A nightly reconciliation job detects that the billing event has a corresponding Stripe charge (found via Stripe API list) but no
BillingRecordin the database. The reconciliation job republishes the billing event to the billing-events GCP topic for reprocessing. - t+72h+1ms — M1 (republished) is delivered to
PubSubInboundChannelAdapter.UUID.randomUUID()generatesUUID_B. Stripe’s 24-hour idempotency cache has long since expired. Stripe createsch_B: a second charge on the customer’s card, 3 days after the first.
Developer misconception
“
BasicAcknowledgeablePubsubMessage.ack()participates in the enclosing Spring@Transactionaljust likeJmsTransactionManagerdeferssession.commit()until the outermost@Transactionalboundary commits. If I callack()inside a@Transactionalmethod 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.
- Attempt 1, t=0ms —
RequestHandlerRetryAdviceintercepts theMessageHandler.handleMessage()call. Spring Integration passes the billingMessage<BillingEvent>toBillingMessageHandler.handleMessage().UUID.randomUUID()executes and generatesUUID_A = "550e8400-e29b-41d4-a716-446655440000"(illustrative). The Stripe API call begins withidempotencyKey=UUID_A. - Attempt 1, t=430ms — Stripe commits charge
ch_Aand returns200 OK.billingRecordRepo.save()throwsCannotGetJdbcConnectionException(pool exhausted). - Attempt 1, t=430ms —
RequestHandlerRetryAdvice’sRetryTemplatecatchesCannotGetJdbcConnectionException(it is in theretryOnlist). Retry is scheduled after the 500ms exponential backoff. - Attempt 2, t=930ms —
RetryTemplatere-invokesBillingMessageHandler.handleMessage(message)with the sameMessage<BillingEvent>object.UUID.randomUUID()executes again and generatesUUID_B = "6ba7b810-9dad-11d1-80b4-00c04fd430c8". This is a different value fromUUID_A; everyUUID.randomUUID()call returns a unique value regardless of whether the sameMessageobject is passed. The Stripe API call begins withidempotencyKey=UUID_B. - Attempt 2, t=1360ms — Stripe commits charge
ch_B(a second, separate charge for the same amount) and returns200 OK.billingRecordRepo.save()throwsCannotGetJdbcConnectionExceptionagain. - Attempt 2, t=1360ms —
RetryTemplateschedules retry 3 after a 1000ms backoff (500ms × 2.0). - Attempt 3, t=2360ms —
RetryTemplatere-invokeshandleMessage(message).UUID.randomUUID()generatesUUID_C. The Stripe API call begins withidempotencyKey=UUID_C. - Attempt 3, t=2790ms — Stripe commits charge
ch_Cand returns200 OK. The database connection pool has recovered (8-second spike over).billingRecordRepo.save()succeeds and storesBillingRecord(UUID_C, ch_C). - Final state — The database contains one
BillingRecordrow withidempotencyKey=UUID_C, chargeId=ch_C. Stripe has three separate charges:ch_A($49.00),ch_B($49.00),ch_C($49.00). The customer was charged $147.00 for a $49.00 billing event. No exception was surfaced; the handler returned successfully on attempt 3. The only observable signals are two unexplained charge entries in Stripe’s dashboard and aBillingRecordtable that shows one record where three Stripe charges exist for the same billing event.
Developer misconception
“
RequestHandlerRetryAdviceretries the same operation with the same input. Since the sameMessage<BillingEvent>object is passed to every retry attempt and we derive the idempotency key at the top ofhandleMessage(), 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
- What is
ackModeon yourPubSubInboundChannelAdapter? IfAUTO(default) or not set, check the next item. - Is
spring.cloud.gcp.pubsub.subscriber.max-ack-extension-periodconfigured? If missing or0, the ack deadline is not extended. Check your GCP subscription’sackDeadlineSecondsvalue. If10(the GCP default), any Stripe call taking more than 10 seconds (including 3DS SCA) risks concurrent redelivery. Setmax-ack-extension-period=600andackDeadlineSeconds=120minimum. - Does your billing
ServiceActivatororMessageHandlercallUUID.randomUUID()at the point of the Stripe API call? If yes, every invocation — whether from GCP redelivery,RequestHandlerRetryAdvice, orerrorChannelrerouting — generates a new Stripe idempotency key and commits a new charge. Replace with a stable content-derived key. - Is
BasicAcknowledgeablePubsubMessage.ack()called inside a@Transactionalmethod? If yes, the GCP ACK fires immediately and cannot be rolled back by Spring’s transaction manager. Moveack()toTransactionSynchronizationManager.registerSynchronization().afterCommit(). - Does the billing handler use
RequestHandlerRetryAdvice? If yes, confirm the stable key is computed from the Message payload (not fromUUID.randomUUID()) and is consistent across retry invocations. Add abillingRecordRepo.existsByIdempotencyKey(stableKey)guard as early return. - Is there an
errorChannelhandler that republishes failed messages back to the same or a retry GCP topic? If yes, trace whether the billing consumer will callUUID.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. - Does the GCP subscription have a dead-letter topic configured? If yes with
maxDeliveryAttempts, know the GCP delivery multiplier: each GCP delivery triggers the fullRequestHandlerRetryAdviceretry cycle. 5 GCP deliveries × 3RetryTemplateattempts = 15handleMessage()calls without a stable key. Check the DLT message’sx-goog-pubsub-delivery-attemptattribute before concluding how many Stripe calls ran. - 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
BillingRecordrow 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:
- Mode 1 —
ackMode=AUTO+ackDeadlineSeconds=10expiry during Stripe 3DS: The adapter does not extend the ack deadline while the subscriber thread is blocked in theServiceActivator. GCP redelivers the message to a second adapter instance at t=10.001s.UUID.randomUUID()on the second instance generatesUUID_B; Stripe commitsch_B. Fix: setmax-ack-extension-period=600; set subscriptionackDeadlineSeconds=120; use a stable content-derived idempotency key. - Mode 2 —
ackMode=MANUAL+ack()inside@Transactional:BasicAcknowledgeablePubsubMessage.ack()fires immediately as an HTTP call to GCP, not as a Spring transactional resource. If@Transactionalrolls back afterack()succeeds, the message is acknowledged with no database record. Reconciliation republish generatesUUID_B; Stripe commitsch_B. Fix: moveack()toTransactionSynchronizationManager.registerSynchronization().afterCommit(); use a stable key so reconciliation republishes are idempotent. - Mode 3 —
RequestHandlerRetryAdvice+UUID.randomUUID()insidehandleMessage(): Each retry re-invokeshandleMessage()as a fresh method call.UUID.randomUUID()generates a new value on each invocation regardless of whether the sameMessageobject is passed. Three retries produce three Stripe charges:ch_A,ch_B,ch_C. Fix: compute the stable key from the message payload; all retry invocations send the same key to Stripe; Stripe’s idempotency cache returnsch_Aon attempts 2 and 3.
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.