Spring Boot @Cacheable and @Retryable Stripe Integration: How AOP Advisor Ordering, Cache Miss on Exception, and SpEL Key Expression Evaluation Generate New Idempotency Keys on Retry
Annotating a Spring Boot billing service method with both @Cacheable and @Retryable introduces three Stripe idempotency failure modes that are structurally distinct from the failure modes in the Spring WebMVC async post and the OAuth2 resource server post in this series. The three modes are: the default Spring AOP advisor ordering places @Retryable (order = 1, lowest numeric value, outermost advisor) outside @Cacheable (order = Integer.MAX_VALUE, innermost advisor), so @Retryable retries the full proxy chain including the @Cacheable layer — and because @Cacheable does not store exceptions, every retry attempt sees another cache MISS, the method body re-runs, and UUID.randomUUID() at method entry generates UUID_B that sends a new charge to Stripe; reversing the ordering so @Cacheable is outer and @Retryable is inner does not solve the problem — @Retryable retries the method body internally without the @Cacheable layer seeing the retry, UUID.randomUUID() regenerates on the internal @Retryable re-invocation, and @Cacheable ultimately stores the last successful result from the retry (ch_B) while ch_A from the initial attempt is orphaned in Stripe; and a SpEL key expression that includes T(java.util.UUID).randomUUID() as a fallback via the Elvis operator re-evaluates a fresh UUID on every cache lookup, producing a permanent cache MISS on every @Retryable retry invocation and allowing UUID.randomUUID() in the method body to generate UUID_B unconstrained.
Background: Spring AOP advisor ordering and the @Cacheable/@Retryable advisor stack
Spring AOP proxies multiple annotations on the same bean method by stacking advisors. The advisor with the lowest numeric order value is the outermost interceptor — it is the first to intercept the incoming call and the last to see the return value or exception on the way back out. The advisor with the highest numeric order value is the innermost, wrapping the actual method body most tightly.
@Retryable is implemented by AnnotationAwareRetryOperationsInterceptor, which ships with a default order of 1. @Cacheable (and @CachePut, @CacheEvict) is implemented by CacheInterceptor, which is registered with a default order of Integer.MAX_VALUE (specifically, Ordered.LOWEST_PRECEDENCE, which equals Integer.MAX_VALUE, representing the lowest possible precedence / innermost advisor position).
Concretely: when both annotations are present on the same method, @Retryable (order = 1) is the outer wrapper and @Cacheable (order = Integer.MAX_VALUE) is the inner wrapper closest to the method body. A call flows like this:
Caller
→ @Retryable interceptor (order=1, outermost)
→ @Cacheable interceptor (order=Integer.MAX_VALUE, innermost)
→ actual billing method body
This ordering has a direct consequence for Stripe idempotency. When the billing method throws a StripeException, the exception propagates outward: @Cacheable sees it first, does not store anything (because there is no return value to cache), and lets the exception propagate. @Retryable then catches the exception and re-calls the proxy — which means re-entering the @Cacheable interceptor on the retry.
Spring Retry and Spring Cache ship as separate modules, so there is no built-in coordination between them. Neither the CacheManager nor the RetryTemplate has visibility into what the other is doing. This independence is the root cause of all three failure modes in this post.
Failure mode 1: @Retryable (order = 1, outer) + @Cacheable (order = Integer.MAX_VALUE, inner) — @Cacheable does not cache exceptions — @Retryable retry sees another cache MISS — method body re-runs — UUID_B — ch_B
A developer building a billing service wants to prevent duplicate Stripe charges on retry. They reason: “If I annotate the method with @Cacheable, Spring will cache the ChargeResult after the first successful call. If @Retryable retries due to a StripeException, the retry attempt will hit the cache and return the already-committed charge result instead of creating a new one.” The service method looks like this:
// BillingService.java — UNSAFE: developer expects @Cacheable to prevent
// duplicate Stripe calls on @Retryable retry. This expectation is wrong
// because @Cacheable does not store exceptions — @Retryable retry sees
// MISS — method body re-runs — UUID_B — ch_B alongside committed ch_A.
@Service
public class BillingService {
private final StripeClient stripeClient;
// @Retryable is the OUTER interceptor (order=1).
// @Cacheable is the INNER interceptor (order=Integer.MAX_VALUE).
// Stack: Caller → @Retryable → @Cacheable → method body.
@Cacheable(
cacheNames = "charges",
key = "'charge:' + #userId + ':' + #billingPeriod"
)
@Retryable(
maxAttempts = 3,
value = {StripeException.class},
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public ChargeResult createCharge(String userId, String billingPeriod, long amountCents) {
// UUID.randomUUID() generates UUID_A on attempt 1.
// On @Retryable retry: @Cacheable cache is MISS again (attempt 1 threw,
// nothing was stored) → method body re-runs → UUID.randomUUID() generates
// UUID_B. Stripe receives a key it has never seen → ch_B is created.
String idempotencyKey = billingPeriod + ":" + userId + ":" + UUID.randomUUID();
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
}
The failure sequence when Stripe commits ch_A but returns a transient 503 before the client receives the success response:
- Caller invokes
createCharge("user-007", "2026-Q4", 4900L). @Retryableinterceptor (order = 1) fires first. No retry state yet. Proceeds inward.@Cacheableinterceptor fires. Checks cache for key"charge:user-007:2026-Q4". Cache MISS (first call ever). Proceeds inward to the method body.- Method body runs.
UUID.randomUUID()→UUID_A = "b3d1-...". Stripe call made withIdempotency-Key: 2026-Q4:user-007:b3d1-.... Stripe processes the charge and commitsch_A = "ch_111". Before Stripe delivers the 200 response, a transient network error returns HTTP 503. StripeExceptionis thrown from the method body.@Cacheableinterceptor catches it on the way out.@Cacheabledoes not store exceptions —CacheInterceptor.invoke()only populates the cache on a successful return value; when an exception propagates, the cache entry for"charge:user-007:2026-Q4"is not written. The exception is re-thrown to@Retryable.@RetryablecatchesStripeException. Waits 1 second (backoff). Retries the proxy chain — this means calling back into the@Cacheableinterceptor, not directly into the method body.@Cacheableinterceptor fires again on the retry invocation. Checks cache for"charge:user-007:2026-Q4". Cache MISS again — nothing was stored in step 5. Proceeds inward to the method body.- Method body runs again.
UUID.randomUUID()→UUID_B = "c7a2-...". Stripe call made withIdempotency-Key: 2026-Q4:user-007:c7a2-.... Stripe has never seen this key. Stripe createsch_B = "ch_222". ch_Aandch_Bare both committed in Stripe foruser-007/2026-Q4. The application database records onlych_B(the retry’s result).ch_Ais billed to the customer but has no corresponding application record.
Why the developer’s deduplication assumption is wrong
@Cacheable is a return-value cache. It stores what the method returns. It has no mechanism to observe or store the side effects of a partially-executed method body — including the fact that a Stripe charge was committed before the method threw. When the billing method throws, @Cacheable’s only options are: (a) let the exception propagate without storing anything, or (b) store a sentinel value if configured with unless or similar. Neither option causes the cache to store ch_A’s result. Option (a) is the default.
The developer’s mental model treats the cache as a Stripe-side idempotency proxy — as if @Cacheable somehow knew whether Stripe had processed the charge before the error. It does not. @Cacheable operates at the JVM method boundary, not the Stripe API boundary. The only way to prevent a second Stripe charge on retry is to ensure the idempotency key is stable across retry invocations — not to prevent the Stripe call from being made at all.
The subtle variant: @Cacheable appears to work in integration tests
A developer who writes an integration test with @SpringBootTest and WireMock configured to return HTTP 200 on the first call will observe that a second call to createCharge("user-007", "2026-Q4", 4900L) hits the cache and returns the stored ChargeResult without sending a second request to WireMock. This confirms the happy-path deduplication behavior of @Cacheable and leads the developer to conclude that the implementation is correct. The test never exercises the path where the first call throws — WireMock always succeeds immediately, @Cacheable stores the result, and the second call hits the cache as expected. The @Retryable path and the cache-miss-on-exception path are invisible in this test configuration.
The fix: stable content-hash idempotency key passed as a method parameter
The correct fix is to compute a stable, deterministic idempotency key from content-addressable inputs before both the @Cacheable and @Retryable layers. Since both interceptors operate at the method boundary, the safest place to compute the key is in the caller, before the annotated method is invoked:
// BillingController.java — SAFE: stable idempotency key computed at the
// controller layer before the @Cacheable/@Retryable proxy boundary.
// The key is content-addressed from user-invariant inputs and does not
// change across @Retryable retries or @Cacheable re-invocations.
@RestController
@RequestMapping("/billing")
public class BillingController {
private final BillingService billingService;
@PostMapping("/charge")
public ResponseEntity<ChargeResult> charge(@RequestBody ChargeRequest req,
Principal principal) {
// Compute idempotency key BEFORE calling the service.
// sha256(userId:billingPeriod:spring-boot-billing)[:32] is stable
// across all @Retryable retries and all @Cacheable lookups.
String idempotencyKey = sha256Hex(
principal.getName() + ":" + req.getBillingPeriod() + ":spring-boot-billing"
).substring(0, 32);
ChargeResult result = billingService.createCharge(
principal.getName(), req.getBillingPeriod(), req.getAmountCents(),
idempotencyKey // ← stable parameter, never UUID.randomUUID()
);
return ResponseEntity.ok(result);
}
}
// BillingService.java — SAFE: idempotencyKey is a method parameter computed
// by the caller. @Cacheable and @Retryable are still present and still useful
// (cache for deduplication of redundant calls, retry for transient errors).
// UUID.randomUUID() is gone — no regeneration possible on retry.
@Service
public class BillingService {
private final StripeClient stripeClient;
@Cacheable(
cacheNames = "charges",
key = "'charge:' + #userId + ':' + #billingPeriod"
)
@Retryable(
maxAttempts = 3,
value = {StripeException.class},
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public ChargeResult createCharge(String userId, String billingPeriod,
long amountCents, String idempotencyKey) {
// idempotencyKey is stable across all retry invocations.
// @Retryable retries with the SAME idempotencyKey value on every attempt.
// Stripe returns the cached ch_A result for any retry that arrives after
// ch_A has already been committed — no ch_B.
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
}
With the stable key as a method parameter, the @Retryable interceptor retries the proxy chain with the same parameter values on every attempt. @Cacheable checks for the same cache key on every retry invocation. If attempt 1 threw and stored nothing, the retry re-runs the method body — but both attempt 1 and the retry send the same idempotency key to Stripe. Stripe returns ch_A from its idempotency cache on the retry. No ch_B.
Separately, if a second controller call arrives for the same userId + billingPeriod after a first call succeeded (the happy path), @Cacheable returns the stored ChargeResult from the JVM cache without hitting Stripe at all — which is the correct deduplication behavior. The fix preserves both the retry semantics of @Retryable and the happy-path deduplication of @Cacheable.
Failure mode 2: developer reverses AOP ordering — @Cacheable becomes outer, @Retryable becomes inner — @Retryable retries method body inside a single @Cacheable invocation — UUID_B on internal retry — ch_A orphaned in Stripe
A developer who reads the Spring documentation and understands that @Retryable is outer by default may attempt to fix the failure mode 1 problem by reversing the ordering: making @Cacheable the outer advisor so it has first visibility into every method invocation. Their reasoning: “If @Cacheable is outer, it will check the cache before @Retryable fires. The first call goes through to Stripe. @Cacheable stores the result. If @Retryable retries, the retry attempt will be intercepted by @Cacheable first — and if the result is in cache, @Cacheable returns the cached value without calling @Retryable at all. No double charge.”
This reasoning is incorrect in a subtle way. The reversed ordering is achieved by configuring Spring’s AOP infrastructure:
// AppConfig.java — developer reverses the default AOP ordering to make
// @Cacheable the outer advisor and @Retryable the inner advisor.
// This is intended to prevent double charges on retry. It does not work
// because @Retryable retries the method body INTERNALLY — @Cacheable
// is not re-invoked per @Retryable retry attempt — UUID_B is generated
// within the @Retryable scope before @Cacheable has a chance to intercept.
@Configuration
@EnableCaching(order = 1) // @Cacheable interceptor gets order=1 (outermost)
@EnableRetry(order = 2) // @Retryable interceptor gets order=2 (inner)
public class AppConfig {
// CacheManager bean, etc.
}
With this configuration the proxy stack is:
Caller
→ @Cacheable interceptor (order=1, outermost)
→ @Retryable interceptor (order=2, inner)
→ actual billing method body
The developer tests the happy path: the first call invokes @Cacheable (MISS) → @Retryable → method body → Stripe success → ChargeResult returned → @Cacheable stores it. A second call for the same userId + billingPeriod invokes @Cacheable → HIT → returns cached ChargeResult without calling @Retryable or the method body. The developer concludes the design is correct: “@Cacheable is outer, subsequent calls are intercepted before @Retryable fires.”
What the developer did not test is the path where the billing method throws on the first invocation after a transient Stripe network error where Stripe has already committed ch_A:
- Caller invokes
createCharge("user-007", "2026-Q4", 4900L). @Cacheableinterceptor (order = 1) fires first. Cache MISS for"charge:user-007:2026-Q4". Proceeds inward to@Retryableproxy.@Retryableinterceptor (order = 2) fires. This is the first call; no prior failures. Proceeds inward to the method body.- Method body runs.
UUID.randomUUID()→UUID_A. Stripe call. Stripe commitsch_A = "ch_111". HTTP 503 returned before the client sees the 200.StripeExceptionthrown. @Retryablecatches the exception. Waits backoff. Retries the method body directly —@Retryable’s retry loop calls the wrapped target (the actual method body), not the@Cacheableproxy. The retry does not bubble back up to@Cacheable’s interceptor.@Cacheableis not involved in@Retryable’s internal retry loop.- Method body runs again within the
@Retryableinternal loop.UUID.randomUUID()→UUID_B. Stripe call withUUID_B. Stripe createsch_B = "ch_222". Returns HTTP 200.ChargeResult(ch_B)returned to@Retryable. @RetryablereturnsChargeResult(ch_B)to@Cacheable’s interceptor.@Cacheablereceives the return value from the inner@Retryableproxy. StoresChargeResult(ch_B)in cache under key"charge:user-007:2026-Q4". ReturnsChargeResult(ch_B)to the original caller.- Result:
ch_Aandch_Bare both committed in Stripe. The application recordsch_Bas the canonical charge (it is in the JVM cache and returned to the controller).ch_Ais billed to the customer but has no application-side record.
Why the reversed ordering does not help
The developer’s mental model treats @Cacheable as if it wraps every individual @Retryable retry attempt. That would require @Cacheable to intercept each retry invocation as a separate incoming call from the top of the proxy stack. That is not how it works.
When @Cacheable (outer) decides the cache is a MISS and proceeds inward, it calls the @Retryable proxy once. The @Retryable proxy internally loops over retry attempts by calling the method body directly — bypassing the @Cacheable interceptor on every retry. From @Cacheable’s perspective, it made one call to the inner proxy and received either a return value (after some number of retries) or a terminal exception (after all retries exhausted). @Cacheable never sees the individual retry attempts as separate invocations.
This is the same reason why a developer cannot put @Cacheable and @Transactional on the same method and expect the transaction to be re-opened per retry — the transaction (inner) is opened once per @Cacheable call, and the @Retryable retry loop runs within the single transaction boundary. Whether @Cacheable is inner or outer, the @Retryable retry loop operates at the method body level, not at the proxy chain level.
The subtle variant: the fix appears to work in the happy-path test
A developer who tests the reversed configuration with the happy path (no retries needed) confirms that @Cacheable correctly deduplicates repeated calls and that the first successful charge is returned from cache on the second invocation. The reversed ordering appears to work perfectly in tests because no test exercises the scenario where attempt 1 commits ch_A and then throws — making WireMock return 503 reliably on the first request and 200 on the second requires explicitly writing the scenario-based WireMock stub, which most happy-path tests omit.
The fix: separate the @Cacheable lookup from the Stripe call
The fundamental problem is that UUID.randomUUID() is in the same method body as the Stripe call. Moving the key computation out of the method body, or using a @Cacheable-protected lookup method to retrieve or create a stable key, separates the concerns:
// BillingService.java — SAFE: @Cacheable on a key-retrieval method (getOrCreateKey),
// @Retryable on a separate Stripe call method (executeCharge).
// @CachePut on getOrCreateKey ensures the key is stored after first generation
// so that @Retryable retries of executeCharge always receive the same key.
@Service
public class BillingService {
private final StripeClient stripeClient;
// @Cacheable on key retrieval, not on the Stripe call.
// First call generates the stable key and caches it.
// All subsequent calls (including @Retryable retries on executeCharge)
// call getOrCreateKey and receive the cached key — same UUID_A every time.
@Cacheable(
cacheNames = "billing-keys",
key = "'key:' + #userId + ':' + #billingPeriod"
)
public String getOrCreateKey(String userId, String billingPeriod) {
// This method body runs AT MOST ONCE per userId+billingPeriod combination.
// @Cacheable stores the return value. All subsequent calls hit the cache.
return billingPeriod + ":" + userId + ":" + UUID.randomUUID();
}
// @Retryable on the Stripe call method, NOT on getOrCreateKey.
// The key is retrieved (from cache after the first generation) BEFORE
// the @Retryable scope begins — it is stable across all retry invocations.
@Retryable(
maxAttempts = 3,
value = {StripeException.class},
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public ChargeResult executeCharge(String userId, String billingPeriod, long amountCents) {
// getOrCreateKey() hits the @Cacheable cache (HIT on all retries after
// the first call that generated and stored the key).
// idempotencyKey is UUID_A on every retry invocation.
String idempotencyKey = getOrCreateKey(userId, billingPeriod);
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
}
Note on self-invocation: The call from
executeCharge()togetOrCreateKey()above is a self-invocation on the same Spring bean. Spring AOP proxies work only for external calls; self-invocation bypasses the proxy and the@Cacheableadvice. For this pattern to work,BillingServicemust inject a reference to itself (via@Autowired BillingService self) and callself.getOrCreateKey()so the call goes through the proxy, orgetOrCreateKey()must be on a separate Spring bean. Alternatively, expose the idempotency key generation as a separate injectableBillingKeyServicebean.
A cleaner architecture extracts the key generation into its own BillingKeyService bean, making the separation explicit and avoiding the self-invocation subtlety entirely:
// BillingKeyService.java — dedicated bean for key generation and caching.
// @Cacheable on a dedicated bean avoids self-invocation issues entirely.
@Service
public class BillingKeyService {
@Cacheable(
cacheNames = "billing-keys",
key = "'key:' + #userId + ':' + #billingPeriod"
)
public String getOrCreateKey(String userId, String billingPeriod) {
return billingPeriod + ":" + userId + ":" + UUID.randomUUID();
}
}
// BillingService.java — Stripe call with @Retryable only.
// Key is always retrieved via BillingKeyService, which caches it.
@Service
public class BillingService {
private final StripeClient stripeClient;
private final BillingKeyService billingKeyService;
@Retryable(
maxAttempts = 3,
value = {StripeException.class},
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public ChargeResult executeCharge(String userId, String billingPeriod, long amountCents) {
// External call to a separate bean — goes through the @Cacheable proxy.
// UUID_A on first call (cache MISS on BillingKeyService), UUID_A on all
// subsequent calls including @Retryable retries (cache HIT).
String idempotencyKey = billingKeyService.getOrCreateKey(userId, billingPeriod);
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
}
With BillingKeyService as a separate bean, the first invocation of executeCharge() calls billingKeyService.getOrCreateKey("user-007", "2026-Q4") via the Spring AOP proxy. Cache MISS: UUID_A is generated and stored in billing-keys cache under key "key:user-007:2026-Q4". The Stripe call is made with UUID_A. If Stripe throws a 503 after committing ch_A, @Retryable retries executeCharge(). The retry calls billingKeyService.getOrCreateKey("user-007", "2026-Q4") again — this time cache HIT, returns UUID_A. The Stripe retry carries UUID_A and Stripe returns the cached ch_A. No ch_B.
Failure mode 3: SpEL key expression with T(java.util.UUID).randomUUID() as Elvis fallback — SpEL re-evaluates per expression evaluation — permanent cache MISS — method body re-runs per retry — UUID_B — ch_B
A developer building a multi-client billing API wants to honor an explicit idempotency key supplied by the caller via the X-Request-Id HTTP header, but fall back to a generated UUID for clients that do not supply the header. They write the cache key expression using the SpEL Elvis operator:
// BillingService.java — UNSAFE: T(java.util.UUID).randomUUID() in the SpEL
// key expression is evaluated EAGERLY on every @Cacheable lookup, not once
// per logical request. On @Retryable retry: SpEL evaluates a new UUID —
// cache key on retry (UUID_B) != cache key on attempt 1 (UUID_A) —
// permanent cache MISS on every retry — method body re-runs with
// UUID.randomUUID() in the body generating UUID_B independently — ch_B.
@Service
public class BillingService {
private final StripeClient stripeClient;
@Cacheable(
cacheNames = "charges",
// The Elvis operator: use #requestId if non-null and non-empty,
// otherwise fall back to T(java.util.UUID).randomUUID().toString().
// PROBLEM: T(java.util.UUID).randomUUID() is a Java method call in SpEL.
// SpEL evaluates it on every cache key expression evaluation.
// For clients that supply X-Request-Id: cache key = requestId (stable).
// For clients that do NOT supply X-Request-Id: cache key = new UUID per call.
key = "(#requestId != null && !#requestId.isEmpty()) ? " +
"#requestId : T(java.util.UUID).randomUUID().toString()"
)
@Retryable(
maxAttempts = 3,
value = {StripeException.class},
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public ChargeResult createCharge(String userId, String billingPeriod,
long amountCents, String requestId) {
// When requestId is null: @Cacheable SpEL generates UUID_KEY_A as
// the cache key on attempt 1, and UUID_KEY_B (a different UUID) as the
// cache key on @Retryable retry — permanent MISS — method body runs.
// UUID.randomUUID() here generates UUID_IDEMPOTENCY_A (attempt 1) and
// UUID_IDEMPOTENCY_B (retry) — two independent UUIDs — Stripe creates
// ch_A (attempt 1) and ch_B (retry).
String idempotencyKey;
if (requestId != null && !requestId.isEmpty()) {
idempotencyKey = billingPeriod + ":" + userId + ":" + requestId;
} else {
idempotencyKey = billingPeriod + ":" + userId + ":" + UUID.randomUUID();
}
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
}
The failure sequence for a client that does not supply X-Request-Id (i.e., requestId is null):
- Caller invokes
createCharge("user-007", "2026-Q4", 4900L, null). @Retryable(order = 1, outer) fires. No prior failures. Proceeds inward.@Cacheablefires. Evaluates SpEL key expression.requestIdisnull: the else branch of the ternary fires.T(java.util.UUID).randomUUID().toString()is evaluated →UUID_KEY_A = "a1b2-...". Cache lookup for key"a1b2-...". Cache MISS. Proceeds inward to method body.- Method body runs.
requestIdisnull:UUID.randomUUID()→UUID_IDEMPOTENCY_A = "c3d4-..."(a different UUID fromUUID_KEY_A— two independentrandomUUID()calls). Stripe call withUUID_IDEMPOTENCY_A. Stripe commitsch_A. HTTP 503 returned.StripeExceptionthrown. @Cacheabledoes not store the exception.@Retryablecatches the exception. Waits backoff. Retries proxy chain.@Cacheablefires again on the retry invocation. Evaluates SpEL key expression.requestIdis stillnull.T(java.util.UUID).randomUUID().toString()is evaluated again →UUID_KEY_B = "e5f6-..."(a fresh UUID, different fromUUID_KEY_A). Cache lookup for key"e5f6-...". Cache MISS (nothing was stored under"a1b2-..."from attempt 1, and"e5f6-..."has never been seen). Proceeds inward to method body.- Method body runs again.
UUID.randomUUID()→UUID_IDEMPOTENCY_B = "g7h8-...". Stripe call withUUID_IDEMPOTENCY_B. Stripe createsch_B. ch_Aandch_Bare both committed. Application records onlych_B.
Note that there are four distinct UUIDs at play here: UUID_KEY_A (SpEL cache key on attempt 1), UUID_IDEMPOTENCY_A (Stripe idempotency key in method body on attempt 1), UUID_KEY_B (SpEL cache key on retry), and UUID_IDEMPOTENCY_B (Stripe idempotency key in method body on retry). Every UUID.randomUUID() call in SpEL or in the method body independently generates a new value.
Why SpEL evaluates T(java.util.UUID).randomUUID() on every expression evaluation
SpEL (Spring Expression Language) is a dynamic expression evaluator. When @Cacheable’s key attribute contains a SpEL expression, Spring evaluates the expression at method interception time — each time the @Cacheable interceptor fires. SpEL does not memoize the results of expression evaluation across calls. T(java.util.UUID).randomUUID() is a static method call expression; SpEL calls UUID.randomUUID() on the JVM each time it appears in an expression that is evaluated. There is no concept of “evaluate this expression once per logical request and cache the result” in SpEL.
The Elvis operator ?: in SpEL is a short-circuit ternary: the right-hand side expression is only evaluated if the left-hand side is null or empty. For clients that supply a non-null, non-empty requestId, T(java.util.UUID).randomUUID() is never evaluated — the left-hand side short-circuits. This is why the behavior works correctly for well-behaved API clients and only fails for clients that omit the header: the UUID generation is hidden in a code path that only fires when the input is absent.
The subtle variant: integration tests always provide X-Request-Id
Integration tests written by the billing team typically include the X-Request-Id header in all test HTTP calls — either explicitly in the test request builder or via a test fixture that adds standard headers. The fallback branch of the Elvis operator never fires in tests. The SpEL expression always evaluates to the stable requestId value. @Cacheable uses a deterministic cache key derived from requestId. No cache miss occurs on retry in the test environment. The double-charge path is only exposed by production clients — typically older agent clients or mobile clients that were built before the X-Request-Id contract was established — that do not supply the header.
The fix: compute the idempotency key before the @Cacheable scope
The Elvis fallback UUID must be generated once per logical operation, not once per SpEL expression evaluation. The generated value should be stored and reused across all retry invocations. The cleanest fix is to move the fallback key generation into the controller (before the @Cacheable proxy) and pass it as a stable method parameter:
// BillingController.java — SAFE: fallback UUID generated once per HTTP request
// in the controller, before calling the @Cacheable-annotated service method.
// If X-Request-Id header is present, use it. If absent, generate UUID_A here
// and pass it as a stable parameter — the same UUID_A is passed on @Retryable
// retries because the controller is invoked once per HTTP request, not per retry.
@RestController
@RequestMapping("/billing")
public class BillingController {
private final BillingService billingService;
@PostMapping("/charge")
public ResponseEntity<ChargeResult> charge(
@RequestBody ChargeRequest req,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
Principal principal) {
// Generate fallback UUID ONCE at the controller level (per HTTP request).
// If the client supplies X-Request-Id, use it as-is.
// If not, generate a UUID now and use it for all downstream calls
// including @Retryable retries — stable because it is evaluated here,
// not inside @Cacheable's SpEL expression or inside the @Retryable loop.
String stableRequestId = (requestId != null && !requestId.isEmpty())
? requestId
: UUID.randomUUID().toString(); // Generated ONCE per HTTP request.
ChargeResult result = billingService.createCharge(
principal.getName(), req.getBillingPeriod(), req.getAmountCents(),
stableRequestId // ← stable across all @Retryable retries
);
return ResponseEntity.ok(result);
}
}
// BillingService.java — SAFE: @Cacheable key expression uses #requestId
// (a method parameter stable per HTTP request). No T(UUID).randomUUID()
// in the SpEL expression. No UUID.randomUUID() in the method body.
@Service
public class BillingService {
private final StripeClient stripeClient;
@Cacheable(
cacheNames = "charges",
// Cache key is now derived entirely from stable method parameters.
// No UUID generation in SpEL. Same cache key on @Retryable retry
// because the parameters are unchanged across retry invocations.
key = "(#requestId != null && !#requestId.isEmpty()) ? " +
"#requestId : ('charge:' + #userId + ':' + #billingPeriod)"
)
@Retryable(
maxAttempts = 3,
value = {StripeException.class},
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public ChargeResult createCharge(String userId, String billingPeriod,
long amountCents, String requestId) {
// Idempotency key derived from stable inputs — no UUID.randomUUID() here.
// requestId was generated once in the controller (per HTTP request).
String idempotencyKey = billingPeriod + ":" + userId + ":" + requestId;
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build());
return new ChargeResult(charge.getId(), amountCents);
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
}
With stableRequestId generated once in the controller, the @Cacheable SpEL expression evaluates to the same value on every invocation (including @Retryable retries, which pass the same parameter values). The cache key is stable. If the first attempt threw and stored nothing, the retry sees a MISS — but the Stripe call uses the same idempotencyKey as attempt 1. Stripe returns ch_A from its idempotency cache. No ch_B. Once a successful result is stored in the JVM @Cacheable cache, subsequent calls for the same request ID return the cached value without touching Stripe.
Integration test patterns for all three failure modes
The three failure modes share a common testing gap: the retry path is not exercised with a WireMock scenario that captures all Idempotency-Key headers and asserts equality across all requests. The following test pattern catches all three:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0)
class BillingServiceRetryIdempotencyTest {
@Autowired private BillingService billingService;
@Autowired private BillingKeyService billingKeyService; // if using extracted key bean
@BeforeEach
void resetCache(CacheManager cacheManager) {
cacheManager.getCacheNames().forEach(name ->
Objects.requireNonNull(cacheManager.getCache(name)).clear());
}
@Test
void retryUsesStableIdempotencyKey_defaultOrdering() {
// Scenario: attempt 1 returns 503 (Stripe may have committed ch_A).
// Attempt 2 returns 200. Assert both WireMock requests carry the same
// Idempotency-Key header — ensures no ch_B.
stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("stripe-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("first-attempt-done"));
stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("stripe-retry")
.whenScenarioStateIs("first-attempt-done")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_111\",\"amount\":4900,\"currency\":\"usd\"}")));
// Provide a stable requestId so SpEL fallback branch does not fire.
billingService.createCharge("user-007", "2026-Q4", 4900L,
"test-stable-request-id-001");
List<LoggedRequest> requests = findAll(postRequestedFor(urlPathEqualTo("/v1/charges")));
assertThat(requests).hasSize(2);
String keyAttempt1 = requests.get(0).getHeader("Idempotency-Key");
String keyAttempt2 = requests.get(1).getHeader("Idempotency-Key");
assertThat(keyAttempt1)
.as("Idempotency-Key must be identical on attempt 1 and attempt 2")
.isEqualTo(keyAttempt2);
}
@Test
void retryUsesStableIdempotencyKey_nullRequestId_fallbackFromController() {
// Test the null-requestId path: controller generates UUID_A once,
// passes it to createCharge as requestId — both attempts carry UUID_A.
// This test simulates the controller behavior by passing the same
// pre-generated ID to the service method on both calls (as the controller
// would do for a single HTTP request).
String generatedOnceAtController = UUID.randomUUID().toString();
stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("stripe-null-requestid-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("attempt1-done"));
stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("stripe-null-requestid-retry")
.whenScenarioStateIs("attempt1-done")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_111\",\"amount\":4900,\"currency\":\"usd\"}")));
billingService.createCharge("user-007", "2026-Q4", 4900L,
generatedOnceAtController);
List<LoggedRequest> requests = findAll(postRequestedFor(urlPathEqualTo("/v1/charges")));
assertThat(requests).hasSize(2);
assertThat(requests.get(1).getHeader("Idempotency-Key"))
.isEqualTo(requests.get(0).getHeader("Idempotency-Key"));
}
@Test
void billingKeyService_returnsStableKeyOnEveryCall() {
// Verify that BillingKeyService.getOrCreateKey() returns the same value
// across multiple calls for the same userId + billingPeriod.
// This confirms the @Cacheable deduplication of the key generation.
String key1 = billingKeyService.getOrCreateKey("user-007", "2026-Q4");
String key2 = billingKeyService.getOrCreateKey("user-007", "2026-Q4");
String key3 = billingKeyService.getOrCreateKey("user-007", "2026-Q4");
assertThat(key1).isEqualTo(key2).isEqualTo(key3);
}
}
The critical assertion is assertThat(keyAttempt1).isEqualTo(keyAttempt2), not merely that both keys are present or match a UUID format pattern. An assertion like assertThat(keyAttempt2).matches("[0-9a-f-]{36}") passes even when keyAttempt1 and keyAttempt2 are different UUIDs — both satisfy the format regex but the billing double-charge has already occurred. Identity across retry attempts is the property under test.
The @BeforeEach cache-clearing step is important for failure mode 3: without clearing the cache between tests, a successful test run can leave cache entries that cause subsequent test runs to take cache HITs prematurely, masking the MISS behavior. In production the cache is eventually evicted or bounded, so tests must exercise the cold-cache path.
Summary table
| Mode | Root cause | When UUID_B fires |
Test gap | Fix |
|---|---|---|---|---|
| 1 | Default ordering: @Retryable outer, @Cacheable inner; @Cacheable does not cache exceptions |
@Retryable retry sees @Cacheable MISS again → method body re-runs |
Happy-path test confirms @Cacheable HIT on 2nd call; never tests 503-on-first-call path |
Stable key as method parameter, computed before both interceptors |
| 2 | Reversed ordering: @Cacheable outer, @Retryable inner; @Retryable internal retry loop bypasses @Cacheable |
@Retryable retries method body without re-entering @Cacheable → UUID_B in method body; @Cacheable stores final result ch_B |
Test confirms reversed ordering deduplicates on 2nd external call; never exercises retry-within-first-call | Separate key-generation bean with @Cacheable; Stripe call with @Retryable receives stable key |
| 3 | SpEL T(UUID).randomUUID() in key expression re-evaluates per lookup; permanent MISS on every @Retryable retry |
Every @Cacheable invocation has a different cache key; method body with UUID_B runs unconstrained |
Integration tests always provide stable X-Request-Id header; Elvis fallback branch never fires in tests |
Generate fallback UUID once in the controller (per HTTP request); pass as stable parameter |
The proxy-layer backstop
Even after applying the fixes above, there are code paths — @Recover methods that fall through to a secondary payment processor, legacy billing code that predates the content-hash key convention, third-party library integrations that manage their own retry logic — where the idempotency key may not be stable. A proxy-layer spend cap at the Stripe API key level provides a backstop that operates independently of application-layer correctness.
Setting a Keybrake vault key with a daily spend cap of expected_daily_revenue × 1.10 for the Stripe key your billing service uses means that a double-charge loop — whether from a @Cacheable/@Retryable interaction, a stale cache on a distributed restart, or an unexpected @CacheEvict clearing entries mid-retry — is capped before it becomes a billing crisis. The cap fires at the proxy layer before the charge reaches Stripe, independently of whether the application-side idempotency key was stable. A 425 Too Early from Keybrake is much easier to recover from than a ch_B in a customer’s Stripe history.
Put a spend cap on your Stripe key
Keybrake issues scoped vault keys with per-vendor daily spend caps, endpoint allowlists, and a per-call audit log. Drop it between your @Retryable billing service and Stripe — the cap fires before Stripe commits a duplicate charge, regardless of whether your @Cacheable key expression regenerated a UUID.