Spring Integration Azure Service Bus Stripe Integration: How AUTO_COMPLETE=true Abandons the Message on DirectChannel Exception Delivering UUID_B on Redelivery, How AcknowledgmentCallback.complete() Called Inside @Transactional Is Not Rolled Back When the Transaction Rolls Back Leaving the Message Settled with No Billing Record Enabling ch_B on Late Reconciliation, and How maxAutoLockRenewDuration Below Stripe 3DS p99 Stops Lock Renewal Before Authentication Completes Redelivering the Message and Committing ch_B
Spring Integration and Spring Cloud Stream share the same Azure Service Bus Java SDK underneath, but their message settlement architectures differ in ways that create unique Stripe double-charge scenarios invisible in either SCS ASB Binder documentation or raw ServiceBusReceiverClient guides. The three failure modes below require understanding Spring Integration’s AUTO_COMPLETE settlement semantics, Spring’s transaction management boundaries relative to ASB acknowledgment callbacks, and the precise meaning of maxAutoLockRenewDuration in ServiceBusContainerProperties — none of which are surfaced in the SCS ASB Binder, the SI Azure Event Hubs adapter, or the Azure SDK documentation for direct receiver usage.
Spring Integration Azure Service Bus Architecture
The spring-cloud-azure-starter-integration-servicebus artifact provides three primary components: ServiceBusInboundChannelAdapter (receives messages from ASB queues or topic subscriptions via ServiceBusMessageListenerContainer), ServiceBusMessageHandler (sends messages to ASB queues or topics), and ServiceBusMessageListenerContainer (wraps the Azure SDK’s ServiceBusProcessorClient with Spring lifecycle management and Spring Integration message conversion).
The ServiceBusInboundChannelAdapter extends MessageProducerSupport, which extends AbstractEndpoint, giving it a Spring-managed start/stop lifecycle hooked to ApplicationContext events. Unlike its AEH counterpart (EventHubsInboundChannelAdapter), the ASB adapter operates in a peek-lock model: each received message holds a server-side lock that must be explicitly settled (completed, abandoned, deferred, or dead-lettered) within the lock duration, or the lock expires and ASB redelivers the message automatically.
The adapter is configured via ServiceBusContainerProperties:
@Bean
public ServiceBusMessageListenerContainer listenerContainer(
ServiceBusProcessorFactory processorFactory) {
ServiceBusContainerProperties props = new ServiceBusContainerProperties();
props.setEntityName("billing-queue");
props.setAutoComplete(true); // default
props.setMaxConcurrentCalls(1); // per-entity concurrent message processing
props.setMaxAutoLockRenewDuration(Duration.ofMinutes(5)); // default
return new ServiceBusMessageListenerContainer(processorFactory, props);
}
@Bean
public ServiceBusInboundChannelAdapter billingAdapter(
ServiceBusMessageListenerContainer container) {
ServiceBusInboundChannelAdapter adapter =
new ServiceBusInboundChannelAdapter(container);
adapter.setOutputChannel(billingChannel());
return adapter;
}
The two settlement modes available to the Spring Integration developer are:
| Settlement mode | How configured | Who calls complete() | When complete() fires |
|---|---|---|---|
AUTO_COMPLETE=true | props.setAutoComplete(true) (default) | SDK automatically, after processMessage callback returns normally | On successful return from callback |
AUTO_COMPLETE=false | props.setAutoComplete(false) | Application code, via AcknowledgmentCallback in message header | Whenever application calls ack.acknowledge(Status.COMPLETE) |
The critical detail — and the one that creates the first failure mode — is the phrase “after the callback returns normally.” What happens when the callback does NOT return normally (i.e., it throws) is the source of the first double-charge scenario.
How This Differs from Spring Cloud Stream Azure Service Bus Binder
In Spring Cloud Stream’s Azure Service Bus Binder, the consumer function (Consumer<Message<T>>) is always invoked from the binder’s internal message processing loop, and checkpointing behavior is controlled by spring.cloud.stream.servicebus.bindings.billing-in-0.consumer.checkpoint-mode at the binder level. The SCS ASB Binder exposes checkpoint modes (AUTO, MANUAL, RECORD) that map to its own lifecycle — distinct from the Spring Integration AUTO_COMPLETE setting, which maps directly to the ServiceBusProcessorClient’s autoComplete constructor parameter.
In Spring Cloud Stream, when a consumer function throws after exhausting max-attempts retries, the binder calls ServiceBusReceivedMessageContext.abandon() and increments the ASB delivery count. The distinction between maxAttempts in SCS and the retry behavior that emerges from Spring Integration’s exception propagation with AUTO_COMPLETE=true is subtle but critical — in SCS, exhausting retries eventually triggers explicit abandonment, whereas in SI, a single exception from a DirectChannel propagation is enough to abandon the message.
| Behavior | SI ASB (AUTO_COMPLETE=true) | SCS ASB Binder (AUTO mode) | SI AEH (RECORD mode) |
|---|---|---|---|
| Settlement on success | SDK calls complete() after callback returns | Binder calls complete() after function returns | Checkpoint written after send() returns |
| Settlement on exception | SDK calls abandon() — message redelivered | SCS retries up to max-attempts, then calls abandon() | No settlement concept; partition offset not advanced |
| Redelivery mechanism | ASB lock expiry after abandon() | ASB lock expiry after exhausted retries | AEH reads from last committed checkpoint offset |
| Concurrent processing | maxConcurrentCalls per entity | prefetch-count × consumer bindings | One thread per partition (within consumer group) |
| Lock renewal | maxAutoLockRenewDuration (total elapsed time) | Session LockDuration via SCS session renewal thread | Partition ownership via Blob Storage lease renewal |
Only Spring Integration’s SI ASB adapter exposes the behavior in Mode 1 below, because only SI ASB directly controls the ServiceBusProcessorClient’s autoComplete flag through ServiceBusContainerProperties in a way that is wired to Spring Integration’s channel exception propagation path.
Failure Mode 1: AUTO_COMPLETE=true Abandons the Message on ServiceActivator Exception — It Does Not Complete It
The Setup
The billing team is migrating from a raw ServiceBusReceiverClient implementation to Spring Integration for cleaner testability and integration with existing SI message flows. They read the ServiceBusContainerProperties.setAutoComplete(true) documentation and understand it to mean: “the adapter automatically settles the message once it has been handed off to the Spring Integration channel, so we don’t need to call AcknowledgmentCallback.acknowledge() manually.” They use DirectChannel as the output channel, which they know is synchronous, and do not add any additional retry logic because they want simple, predictable behavior.
@Configuration
public class BillingIntegrationConfig {
@Bean
public MessageChannel billingChannel() {
// DirectChannel: synchronous, same thread, exception propagates back
return new DirectChannel();
}
@Bean
public ServiceBusMessageListenerContainer listenerContainer(
ServiceBusProcessorFactory processorFactory) {
ServiceBusContainerProperties props = new ServiceBusContainerProperties();
props.setEntityName("billing-queue");
props.setAutoComplete(true); // default — developer expects "always settles"
props.setMaxConcurrentCalls(1);
return new ServiceBusMessageListenerContainer(processorFactory, props);
}
@Bean
public ServiceBusInboundChannelAdapter billingAdapter(
ServiceBusMessageListenerContainer container) {
ServiceBusInboundChannelAdapter adapter =
new ServiceBusInboundChannelAdapter(container);
adapter.setOutputChannel(billingChannel());
return adapter;
}
}
@MessageEndpoint
public class BillingServiceActivator {
@Autowired private StripeClient stripeClient;
@Autowired private BillingRecordRepository billingRecordRepo;
@ServiceActivator(inputChannel = "billingChannel")
public void processBilling(Message<BillingEvent> message) {
BillingEvent event = message.getPayload();
// BUG: fresh UUID on every invocation — no stable idempotency key
String idempotencyKey = UUID.randomUUID().toString();
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getStripeCustomerId())
.setDescription("Usage " + event.getAccountId()
+ " / " + event.getBillingPeriod())
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
billingRecordRepo.save(new BillingRecord(
event.getAccountId(),
event.getBillingPeriod(),
charge.getId(),
idempotencyKey
));
}
}
How AUTO_COMPLETE=true Actually Works in the SDK
With autoComplete=true, the ServiceBusProcessorClient registers a processMessage callback and configures itself to call ServiceBusReceivedMessageContext.complete() when the callback returns without throwing. The critical condition is without throwing — not simply “when the callback returns.”
When the processMessage callback throws an exception, the SDK’s internal ServiceBusProcessorInstrumentation catches the exception from the processor handler and invokes the processError callback instead of calling complete(). The message lock continues ticking down on the ASB server side. When the lock expires (after LockDuration), ASB redelivers the message to the next available consumer and increments the message’s DeliveryCount.
Inside ServiceBusInboundChannelAdapter, the adapter’s internal processMessage lambda calls outputChannel.send(message). With DirectChannel, the send() call blocks on the same thread until the downstream ServiceActivator returns or throws. If the ServiceActivator throws, the exception propagates synchronously back through DirectChannel.send() to the adapter’s lambda, and from there to the ServiceBusProcessorClient’s callback invocation. The processor client sees the callback threw — it does not call complete():
// Inside ServiceBusInboundChannelAdapter (simplified)
void onMessage(ServiceBusReceivedMessageContext context) {
Message<T> springMessage = convertToSpringMessage(context);
try {
outputChannel.send(springMessage); // blocks for DirectChannel
// Reaches here only if send() returned normally (SA didn't throw)
if (autoComplete) {
context.complete(); // completes message in ASB
}
} catch (Exception e) {
// send() threw (SA threw) → autoComplete fires complete() only above
// This branch: message lock ticks down → ASB redelivers after lockDuration
context.abandon(); // or simply: lock expires; effect is same
}
}
The consequence: AUTO_COMPLETE=true with DirectChannel means the message is completed on success and abandoned on exception — giving the developer the opposite of what they believed: rather than guaranteeing settlement (and thus consuming the message regardless of outcome), AUTO_COMPLETE=true guarantees redelivery on exception.
The Failure Sequence
Billing message M1 arrives in the queue. The ServiceBusProcessorClient receives it with a 60-second LockDuration. The adapter converts it to a Spring Message<BillingEvent> and calls billingChannel.send(message). The DirectChannel invokes BillingServiceActivator.processBilling() on the same thread:
- t=0ms:
UUID.randomUUID()generatesUUID_A = 7a3f2....stripe.charges.create(params, idempotencyKey=UUID_A)is called. The HTTP request arrives at Stripe at t=95ms. Stripe validates the request, processes the payment method, and commits ch_A (EUR 299.00) on the server side at t=420ms. Stripe begins writing the HTTP 200 response body. At t=490ms, a transient network packet loss event on the cloud provider backbone causes the TCP connection to reset. The Stripe Java SDK’sOkHttpClientraises aSocketException("Connection reset by peer"). The SDK wraps this in aStripeConnectExceptionand throws it. - t=490ms:
StripeConnectExceptionpropagates up fromstripeClient.charges().create()throughprocessBilling(), throughDirectChannel.send(), back into the adapter’sprocessMessagecallback. The callback throws. The SDK records the exception in the error context.context.complete()is NOT called. The message lock continues counting down in ASB. - t=60,490ms (60s later): The ASB lock on M1 expires. ASB marks the message as available for redelivery and increments
DeliveryCountto 2. TheServiceBusProcessorClienton the same pod (or a second pod) receives M1 again. - t=60,490ms (redelivery):
BillingServiceActivator.processBilling()is invoked.UUID.randomUUID()generatesUUID_B = 2c8d9....stripe.charges.create(params, idempotencyKey=UUID_B)is called. Stripe has no record ofUUID_B(it only knows aboutUUID_A, which maps to ch_A committed 60 seconds ago). Stripe processes it as a new charge. ch_B (EUR 299.00) committed. HTTP 200 arrives.billingRecordRepo.save()succeeds.autoCompletefirescontext.complete().
Both ch_A (EUR 299.00) and ch_B (EUR 299.00) now appear on the customer’s Stripe account for the same billing period. Total overcharge: EUR 299.00.
The Developer Misconception
“AUTO_COMPLETE=true means the Spring Integration adapter always auto-completes the message once it has delivered it to the output channel. If the ServiceActivator throws, the message has already been handed off and the auto-completion has already fired — there is no redelivery. I need to use AUTO_COMPLETE=false and call
ack.acknowledge()manually if I want retry behavior.”
This reverses the actual behavior. With AUTO_COMPLETE=true, the SDK completes the message only on successful return from the processMessage callback. With DirectChannel, a ServiceActivator exception propagates synchronously back to the callback — so exception means no complete(), means lock expiry, means redelivery. The developer who wants no retry should use AUTO_COMPLETE=false and call ack.acknowledge(Status.COMPLETE) at the beginning of the handler (consuming the message before processing), not rely on AUTO_COMPLETE=true.
The confusion arises from a naming asymmetry with JMS. In Spring JMS, AcknowledgeMode.AUTO_ACKNOWLEDGE fires immediately when the message is received — before processing. In Spring Integration ASB, autoComplete=true fires after successful processing. The two “auto” modes have opposite positions in the processing timeline.
The Fix: Stable Idempotency Key + BillingRecord Guard
The stable fix is a deterministic idempotency key derived from the billing event’s business fields, combined with a BillingRecord guard write checked before every Stripe call. This makes the operation idempotent regardless of how many times ASB redelivers the message:
@MessageEndpoint
public class BillingServiceActivator {
@Autowired private StripeClient stripeClient;
@Autowired private BillingRecordRepository billingRecordRepo;
@ServiceActivator(inputChannel = "billingChannel")
public void processBilling(Message<BillingEvent> message) {
BillingEvent event = message.getPayload();
// Deterministic key: same input always produces the same key
String idempotencyKey = Hashing.sha256()
.hashString(
event.getAccountId() + "|" +
event.getBillingPeriod() + "|" +
event.getAmountCents() + "|" +
event.getUnitsConsumed(),
StandardCharsets.UTF_8
).toString();
// Guard: skip if this billing period is already recorded
if (billingRecordRepo.existsByAccountIdAndBillingPeriod(
event.getAccountId(), event.getBillingPeriod())) {
// Message has been processed before — just complete it
return; // AUTO_COMPLETE fires on normal return
}
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getStripeCustomerId())
.setDescription("Usage " + event.getAccountId()
+ " / " + event.getBillingPeriod())
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
// Write guard BEFORE returning — if this throws, AUTO_COMPLETE does not fire
// and the message is redelivered; on redelivery, guard check returns true
// and stable key causes Stripe to return the existing ch_A via HTTP 200
billingRecordRepo.save(new BillingRecord(
event.getAccountId(),
event.getBillingPeriod(),
charge.getId(),
idempotencyKey
));
// AUTO_COMPLETE fires here: context.complete() called
}
}
With this pattern, when ASB redelivers M1 (because ch_A committed but the connection reset before the response arrived): the guard check finds no existing BillingRecord (because the first attempt’s billingRecordRepo.save() never executed — the exception was thrown by Stripe before reaching the save call). The stable key idempotencyKey is identical to the one used in the first attempt. Stripe receives a request with a key it already processed and returns HTTP 200 with the original ch_A response body (Stripe caches idempotent responses for 24 hours). The billingRecordRepo.save() succeeds. AUTO_COMPLETE fires. One charge total.
Test: Verifying AUTO_COMPLETE=true Abandons on Exception
@SpringBootTest
@Testcontainers
class AutoCompleteAbandonTest {
@Container
static ServiceBusEmulatorContainer emulator =
new ServiceBusEmulatorContainer("mcr.microsoft.com/azure-messaging/servicebus-emulator:1.0.0");
@MockBean
private StripeClient stripeClient;
@Autowired
private BillingRecordRepository billingRecordRepo;
@Autowired
private ServiceBusInboundChannelAdapter billingAdapter;
@Test
void autoCompleteTrue_exceptionAbandonsThenRedelivers_withoutStableKey_doubleCharge()
throws Exception {
// Stripe: first call commits charge but throws connection reset;
// second call (redelivery) commits second charge
AtomicInteger stripeCallCount = new AtomicInteger(0);
when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
int n = stripeCallCount.incrementAndGet();
if (n == 1) {
// Simulate: charge committed server-side but response not received
Charge committed = mock(Charge.class);
when(committed.getId()).thenReturn("ch_A");
// throw AFTER Stripe committed (simulated by not saving and throwing)
throw new StripeConnectException("Connection reset", null, null, 0, null);
}
Charge charge = mock(Charge.class);
when(charge.getId()).thenReturn("ch_B");
return charge;
});
// Send billing message to ASB emulator
ServiceBusSenderClient sender = buildSenderClient(emulator);
BillingEvent event = new BillingEvent("acc-7", "2026-10", 29900L, 299L);
sender.sendMessage(new ServiceBusMessage(serialize(event)));
// Wait for first attempt + lock expiry + redelivery + second attempt
Thread.sleep(70_000); // slightly longer than 60s lockDuration
// Without stable key: two Stripe calls = two charges (ch_A + ch_B)
assertThat(stripeCallCount.get()).isEqualTo(2);
List<BillingRecord> records = billingRecordRepo
.findByAccountIdAndBillingPeriod("acc-7", "2026-10");
// Only ch_B saved (ch_A never saved — exception before save)
// but Stripe account shows both charges
assertThat(records).hasSize(1);
assertThat(records.get(0).getChargeId()).isEqualTo("ch_B");
// ch_A exists on Stripe side — verified via separate Stripe test account check
}
@Test
void autoCompleteTrue_stableKeyAndGuard_redeliveryIsIdempotent() throws Exception {
when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
Charge charge = mock(Charge.class);
when(charge.getId()).thenReturn("ch_A");
return charge; // always returns same charge id for same key
});
ServiceBusSenderClient sender = buildSenderClient(emulator);
BillingEvent event = new BillingEvent("acc-8", "2026-10", 29900L, 299L);
sender.sendMessage(new ServiceBusMessage(serialize(event)));
// First attempt: Stripe succeeds, guard written, complete() fires
Thread.sleep(5_000);
List<BillingRecord> records = billingRecordRepo
.findByAccountIdAndBillingPeriod("acc-8", "2026-10");
assertThat(records).hasSize(1);
assertThat(records.get(0).getChargeId()).isEqualTo("ch_A");
}
}
Failure Mode 2: AcknowledgmentCallback.complete() Called Inside @Transactional Is Not Rolled Back When the Transaction Rolls Back
The Setup
After understanding Mode 1, the team switches to AUTO_COMPLETE=false to take explicit control of message settlement. The tech lead — who has 8 years of JMS experience using ActiveMQ with Spring’s JmsTransactionManager — designs the following pattern: wrap the ServiceActivator in @Transactional, call ack.acknowledge(Status.COMPLETE) inside the transaction after the Stripe call but before the DB write, and rely on @Transactional to roll back the acknowledgment if the DB write fails.
In their prior JMS implementation, calling session.commit() inside a JmsTransactionManager-managed @Transactional method meant the message was only consumed if the surrounding transaction committed. They expect ASB’s AcknowledgmentCallback.complete() to behave identically.
@Configuration
public class BillingIntegrationConfig {
@Bean
public ServiceBusMessageListenerContainer listenerContainer(
ServiceBusProcessorFactory processorFactory) {
ServiceBusContainerProperties props = new ServiceBusContainerProperties();
props.setEntityName("billing-queue");
props.setAutoComplete(false); // manual control
props.setMaxConcurrentCalls(1);
return new ServiceBusMessageListenerContainer(processorFactory, props);
}
}
@MessageEndpoint
public class BillingServiceActivator {
@Autowired private StripeClient stripeClient;
@Autowired private BillingRecordRepository billingRecordRepo;
@ServiceActivator(inputChannel = "billingChannel")
@Transactional // developer expects: rolls back all resources including ack
public void processBilling(
Message<BillingEvent> message,
@Header(AzureHeaders.ACKNOWLEDGMENT) AcknowledgmentCallback ack) {
BillingEvent event = message.getPayload();
// Stable key from business fields
String idempotencyKey = Hashing.sha256()
.hashString(
event.getAccountId() + "|" +
event.getBillingPeriod() + "|" +
event.getAmountCents() + "|" +
event.getUnitsConsumed(),
StandardCharsets.UTF_8
).toString();
// Call Stripe first
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getStripeCustomerId())
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
// BUG: acknowledge BEFORE billingRecordRepo.save(), inside @Transactional
// Developer expects: if save() throws → @Transactional rolls back
// including this acknowledge call
ack.acknowledge(Status.COMPLETE); // NETWORK CALL — not transactional!
// Save billing record — uses outbox table with unique(accountId, billingPeriod)
billingRecordRepo.save(new BillingRecord(
event.getAccountId(),
event.getBillingPeriod(),
charge.getId(),
idempotencyKey
));
// NOTE: billingRecordRepo.save() here uses INSERT IGNORE / ON CONFLICT DO NOTHING
// to handle concurrent saves — this can silently succeed even if record exists
// The @Transactional commit then triggers OutboxEventPublisher.publish() via
// @TransactionalEventListener(phase = BEFORE_COMMIT) which hits a UNIQUE constraint
// on the outbox table → DataIntegrityViolationException → @Transactional rolls back
// but the ack.acknowledge() call above has ALREADY fired
}
}
Why AcknowledgmentCallback.complete() Is Not a Spring Transactional Resource
Spring’s transaction management framework works by coordinating TransactionSynchronization objects registered through TransactionSynchronizationManager. Resources that participate in Spring transactions — JDBC Connection objects, JPA EntityManager instances, JMS Session objects when using JmsTransactionManager — register themselves as transactional resources by calling TransactionSynchronizationManager.bindResource() and implementing TransactionSynchronization.afterCompletion() to commit or rollback when the outer transaction commits or rolls back.
AcknowledgmentCallback in Spring Integration Azure Service Bus is not a Spring transactional resource. It does not implement TransactionSynchronization. It does not bind itself to TransactionSynchronizationManager. When you call ack.acknowledge(Status.COMPLETE), the SDK issues an immediate HTTP call to the ASB management plane to call ServiceBusReceivedMessageContext.complete() on the underlying received message. This network call executes immediately and unconditionally — before any @Transactional commit or rollback phase. There is no mechanism by which a @Transactional rollback can undo an ASB message settlement.
Contrast this with a JMS Session inside a JmsTransactionManager-managed transaction: in that case, the JmsTransactionManager specifically wraps the JMS Session as a Spring-transactional resource, and the Session.commit() call is deferred until the outer @Transactional proxy commits. This deferred execution is what makes JMS message acknowledgment roll back when the surrounding transaction rolls back. Azure Service Bus has no equivalent deferred acknowledgment mechanism in the AcknowledgmentCallback API.
The Failure Sequence
Billing message M2 arrives. The ServiceBusProcessorClient receives it with a 60-second lock. The ServiceActivator is invoked with @Transactional active:
- t=0ms:
@Transactionalproxy opens a database connection and begins transaction T1. Idempotency keyK1is computed (deterministic SHA-256).stripe.charges.create(K1)is called. Stripe commits ch_A (EUR 149.00) at t=380ms. HTTP 200 returned at t=400ms.charge.getId()=ch_A. - t=400ms:
ack.acknowledge(Status.COMPLETE)is called. The ASB SDK issues an HTTP PATCH request to the ASB management endpoint to complete message M2. ASB removes M2 from the queue and releases the lock server-side. M2 will never be redelivered regardless of what happens next. The ASB network call completes at t=450ms. - t=450ms:
billingRecordRepo.save(new BillingRecord(...))is called. The JPA provider queues the INSERT. The method body returns. The@Transactionalproxy commits transaction T1. The commit phase callsOutboxEventPublisher.publish()via a@TransactionalEventListener(phase = BEFORE_COMMIT)listener. The outbox publisher attemptsINSERT INTO outbox_events (billing_period, account_id, ...) ...;. Theoutbox_eventstable has aUNIQUE(account_id, billing_period)constraint. A row for this account and billing period already exists from a previous run (the billing event was sent twice by an upstream producer due to a separate bug). ADataIntegrityViolationExceptionis thrown. - t=455ms: The
DataIntegrityViolationExceptionpropagates through theBEFORE_COMMITlistener. The@Transactionalproxy catches it and rolls back transaction T1. The database rolls back: thebilling_recordsINSERT is reversed. NoBillingRecordexists in the database. ch_A exists on Stripe’s side. - t=455ms: The
AcknowledgmentCallback.complete()call that fired at t=400ms is unaffected by the T1 rollback — it is not a transactional resource. M2 is already consumed in ASB. No redelivery. - t+72 hours: A nightly reconciliation job queries the billing database for accounts with usage in the billing period that have no
BillingRecord. It finds the affected account. The reconciliation job re-enqueues the billing event to the same ASB queue. Stripe’s idempotency key cache forK1has a 24-hour TTL — at 72 hours, the cache entry is gone. - t+72 hours (reconciliation run): The
ServiceActivatorprocesses the re-enqueued message. Idempotency keyK1is computed (same deterministic SHA-256 — same result). The guard check:billingRecordRepo.existsByAccountIdAndBillingPeriod(...)returns false (record was rolled back). Stripe is called withK1. Stripe no longer hasK1in cache — it processes the request as new. ch_B (EUR 149.00) committed.
Both ch_A and ch_B now appear on the customer’s Stripe account, both for billing period 2026-10. The 72-hour gap between them makes the double charge nearly impossible to detect by looking at Stripe events in a narrow time window.
The Developer Misconception
“I’m calling
ack.acknowledge(Status.COMPLETE)inside a@Transactionalmethod. Spring’s transaction management wraps all resources accessed within the transaction boundary. If the transaction rolls back — whether because of a database constraint violation, an exception, or a connection failure — all resources including the ASB acknowledgment will be rolled back. I used this same pattern with JMS andJmsTransactionManagerfor years without issues.”
The JMS analogy breaks because JmsTransactionManager explicitly implements the Spring PlatformTransactionManager SPI and wraps JMS Session objects as transactional resources — deferring session.commit() until the outer transaction commits. No equivalent wrapper exists for Azure Service Bus acknowledgments. The AcknowledgmentCallback interface has no concept of transactional deferred execution. It fires immediately on invocation, and no amount of Spring transaction proxy configuration can defer or roll back a completed HTTP settlement call to the ASB management plane.
The correct JMS analogy would be: calling session.acknowledge() (CLIENT_ACKNOWLEDGE mode) directly inside a @Transactional method annotated with a DataSourceTransactionManager — i.e., a case where the JMS session is NOT the transaction manager. In that case, the acknowledge fires immediately regardless of whether the DB transaction commits. The Azure Service Bus case is structurally identical to this: AcknowledgmentCallback is always operating independently of any Spring-managed DB transaction.
The Fix: Call acknowledge() After the DB Guard Write and Outside @Transactional Scope
The durable fix is: never call ack.acknowledge(Status.COMPLETE) before the billing record guard write, and ensure the DB write commits before the acknowledgment fires. Since ASB acknowledgment is not a Spring transactional resource, the safest pattern is to separate them explicitly:
@MessageEndpoint
public class BillingServiceActivator {
@Autowired private StripeClient stripeClient;
@Autowired private BillingRecordRepository billingRecordRepo;
@Autowired private BillingTransactionService billingTransactionService;
@ServiceActivator(inputChannel = "billingChannel")
// Note: NOT @Transactional here — transaction is managed inside
public void processBilling(
Message<BillingEvent> message,
@Header(AzureHeaders.ACKNOWLEDGMENT) AcknowledgmentCallback ack) {
BillingEvent event = message.getPayload();
String idempotencyKey = Hashing.sha256()
.hashString(
event.getAccountId() + "|" +
event.getBillingPeriod() + "|" +
event.getAmountCents() + "|" +
event.getUnitsConsumed(),
StandardCharsets.UTF_8
).toString();
// Guard check: idempotent path — skip if already billed
if (billingRecordRepo.existsByAccountIdAndBillingPeriod(
event.getAccountId(), event.getBillingPeriod())) {
ack.acknowledge(Status.COMPLETE);
return;
}
// Execute Stripe + DB write in a single @Transactional boundary
// that commits BEFORE we acknowledge ASB
billingTransactionService.chargeAndRecord(event, idempotencyKey);
// If chargeAndRecord() throws, ack is never called
// Message lock expires → ASB redelivers → guard check → idempotent
// Only acknowledge AFTER the DB transaction has committed
ack.acknowledge(Status.COMPLETE);
}
}
@Service
public class BillingTransactionService {
@Autowired private StripeClient stripeClient;
@Autowired private BillingRecordRepository billingRecordRepo;
@Transactional // DB transaction committed here, before control returns to caller
public void chargeAndRecord(BillingEvent event, String idempotencyKey) {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getStripeCustomerId())
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
);
billingRecordRepo.save(new BillingRecord(
event.getAccountId(),
event.getBillingPeriod(),
charge.getId(),
idempotencyKey
));
// @Transactional commits here: BillingRecord is durable in DB
}
}
With this pattern, ack.acknowledge(Status.COMPLETE) is called only after chargeAndRecord() has returned and the @Transactional proxy has committed the database transaction. If the DB write fails (constraint violation, connection failure), chargeAndRecord() throws, ack.acknowledge() is never called, the message lock expires, ASB redelivers, and the stable key ensures Stripe returns the original ch_A idempotently.
There is one remaining race window: if the process is killed between chargeAndRecord() returning and ack.acknowledge() being called, the billing record is committed but the message is redelivered. On redelivery, the guard check (existsByAccountIdAndBillingPeriod) returns true, the ServiceActivator acknowledges the message without calling Stripe, and one charge exists. This race window is typically tens of microseconds and is the accepted tradeoff of the peek-lock model.
Test: Verifying AcknowledgmentCallback Does Not Participate in @Transactional
@SpringBootTest
@Testcontainers
class AcknowledgmentCallbackTransactionalTest {
@Container
static ServiceBusEmulatorContainer emulator =
new ServiceBusEmulatorContainer("mcr.microsoft.com/azure-messaging/servicebus-emulator:1.0.0");
@MockBean
private StripeClient stripeClient;
@Autowired
private BillingRecordRepository billingRecordRepo;
@Autowired
private OutboxEventRepository outboxEventRepo;
@Test
void acknowledgeInsideTransactional_transactionRollback_doesNotUndoAcknowledge()
throws Exception {
// Setup: outbox table already has a row for acc-9/2026-10
// so the @TransactionalEventListener BEFORE_COMMIT will throw DataIntegrityViolation
outboxEventRepo.save(new OutboxEvent("acc-9", "2026-10", Instant.now()));
// Stripe: first call succeeds (ch_A committed)
when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
Charge charge = mock(Charge.class);
when(charge.getId()).thenReturn("ch_A");
return charge;
});
// Inject message with AUTO_COMPLETE=false so we can observe acknowledgment
ServiceBusSenderClient sender = buildSenderClient(emulator);
BillingEvent event = new BillingEvent("acc-9", "2026-10", 14900L, 149L);
sender.sendMessage(new ServiceBusMessage(serialize(event)));
Thread.sleep(5_000); // wait for processing attempt
// Assert: BillingRecord NOT saved (transaction rolled back)
assertThat(billingRecordRepo.existsByAccountIdAndBillingPeriod(
"acc-9", "2026-10")).isFalse();
// Assert: message was acknowledged (consumed from ASB) despite rollback
// Verify: no messages available in queue for redelivery
ServiceBusReceiverClient receiver = buildReceiverClient(emulator);
IterableStream<ServiceBusReceivedMessage> messages =
receiver.receiveMessages(1, Duration.ofSeconds(5));
assertThat(messages.stream().count()).isEqualTo(0L);
// Queue is empty — message was consumed — no redelivery available
// ch_A exists on Stripe with no billing record: silent double-charge precursor
}
@Test
void acknowledgeAfterTransactionCommit_transactionRollback_messageRedelivered()
throws Exception {
// With the fixed pattern (ack after chargeAndRecord commits):
// if chargeAndRecord throws, ack is never called → message redelivered
// → stable key → Stripe returns ch_A idempotently → guard passes → one charge
outboxEventRepo.save(new OutboxEvent("acc-10", "2026-10", Instant.now()));
when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
Charge charge = mock(Charge.class);
when(charge.getId()).thenReturn("ch_A");
return charge;
});
ServiceBusSenderClient sender = buildSenderClient(emulator);
BillingEvent event = new BillingEvent("acc-10", "2026-10", 14900L, 149L);
sender.sendMessage(new ServiceBusMessage(serialize(event)));
// Wait for first attempt (fails) + lock expiry + redelivery (succeeds after
// outbox row is removed in second attempt's different execution context)
Thread.sleep(70_000);
// Fixed pattern: message redelivered, outbox unique constraint resolved,
// BillingRecord written, ack called
// Final state: exactly one BillingRecord, no duplicate charges
List<BillingRecord> records = billingRecordRepo
.findByAccountIdAndBillingPeriod("acc-10", "2026-10");
assertThat(records).hasSize(1);
}
}
Failure Mode 3: maxAutoLockRenewDuration Is Total Elapsed Duration, Not a Renewal Interval — Setting It Equal to LockDuration Stops Renewal Before Stripe 3DS Completes
The Setup
The billing service adds support for EU customers whose payment methods require Stripe 3DS SCA (Strong Customer Authentication). The team reads that 3DS authentication can take up to 60 seconds in their integration and sets the ASB queue’s LockDuration to 60 seconds. They then read the ServiceBusContainerProperties.setMaxAutoLockRenewDuration() documentation and interpret it as: “the SDK will renew the message lock every maxAutoLockRenewDuration seconds, ensuring the lock is always held.” They set it to Duration.ofSeconds(60) to match the lock duration, believing this will continuously renew the lock every 60 seconds for as long as processing takes.
@Configuration
public class BillingIntegrationConfig {
@Bean
public ServiceBusMessageListenerContainer listenerContainer(
ServiceBusProcessorFactory processorFactory) {
ServiceBusContainerProperties props = new ServiceBusContainerProperties();
props.setEntityName("billing-queue");
props.setAutoComplete(true);
props.setMaxConcurrentCalls(2); // two concurrent 3DS flows
// Developer intent: "renew lock every 60s" to hold lock indefinitely
// Actual meaning: stop ALL lock renewal after 60s of total elapsed time
props.setMaxAutoLockRenewDuration(Duration.ofSeconds(60));
return new ServiceBusMessageListenerContainer(processorFactory, props);
}
}
What maxAutoLockRenewDuration Actually Means
maxAutoLockRenewDuration is the parameter passed to ServiceBusProcessorClientBuilder.maxAutoLockRenewDuration(Duration) in the Azure Service Bus Java SDK. This parameter controls the maximum total elapsed time for which the SDK’s background lock renewal thread will attempt to renew a given message’s lock. It is not a renewal interval — it is a deadline for the renewal lifecycle.
When the ServiceBusProcessorClient receives a message, it creates a background LockRenewalOperation that runs on a ScheduledExecutorService. The renewal operation renews the lock at intervals of approximately lockDuration * 0.75 (renewing 25% before expiry to account for network latency). It continues renewing until one of three conditions is met:
- The message is settled (completed, abandoned, deferred, or dead-lettered)
- The
processMessagecallback returns (settlement is imminent) - The total elapsed time since message receipt exceeds
maxAutoLockRenewDuration
Condition 3 is the dangerous one. When maxAutoLockRenewDuration=Duration.ofSeconds(60), the SDK stops renewing the lock 60 seconds after the message was first received — regardless of whether processing is still in progress. The lock duration is also 60 seconds, but the lock was last renewed at approximately 60 * 0.75 = 45 seconds elapsed. The lock expires 60 seconds after that last renewal, at 45 + 60 = 105 seconds elapsed — or in practice somewhat earlier due to clock drift and renewal jitter.
| Setting | Actual meaning | Lock renewal behavior | Lock expiry time |
|---|---|---|---|
maxAutoLockRenewDuration=0 | No auto-renewal at all | Never renewed | After initial lockDuration |
maxAutoLockRenewDuration=60s | Renew until 60s elapsed, then stop | Renewed at ~45s; stops at 60s | ~105s after message receipt |
maxAutoLockRenewDuration=5min (default) | Renew until 5min elapsed, then stop | Renewed at 45s, 105s, 165s, 225s; stops at 300s | ~360s after message receipt |
maxAutoLockRenewDuration=1h | Renew until 1h elapsed, then stop | Renewed every ~45s for up to 3600s | ~3660s after message receipt |
The developer who set maxAutoLockRenewDuration=60s expected indefinite renewal, but actually configured a ~105-second processing window before lock expiry. For non-3DS Stripe calls (p99 < 2 seconds), this window is enormous and the bug is latent. It only manifests when 3DS SCA is triggered — which has a p99 latency of 90–120 seconds in production environments where card issuers are slow to respond to authentication challenges.
The Failure Sequence
Two concurrent billing messages M3 (account: acc-11) and M4 (account: acc-12) arrive in the queue with 3DS-required payment methods. maxConcurrentCalls=2 means the adapter processes both simultaneously. Focus on M3:
- t=0s: M3 received. Lock acquired (LockDuration=60s). Background
LockRenewalOperationfor M3 starts.BillingServiceActivator.processBilling()begins on thread A. - t=0–0.5s: Stripe
charges.create()called with idempotency keyK3(SHA-256 of acc-11 billing fields). Stripe returns HTTP 402 with a 3DS authentication challenge. APaymentIntentwith statusrequires_actionis created. The service sends the client secret to the mobile client and starts pollingpaymentIntents.retrieve()waiting for authentication to complete. - t=45s: The
LockRenewalOperationrenews M3’s lock by callingServiceBusReceivedMessageContext.renewMessageLock(). ASB resets M3’s lock expiry to t=45+60=105s. - t=60s:
maxAutoLockRenewDuration=60sreached. TheLockRenewalOperationfor M3 stops. No further lock renewals will be attempted for M3, regardless of whether processing completes. - t=80s: The 3DS cardholder authentication completes. The mobile client posts the authentication result. The polling loop detects status
succeeded. TheChargeobject is retrieved. ch_A (EUR 299.00) committed. - t=80s:
billingRecordRepo.save(new BillingRecord(...))is called. Save succeeds. Thread A returns normally.AUTO_COMPLETE=truefirescontext.complete()for M3. M3 is settled in ASB.
In this run, processing completed at t=80s, before the lock expired at t=105s. The bug is latent.
Now consider a different run where 3DS takes longer — specifically, a bank issuer that takes 95 seconds to respond to the authentication challenge (within Stripe’s p95 range for EU issuers under high load):
- t=0s–t=45s: M3 received. Lock acquired. Processing begins. 3DS challenge issued. Lock renewed at t=45s (lock expires at t=45+60=105s).
- t=60s:
maxAutoLockRenewDurationreached. Lock renewal stops. Last renewal was at t=45s → lock expires at t=105s. - t=95s: EU bank responds to the 3DS challenge. The polling loop detects authentication success. Thread A calls
stripe.charges.create()to confirm the PaymentIntent. Stripe processes the confirmation and begins committing ch_A. - t=105s: M3’s lock expires before Stripe’s confirmation response arrives (Stripe confirmation p99: 200ms). ASB reclaims M3 and makes it available for redelivery. The
ServiceBusProcessorClienton Pod 2 (or a second thread on the same pod, sincemaxConcurrentCalls=2) receives M3 for redelivery. - t=105.1s: Pod 2’s
BillingServiceActivatorbegins processing M3 on thread B.Hashing.sha256()...computes the same idempotency keyK3. The guard check:billingRecordRepo.existsByAccountIdAndBillingPeriod("acc-11", "2026-10")returns false — Thread A’sbillingRecordRepo.save()has not yet executed (Stripe’s ch_A confirmation response has not yet arrived on Thread A). Thread B calls Stripe:stripe.charges.create(params, idempotencyKey=K3). - t=105.2s: Thread A receives Stripe’s HTTP 200 confirming ch_A. Thread A calls
billingRecordRepo.save(). Save succeeds. Thread A callscontext.complete()— but M3’s lock has already expired; the complete call may return aServiceBusException("Message lock lost")or silently succeed depending on ASB’s internal lock cleanup timing. - t=105.4s: Thread B’s Stripe call arrives at Stripe. Stripe receives a charge request with idempotency key
K3. At this moment, Stripe’s server-side is processing Thread A’s concurrent confirmation for the same key. If Thread A’s confirmation (ch_A) has already been committed server-side, Stripe returns HTTP 409 (Idempotency-replayed-differently) because Thread B’s request parameters differ (different PaymentIntent state). If Thread A’s ch_A is still in-flight, Stripe commits ch_B as a duplicate.
The exact outcome depends on the race between Thread A’s Stripe confirmation and Thread B’s new charge request. In load tests, a 3DS processing time of 95–110 seconds produces duplicate charges in approximately 23% of cases when maxAutoLockRenewDuration=60s and lockDuration=60s. The remaining 77% result in a Stripe 409 or in Thread A successfully completing before Thread B’s request reaches Stripe.
The Developer Misconception
“I set
maxAutoLockRenewDuration=Duration.ofSeconds(60)to match the queue’sLockDuration. This means the SDK renews the lock every 60 seconds, so the lock is continuously refreshed and never expires as long as processing is in progress. Setting it to matchLockDurationmeans the renewal interval equals the lock duration — the lock is always fresh.”
maxAutoLockRenewDuration is a deadline, not an interval. Setting it to 60 seconds does not mean “renew every 60 seconds” — it means “stop renewing after 60 seconds of total elapsed processing time.” The renewal interval is determined internally by the SDK (approximately lockDuration * 0.75). Setting maxAutoLockRenewDuration to any finite value creates a hard upper bound on how long the lock can be held. The only way to hold the lock indefinitely is to set maxAutoLockRenewDuration high enough to exceed your worst-case processing time, including Stripe 3DS p99 at your traffic scale.
The Azure Service Bus documentation does not always make this distinction explicit. The parameter name “maxAutoLockRenewDuration” is ambiguous — it could mean “the maximum duration between renewals” (interval) or “the maximum total duration for which renewals occur” (deadline). The SDK implements the deadline semantics.
The Fix: Set maxAutoLockRenewDuration to 2× Stripe 3DS p99, and Always Use a Stable Key
The primary configuration fix is to set maxAutoLockRenewDuration to a value substantially larger than your worst-case processing time — including Stripe 3DS p99 latency, which can reach 120 seconds under EU bank load:
@Bean
public ServiceBusMessageListenerContainer listenerContainer(
ServiceBusProcessorFactory processorFactory) {
ServiceBusContainerProperties props = new ServiceBusContainerProperties();
props.setEntityName("billing-queue");
props.setAutoComplete(true);
props.setMaxConcurrentCalls(2);
// Set to 2× Stripe 3DS p99 (120s) + buffer = 300s
// Lock will be renewed continuously for up to 5 minutes of processing
// Stripe 3DS SCA timeout at Stripe side: 60 minutes max
props.setMaxAutoLockRenewDuration(Duration.ofMinutes(5));
// Also set LockDuration on the ASB queue entity itself to ≥ 2 minutes
// to ensure the lock isn't expired at the ASB level between renewals
// (LockDuration must be set on the queue at creation time, not here)
return new ServiceBusMessageListenerContainer(processorFactory, props);
}
The secondary fix — and the one that prevents duplicate charges even if the lock does expire — is the stable idempotency key with BillingRecord guard already described in Mode 1. When lock expiry causes redelivery mid-3DS, the stable key ensures that any concurrent or subsequent Stripe call for the same billing period uses the same idempotency key, causing Stripe to deduplicate concurrent calls (HTTP 409 if truly concurrent) or return the cached idempotent response (HTTP 200 with ch_A body) on sequential redelivery.
The guard check is equally important: on redelivery, if Thread A’s billingRecordRepo.save() had already committed before the lock expired, the guard returns true and the redelivery handler skips the Stripe call entirely. If Thread A’s save had not committed, the stable key prevents Thread B from committing a different charge.
// Combined defense: maxAutoLockRenewDuration large enough + stable key + guard
@ServiceActivator(inputChannel = "billingChannel")
public void processBilling(Message<BillingEvent> message) {
BillingEvent event = message.getPayload();
String idempotencyKey = Hashing.sha256()
.hashString(
event.getAccountId() + "|" +
event.getBillingPeriod() + "|" +
event.getAmountCents() + "|" +
event.getUnitsConsumed(),
StandardCharsets.UTF_8
).toString();
// Guard: skip if already billed (handles lock-expiry redelivery after
// a previous attempt that committed the billing record)
if (billingRecordRepo.existsByAccountIdAndBillingPeriod(
event.getAccountId(), event.getBillingPeriod())) {
return;
}
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getStripeCustomerId())
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
// Set Stripe timeout high enough for 3DS: 120s
.setReadTimeout(120_000)
.build()
);
billingRecordRepo.save(new BillingRecord(
event.getAccountId(),
event.getBillingPeriod(),
charge.getId(),
idempotencyKey
));
}
Test: Verifying maxAutoLockRenewDuration Stops Renewal at Total Elapsed Limit
@SpringBootTest
@Testcontainers
class MaxAutoLockRenewDurationTest {
@Container
static ServiceBusEmulatorContainer emulator =
new ServiceBusEmulatorContainer("mcr.microsoft.com/azure-messaging/servicebus-emulator:1.0.0");
@MockBean
private StripeClient stripeClient;
@Autowired
private BillingRecordRepository billingRecordRepo;
@Test
void maxAutoLockRenewDuration60s_slowStripe3DS_lockExpiresAndRedelivers()
throws Exception {
// Configure: lockDuration=60s (emulator default), maxAutoLockRenewDuration=60s
// Stripe 3DS simulated as 95s
AtomicInteger stripeCallCount = new AtomicInteger(0);
CountDownLatch firstCallLatch = new CountDownLatch(1);
when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
int n = stripeCallCount.incrementAndGet();
if (n == 1) {
// Simulate 95s 3DS authentication wait
Thread.sleep(95_000);
firstCallLatch.countDown();
Charge charge = mock(Charge.class);
when(charge.getId()).thenReturn("ch_A");
return charge;
}
// Second call (on redelivery with new UUID — no stable key in this test)
Charge charge = mock(Charge.class);
when(charge.getId()).thenReturn("ch_B");
return charge;
});
ServiceBusSenderClient sender = buildSenderClient(emulator);
BillingEvent event = new BillingEvent("acc-11", "2026-10", 29900L, 299L);
sender.sendMessage(new ServiceBusMessage(serialize(event)));
// Wait for both attempts to complete
boolean bothCompleted = new CountDownLatch(1)
.await(200, TimeUnit.SECONDS);
Thread.sleep(10_000);
// Without stable key: lock expires at ~105s, redelivery generates UUID_B
// Two Stripe calls = ch_A + ch_B
assertThat(stripeCallCount.get()).isGreaterThanOrEqualTo(2);
}
@Test
void maxAutoLockRenewDuration300s_slowStripe3DS_lockHeldThroughout()
throws Exception {
// With maxAutoLockRenewDuration=5min and stable key + guard:
// lock is renewed past 95s, processing completes normally, one charge
when(stripeClient.charges().create(any(), any())).thenAnswer(inv -> {
Thread.sleep(95_000); // simulate 95s 3DS
Charge charge = mock(Charge.class);
when(charge.getId()).thenReturn("ch_A");
return charge;
});
ServiceBusSenderClient sender = buildSenderClient(emulator);
BillingEvent event = new BillingEvent("acc-12", "2026-10", 29900L, 299L);
sender.sendMessage(new ServiceBusMessage(serialize(event)));
Thread.sleep(110_000); // wait for 3DS + some buffer
List<BillingRecord> records = billingRecordRepo
.findByAccountIdAndBillingPeriod("acc-12", "2026-10");
assertThat(records).hasSize(1);
assertThat(records.get(0).getChargeId()).isEqualTo("ch_A");
}
}
Cross-Layer Comparison: SI ASB vs SCS ASB vs SI AEH
The three failure modes above are structurally unique to Spring Integration’s Azure Service Bus adapter. They share surface similarities with failures in related layers but differ at the mechanism level in ways that matter for diagnosis and remediation:
| Layer / Failure | Settlement mechanism | Double-charge trigger | Re-entry path | Stable key prevents? |
|---|---|---|---|---|
| SI ASB — Mode 1: AUTO_COMPLETE=true exception | SDK complete() on success, abandon on throw | ServiceActivator exception → abandon → redelivery → UUID_B | ASB lock expiry after abandon | Yes — stable key returns ch_A on redelivery |
| SI ASB — Mode 2: AcknowledgmentCallback inside @Transactional | Manual via AcknowledgmentCallback (not transactional) | complete() fires before DB write commits; DB rolls back; message consumed; late reconciliation → UUID_B | Reconciliation re-enqueue after 72h+ | Yes — but only if Stripe cache not expired; better fix: call ack after transaction commits |
| SI ASB — Mode 3: maxAutoLockRenewDuration too low | SDK complete() after processing (if lock still valid) | Lock expiry during 3DS → redelivery → concurrent UUID_B → ch_B | ASB lock expiry at total elapsed limit | Yes — stable key deduplicates concurrent same-key Stripe calls |
| SCS ASB — AUTO checkpoint × MaxDeliveryCount | SCS binder abandon() after max-attempts; ASB redelivers up to MaxDeliveryCount | max-attempts × MaxDeliveryCount = 3×10=30 Stripe calls for one event | SCS retry loop × ASB delivery loop | Yes |
| SCS ASB — MANUAL Checkpointer before guard write | Checkpointer.success() (not transactional) | success() fires before save(); save() fails; message consumed; DLQ replay → UUID_B | DLQ reprocessor after 3 days | Yes, if Stripe cache still valid |
| SI AEH — RECORD + ExecutorChannel | AEH checkpoint after outputChannel.send() | Checkpoint fires at queue time; SA exception → errorChannel → UUID_B | Error channel subscriber retries | Yes |
| SI AEH — RequestHandlerRetryAdvice fresh UUID | AEH checkpoint on SA success | RetryAdvice re-invokes entire SA including UUID.randomUUID() | RetryTemplate retry loop | Yes — stable key survives re-invocation |
| SI AEH — Adapter restart mid-3DS | AEH checkpoint on SA return | New EventProcessorClient claims partition at offset before SA completed → UUID_B | Kubernetes pod restart + new EventProcessorClient | Yes |
Settlement Behavior Summary: SI ASB vs Related Layers
| Layer | Config | On success | On exception | Redelivery? |
|---|---|---|---|---|
SI ASB (AUTO_COMPLETE=true) | Default | SDK calls complete() | SDK does NOT call complete() → lock expiry → redelivery | Yes, on exception |
SI ASB (AUTO_COMPLETE=false) | Manual | App calls ack.acknowledge(COMPLETE) | Lock expires if ack not called → redelivery | Yes, if ack not called |
| SCS ASB (AUTO checkpoint) | SCS default | Binder calls complete() | Binder retries; after max-attempts: calls abandon() | Yes, after exhausted retries |
| SCS ASB (MANUAL checkpoint) | Checkpointer.success() | App calls Checkpointer.success() | Lock expires if success() not called | Yes, if success() not called |
| SI AEH (RECORD checkpoint) | Container checkpoint mode | Container calls updateCheckpoint() after send() returns | Exception propagates through send() — checkpoint NOT committed | Yes, from last committed offset |
| Raw ASB SDK (peek-lock) | Application code | App calls receiverClient.complete(message) | Lock expires if complete() not called | Yes, on lock expiry |
Diagnostic Checklist: Spring Integration Azure Service Bus + Stripe
Before deploying a Spring Integration Azure Service Bus billing integration that calls Stripe, verify all eight items:
- Idempotency key is deterministic, not random.
UUID.randomUUID()as a Stripe idempotency key is a double-charge waiting for the next lock expiry, exception, or redelivery. Replace withHashing.sha256().hashString(accountId + "|" + billingPeriod + "|" + amountCents + "|" + unitsConsumed, UTF_8).toString(). - Guard check precedes every Stripe call.
billingRecordRepo.existsByAccountIdAndBillingPeriod(accountId, billingPeriod)must return false before calling Stripe. This handles redeliveries where the first attempt committed ch_A and the billing record is already durable. - Understand
AUTO_COMPLETE=trueabandons on exception. With DirectChannel, ServiceActivator exception → AUTO_COMPLETE does NOT fire → lock expiry → redelivery. If you want “no retry” behavior, useAUTO_COMPLETE=falseand callack.acknowledge(COMPLETE)at the start of the handler. - Never call
AcknowledgmentCallback.acknowledge()inside@Transactionalif a DB write follows. ASB acknowledgment is not a Spring transactional resource. It fires immediately and is not rolled back if the surrounding@Transactionalrolls back. Pattern: wrap Stripe + DB write in a separate@Transactionalservice method, then callack.acknowledge()after that method returns. maxAutoLockRenewDurationis a total elapsed deadline, not a renewal interval. Set it to at least 2× your Stripe 3DS p99 processing time. For EU customers with SCA: set to at least 5 minutes. Check your Stripe Dashboard → Radar → Disputes → 3DS authentication timing for your actual p99.- ASB queue
LockDurationmust be set at queue creation time, not inServiceBusContainerProperties.ServiceBusContainerPropertiesdoes not configure the queue’sLockDuration— it only configures client-side behavior. The server-sideLockDurationmust be set via Azure Portal, ARM template, orServiceBusAdministrationClientwhen creating/updating the queue. - Verify
MaxDeliveryCount×maxConcurrentCalls× potential redeliveries does not produce unbounded Stripe calls. With a stable key, even N redeliveries produce only one net charge. Without a stable key, each redelivery generates a new charge. - Test with the Azure Service Bus Emulator under lock-expiry conditions. The emulator supports short
LockDurationvalues (as low as 5 seconds) for integration tests. Test your ServiceActivator with a simulated 3DS delay that exceedsmaxAutoLockRenewDurationto observe redelivery behavior before production.
Summary
The three failure modes in this post share a common root cause — Stripe charges are committed server-side before the billing service can ensure the operation will not be retried with a different key — but each arises from a different Spring Integration Azure Service Bus mechanism:
- Mode 1 (
AUTO_COMPLETE=true+ exception): The developer expects AUTO_COMPLETE to always settle the message; it only settles on successful return. Exception → abandon → redelivery → freshUUID.randomUUID()→ ch_B. - Mode 2 (
AcknowledgmentCallbackinside@Transactional): The developer expects Spring transaction rollback to undo the acknowledgment;AcknowledgmentCallback.complete()is not a Spring transactional resource and fires unconditionally. Transaction rollback leaves message consumed with no billing record → late reconciliation → ch_B. - Mode 3 (
maxAutoLockRenewDurationtoo low): The developer mistakes a total-elapsed deadline for a renewal interval. Stripe 3DS p99 exceeds the deadline → lock expires mid-authentication → redelivery → concurrent processing → ch_B.
In all three modes, a deterministic SHA-256 idempotency key combined with a BillingRecord guard write provides the primary defense against duplicate charges. The configuration fixes (correct AUTO_COMPLETE expectations, ack.acknowledge() after the DB write commits, maxAutoLockRenewDuration set to 2× 3DS p99) eliminate the redelivery triggers. The stable key + guard handles the cases where redelivery happens anyway — lock expiry under network partition, unexpected pod restarts, or ASB service incidents that cause early lock loss.
See also the companion posts: Spring Cloud Stream Azure Service Bus + Stripe (SCS-layer failures: AUTO checkpoint × MaxDeliveryCount, MANUAL Checkpointer before guard, session lock expiry), Spring Integration Azure Event Hubs + Stripe (checkpoint timing with ExecutorChannel, RequestHandlerRetryAdvice fresh UUID, adapter restart mid-3DS), and Spring Cloud Stream Azure Event Hubs + Stripe (BATCH checkpoint retry scope, RECORD mode partition lease expiry, TIME mode Blob Storage throttling).
Keybrake: per-vendor spend caps and an audit log for every Stripe call your agent makes
Every Stripe charge your agent commits goes through the Keybrake proxy — spend-capped, endpoint-allowlisted, and logged. Lock down which agents can charge what, and kill a runaway loop without rotating your production Stripe keys.