Spring Boot @EventListener + @Retryable and Stripe Integration: How UUID in @EventListener Method Body, @TransactionalEventListener Proxy Bypass, and Retry-by-Republish Pattern Generate Duplicate Charges or Idempotency Conflicts

Spring’s @EventListener and @Retryable annotations interact in ways that produce three distinct Stripe billing failure modes: UUID.randomUUID() placed inside the body of an @EventListener + @Retryable listener method re-evaluates when @Retryable re-invokes the method from scratch after a transient StripeException — the same event object is received again as the parameter, but the UUID in the method body regenerates — UUID_B — ch_B alongside already-committed ch_A; @TransactionalEventListener delivers the event via TransactionSynchronization.afterCommit(), which invokes the listener method directly on the target bean using Java reflection — bypassing the Spring AOP proxy — so @Retryable’s interceptor is never in the call stack — StripeException propagates unretried — and the developer’s subsequent manual retry loop generates a new UUID.randomUUID() per iteration, introducing the duplicate-charge bug that a stable key would have prevented; and a retry-by-republish pattern where the listener catches StripeException and calls applicationEventPublisher.publishEvent(new BillingRequestedEvent(...)) to retry through the event bus — the republished new BillingRequestedEvent() constructor calls UUID.randomUUID() — UUID_B — ch_B alongside committed ch_A.

Background: how @EventListener, @TransactionalEventListener, and Spring AOP proxies compose with @Retryable

Spring’s application event mechanism is built around ApplicationEventMulticaster, implemented by SimpleApplicationEventMulticaster by default. When a bean method is annotated with @EventListener, ApplicationListenerMethodAdapter wraps it and registers the wrapper with the multicaster at context startup. When ApplicationEventPublisher.publishEvent(event) is called, the multicaster iterates over registered listeners that match the event type and invokes each listener.

The multicaster holds a reference to the Spring-managed bean that owns the listener method. For a bean annotated with @Component or @Service, the reference stored in the application context is the Spring AOP proxy, not the raw target object. This means that when the multicaster invokes the @EventListener method through the proxy reference, the proxy’s interceptor chain fires as it would for any inter-bean call. An @EventListener method annotated with @Retryable will have @Retryable’s RetryOperationsInterceptor in the proxy’s interceptor chain, and it will fire correctly when the listener throws a matching exception.

@TransactionalEventListener is different. When a method is annotated with @TransactionalEventListener (the default phase is AFTER_COMMIT), Spring registers a TransactionalApplicationListenerMethodAdapter as the ApplicationListener. During event publication, if a transaction is active, the adapter does not invoke the listener immediately; instead, it registers a TransactionSynchronization object with TransactionSynchronizationManager. The synchronization object captures a reference to the event and to the listener invocation target. When the active transaction commits, Spring calls TransactionSynchronization.afterCommit() on each registered synchronization. The synchronization object’s afterCommit() method calls the listener method via Java reflection directly on the target bean instance — not through the proxy. The proxy reference is not used at this point in the call chain. The result: any AOP interceptors configured on the listener method — including @Retryable’s RetryOperationsInterceptor — are not in the call stack. @Retryable is silently bypassed, exactly as it is bypassed when a @Scheduled method calls a @Retryable method on the same bean via this.

@Retryable’s own annotation processing also requires @EnableRetry on a @Configuration class. Without it, @Retryable is silently ignored regardless of how the method is called. With @EnableRetry present and a correct cross-bean call path, @Retryable fires for @EventListener invocations but not for @TransactionalEventListener’s deferred afterCommit() invocations.

These two invocation paths — one through the proxy (standard @EventListener), one bypassing it (@TransactionalEventListener’s afterCommit()) — create two structurally distinct failure modes. A third failure mode arises not from the annotation machinery but from a common ad-hoc retry pattern: when a listener catches StripeException and attempts to “retry” by republishing the event through ApplicationEventPublisher.

Failure mode 1: UUID.randomUUID() inside @EventListener + @Retryable method body — @Retryable re-invokes listener with same event object — UUID regenerates — UUID_B — ch_B

The developer builds an event-driven billing system. A BillingRequestedEvent is published whenever a subscription becomes due. An event listener handles the event by charging the customer on Stripe. To handle transient Stripe errors, the developer annotates the listener method with both @EventListener and @Retryable. The cross-bean invocation path is correct: the multicaster invokes the listener through the proxy, so @Retryable is active. The bug is UUID placement.

// BillingRequestedEvent.java — event object carrying billing intent.
// Note: no idempotency key field. The developer chose to generate the key in the listener.
public class BillingRequestedEvent extends ApplicationEvent {
    private final String customerId;
    private final String planId;
    private final long amountCents;
    private final String billingPeriod;  // e.g. "2026-10"

    public BillingRequestedEvent(Object source, String customerId, String planId,
                                  long amountCents, String billingPeriod) {
        super(source);
        this.customerId = customerId;
        this.planId = planId;
        this.amountCents = amountCents;
        this.billingPeriod = billingPeriod;
    }

    public String getCustomerId() { return customerId; }
    public String getPlanId() { return planId; }
    public long getAmountCents() { return amountCents; }
    public String getBillingPeriod() { return billingPeriod; }
}

// BillingEventListener.java — UNSAFE: UUID inside @Retryable @EventListener method body.
// Developer intent: "Each billing event generates a unique idempotency key."
// Developer assumption: "The listener fires once per event, so UUID.randomUUID() evaluates once."
// Actual behavior: @Retryable re-invokes the listener method body from scratch on StripeException.
// On attempt 2, UUID.randomUUID() evaluates again — UUID_B — ch_B alongside committed ch_A.
@Component
@EnableRetry
public class BillingEventListener {

    @Autowired
    private StripeClient stripeClient;

    @EventListener
    @Retryable(
        retryFor = { StripeException.class, SocketTimeoutException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1500, multiplier = 2.0)
    )
    public void onBillingRequested(BillingRequestedEvent event) throws StripeException {
        // BUG: UUID generated here, inside the @Retryable listener method body.
        // The same event object is passed to this method on every @Retryable attempt.
        // But UUID.randomUUID() is called inside the method body, not read from the event.
        // @Retryable re-invokes this method from the first line on each retry attempt.
        // UUID.randomUUID() evaluates fresh on every attempt — UUID_B on attempt 2 — ch_B.
        String idempotencyKey = event.getCustomerId() + ":billing:"
            + event.getPlanId() + ":" + UUID.randomUUID();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(event.getAmountCents())
            .setCurrency("usd")
            .setCustomer(event.getCustomerId())
            .putMetadata("plan_id", event.getPlanId())
            .putMetadata("billing_period", event.getBillingPeriod())
            .build();

        stripeClient.charges().create(params,
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build());
    }
}

The failure sequence when Stripe commits the charge on attempt 1 but a transient network error prevents the response from arriving at the client:

  1. The subscription service calls applicationEventPublisher.publishEvent(new BillingRequestedEvent(this, "cust_xyz", "pro-annual", 11988L, "2026-10")). The SimpleApplicationEventMulticaster invokes BillingEventListener.onBillingRequested(event) through the Spring AOP proxy on BillingEventListener.
  2. The RetryOperationsInterceptor wraps the call (attempt 1). Inside the method body: idempotencyKey = "cust_xyz:billing:pro-annual:" + UUID_A. The charge request is sent to Stripe with Idempotency-Key: cust_xyz:billing:pro-annual:UUID_A.
  3. Stripe processes the request, commits ch_A = "ch_3P...", charges $119.88. A SocketTimeoutException fires before the 200 OK reaches the client. From Stripe’s perspective, the charge was committed.
  4. The SocketTimeoutException propagates to the RetryOperationsInterceptor. It matches SocketTimeoutException.class in retryFor. The interceptor waits 1,500 ms and re-invokes the entire onBillingRequested method body from the first statement (attempt 2). The same BillingRequestedEvent object is passed as the parameter — event.getCustomerId() returns "cust_xyz", same as before.
  5. Inside the method body on attempt 2: UUID.randomUUID() evaluates again. idempotencyKey = "cust_xyz:billing:pro-annual:" + UUID_B. UUID_B is a different value.
  6. Stripe receives a charge request with Idempotency-Key: cust_xyz:billing:pro-annual:UUID_B. Stripe has no record of UUID_B. It treats this as a new charge request and commits ch_B = "ch_4Q...". Customer cust_xyz is billed $119.88 twice in October.

The developer’s error is conflating the event object’s identity with the UUID generation site. The event is the same object on every retry — @Retryable does not recreate the event; it re-executes the method body with the same arguments. But UUID.randomUUID() is not reading a value from the event; it is computing a new value independently each time the line is reached. The event being the same object provides no stability guarantee for code that does not read from it.

The fix for failure mode 1

Move idempotency key generation into the event object at publication time, before publishEvent() is called. The event object is created once and its state is fixed at construction. Because @Retryable passes the same event object to the listener on all retry attempts, the key stored in the event is stable across all attempts.

// BillingRequestedEvent.java — SAFE: idempotency key computed at construction time.
// The key is part of the event's immutable state. All @Retryable retries read the same value.
public class BillingRequestedEvent extends ApplicationEvent {
    private final String customerId;
    private final String planId;
    private final long amountCents;
    private final String billingPeriod;
    private final String idempotencyKey;  // computed once, at event creation

    public BillingRequestedEvent(Object source, String customerId, String planId,
                                  long amountCents, String billingPeriod) {
        super(source);
        this.customerId = customerId;
        this.planId = planId;
        this.amountCents = amountCents;
        this.billingPeriod = billingPeriod;
        // Content-hash key: stable across JVM restarts and across multiple event object
        // instances representing the same billing intent. Prefer this over UUID.randomUUID()
        // even in the constructor: if the event is re-published after a JVM crash, the
        // content-hash key produces the same string for the same inputs, allowing Stripe
        // to deduplicate the re-attempt against the already-committed charge.
        this.idempotencyKey = "billing:" + customerId + ":" + planId + ":" + billingPeriod;
    }

    public String getIdempotencyKey() { return idempotencyKey; }
    // ... other getters
}

// BillingEventListener.java — SAFE: key read from event, not generated in method body.
@Component
public class BillingEventListener {

    @Autowired
    private StripeClient stripeClient;

    @EventListener
    @Retryable(
        retryFor = { StripeException.class, SocketTimeoutException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1500, multiplier = 2.0)
    )
    public void onBillingRequested(BillingRequestedEvent event) throws StripeException {
        // Key read from the event object: same value on every @Retryable attempt
        // because the same event object is passed each time.
        String idempotencyKey = event.getIdempotencyKey();

        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(event.getAmountCents())
            .setCurrency("usd")
            .setCustomer(event.getCustomerId())
            .putMetadata("plan_id", event.getPlanId())
            .putMetadata("billing_period", event.getBillingPeriod())
            .build();

        stripeClient.charges().create(params,
            RequestOptions.builder()
                .setIdempotencyKey(idempotencyKey)
                .build());
    }
}

Content-hash keys are preferable to UUID.randomUUID() even when placed correctly in the event constructor. A UUID key generated in the constructor is stable within one JVM run: the same event object is passed across all retries, so the UUID does not change between attempts. But if the publisher creates a new BillingRequestedEvent for the same billing intent (after a JVM crash and restart, or as part of a retry-by-republish pattern described in failure mode 3 below), the new event object’s constructor generates a new UUID — UUID_B — and Stripe sees a new idempotency key, potentially committing a duplicate charge. A content-hash key derived from customerId + ":" + planId + ":" + billingPeriod produces the same string from the same business inputs regardless of which event object computes it, making it safe across JVM restarts and across separate event object instances.

Failure mode 2: @TransactionalEventListener + @Retryable — afterCommit() bypasses the AOP proxy — @Retryable silently never fires — developer manual retry loop generates UUID_B — ch_B

The developer upgrades the billing event listener to use @TransactionalEventListener with the default AFTER_COMMIT phase. The motivation is correct: the billing event should only trigger a Stripe charge after the subscription record has been committed to the database by the publishing transaction. Publishing the event inside a @Transactional service method and handling it in a @TransactionalEventListener(phase = AFTER_COMMIT) method guarantees that the Stripe charge is not attempted if the subscription record write rolls back. The developer also adds @Retryable to the listener, expecting the same retry behavior as in failure mode 1’s correct setup. The behavior is different.

// SubscriptionService.java — publishes the event inside a @Transactional method.
// The @TransactionalEventListener will receive the event after this transaction commits.
@Service
public class SubscriptionService {

    @Autowired
    private SubscriptionRepository subscriptionRepository;

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @Transactional
    public void activateSubscription(String customerId, String planId,
                                      long amountCents, String billingPeriod) {
        Subscription sub = new Subscription(customerId, planId, amountCents, billingPeriod);
        subscriptionRepository.save(sub);

        // Event is captured by the @TransactionalEventListener at this point.
        // The listener is NOT called immediately — it is deferred to AFTER_COMMIT.
        // When this @Transactional method returns and the transaction commits,
        // Spring will call the listener's afterCommit() synchronization callback.
        eventPublisher.publishEvent(
            new BillingRequestedEvent(this, customerId, planId, amountCents, billingPeriod));
    }
}

// BillingEventListener.java — UNSAFE: @TransactionalEventListener + @Retryable.
// @Retryable is silently ignored: afterCommit() calls the listener method via direct
// reflection on the raw target bean, not through the Spring AOP proxy.
// @Retryable's RetryOperationsInterceptor is not in the call stack. StripeException
// propagates unhandled out of the listener into the TransactionSynchronization framework.
@Component
public class BillingEventListener {

    @Autowired
    private StripeClient stripeClient;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Retryable(
        retryFor = { StripeException.class, SocketTimeoutException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 2000, multiplier = 2.0)
    )
    public void onBillingRequestedAfterCommit(BillingRequestedEvent event) throws StripeException {
        // Developer intent: "The subscription record is committed. Now charge Stripe.
        // @Retryable will retry if Stripe returns a transient error."
        // Actual: @Retryable's interceptor is not in the call stack. This annotation is
        // effectively ignored. The method runs once. If StripeException is thrown, it
        // propagates to TransactionSynchronizationManager, which logs it and swallows it.
        String idempotencyKey = event.getCustomerId() + ":billing:"
            + event.getPlanId() + ":" + event.getBillingPeriod();

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

        stripeClient.charges().create(params,
            RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
    }
}

What happens at runtime when Stripe is unavailable:

  1. activateSubscription() saves the subscription record. The @Transactional proxy commits the transaction. During commit, Spring calls TransactionSynchronization.afterCommit() on the synchronization object registered by TransactionalApplicationListenerMethodAdapter.
  2. The synchronization object’s afterCommit() invokes onBillingRequestedAfterCommit(event) via Method.invoke(targetBean, event) — Java reflection on the raw BillingEventListener bean instance. The call does not pass through the CGLIB proxy. The RetryOperationsInterceptor registered by @Retryable on the proxy’s method interceptor chain is not in the call stack.
  3. Stripe is unavailable. stripeClient.charges().create() throws StripeException. The exception propagates out of onBillingRequestedAfterCommit() into afterCommit(). AbstractPlatformTransactionManager’s commit sequence catches the synchronization exception via invokeAfterCommit(): it logs a DEBUG line (“TransactionSynchronization.afterCommit threw exception”) and continues processing remaining synchronizations. No re-attempt. No caller notification. The @Retryable annotation did nothing.
  4. The developer discovers that no charges are collected after transient Stripe outages. Looking at the code, they see @Retryable and expect it to retry. They add logging and confirm no retry occurs. They conclude the annotation “doesn’t work with @TransactionalEventListener” and add a manual retry loop.
// BillingEventListener.java — UNSAFE manual retry workaround inside @TransactionalEventListener.
// Developer adds a for-loop after discovering @Retryable doesn't fire.
// BUG: New UUID.randomUUID() on each loop iteration — UUID_B on attempt 2 — ch_B.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onBillingRequestedAfterCommit(BillingRequestedEvent event) {
    for (int attempt = 1; attempt <= 3; attempt++) {
        try {
            // BUG: UUID generated per loop iteration, not per event.
            // On attempt 1: UUID_A → request sent → Stripe commits ch_A → SocketTimeoutException.
            // On attempt 2: UUID_B → request sent → Stripe sees new key → commits ch_B.
            // Customer is charged twice.
            String idempotencyKey = event.getCustomerId() + ":billing:"
                + event.getPlanId() + ":" + event.getBillingPeriod()
                + ":" + UUID.randomUUID();  // different UUID every iteration

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

            stripeClient.charges().create(params,
                RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
            return;  // success

        } catch (StripeException e) {
            log.warn("Stripe attempt {} failed: {}", attempt, e.getMessage());
            if (attempt == 3) {
                log.error("All Stripe attempts failed for customer {}", event.getCustomerId());
            }
            try { Thread.sleep(2000L * attempt); } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }
}

The manual loop re-introduces the duplicate-charge bug that a stable key was intended to prevent. The developer moved the UUID generation inside the loop, reasoning that a new attempt is conceptually a new request and therefore needs a new key. But Stripe’s idempotency system works in the opposite direction: a retry of a previously-committed charge must present the same key so that Stripe returns the prior committed result rather than processing a new charge. Using a new UUID per attempt on a retry after a committed attempt produces two committed charges.

The fix for failure mode 2

The @TransactionalEventListener method cannot use @Retryable directly because of the proxy bypass. The correct pattern is to extract the Stripe charge call into a separate @Service bean annotated with @Retryable, inject that service into the @TransactionalEventListener method, and call it as a cross-bean call. The cross-bean call from the listener method to the service traverses the service bean’s AOP proxy, and @Retryable’s interceptor fires correctly on StripeException.

// BillingChargeService.java — SAFE: @Retryable on a separate @Service bean.
// Calls to this bean's chargeCustomer() method from outside beans traverse the proxy.
// @Retryable's RetryOperationsInterceptor fires on StripeException.
@Service
public class BillingChargeService {

    @Autowired
    private StripeClient stripeClient;

    @Retryable(
        retryFor = { StripeException.class, SocketTimeoutException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 2000, multiplier = 2.0)
    )
    public void chargeCustomer(String customerId, String planId,
                                long amountCents, String idempotencyKey) throws StripeException {
        // Key received as parameter: same value on every @Retryable attempt.
        ChargeCreateParams params = ChargeCreateParams.builder()
            .setAmount(amountCents)
            .setCurrency("usd")
            .setCustomer(customerId)
            .putMetadata("plan_id", planId)
            .build();

        stripeClient.charges().create(params,
            RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
    }

    @Recover
    public void recoverFromStripeFailure(StripeException ex, String customerId,
                                          String planId, long amountCents,
                                          String idempotencyKey) {
        log.error("All Stripe retries exhausted for customer {} plan {} key {}: {}",
            customerId, planId, idempotencyKey, ex.getMessage());
        // Alert, dead-letter queue, incident creation, etc.
    }
}

// BillingEventListener.java — SAFE: @TransactionalEventListener delegates to @Retryable service.
// The cross-bean call to billingChargeService.chargeCustomer() traverses the proxy.
// @Retryable fires on StripeException inside chargeCustomer().
@Component
public class BillingEventListener {

    @Autowired
    private BillingChargeService billingChargeService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onBillingRequestedAfterCommit(BillingRequestedEvent event) {
        // Generate the key ONCE in the listener, before calling the @Retryable service.
        // Content-hash: stable across retries and across republished events.
        String idempotencyKey = "billing:" + event.getCustomerId()
            + ":" + event.getPlanId() + ":" + event.getBillingPeriod();

        // Cross-bean call: traverses the BillingChargeService proxy.
        // @Retryable fires inside chargeCustomer() on StripeException.
        billingChargeService.chargeCustomer(
            event.getCustomerId(), event.getPlanId(),
            event.getAmountCents(), idempotencyKey);
    }
}

One additional nuance: @TransactionalEventListener(phase = AFTER_COMMIT) runs after the publishing transaction has committed, which means the listener method itself is not running inside an active transaction. If chargeCustomer() in BillingChargeService needs to write a charge record to the database on success (e.g., updating the subscription’s last-charged timestamp), the @Retryable service method must either open its own transaction with @Transactional or the listener must handle the database write separately. Adding @Transactional to the @Retryable service method is valid; Spring processes the transaction and retry interceptors as an ordered chain on the proxy, so both fire correctly for cross-bean calls.

Failure mode 3: retry-by-republish pattern — new BillingRequestedEvent object in catch block — constructor calls UUID.randomUUID() — UUID_B — ch_B alongside committed ch_A

Some architectures use the application event bus as the primary retry mechanism. Instead of adding @Retryable to the listener, the developer catches StripeException inside the listener body and publishes a new event to trigger another billing attempt. The motivation is coherent: re-routing the retry through the event bus allows all event listeners — audit listeners, notification listeners, metrics listeners — to observe the retry attempt as a separate event, providing a complete record of all billing attempts in the event stream. The bug appears when the idempotency key is tied to the event object’s identity rather than to the business intent behind the event.

// BillingRequestedEvent.java — UNSAFE idempotency key in constructor via UUID.randomUUID().
// Every call to new BillingRequestedEvent(...) generates a fresh UUID.
// When the listener re-publishes the event as a retry, the new event object has a new UUID.
public class BillingRequestedEvent extends ApplicationEvent {
    private final String customerId;
    private final String planId;
    private final long amountCents;
    private final String billingPeriod;
    private final String idempotencyKey;  // generated at construction — different per instance
    private final int attemptNumber;

    public BillingRequestedEvent(Object source, String customerId, String planId,
                                  long amountCents, String billingPeriod, int attemptNumber) {
        super(source);
        this.customerId = customerId;
        this.planId = planId;
        this.amountCents = amountCents;
        this.billingPeriod = billingPeriod;
        this.attemptNumber = attemptNumber;
        // BUG: UUID.randomUUID() here — different value for every BillingRequestedEvent instance.
        // The event object on the original attempt has UUID_A.
        // The republished event object has UUID_B — a different instance.
        this.idempotencyKey = UUID.randomUUID().toString();
    }

    public String getIdempotencyKey() { return idempotencyKey; }
    public int getAttemptNumber() { return attemptNumber; }
    // ... other getters
}

// BillingEventListener.java — UNSAFE retry-by-republish pattern.
// On StripeException, the listener publishes a new BillingRequestedEvent.
// The new event object's constructor calls UUID.randomUUID() — UUID_B — ch_B.
@Component
public class BillingEventListener {

    @Autowired
    private StripeClient stripeClient;

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    private static final int MAX_ATTEMPTS = 3;

    @EventListener
    public void onBillingRequested(BillingRequestedEvent event) {
        String idempotencyKey = event.getIdempotencyKey();  // reads UUID from this event object

        try {
            ChargeCreateParams params = ChargeCreateParams.builder()
                .setAmount(event.getAmountCents())
                .setCurrency("usd")
                .setCustomer(event.getCustomerId())
                .putMetadata("plan_id", event.getPlanId())
                .putMetadata("billing_period", event.getBillingPeriod())
                .putMetadata("attempt", String.valueOf(event.getAttemptNumber()))
                .build();

            stripeClient.charges().create(params,
                RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());

        } catch (StripeException e) {
            log.warn("Stripe charge failed on attempt {} for customer {}: {}",
                event.getAttemptNumber(), event.getCustomerId(), e.getMessage());

            if (event.getAttemptNumber() < MAX_ATTEMPTS) {
                // Retry via re-publish: a new BillingRequestedEvent is created.
                // BUG: new event object → new UUID.randomUUID() in constructor → UUID_B.
                // If ch_A was committed on attempt 1 before the StripeException, the
                // republished event carries UUID_B, which Stripe has never seen.
                // Stripe processes it as a new charge: ch_B alongside ch_A.
                BillingRequestedEvent retryEvent = new BillingRequestedEvent(
                    this,
                    event.getCustomerId(),
                    event.getPlanId(),
                    event.getAmountCents(),
                    event.getBillingPeriod(),
                    event.getAttemptNumber() + 1  // increments attempt counter
                );
                eventPublisher.publishEvent(retryEvent);
            } else {
                log.error("All {} attempts exhausted for customer {}",
                    MAX_ATTEMPTS, event.getCustomerId());
            }
        }
    }
}

The failure sequence when Stripe commits the first charge but the response does not arrive:

  1. The publisher creates BillingRequestedEvent(source, "cust_abc", "enterprise", 49900L, "2026-10", 1). In the constructor, UUID.randomUUID() generates UUID_A. event1.getIdempotencyKey() == "UUID_A".
  2. The multicaster invokes onBillingRequested(event1). idempotencyKey = UUID_A. The charge request is sent to Stripe with Idempotency-Key: UUID_A.
  3. Stripe commits ch_A = "ch_3R...", charges $499.00. A SocketTimeoutException wraps a StripeException before the 200 OK arrives. ch_A is committed on Stripe’s side; the client received an exception.
  4. The catch (StripeException) block runs. Attempt 1 of 3. A new event is created: new BillingRequestedEvent(this, "cust_abc", "enterprise", 49900L, "2026-10", 2). The constructor calls UUID.randomUUID() — UUID_B. event2.getIdempotencyKey() == "UUID_B".
  5. eventPublisher.publishEvent(event2). The multicaster invokes onBillingRequested(event2). idempotencyKey = UUID_B. Stripe receives a charge request with Idempotency-Key: UUID_B. UUID_B is new to Stripe. Stripe processes it as a new charge and commits ch_B = "ch_4S...". Customer cust_abc is billed $499.00 twice in October.

The developer’s mental model: “Each billing attempt is a separate event with a separate UUID. Stripe treats each UUID as a unique request. On a retry, I want Stripe to process a new charge in case the first one failed.” This is correct reasoning for a scenario where the first attempt definitely did not commit. It is wrong for the scenario where the first attempt committed but the acknowledgment was lost — the exactly-once delivery failure mode that idempotency keys are designed to protect against. When a transient network error hides a committed charge, the idempotency key must be the same on the retry so Stripe returns the prior committed result.

The deeper issue: the developer built the retry mechanism to handle both “the charge failed” and “the charge committed but I didn’t receive the response” scenarios with the same code path. A new UUID serves the first scenario but causes a duplicate in the second. The idempotency key is precisely the mechanism that differentiates the two scenarios on Stripe’s side, and the code discards it by generating a new key per event object.

The fix for failure mode 3

Decouple the idempotency key from the event object’s identity. Use a content-hash key derived from immutable business data that represents the billing intent: the tuple of (customerId, planId, billingPeriod). This key is identical for the original event and any republished event with the same billing intent, regardless of how many BillingRequestedEvent objects are constructed.

// BillingRequestedEvent.java — SAFE: content-hash key, independent of object identity.
public class BillingRequestedEvent extends ApplicationEvent {
    private final String customerId;
    private final String planId;
    private final long amountCents;
    private final String billingPeriod;
    private final int attemptNumber;

    public BillingRequestedEvent(Object source, String customerId, String planId,
                                  long amountCents, String billingPeriod, int attemptNumber) {
        super(source);
        this.customerId = customerId;
        this.planId = planId;
        this.amountCents = amountCents;
        this.billingPeriod = billingPeriod;
        this.attemptNumber = attemptNumber;
        // No UUID in constructor. Key is computed deterministically from business data.
    }

    // Content-hash key: same value for every BillingRequestedEvent representing the same
    // billing intent, regardless of which event object computes it or when.
    // Stable across JVM restarts, retry-by-republish, and @Retryable re-invocations.
    public String getIdempotencyKey() {
        return "billing:" + customerId + ":" + planId + ":" + billingPeriod;
    }

    public int getAttemptNumber() { return attemptNumber; }
    // ... other getters
}

// BillingEventListener.java — SAFE retry-by-republish: same key on all attempts.
@Component
public class BillingEventListener {

    @Autowired
    private StripeClient stripeClient;

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    private static final int MAX_ATTEMPTS = 3;

    @EventListener
    public void onBillingRequested(BillingRequestedEvent event) {
        // Content-hash key: same value whether this is the original event or a republished retry.
        // "billing:cust_abc:enterprise:2026-10" is the same string for all attempts.
        // Stripe returns the prior committed ch_A on the retry → no duplicate charge.
        String idempotencyKey = event.getIdempotencyKey();

        try {
            ChargeCreateParams params = ChargeCreateParams.builder()
                .setAmount(event.getAmountCents())
                .setCurrency("usd")
                .setCustomer(event.getCustomerId())
                .putMetadata("plan_id", event.getPlanId())
                .putMetadata("billing_period", event.getBillingPeriod())
                .build();

            stripeClient.charges().create(params,
                RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());

        } catch (StripeException e) {
            if (event.getAttemptNumber() < MAX_ATTEMPTS) {
                // Republished event: new object, but getIdempotencyKey() returns the same
                // content-hash string — "billing:cust_abc:enterprise:2026-10" — because
                // customerId, planId, and billingPeriod are the same business values.
                // Stripe sees the same key and returns the prior committed charge if it exists.
                BillingRequestedEvent retryEvent = new BillingRequestedEvent(
                    this, event.getCustomerId(), event.getPlanId(),
                    event.getAmountCents(), event.getBillingPeriod(),
                    event.getAttemptNumber() + 1);
                // Add backoff before republish
                sleepBackoff(event.getAttemptNumber());
                eventPublisher.publishEvent(retryEvent);
            } else {
                log.error("All attempts exhausted for customer {} plan {} period {}",
                    event.getCustomerId(), event.getPlanId(), event.getBillingPeriod());
            }
        }
    }

    private void sleepBackoff(int attempt) {
        try { Thread.sleep(1500L * attempt); }
        catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
    }
}

Note that the retry-by-republish pattern is synchronous in the default SimpleApplicationEventMulticaster configuration: eventPublisher.publishEvent(retryEvent) synchronously invokes onBillingRequested(retryEvent) on the same thread before returning. The recursion depth equals the number of retry attempts, which is bounded by MAX_ATTEMPTS. If the event multicaster is configured with an AsyncTaskExecutor (making event delivery asynchronous), the retry-by-republish call returns immediately and the retry runs on a separate thread — changing the timing semantics but not the idempotency key correctness with the content-hash fix.

Cross-mode structural distinctions

Mode Root cause Symptom Detection difficulty
1: UUID in @EventListener + @Retryable body UUID.randomUUID() in method body; same event object passed on every retry but key regenerates independently Duplicate charges (ch_B on retry) Requires a transient Stripe error to manifest; never triggered in tests with stubbed-success stubs
2: @TransactionalEventListener proxy bypass afterCommit() invokes listener via reflection, bypassing proxy; @Retryable interceptor absent from call stack; developer workaround generates UUID per loop iteration No retry at all on transient errors; duplicate charges from manual loop workaround @Retryable silently no-ops; symptoms appear only in production under Stripe transient failures; log level for sync exception is DEBUG
3: Retry-by-republish with UUID in event constructor Retry path creates new event object; new constructor call generates new UUID Duplicate charges (ch_B on first retry after a committed-but-unacknowledged charge) Requires a specific failure scenario (committed charge, lost acknowledgment); UUID-per-event design intention feels natural

Mode 1 and Mode 3 both result in duplicate charges via UUID_B, but through different mechanisms. Mode 1 generates UUID_B because @Retryable re-executes the method body containing UUID.randomUUID() — the UUID generation is in the wrong scope relative to the retry boundary. Mode 3 generates UUID_B because a new event object is constructed for the retry — the UUID generation is tied to object instantiation rather than to business intent. Both are fixed by content-hash keys derived from business-immutable data.

Mode 2 is structurally different. It is a proxy composition failure that silences @Retryable entirely, similar in mechanism to the @Scheduled self-invocation failure described in the prior post on @Scheduled + @Retryable. In both cases, the annotation that implements retry is present in the code but not active at runtime because the call does not traverse the AOP proxy. The path to the duplicate-charge bug in Mode 2 is indirect: the developer’s workaround for the broken retry introduces the bad UUID generation pattern, not the original annotation placement.

Test patterns

Mode 1: confirm idempotency key is stable across @Retryable retries of the @EventListener

// BillingEventListenerRetryTest.java — verifies key stability across @Retryable attempts.
@SpringBootTest
@EnableRetry
class BillingEventListenerRetryTest {

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    private WireMockServer wireMock;
    private List<String> capturedKeys;

    @BeforeEach
    void setUp() {
        wireMock = new WireMockServer(WireMockConfiguration.options().port(18080));
        wireMock.start();
        capturedKeys = Collections.synchronizedList(new ArrayList<>());

        // Stub: first request returns 503 (transient error); second returns 200 with charge.
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("stripe-retry")
            .whenScenarioStateIs(Scenario.STARTED)
            .willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
            .willSetStateTo("attempt-2"));

        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("stripe-retry")
            .whenScenarioStateIs("attempt-2")
            .willReturn(aResponse().withStatus(200).withBody(
                "{\"id\":\"ch_test\",\"object\":\"charge\",\"amount\":11988}")));

        // Capture Idempotency-Key header from all requests
        wireMock.addMockServiceRequestListener((req, resp) ->
            capturedKeys.add(req.getHeader("Idempotency-Key")));
    }

    @Test
    void eventListenerRetryableUsesStableIdempotencyKey() {
        // The event's idempotency key must be stable regardless of retry count.
        // With the fix (key in event object), this passes.
        // With the bug (UUID in method body), capturedKeys will have two distinct values.
        BillingRequestedEvent event = new BillingRequestedEvent(
            this, "cust_test", "pro-annual", 11988L, "2026-10");

        eventPublisher.publishEvent(event);

        // Two HTTP requests reached the mock: attempt 1 (503) and attempt 2 (200).
        assertThat(capturedKeys).hasSize(2);

        // CRITICAL: both requests must use the same idempotency key.
        // If this fails, UUID.randomUUID() is in the method body.
        assertThat(new HashSet<>(capturedKeys)).hasSize(1);
        assertThat(capturedKeys.get(0)).isEqualTo(capturedKeys.get(1));
    }

    @AfterEach
    void tearDown() { wireMock.stop(); }
}

Mode 2: confirm that @Retryable does not fire for @TransactionalEventListener

// TransactionalEventListenerRetryBehaviorTest.java — confirms @Retryable is bypassed.
// This test documents the failure so the team understands why the fix uses a separate service.
@SpringBootTest
@EnableRetry
@Transactional
class TransactionalEventListenerRetryBehaviorTest {

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @Autowired
    private BillingChargeService billingChargeService;  // the @Retryable service (fixed version)

    private WireMockServer wireMock;
    private AtomicInteger requestCount;

    @BeforeEach
    void setUp() {
        wireMock = new WireMockServer(WireMockConfiguration.options().port(18081));
        wireMock.start();
        requestCount = new AtomicInteger(0);

        // All requests return 503 — with correct @Retryable, 3 attempts; without, 1 attempt.
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .willReturn(aResponse().withStatus(503)
                .withBody("{\"error\":{\"type\":\"api_error\"}}")));

        wireMock.addMockServiceRequestListener((req, resp) -> requestCount.incrementAndGet());
    }

    @Test
    void billingChargeServiceRetriesThreeTimes() throws Exception {
        // BillingChargeService uses @Retryable with maxAttempts=3.
        // Called via cross-bean call: proxy is traversed; @Retryable fires.
        // All 3 attempts return 503: WireMock should see 3 requests.
        assertThatThrownBy(() -> billingChargeService.chargeCustomer(
                "cust_test", "pro-annual", 11988L,
                "billing:cust_test:pro-annual:2026-10"))
            .isInstanceOf(StripeException.class);

        assertThat(requestCount.get()).isEqualTo(3);  // 3 attempts via @Retryable
    }
}

Mode 3: confirm content-hash key is stable across retry-by-republish

// RetryByRepublishIdempotencyTest.java — verifies content-hash key is same across republished events.
@SpringBootTest
class RetryByRepublishIdempotencyTest {

    @Test
    void contentHashKeyIsStableAcrossNewEventObjects() {
        // Two separate BillingRequestedEvent objects for the same billing intent.
        // Original event (attempt 1):
        BillingRequestedEvent originalEvent = new BillingRequestedEvent(
            this, "cust_abc", "enterprise", 49900L, "2026-10", 1);

        // Republished event (attempt 2) — new object, same business data:
        BillingRequestedEvent retryEvent = new BillingRequestedEvent(
            this, "cust_abc", "enterprise", 49900L, "2026-10", 2);

        // With content-hash key: same string from both objects.
        assertThat(originalEvent.getIdempotencyKey())
            .isEqualTo(retryEvent.getIdempotencyKey());

        assertThat(originalEvent.getIdempotencyKey())
            .isEqualTo("billing:cust_abc:enterprise:2026-10");

        // With UUID-in-constructor (the bug): UUIDs differ — this assertion would fail.
        // assertThat(originalEvent.getIdempotencyKey())
        //     .isEqualTo(retryEvent.getIdempotencyKey());  // FAILS: UUID_A != UUID_B
    }
}

Comparison with related Spring posts on this site

This post’s failure modes are distinct from all prior posts in this series. The Spring @Scheduled + @Retryable post covers three analogous modes but driven by the scheduler’s execution model: UUID in a @Retryable service method body called from @Scheduled; self-invocation proxy bypass in @Scheduled; and stale singleton instance fields across consecutive scheduled executions. The present post’s Mode 2 (@TransactionalEventListener proxy bypass) is mechanistically similar to that post’s Mode 2 (self-invocation bypass), but the call chain differs: in @Scheduled self-invocation, this.method() bypasses the proxy because this is the raw target; in @TransactionalEventListener, the proxy bypass happens in the framework layer via Method.invoke(targetBean, ...) inside TransactionSynchronization.afterCommit().

The Spring @Transactional + @Async post covers TransactionSynchronizationManager state not being inherited across @Async thread boundaries, and thenApply() lambdas in CompletableFuture chains regenerating UUIDs. The present post’s Mode 2 involves TSM in a different way: the @TransactionalEventListener deferral mechanism uses TSM to register synchronizations, but the proxy bypass is in the synchronization callback, not in TSM thread-local inheritance.

The Spring Retry post covers the basic @Retryable UUID-in-body pattern in a simple synchronous context. The present post’s Mode 1 is the same root cause but in an event-driven context where the distinction between “UUID stability within one event delivery” and “UUID stability within one @Retryable method invocation” is the crux of the confusion. The event object being the same across retries is the misleading signal: the developer assumes same-event-object implies stable UUID, but the UUID is in the method body, not in the event object.

Mode 3 (retry-by-republish) has no equivalent in any prior post. It is a pattern-level failure rather than an annotation-composition failure: it arises not from how Spring AOP proxies compose but from an architectural choice to use event republication as a retry mechanism, combined with a UUID generation pattern that ties key generation to object instantiation rather than to business intent. The fix — content-hash keys derived from immutable business data — is the same fix recommended across all prior posts, but the reason it is necessary here is different: multiple separate event objects represent the same billing intent, and the key must be the same for all of them.

Keybrake — idempotency enforcement for Stripe API calls

Keybrake intercepts your outbound Stripe requests, validates that every charge and payment-intent carries a stable idempotency key, and blocks requests that would generate a duplicate charge. Catches UUID-in-retry-body bugs, UUID-in-event-constructor bugs, and retry-by-republish mismatches before they reach Stripe.