Spring Boot @Transactional + Feign Client and Stripe Integration: How @Retryable/@Transactional Interceptor Ordering, REQUIRES_NEW Service Retry, and RetryTemplate Lambda Scope Generate New Idempotency Keys
When a Spring Boot service is annotated with both @Retryable and @Transactional, calls a Feign client for Stripe, and computes UUID.randomUUID() inside the method body, three structurally distinct retry mechanisms each silently generate a new idempotency key — and Stripe creates a second charge.
The combination of @Transactional and Feign clients for Stripe is common in billing services: the developer wants to wrap Stripe calls in a database transaction so that a failed charge rolls back any audit or subscription record writes that preceded it. The combination is sensible — but it introduces idempotency-key failure modes that do not exist when using the Stripe Java SDK directly without @Transactional, because Spring’s AOP proxy infrastructure controls which proxy is outermost, and the outermost proxy determines what counts as a single “attempt.”
A prior post on Feign and Spring Cloud OpenFeign Stripe Integration covered three Feign-specific failure modes: RequestInterceptor.apply() computing UUID.randomUUID() per Feign-level retry attempt, Resilience4j @Retry at the call site compounding Feign’s own Retryer.Default, and @Scheduled multi-pod TOCTOU races. Those modes focus on the Feign-layer and Resilience4j-layer retry mechanics. This post covers a distinct set: the interaction between Spring’s @Transactional AOP proxy and the retry mechanisms that wrap or nest @Transactional service methods that call Feign clients for Stripe.
Background: how Spring AOP proxy ordering affects @Retryable and @Transactional
When a Spring bean method is annotated with both @Retryable and @Transactional, Spring creates two AOP proxies around the bean. The proxies are applied in order number order: the proxy with the lower Ordered value wraps the bean from the outside; the proxy with the higher value wraps the bean from the inside. This is the opposite of intuition (“higher value = outer”) — in Spring’s ordering model, lower number means higher precedence, meaning it intercepts first.
Spring Retry’s RetryConfiguration — the BeanPostProcessor that creates the @Retryable proxy — implements Ordered with Ordered.LOWEST_PRECEDENCE - 5, which equals 2,147,483,642. TransactionInterceptor, which backs the @Transactional proxy, uses Ordered.LOWEST_PRECEDENCE by default, which equals 2,147,483,647. Because 2,147,483,642 < 2,147,483,647, @Retryable is the outer proxy and @Transactional is the inner proxy.
The practical consequence: when a method annotated with both throws an exception, execution unwinds through the inner @Transactional proxy first — which rolls back the database transaction — and then through the outer @Retryable proxy, which decides whether to retry. On a retry, @Retryable calls the method body again. The @Transactional proxy opens a fresh transaction for the new invocation. Any UUID.randomUUID() call in the method body executes again — producing UUID_B.
This ordering is invisible from the method signature. Adding @Transactional to a @Retryable service method — or adding @Retryable to a @Transactional service method — does not change which proxy is outer. The order numbers are fixed by the Spring framework. The only way to change the ordering is to explicitly set an @Order annotation on one or both interceptors, which most applications do not do.
Mode 1: @Retryable (outer) + @Transactional (inner) — UUID in service method body — FeignException → rollback → @Retryable re-invokes method — UUID_B — ch_B
The most direct failure mode. A billing service method is annotated with both @Retryable (to retry on transient Feign errors) and @Transactional (to roll back database writes if Stripe fails). UUID.randomUUID() is computed at the start of the method body to generate a per-call idempotency key. The Feign client is explicitly configured with Feign.Retryer.NEVER_RETRY — the developer has already learned that combining Feign-level retry with @Retryable-level retry produces multiplicative retry attempts and has disabled the Feign-level retry. But disabling Feign retry does not prevent @Retryable from re-invoking the method body on failure.
// BillingService.java
@Service
public class BillingService {
private final StripeChargeClient stripeChargeClient; // Feign client interface
private final BillingAuditRepository auditRepository;
// @Retryable is the OUTER proxy (order 2,147,483,642).
// @Transactional is the INNER proxy (order 2,147,483,647).
// @Retryable re-invokes the entire method body on retry.
// @Transactional opens a new transaction for each @Retryable invocation.
@Retryable(
include = FeignException.ServiceUnavailable.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
@Transactional(rollbackFor = FeignException.class)
public String chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// UUID computed at method entry — inside @Retryable scope.
// Re-evaluates on every @Retryable retry attempt.
String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE
// DB write: mark billing attempt as pending.
auditRepository.save(
new BillingAudit(customerId, billingPeriod, idempotencyKey, PENDING));
// Feign call to Stripe. Feign.Retryer is NEVER_RETRY on this client.
// All retry responsibility delegated to @Retryable.
ChargeResponse response = stripeChargeClient.charge(
new ChargeRequest(customerId, amountCents, "usd"), idempotencyKey);
// Update audit record with returned charge ID.
auditRepository.updateChargeId(customerId, billingPeriod, response.getId());
return response.getId();
}
}
// FeignClient configuration: Feign-level retry disabled.
@FeignClient(
name = "stripe-charge",
url = "${stripe.api.base-url}",
configuration = StripeClientConfig.class
)
public interface StripeChargeClient {
@PostMapping("/v1/charges")
ChargeResponse charge(@RequestBody ChargeRequest body,
@RequestHeader("Idempotency-Key") String idempotencyKey);
}
@Configuration
public class StripeClientConfig {
@Bean
public Retryer retryer() {
// Explicitly disable Feign-level retry to avoid Feign × @Retryable compounding.
return Retryer.NEVER_RETRY;
}
}
The failure sequence: the @Retryable proxy intercepts the call to chargeCustomer(). It delegates to the @Transactional proxy, which opens a database transaction. The method body executes: UUID.randomUUID() generates UUID_A. The audit record is written as PENDING with UUID_A. The Feign client calls Stripe’s /v1/charges endpoint with Idempotency-Key: UUID_A. Stripe receives the request, creates ch_A, and attempts to return the response. At the network layer, a SocketTimeoutException occurs before the response is received. Feign, configured with NEVER_RETRY, wraps the exception in FeignException.ServiceUnavailable and throws it.
The exception propagates to the @Transactional proxy. Because the proxy is configured with rollbackFor = FeignException.class, it rolls back the transaction — the PENDING audit record with UUID_A is removed from the database. The exception propagates to the @Retryable proxy. FeignException.ServiceUnavailable matches the include list. @Retryable waits the backoff delay, then calls the method body again.
Second invocation: the @Transactional proxy opens a new transaction. The method body executes again. UUID.randomUUID() generates UUID_B. A new PENDING audit record is written with UUID_B. Feign calls Stripe with Idempotency-Key: UUID_B. Stripe has no record of UUID_B — it recognises UUID_A as the key for ch_A, but UUID_B is a completely different request — and creates ch_B. The response arrives successfully. The @Transactional proxy commits, writing ch_B to the audit record. The customer has been charged twice: ch_A from the first attempt (which Stripe committed before the timeout) and ch_B from the retry.
The developer’s reasoning: “Feign retry is disabled, so there is only one Stripe call per @Retryable attempt.” This is correct. The trap is that @Retryable attempts are not Feign-level retries — they are full method-body re-invocations. Each @Retryable attempt calls the entire chargeCustomer() method body from the first line, including the UUID.randomUUID() call at the top.
The @Transactional(rollbackFor) contribution
The rollbackFor = FeignException.class configuration makes the transaction-level behaviour correct — the database does not record a charge that was never confirmed — but it does not make the idempotency-key behaviour correct. The @Transactional proxy can only roll back database writes. It cannot undo the Stripe charge that was committed at Stripe’s server before the timeout. The two systems — the database and Stripe’s ledger — are not in the same distributed transaction. After rollback, the database has no record of UUID_A or ch_A. Stripe does. The combination produces the worst possible outcome: a Stripe charge with no corresponding database record.
Fix for mode 1
Compute the idempotency key outside the @Retryable/@Transactional scope — either from a stable, pre-computed caller parameter or as a content-hash derived from immutable billing intent data. The key must be the same on every @Retryable invocation of the method, which means it must be computed before the method is entered, or derived from parameters that do not change across retry attempts:
// SAFE: content-hash key computed from stable billing intent parameters.
// Same value on every @Retryable re-invocation with the same arguments.
@Retryable(
include = FeignException.ServiceUnavailable.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
@Transactional(rollbackFor = FeignException.class)
public String chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// Content-hash key: deterministic from (customerId, amountCents, billingPeriod).
// @Retryable re-invokes this method with the same arguments — same key.
String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
auditRepository.save(
new BillingAudit(customerId, billingPeriod, idempotencyKey, PENDING));
ChargeResponse response = stripeChargeClient.charge(
new ChargeRequest(customerId, amountCents, "usd"), idempotencyKey);
auditRepository.updateChargeId(customerId, billingPeriod, response.getId());
return response.getId();
}
When chargeCustomer("cus_abc", 4999, "2026-10") is called the first time, idempotencyKey is "charge:cus_abc:4999:2026-10". When @Retryable re-invokes the method after rolling back the first attempt, the arguments are the same — customerId, amountCents, and billingPeriod are unchanged — and the key evaluates to "charge:cus_abc:4999:2026-10" again. Stripe receives the same key on the retry and returns the existing ch_A without creating ch_B.
The billingPeriod component in the key ensures that October 2026 billing ("2026-10") produces a different key from November 2026 billing ("2026-11"), even for the same customer and amount. Stripe processes each period’s charge as an independent request. Removing billingPeriod from the key would cause Stripe to treat November billing as a duplicate of October billing and return the October charge — not the intended behaviour.
Mode 2: @Transactional(REQUIRES_NEW) service called from outer @Transactional try-catch retry loop — each REQUIRES_NEW invocation is a new method call — UUID regenerates — UUID_B — ch_B
The second failure mode does not involve @Retryable at all. Instead, the retry is explicit — a try-catch loop in an outer @Transactional orchestration method that catches FeignException from an inner service method and calls the inner method again. The inner service method is annotated with @Transactional(propagation = REQUIRES_NEW) to isolate per-customer billing failures: the developer wants a failed Stripe call for customer A to roll back only the work done for customer A, not the entire batch orchestration transaction that has already written audit records for customers already processed.
The isolation is architecturally correct — REQUIRES_NEW is the right tool for isolating per-entity operations within a larger orchestration transaction. The idempotency failure arises because REQUIRES_NEW means every call to the inner service method is a new method invocation, and UUID.randomUUID() inside the inner method body generates a new UUID on every invocation.
// BillingOrchestrationService.java
@Service
public class BillingOrchestrationService {
private final StripeChargeService stripeChargeService;
private final BillingAuditRepository auditRepository;
@Transactional // outer REQUIRED transaction: tracks batch-level orchestration
public void runMonthlyBillingBatch(List<Customer> pendingCustomers,
int amountCents, String billingPeriod) {
for (Customer customer : pendingCustomers) {
boolean charged = false;
for (int attempt = 0; attempt < 3 && !charged; attempt++) {
try {
// Each call to chargeCustomer() opens a NEW transaction
// (REQUIRES_NEW), suspending the outer REQUIRED transaction.
String chargeId = stripeChargeService.chargeCustomer(
customer, amountCents, billingPeriod);
// On success: inner transaction committed, outer transaction
// records the success in its own batch log.
auditRepository.recordBatchSuccess(
customer.getId(), billingPeriod, chargeId);
charged = true;
} catch (FeignException e) {
if (attempt == 2) {
auditRepository.recordBatchFailure(
customer.getId(), billingPeriod);
}
// Fall through — inner loop retries by calling chargeCustomer() again.
}
}
}
}
}
// StripeChargeService.java
@Service
public class StripeChargeService {
private final StripeChargeClient stripeChargeClient;
private final BillingAuditRepository auditRepository;
// REQUIRES_NEW: each call opens an independent transaction.
// Isolates per-customer failures from the outer orchestration transaction.
@Transactional(propagation = REQUIRES_NEW,
rollbackFor = FeignException.class)
public String chargeCustomer(Customer customer, int amountCents,
String billingPeriod) {
// UUID generated at method entry — inside REQUIRES_NEW scope.
// REQUIRES_NEW = new method call = UUID re-evaluates on every call.
String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE
auditRepository.save(new BillingAudit(
customer.getId(), billingPeriod, idempotencyKey, PENDING));
ChargeResponse response = stripeChargeClient.charge(
new ChargeRequest(customer.getStripeCustomerId(), amountCents, "usd"),
idempotencyKey);
auditRepository.updateChargeId(customer.getId(), billingPeriod, response.getId());
return response.getId();
}
}
The failure sequence for a single customer: the outer orchestration loop calls stripeChargeService.chargeCustomer(customer, amountCents, billingPeriod) on attempt 0. The REQUIRES_NEW proxy suspends the outer transaction and opens a new independent transaction. The method body executes: UUID.randomUUID() generates UUID_A. The audit record is written with UUID_A. The Feign client calls Stripe with Idempotency-Key: UUID_A. Stripe creates ch_A. The response is lost in transit. FeignException.ServiceUnavailable is thrown. The REQUIRES_NEW transaction is rolled back — the audit record with UUID_A is removed. The exception propagates to the outer try-catch loop. attempt is 0, so the loop does not record failure yet — it tries again.
Attempt 1: the outer loop calls chargeCustomer() again. The REQUIRES_NEW proxy opens a fresh transaction. The method body runs from the start. UUID.randomUUID() generates UUID_B. Feign calls Stripe with Idempotency-Key: UUID_B. Stripe has no record of UUID_B — UUID_A is the key for ch_A, not UUID_B — and creates ch_B. The response arrives. The REQUIRES_NEW transaction commits. The outer loop records success for this customer. The customer has been charged twice: ch_A from attempt 0 (committed at Stripe before the timeout) and ch_B from attempt 1.
Why REQUIRES_NEW is the right isolation tool but the wrong place for UUID generation
The developer’s intent with REQUIRES_NEW is correct: per-customer billing should be isolated so that a failure for customer B doesn’t affect the already-committed work for customer A. REQUIRES_NEW achieves this. The problem is that REQUIRES_NEW’s isolation guarantee and UUID generation are independent concerns that happen to be co-located in the same method. REQUIRES_NEW says “each call to this method is an independent unit of work.” That is exactly true — and an “independent unit of work” that generates UUID.randomUUID() at entry will produce a different UUID for each independent unit. The isolation that makes REQUIRES_NEW safe for database operations is the same property that makes it unsafe for Stripe idempotency keys.
A subtler variant: the outer orchestration method does not have an explicit retry loop. Instead, it calls chargeCustomer() exactly once per customer — but the outer batch job is itself retried. The outer @Scheduled job runs again after a failure window and processes customers whose BillingAudit record has status PENDING (set before the Feign call) or whose record was rolled back (absent from the audit table). For absent records, the batch picks up the customer as not-yet-billed and calls chargeCustomer() again — UUID_B — ch_B. This variant is slower (hours rather than seconds between attempts) but the mechanism is identical.
Fix for mode 2
Compute the idempotency key outside the REQUIRES_NEW method, in the caller, before the first attempt. Pass it as a parameter to every call — including retries. The key is derived from billing intent data (customerId, amountCents, billingPeriod) that is stable across the entire batch operation:
// BillingOrchestrationService.java — SAFE: compute key in caller
@Transactional
public void runMonthlyBillingBatch(List<Customer> pendingCustomers,
int amountCents, String billingPeriod) {
for (Customer customer : pendingCustomers) {
// Content-hash key computed once per customer, before any retry.
// Same value for all retry attempts for this customer.
String idempotencyKey = "charge:" + customer.getId()
+ ":" + amountCents + ":" + billingPeriod;
boolean charged = false;
for (int attempt = 0; attempt < 3 && !charged; attempt++) {
try {
String chargeId = stripeChargeService.chargeCustomer(
customer, amountCents, billingPeriod, idempotencyKey);
auditRepository.recordBatchSuccess(
customer.getId(), billingPeriod, chargeId);
charged = true;
} catch (FeignException e) {
if (attempt == 2) {
auditRepository.recordBatchFailure(
customer.getId(), billingPeriod);
}
}
}
}
}
// StripeChargeService.java — SAFE: key received as parameter
@Transactional(propagation = REQUIRES_NEW, rollbackFor = FeignException.class)
public String chargeCustomer(Customer customer, int amountCents,
String billingPeriod, String idempotencyKey) {
// Key arrives as a parameter — same value on every REQUIRES_NEW invocation
// that the caller makes for this customer-billingPeriod combination.
auditRepository.save(new BillingAudit(
customer.getId(), billingPeriod, idempotencyKey, PENDING));
ChargeResponse response = stripeChargeClient.charge(
new ChargeRequest(customer.getStripeCustomerId(), amountCents, "usd"),
idempotencyKey);
auditRepository.updateChargeId(customer.getId(), billingPeriod, response.getId());
return response.getId();
}
The key is computed once in the outer orchestration method — "charge:cus_abc:4999:2026-10" — and passed to every call to chargeCustomer(), including retries. Even though REQUIRES_NEW opens a fresh transaction for each call, the idempotency key string is the same value every time. Stripe receives "charge:cus_abc:4999:2026-10" on both attempt 0 and attempt 1, deduplicates them, and returns the existing ch_A on attempt 1 without creating ch_B.
For the subtler variant where the retry is the outer batch job re-running: the content-hash key derived from stable parameters is identical whether the batch runs at 2:00am or at 2:30am as a retry. The database-absent customer is picked up, the same key computed, and Stripe deduplicates correctly. The only risk is if the batch re-runs in a different billing period — but the key includes billingPeriod, so an October retry key ("2026-10") differs from a November key ("2026-11"), and Stripe processes each correctly.
Mode 3: RetryTemplate inside @Transactional service — UUID.randomUUID() inside RetryCallback lambda body — lambda body re-executed per retry — UUID_B — ch_B
The third failure mode involves Spring’s RetryTemplate — the programmatic retry API that underpins @Retryable, but used explicitly in code rather than via annotation. Developers reach for RetryTemplate when they need fine-grained control over retry policy that @Retryable’s annotation attributes don’t expose: context-aware backoff, retry state shared across a batch, or conditional logic based on the exception message rather than just the exception type.
The failure pattern: UUID.randomUUID() is placed inside the RetryCallback lambda body passed to retryTemplate.execute(). RetryTemplate.execute(RetryCallback callback) calls callback.doWithRetry(RetryContext context) on each attempt. The lambda is a RetryCallback instance — its body is not evaluated once at lambda-creation time, but on each invocation of doWithRetry(). UUID.randomUUID() inside the lambda body generates a new UUID per invocation.
// BillingService.java
@Service
public class BillingService {
private final StripeChargeClient stripeChargeClient;
private final BillingAuditRepository auditRepository;
private final RetryTemplate retryTemplate;
public BillingService(StripeChargeClient stripeChargeClient,
BillingAuditRepository auditRepository) {
this.stripeChargeClient = stripeChargeClient;
this.auditRepository = auditRepository;
this.retryTemplate = RetryTemplate.builder()
.maxAttempts(3)
.exponentialBackoff(1000, 2, 8000)
.retryOn(FeignException.ServiceUnavailable.class)
.build();
}
// @Transactional wraps the ENTIRE method — stays open across all
// RetryTemplate retries. RetryTemplate is inside the @Transactional boundary.
@Transactional(rollbackFor = FeignException.class)
public String chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// The RetryCallback lambda body executes on every RetryTemplate attempt.
// UUID.randomUUID() inside the lambda body evaluates per attempt.
return retryTemplate.execute(context -> {
String idempotencyKey = UUID.randomUUID().toString(); // UNSAFE
auditRepository.save(new BillingAudit(
customerId, billingPeriod, idempotencyKey, PENDING));
ChargeResponse response = stripeChargeClient.charge(
new ChargeRequest(customerId, amountCents, "usd"),
idempotencyKey);
auditRepository.updateChargeId(customerId, billingPeriod, response.getId());
return response.getId();
});
}
}
The failure sequence: the @Transactional proxy opens a database transaction (a single transaction for the entire chargeCustomer() method). The RetryTemplate.execute() call begins. The lambda’s doWithRetry() executes: UUID.randomUUID() generates UUID_A. A PENDING audit record is saved with UUID_A (this write is inside the open @Transactional transaction — not yet committed). The Feign client calls Stripe with Idempotency-Key: UUID_A. Stripe creates ch_A. The response is lost. FeignException.ServiceUnavailable is thrown.
The exception propagates to RetryTemplate.execute(). RetryTemplate catches it — this matches retryOn(FeignException.ServiceUnavailable.class). RetryTemplate does not re-throw the exception; it waits the backoff delay and calls doWithRetry() again. This is the key difference from Mode 1: in Mode 1, the exception propagated all the way out to @Retryable, which rolled back the transaction before retrying. In Mode 3, RetryTemplate catches the exception inside the @Transactional boundary. The @Transactional proxy never sees the FeignException from the first attempt. The transaction stays open.
The doWithRetry() callback executes again on the second attempt: UUID.randomUUID() generates UUID_B. A second PENDING audit record is saved with UUID_B (also inside the same open transaction). Feign calls Stripe with Idempotency-Key: UUID_B. Stripe creates ch_B. The second attempt succeeds. RetryTemplate.execute() returns. The @Transactional proxy commits — both audit records (UUID_A/PENDING and UUID_B/ch_B) are committed to the database. The customer has ch_A at Stripe (committed during attempt 1) and ch_B at Stripe (committed during attempt 2). The database has two audit records for the same (customerId, billingPeriod) pair.
The open transaction across RetryTemplate retries: double audit writes
This is distinct from Mode 1 in an important operational way. In Mode 1, the @Transactional rollback cleans up the first attempt’s database writes before the retry. In Mode 3, there is no rollback between retry attempts — the @Transactional transaction stays open. If the service writes a PENDING audit record on attempt 1 and another on attempt 2, both writes accumulate in the open transaction. When the transaction commits, both records are persisted.
If the BillingAudit table has a unique constraint on (customerId, billingPeriod) with the PENDING status, the second auditRepository.save() call will throw a DataIntegrityViolationException within the open transaction. The @Transactional proxy marks the transaction as rollback-only. When RetryTemplate.execute() finishes, the proxy tries to commit — and finds the transaction is rollback-only — throwing UnexpectedRollbackException. This surface as a completely different error from the original FeignException, obscuring the root cause.
If there is no unique constraint on the audit table, both records are committed silently: the database shows two billing attempts for the same customer in the same period, and the application code only returns the last attempt’s charge ID — the first attempt’s orphaned ch_A record may go unnoticed until a reconciliation job runs.
Fix for mode 3
Compute the idempotency key outside the RetryCallback lambda body — in the method body, before retryTemplate.execute() is called. The key is captured by the lambda as a final variable and the same string value is passed to Stripe on every doWithRetry() invocation:
// SAFE: key computed before retryTemplate.execute() — captured by lambda
@Transactional(rollbackFor = FeignException.class)
public String chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
// Content-hash key computed once, outside the RetryCallback lambda.
// The lambda captures this as an effectively-final variable from its enclosing scope.
final String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
return retryTemplate.execute(context -> {
// idempotencyKey is the same String reference on every retry attempt.
// Lambda does NOT call UUID.randomUUID() — it uses the captured variable.
auditRepository.save(new BillingAudit(
customerId, billingPeriod, idempotencyKey, PENDING));
ChargeResponse response = stripeChargeClient.charge(
new ChargeRequest(customerId, amountCents, "usd"),
idempotencyKey);
auditRepository.updateChargeId(customerId, billingPeriod, response.getId());
return response.getId();
});
}
With the key outside the lambda, doWithRetry() uses the same "charge:cus_abc:4999:2026-10" string on attempt 1 and attempt 2. Stripe deduplicates and returns ch_A on the retry without creating ch_B.
However, the double-audit-write problem remains — if the first attempt’s auditRepository.save() is in the open transaction and the retry’s save() also runs in the same open transaction, there will be two saves. The fix for this: change the audit write to an upsert pattern — INSERT ... ON CONFLICT (customer_id, billing_period) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key — or move the initial audit write to before retryTemplate.execute() and the charge result update to inside the lambda:
// SAFE: upsert pattern — idempotency key outside lambda, DB write idempotent
@Transactional(rollbackFor = FeignException.class)
public String chargeCustomer(String customerId, int amountCents,
String billingPeriod) {
final String idempotencyKey = "charge:" + customerId + ":" + amountCents
+ ":" + billingPeriod;
// Write audit record once, before retrying — not inside the lambda.
// If method is re-entered (by @Retryable or outer caller), this is
// idempotent because the key is the same.
auditRepository.upsert(
new BillingAudit(customerId, billingPeriod, idempotencyKey, PENDING));
String chargeId = retryTemplate.execute(context -> {
ChargeResponse response = stripeChargeClient.charge(
new ChargeRequest(customerId, amountCents, "usd"),
idempotencyKey);
return response.getId();
});
auditRepository.updateChargeId(customerId, billingPeriod, chargeId);
return chargeId;
}
This pattern writes the audit record once (before retryTemplate.execute()) and only calls the Feign client inside the lambda. Multiple retries of the Feign call do not produce multiple audit writes. The audit write itself is idempotent via the upsert — if the method is ever re-entered from an outer retry mechanism, the existing PENDING record is updated in place rather than duplicated.
Cross-mode analysis: how the three modes relate to each other and to the broader pattern
All three modes produce ch_A / ch_B double charges via the same root mechanism: UUID.randomUUID() is placed inside a scope that is re-executed by a retry mechanism. The scope differs:
- Mode 1: UUID in the
@Transactionalservice method body — the retry scope is the@RetryableAOP proxy re-invoking the method body. - Mode 2: UUID in the
REQUIRES_NEWservice method body — the retry scope is the outer try-catch loop calling the method again. - Mode 3: UUID inside the
RetryTemplatelambda body — the retry scope isRetryTemplate.execute()callingdoWithRetry()again.
Mode 1 vs. spring-transactional-scheduled mode 1: Both cover @Retryable (outer) / @Transactional (inner) with UUID in the method body. The critical difference: the spring-transactional-scheduled post covered a service using the direct Stripe Java SDK. This post covers a Feign client for Stripe. The mechanism is identical, but Feign adds a configuration dimension: the developer actively disabled Feign’s own Retryer to avoid Feign×@Retryable compounding. This is the correct decision — but it makes the remaining @Retryable-level UUID regeneration less obvious, because the developer has already reasoned about Feign retry and concluded they have it under control. The false confidence from correctly addressing the Feign-level retry can mask the @Retryable-level problem.
Mode 2 vs. feign-stripe-integration mode 2 (Resilience4j @Retry at call site): The feign-stripe-integration post covered Resilience4j @Retry on the outer service with UUID at the call-site argument position — feign.charge(UUID.randomUUID().toString(), body). Mode 2 in this post has UUID inside the REQUIRES_NEW inner service method body, with the outer retry as an explicit try-catch loop rather than an AOP annotation. Two structural differences: (1) the UUID is inside the inner service rather than at the call site in the outer service — the developer who reads the outer service sees only the key passed as a parameter and cannot see where it is generated without reading the inner service; (2) the retry is explicit in the outer loop, not hidden behind an AOP annotation — but the explicit retry is no safer if the inner service generates a new UUID per invocation.
Mode 3 vs. vertx-future-compose-retry-stripe-integration mode 1 (executeBlocking Callable body): Both modes place UUID inside a deferred computation body that is re-evaluated per invocation: the Vert.x executeBlocking() Callable vs. the Spring RetryCallback lambda. The structural equivalence is precise: both are callback objects whose body is passed to a framework that calls the body multiple times on failure. The Spring-specific nuance is the @Transactional context. In Vert.x, there is no transaction wrapping the retry loop (Vert.x has no built-in @Transactional equivalent in the Spring AOP sense). In Spring Mode 3, the @Transactional proxy is outside RetryTemplate — the transaction stays open across all retry attempts — producing the double-audit-write problem as a secondary consequence.
Mode 3 vs. Mode 1 — the transaction boundary difference: In Mode 1, the @Transactional proxy rolls back and reopens for each @Retryable attempt. Each attempt starts with a clean database state. In Mode 3, the @Transactional proxy wraps the entire chargeCustomer() call — including the RetryTemplate loop — and stays open across all RetryTemplate retries. This creates a distinct operational risk: the first attempt’s partial database writes accumulate in the open transaction alongside the retry’s database writes. Whether this produces a duplicate-write error, a silent duplication, or a constraint violation depends entirely on the schema design. Mode 1’s rollback-and-reopen pattern is safer for database consistency (each attempt starts fresh) but wastes the transaction overhead of opening/closing for each retry. Mode 3’s single-transaction pattern is cheaper but requires the database writes inside the lambda to be idempotent across retries.
Testing: detecting UUID regeneration with @Transactional + Feign
Integration tests for these three modes use WireMock to simulate Stripe’s API — returning 503 on the first attempt and 200 on the second — and capture the Idempotency-Key header on each request. The assertion is that all captured keys are the same string. A test that passes only with the safe content-hash implementation and fails with UUID.randomUUID() confirms the fix.
Tests for modes 1 and 3 must handle the Spring context correctly: the @SpringBootTest slice used for integration tests must load the RetryConfiguration bean (for @Retryable) and the transaction manager. Tests using @DataJpaTest or @WebMvcTest slices typically do not load @Retryable infrastructure — the proxy is not applied — and mode 1 tests run the method body only once, masking the UUID regeneration bug. Use @SpringBootTest or a custom slice that explicitly includes @EnableRetry.
@SpringBootTest
@Transactional // test-level transaction rolled back after each test
class BillingServiceIdempotencyTest {
@Autowired
private BillingService billingService;
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@BeforeEach
void setUp() {
// Override Feign client base URL to WireMock.
// Achieved via @DynamicPropertySource or test application.properties.
}
@Test
void chargeCustomer_retryable_carries_same_key_on_retry() {
List<String> capturedKeys = Collections.synchronizedList(new ArrayList<>());
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retry-test")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503)
.withBody("{\"error\":{\"type\":\"api_error\"," +
"\"message\":\"service unavailable\"}}"))
.willSetStateTo("second-attempt"));
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retry-test")
.whenScenarioStateIs("second-attempt")
.willReturn(aResponse().withStatus(200)
.withBody(buildChargeJson("ch_safe_test"))));
wireMock.addMockServiceRequestListener((request, response) -> {
if (request.getUrl().contains("/v1/charges")) {
String key = request.getHeader("Idempotency-Key");
if (key != null) capturedKeys.add(key);
}
});
billingService.chargeCustomer("cus_test", 4999, "2026-10");
// Both attempts must send the same idempotency key.
assertThat(capturedKeys).hasSize(2);
assertThat(new HashSet<>(capturedKeys)).hasSize(1);
// Safe implementation: "charge:cus_test:4999:2026-10" on both attempts.
assertThat(capturedKeys.get(0)).isEqualTo("charge:cus_test:4999:2026-10");
}
}
For Mode 2 (REQUIRES_NEW), the test structure differs: the outer orchestration method is called with a list of customers. The test captures keys per customer. For the customer whose first Feign call returns 503, both retry attempts must use the same key. For other customers whose calls succeed on the first attempt, exactly one key is sent.
@Test
void runBillingBatch_requires_new_carries_same_key_per_customer_on_retry() {
Map<String, List<String>> keysByCustomer = new ConcurrentHashMap<>();
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("batch-test")
.whenScenarioStateIs(STARTED)
.withHeader("X-Customer-Id", equalTo("cus_retry"))
.willReturn(aResponse().withStatus(503)
.withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("retry-ready"));
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("batch-test")
.whenScenarioStateIs("retry-ready")
.withHeader("X-Customer-Id", equalTo("cus_retry"))
.willReturn(aResponse().withStatus(200)
.withBody(buildChargeJson("ch_retry_test"))));
// Default stub for other customers: always succeed.
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.willReturn(aResponse().withStatus(200)
.withBody(buildChargeJson("ch_default"))));
wireMock.addMockServiceRequestListener((request, response) -> {
String key = request.getHeader("Idempotency-Key");
String customer = request.getHeader("X-Customer-Id");
if (key != null && customer != null) {
keysByCustomer.computeIfAbsent(customer,
k -> Collections.synchronizedList(new ArrayList<>())).add(key);
}
});
List<Customer> customers = List.of(
new Customer("cus_retry", "cus_stripe_retry"),
new Customer("cus_ok", "cus_stripe_ok")
);
orchestrationService.runMonthlyBillingBatch(customers, 4999, "2026-10");
// cus_retry: two attempts, same key.
List<String> retryKeys = keysByCustomer.get("cus_retry");
assertThat(retryKeys).hasSize(2);
assertThat(new HashSet<>(retryKeys)).hasSize(1);
// cus_ok: one attempt.
List<String> okKeys = keysByCustomer.get("cus_ok");
assertThat(okKeys).hasSize(1);
}
The relationship between @Transactional + Feign failures and the Keybrake governance model
All three modes share a common operational footprint after the double-charge event: Stripe’s ledger contains ch_A and ch_B for the same customer in the same billing period. The application’s database records only ch_B (in Mode 1 and Mode 2, the first attempt’s database write is rolled back; in Mode 3, both are committed but only ch_B’s audit record reflects the completed charge). The customer receives two charges on their credit card statement.
Standard Stripe reconciliation — comparing charges in Stripe against billing records in your database — surfaces the discrepancy: ch_A appears in Stripe with no matching database record. But reconciliation typically runs in a nightly batch with a 24-to-48-hour lag. The customer may notice first.
The upstream governance layer pattern — a per-billing-period vault key with a USD spend cap set to customerCount × maxChargeAmount × 1.10 (10% buffer) — provides a second defence layer. If any of the three modes fires at scale during a billing run (for example, a transient Stripe API degradation causing hundreds of retries), the spend cap limits the blast radius to the expected monthly billing total plus 10%. After the cap is reached, the proxy rejects further Stripe charges, preventing an unbounded cascade. The proxy’s audit log records every proxied request with its Idempotency-Key header and the Stripe response charge ID, which surfaces the orphaned ch_A records without waiting for the nightly reconciliation job.
The combination of content-hash idempotency keys (eliminates the root cause at the Stripe layer) and per-period spend caps (limits blast radius at the proxy layer) provides two independent protections. A correctly-keyed billing run that deduplicates correctly still benefits from a spend cap as insurance against bugs in the amount calculation. A spend cap without correct keys allows individual double charges to occur up to the point where the cap triggers — and at that point the billing run halts, which is its own operational problem. Both layers together mean that a key-computation bug during a retry surfaces as a cap hit rather than an unbounded run of duplicate charges.
Summary
Three Spring @Transactional + Feign Client failure modes produce Stripe duplicate charges:
@Retryable(outer) +@Transactional(inner) with UUID in service method body: Spring Retry’s default order (LOWEST_PRECEDENCE − 5 = 2,147,483,642) places@Retryableoutside@Transactional(LOWEST_PRECEDENCE = 2,147,483,647). When aFeignExceptionpropagates,@Transactionalrolls back the database transaction first, then@Retryablere-invokes the method body.UUID.randomUUID()in the method body generates UUID_B. Feign — configured withRetryer.NEVER_RETRYto avoid Feign×@Retryablecompounding — calls Stripe with UUID_B. Stripe creates ch_B. Fix: content-hash key derived from stable billing intent parameters in the method body, notUUID.randomUUID().@Transactional(REQUIRES_NEW)inner service called from outer try-catch retry loop: The developer addsREQUIRES_NEWto the inner service for correct per-customer transaction isolation. The outer orchestration method retries by calling the inner service again onFeignException. Each call to aREQUIRES_NEWmethod is an independent method invocation.UUID.randomUUID()inside the inner method body generates UUID_B on the retry call. Stripe creates ch_B. Fix: compute the idempotency key in the outer orchestration method, before the retry loop, and pass it as a parameter to the inner service on all attempts.RetryTemplatelambda with UUID inside theRetryCallbackbody:RetryTemplate.execute(callback)callscallback.doWithRetry(context)on each attempt. The lambda body is not evaluated at lambda-creation time — it runs on everydoWithRetry()invocation.UUID.randomUUID()inside the lambda generates UUID_B on the retry attempt. The@Transactionalproxy wraps the entire method and stays open across allRetryTemplateretries — accumulating multiple audit writes in the open transaction as a secondary consequence. Fix: compute the key in the method body outside the lambda; capture it as a final variable; structure database writes as upserts to avoid duplicate records across retry attempts.
In all three modes, the fix is the same logical operation: compute the idempotency key outside the retry boundary using a content-hash derived from immutable billing intent parameters. The retry boundary differs by mode — the @Retryable proxy scope, the REQUIRES_NEW method invocation, the RetryCallback lambda body — but the corrective action is uniform. A key computed as "charge:" + customerId + ":" + amountCents + ":" + billingPeriod is identical on every retry attempt that carries the same billing intent, because the parameters are identical. Stripe receives the same key, deduplicates, and returns the existing charge without creating a second one.
Keybrake: per-vendor spend caps for the APIs your agents call
A scoped API-key proxy for Stripe, Twilio, and Resend. Issue a vault key with a daily USD cap, allowlisted endpoints, and a kill switch. The proxy sits between your agent and the vendor — enforcing the cap pre-call, logging every request with parsed cost, and letting you revoke in one click without rotating the upstream secret.