Spring WebMVC @RequestScope Bean and Stripe Integration: How ScopeNotActiveException UUID Fallback, RequestContextHolder Null in Background Jobs, and Non-Memoized @RequestScope Methods Generate New Idempotency Keys on @Retryable Retry
Spring’s @RequestScope provides an attractive pattern for carrying per-request state — including a stable billing idempotency key — across a chain of service calls. Three distinct failure modes arise when this pattern meets Stripe and @Retryable: UUID.randomUUID() inside a CompletableFuture.supplyAsync() lambda in a @Retryable method regenerates when @Retryable re-invokes the method because a new lambda is created with a new evaluation of the ScopeNotActiveException catch block; a RequestContextHolder.getRequestAttributes() null-check with UUID.randomUUID() fallback placed inside the @Retryable method body re-evaluates on every @Retryable attempt in background billing jobs where no HTTP request context is active; and a @RequestScope BillingContext.getIdempotencyKey() method that calls UUID.randomUUID() without memoizing the result generates a new UUID on every invocation even though the @RequestScope bean instance itself is stable for the duration of the HTTP request.
Background: how @RequestScope proxies work and where they break
Spring’s @RequestScope annotation is shorthand for @Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS). The two parts are independent: the scope determines the bean’s lifetime (one instance per HttpServletRequest) and the proxy mode determines how the bean is accessed.
Because @RequestScope beans are scoped narrower than the singleton beans that typically inject them (e.g., a singleton BillingService injected with a request-scoped BillingContext), Spring cannot store a direct reference to the bean instance. It stores a CGLIB-generated proxy instead. The proxy implements the target class interface and, on every method call, delegates to the actual bean instance by calling RequestContextHolder.currentRequestAttributes() to look up the instance in the current request scope.
This lookup succeeds only when the calling thread has a Spring request context bound to it via RequestContextHolder. The request context is bound at the entry to the Spring DispatcherServlet (or the Spring Security filter chain that precedes it) and is removed when the servlet processing completes. Threads that are not the request-processing thread — including ForkJoinPool.commonPool() threads used by CompletableFuture.supplyAsync() without an explicit executor, @Async thread pool threads, and scheduled background job threads — do not have a request context bound unless one is explicitly propagated.
When the proxy is invoked on a thread with no active request context, Spring 6.x throws org.springframework.beans.factory.support.ScopeNotActiveException (introduced in Spring Framework 5.3 as a specific subclass of IllegalStateException). In earlier Spring versions the proxy throws BeanCreationException wrapping IllegalStateException: No thread-bound request found. Both exceptions propagate out of the proxy method call and, unless caught, terminate the enclosing method.
The natural developer response to a ScopeNotActiveException on an async thread is to catch it and fall back to a locally generated UUID. This response is correct in spirit — the async thread does need an idempotency key — but the placement and stability of the fallback UUID is what introduces the double-charge failure modes described in this post.
Failure mode 1: UUID.randomUUID() inside CompletableFuture.supplyAsync() lambda — ScopeNotActiveException catch — @Retryable retry creates new lambda — UUID_B — ch_B
A billing service annotated with @Retryable delegates the actual Stripe API call to a CompletableFuture.supplyAsync() lambda for non-blocking parallelism. The developer tries to use the @RequestScope BillingContext inside the lambda and catches ScopeNotActiveException as a fallback:
// BillingService.java — UNSAFE: UUID.randomUUID() inside the supplyAsync() lambda
// The catch block is a fallback for the ScopeNotActiveException on the async thread.
// Developer mental model: the catch block fires once per chargeCustomer() call and
// produces a stable fallback key for that invocation.
// Actual behavior: @Retryable re-invokes chargeCustomer() from the top on StripeException.
// A new CompletableFuture.supplyAsync() is created with a new lambda instance.
// The catch block inside the new lambda evaluates UUID.randomUUID() again — UUID_B — ch_B.
@Service
public class BillingService {
@Autowired
private StripeClient stripeClient;
@Autowired
private BillingContext billingContext; // @RequestScope proxy — works on request thread only
@Retryable(
retryFor = { StripeException.class, IOException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
public ChargeResult chargeCustomer(String userId, long amountCents) throws Exception {
CompletableFuture<ChargeResult> future = CompletableFuture.supplyAsync(() -> {
// This lambda runs on ForkJoinPool.commonPool() — no Spring request context.
// billingContext is a CGLIB proxy. Calling any method on it invokes
// RequestContextHolder.currentRequestAttributes() under the hood.
// No request context on this thread → ScopeNotActiveException.
String idempotencyKey;
try {
idempotencyKey = billingContext.getIdempotencyKey();
} catch (ScopeNotActiveException | BeanCreationException e) {
// Fallback: generate a UUID when the request context is unavailable.
// This evaluates on EVERY @Retryable re-invocation of chargeCustomer().
idempotencyKey = userId + ":" + amountCents + ":" + UUID.randomUUID(); // UUID_B on retry
}
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) {
// StripeException is a checked exception.
// CompletableFuture.supplyAsync() requires an unchecked exception —
// wrap it so it reaches the future's exceptionally handler.
throw new CompletionException(e);
}
});
try {
return future.get();
} catch (ExecutionException e) {
// Unwrap to give @Retryable the original StripeException.
if (e.getCause() instanceof StripeException se) throw se;
if (e.getCause() instanceof IOException ioe) throw ioe;
throw e;
}
}
}
The failure sequence when Stripe commits ch_A but a transient 503 prevents the client from receiving the response:
- Caller invokes
chargeCustomer("user-007", 4900L)on the request thread. @Retryableintercepts and calls the actual method body (attempt 1).CompletableFuture.supplyAsync()submits the lambda toForkJoinPool.commonPool(). The main method thread blocks atfuture.get().- On the pool thread,
billingContext.getIdempotencyKey()is called. The CGLIB proxy finds no request context on this thread.ScopeNotActiveExceptionis thrown. - The catch block executes:
idempotencyKey = "user-007:4900:" + UUID_A. - The Stripe SDK sends the charge request with
Idempotency-Key: user-007:4900:UUID_A. Stripe processes and commitsch_A = "ch_111". Before the 200 response is delivered, a network 503 is returned. StripeExceptionis thrown from the SDK, wrapped in aCompletionException, and stored in the future.future.get()on the main thread throwsExecutionException. The catch block unwraps and rethrowsStripeException.@RetryablecatchesStripeException. It waits 1 second. It re-invokeschargeCustomer("user-007", 4900L)from the beginning of the method body.- A new
CompletableFuture.supplyAsync()call creates a new lambda instance. The lambda executes on a pool thread (possibly a different thread, possibly the same one — the request context is absent on both).billingContext.getIdempotencyKey()throwsScopeNotActiveExceptionagain. - The catch block executes again:
idempotencyKey = "user-007:4900:" + UUID_B. This is a newUUID.randomUUID()call.UUID_B ≠ UUID_A. - The Stripe SDK sends the charge request with
Idempotency-Key: user-007:4900:UUID_B. Stripe has never seen this key. Stripe createsch_B = "ch_222". - Both
ch_Aandch_Bare committed in Stripe foruser-007. The application records onlych_B.
Why the lambda placement is the mistake
The developer’s mental model is that the catch block acts as a one-time initializer for the method call: “if the request context is unavailable, generate one UUID for this billing operation and use it throughout.” This model would hold if the catch block were outside the @Retryable-intercepted method boundary. It does not hold when the catch block is inside the method body, because @Retryable re-invokes the method from its first executable statement.
The catch block is inside the lambda, which is inside the method body. On every @Retryable re-invocation:
- A new
Supplier<ChargeResult>object is created byCompletableFuture.supplyAsync(). - The new supplier’s
get()method contains the catch block with a fresh evaluation ofUUID.randomUUID(). - The previous
idempotencyKeylocal variable is garbage-collected; there is no memory ofUUID_Ain attempt 2.
The lambda creates a closure, but a closure captures variables from the enclosing scope, not invocation history. Each new lambda invocation has no access to what the previous invocation computed. The developer may be thinking of a closure as a stateful object that “remembers” its previous execution — a lambda captures variables that were defined before it, not the results it produced when it last ran.
The fix: compute the idempotency key outside the @Retryable boundary
The idempotency key must be stable across all @Retryable attempts. The only way to achieve this is to compute it in a scope that @Retryable does not control — i.e., in the CALLER of the @Retryable-annotated method:
// SAFE: idempotency key computed BEFORE the @Retryable method is called.
// The caller is the request-handling controller (on the request thread).
// @RequestScope proxy works on the request thread.
// The same key is passed to all @Retryable attempts.
@RestController
public class BillingController {
@Autowired
private BillingService billingService;
@Autowired
private BillingContext billingContext; // @RequestScope proxy — works here (request thread)
@PostMapping("/charge")
public ResponseEntity<ChargeResult> charge(@RequestBody ChargeRequest req) throws Exception {
// Compute once, on the request thread, before entering @Retryable.
// @RequestScope proxy is accessible here — no ScopeNotActiveException.
String idempotencyKey = billingContext.getIdempotencyKey();
// Pass the stable key as a parameter.
// All @Retryable retries of chargeCustomer() use the same idempotencyKey.
ChargeResult result = billingService.chargeCustomer(req.getUserId(), req.getAmountCents(), idempotencyKey);
return ResponseEntity.ok(result);
}
}
@Service
public class BillingService {
@Autowired
private StripeClient stripeClient;
@Retryable(
retryFor = { StripeException.class, IOException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
public ChargeResult chargeCustomer(String userId, long amountCents, String idempotencyKey)
throws Exception {
CompletableFuture<ChargeResult> future = CompletableFuture.supplyAsync(() -> {
// idempotencyKey is a captured final variable — the same value on every attempt.
// No @RequestScope access inside the lambda — no ScopeNotActiveException.
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 CompletionException(e);
}
});
try {
return future.get();
} catch (ExecutionException e) {
if (e.getCause() instanceof StripeException se) throw se;
if (e.getCause() instanceof IOException ioe) throw ioe;
throw e;
}
}
}
With this structure, billingContext.getIdempotencyKey() is called exactly once, on the request thread where the @RequestScope proxy is accessible. The result is passed as a method parameter to the @Retryable-annotated method. The lambda captures the parameter variable. All three @Retryable attempts use the same captured string — Stripe receives the same Idempotency-Key header on every attempt and returns the cached result for ch_A on attempt 2 and 3.
Integration test pattern
The test for Mode 1 must confirm that all @Retryable attempts send the same Idempotency-Key header, not just that the header is present and well-formed:
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BillingServiceRetryTest {
@Autowired
private BillingController billingController;
@Autowired
private BillingContext billingContext;
@BeforeEach
void setupStubs() {
// Attempt 1: Stripe processes ch_A but returns 503 (simulates network error
// after commit — the scenario that triggers the double-charge bug).
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry-scenario")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("first-attempt-done"));
// Attempt 2 and beyond: normal 200 response.
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry-scenario")
.whenScenarioStateIs("first-attempt-done")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_test\",\"object\":\"charge\",\"amount\":4900}")));
}
@Test
void allRetryAttemptsSendSameIdempotencyKey() throws Exception {
// Execute within a mock request context so @RequestScope works.
MockHttpServletRequest mockRequest = new MockHttpServletRequest();
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(mockRequest));
try {
billingController.charge(new ChargeRequest("user-007", 4900L));
} finally {
RequestContextHolder.resetRequestAttributes();
}
// Find all Stripe charge requests made during the test.
List<LoggedRequest> requests = WireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests).hasSizeGreaterThanOrEqualTo(2); // at least one retry occurred
// All requests must carry the same idempotency key.
List<String> idempotencyKeys = requests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toList());
assertThat(idempotencyKeys).doesNotContainNull();
assertThat(new HashSet<>(idempotencyKeys)).hasSize(1); // all identical
}
}
The critical assertion is hasSize(1) on the set of idempotency keys, not on the list. If both attempts carry the same key, the set has one element. If they differ (the bug), the set has two. This is the only assertion that catches the double-charge scenario — asserting that the header is non-null, or that it matches a UUID pattern, does not.
Failure mode 2: RequestContextHolder.getRequestAttributes() null-check with UUID.randomUUID() fallback inside the @Retryable method body — background billing job — every attempt generates a new UUID
The second failure mode does not involve an async lambda. It occurs when a @Retryable-annotated billing method is invoked from a background job (a @Scheduled task, a message consumer, or a CommandLineRunner) where no HTTP request context is active. The developer adds a null-check on RequestContextHolder.getRequestAttributes() inside the method body to handle the dual invocation contexts — both from HTTP request handlers and from the background job:
// BillingService.java — UNSAFE: UUID.randomUUID() fallback inside the @Retryable method body.
// When called from a background job (no active request context), getRequestAttributes() returns null.
// The else-branch generates UUID.randomUUID() — but this evaluates on EVERY @Retryable attempt.
// Developer mental model: the else-branch fires once, producing a stable fallback key.
// Actual behavior: the else-branch is inside the @Retryable method body and re-evaluates
// on attempt 2, attempt 3, etc. — UUID_B — ch_B alongside committed ch_A.
@Service
public class BillingService {
@Autowired
private StripeClient stripeClient;
@Autowired
private BillingContext billingContext; // @RequestScope proxy
@Retryable(
retryFor = { StripeException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2.0)
)
public ChargeResult chargeCustomer(String userId, long amountCents) throws StripeException {
// Dual-context key generation:
// - HTTP request context active: use @RequestScope bean (stable per-request UUID)
// - No request context (background job): fall back to locally generated UUID
String idempotencyKey;
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes != null) {
// On the request thread — @RequestScope proxy accessible.
idempotencyKey = billingContext.getIdempotencyKey();
} else {
// No request context — background job path.
// Developer intent: generate one UUID for this billing operation.
// Actual behavior: this branch executes on EVERY @Retryable attempt —
// UUID.randomUUID() re-evaluates — UUID_B on attempt 2 — ch_B.
idempotencyKey = userId + ":" + amountCents + ":" + UUID.randomUUID(); // UUID_B
}
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);
}
}
The failure sequence when the background billing job retries:
@Scheduledtask invokesbillingService.chargeCustomer("user-007", 4900L). The scheduler thread is not a request-processing thread; no request context is bound.@Retryableintercepts and calls the method body (attempt 1).RequestContextHolder.getRequestAttributes()returnsnull(no request context on the scheduler thread). The else-branch executes:idempotencyKey = "user-007:4900:" + UUID_A.- Stripe processes the charge and commits
ch_A = "ch_111". A transient 503 is returned before the client receives the response. StripeExceptionis thrown.@Retryablecatches it, waits 2 seconds, and re-invokeschargeCustomer("user-007", 4900L)from the beginning of the method body.- The method body executes for attempt 2.
RequestContextHolder.getRequestAttributes()still returnsnull— the scheduler thread still has no request context. The else-branch executes again:idempotencyKey = "user-007:4900:" + UUID_B. - Stripe receives
Idempotency-Key: user-007:4900:UUID_B. Stripe has never seen this key.ch_B = "ch_222"is created. Bothch_Aandch_Bare billed touser-007.
Why the null-check mental model breaks for @Retryable
The developer reasons: “The null-check at the top of the method acts like an if/else initializer — one of the two branches runs, and the result is used for the rest of the method call.” In a single-attempt execution, this reasoning holds: the else-branch runs once, produces UUID_A, and the Stripe call uses UUID_A.
The reasoning breaks under @Retryable because @Retryable does not have a concept of “the same method call.” From the interceptor’s perspective, a retry is a fresh invocation of the method via the Spring proxy. The proxy calls the bean method from its first line. The null-check runs again. The else-branch runs again. UUID.randomUUID() is a function call with no memory of previous calls; it generates a new UUID on every invocation regardless of what it returned before.
The developer might intuitively compare this to an if/else inside a constructor — it runs once per object creation and the result is stored. But a method body runs once per call. @Retryable makes multiple calls.
The fix: generate the idempotency key in the caller, outside the @Retryable boundary
For background jobs, the fix follows the same principle as Mode 1: compute the stable key before calling the @Retryable method and pass it as a parameter:
// SAFE: idempotency key generated once by the caller, passed as a stable parameter.
// Works for both HTTP request handlers and background job callers.
@Component
public class BackgroundBillingJob {
@Autowired
private BillingService billingService;
@Scheduled(fixedDelay = 60000)
public void processPendingBillings() {
for (PendingCharge charge : fetchPendingCharges()) {
// Generate the idempotency key ONCE per billing operation, outside @Retryable.
// Use a content-addressable key so that a crash-and-restart of the scheduler
// produces the same key for the same logical charge, not a new UUID.
String idempotencyKey = "sched:" + charge.getUserId() + ":"
+ charge.getBillingPeriod() + ":"
+ charge.getAmountCents();
// Note: for background jobs, UUID-based keys are dangerous even when generated once.
// A content-hash key (user + period + amount) is idempotent across scheduler restarts.
// UUID keys would be lost on crash. Content-hash keys survive restarts because the
// same logical charge always produces the same key.
try {
billingService.chargeCustomer(charge.getUserId(), charge.getAmountCents(), idempotencyKey);
} catch (StripeException e) {
log.error("Billing failed after all retries for charge {}: {}", charge.getId(), e.getMessage());
markChargeFailed(charge.getId());
}
}
}
}
@Service
public class BillingService {
@Retryable(retryFor = { StripeException.class }, maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2.0))
public ChargeResult chargeCustomer(String userId, long amountCents, String idempotencyKey)
throws StripeException {
// idempotencyKey is a parameter — stable across all @Retryable attempts.
// No @RequestScope access here.
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);
}
}
The content-hash key "sched:" + userId + ":" + billingPeriod + ":" + amountCents is additionally safer than a UUID for background jobs: if the scheduler crashes after Stripe commits ch_A but before the scheduler records the result, the next scheduler run recomputes the same key, sends it to Stripe, and Stripe returns the cached ch_A result. A UUID key generated at scheduling time would be lost on crash, and a new UUID on restart would create ch_B.
Integration test pattern for background job billing
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BackgroundBillingJobTest {
@Autowired
private BillingService billingService;
@BeforeEach
void setupStripe503ThenSuccess() {
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("sched-retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("first-done"));
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("sched-retry")
.whenScenarioStateIs("first-done")
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_sched\",\"object\":\"charge\",\"amount\":4900}")));
}
@Test
void backgroundJobRetriesWithSameContentHashKey() throws Exception {
String expectedKey = "sched:user-007:2026-Q4:4900";
// Call billingService directly (no HTTP context — simulates scheduler thread).
// Note: @Retryable is a Spring AOP proxy — must inject via Spring, not new().
billingService.chargeCustomer("user-007", 4900L, expectedKey);
List<LoggedRequest> stripeRequests = WireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(stripeRequests).hasSize(2); // one retry occurred
// All attempts must send the same content-hash key.
stripeRequests.forEach(r ->
assertThat(r.getHeader("Idempotency-Key")).isEqualTo(expectedKey));
}
}
Failure mode 3: @RequestScope BillingContext.getIdempotencyKey() not memoized — calls UUID.randomUUID() on every invocation — @Retryable calls it again — UUID_B
The third failure mode operates entirely on the request thread where @RequestScope works correctly. The proxy resolves without error. The @RequestScope bean instance is stable for the duration of the HTTP request. But the bean’s getIdempotencyKey() method calls UUID.randomUUID() on every invocation without caching the result, and @Retryable calls the method again on retry.
// BillingContext.java — UNSAFE: getIdempotencyKey() not memoized.
// Developer's mental model: "@RequestScope means one bean instance per request.
// Calling getIdempotencyKey() multiple times within the same request returns the same UUID
// because it's the same bean instance."
// Actual behavior: getIdempotencyKey() calls UUID.randomUUID() on every invocation.
// The same bean instance does NOT mean idempotent method calls — it means the bean's
// fields survive for the lifetime of the request. If there are no fields, the method
// has no instance state to return and re-computes its result every time it is called.
@Component
@RequestScope
public class BillingContext {
private final HttpServletRequest request;
public BillingContext(HttpServletRequest request) {
this.request = request;
}
public String getIdempotencyKey() {
// Derives prefix from request parameters (stable within request).
String userId = request.getParameter("userId");
String period = request.getParameter("billingPeriod");
// Appends UUID.randomUUID() — generates a new UUID on every call.
// Two calls to getIdempotencyKey() within the same request return different strings.
return userId + ":" + period + ":" + UUID.randomUUID(); // UUID_B on second call
}
}
// BillingService.java — UNSAFE: @Retryable calls billingContext.getIdempotencyKey() on retry.
// Attempt 1: getIdempotencyKey() → UUID_A → Stripe commits ch_A → 503 → StripeException.
// @Retryable retries: getIdempotencyKey() → UUID_B → Stripe creates ch_B → ch_B alongside ch_A.
@Service
public class BillingService {
@Autowired
private StripeClient stripeClient;
@Autowired
private BillingContext billingContext; // @RequestScope proxy
@Retryable(
retryFor = { StripeException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
public ChargeResult chargeCustomer(String userId, long amountCents) throws StripeException {
// On the request thread — @RequestScope proxy works.
// But getIdempotencyKey() is not memoized — returns a new UUID on every call.
String idempotencyKey = billingContext.getIdempotencyKey(); // UUID_A on attempt 1
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // UUID_A — Stripe commits ch_A
.build());
return new ChargeResult(charge.getId(), amountCents);
// 503 → StripeException → @Retryable retries chargeCustomer()
// → billingContext.getIdempotencyKey() called again → UUID_B → ch_B
}
}
The failure sequence:
- HTTP POST
/charge?userId=user-007&billingPeriod=2026-Q4arrives. Spring binds the request context. @Retryableintercepts and calls the method body (attempt 1).billingContext.getIdempotencyKey()is called on the request thread. The proxy resolves the@RequestScopebean successfully.getIdempotencyKey()executes:userId = "user-007",period = "2026-Q4",UUID.randomUUID()→UUID_A. Method returns"user-007:2026-Q4:UUID_A".- Stripe processes the charge and commits
ch_A = "ch_111". A transient 503 is returned. StripeExceptionthrown.@Retryablecatches it, waits 1 second, and re-invokeschargeCustomer("user-007", 4900L).- Method body executes for attempt 2.
billingContext.getIdempotencyKey()is called again. The proxy resolves the SAME@RequestScopebean instance (same HTTP request is still active).getIdempotencyKey()executes again:UUID.randomUUID()→UUID_B ≠ UUID_A. Method returns"user-007:2026-Q4:UUID_B". - Stripe receives
Idempotency-Key: user-007:2026-Q4:UUID_B. This is a new key. Stripe createsch_B = "ch_222". - Both
ch_Aandch_Bare in Stripe foruser-007/2026-Q4.
Why “same bean instance” does not mean “same return value”
The @RequestScope guarantee is: within a single HttpServletRequest lifecycle, all accesses to a @RequestScope bean resolve to the same bean instance. This is analogous to how a singleton bean always resolves to the same instance across the entire application lifetime.
But bean scope controls instance identity, not method idempotency. A singleton bean whose method calls System.currentTimeMillis() returns a different value on every call. The fact that it’s the same instance is irrelevant — the method is stateless (it reads from the system clock, not from a field). The same applies to getIdempotencyKey(): if the method calls UUID.randomUUID() without storing the result in an instance field, every call is an independent invocation of a stateless function. The bean’s identity is irrelevant.
The developer’s model — “same bean instance → same key” — would be correct if the bean cached the key in an instance field:
// SAFE: BillingContext memoizes the idempotency key in an instance field.
// @RequestScope ensures one bean instance per request — the field is initialized once
// per request and returned unchanged on all subsequent calls within the same request.
@Component
@RequestScope
public class BillingContext {
private final HttpServletRequest request;
private String idempotencyKey; // instance field — initialized once, stable thereafter
public BillingContext(HttpServletRequest request) {
this.request = request;
}
// Lazily initializes on first call within the request.
// Returns the same value on subsequent calls within the same request.
public String getIdempotencyKey() {
if (this.idempotencyKey == null) {
String userId = request.getParameter("userId");
String period = request.getParameter("billingPeriod");
if (userId != null && period != null) {
// Derive from request parameters if available.
this.idempotencyKey = userId + ":" + period + ":" + UUID.randomUUID();
} else {
// Request parameters missing — generate a pure UUID.
this.idempotencyKey = UUID.randomUUID().toString();
}
}
return this.idempotencyKey; // same value on all subsequent calls within this request
}
}
With this implementation, getIdempotencyKey() initializes this.idempotencyKey on the first call and returns the stored value on all subsequent calls. @Retryable re-invokes chargeCustomer(), which calls billingContext.getIdempotencyKey() on the same @RequestScope bean instance, which returns the stored UUID_A without re-calling UUID.randomUUID(). All @Retryable attempts send UUID_A to Stripe. The second and third attempts return the cached ch_A result.
An alternative: initialize in @PostConstruct
If the key should always incorporate request parameters (and the parameters are guaranteed to be present), @PostConstruct is a cleaner initialization point than a lazy-null-check field:
// SAFE alternative: idempotency key initialized in @PostConstruct.
// @PostConstruct runs once per @RequestScope bean instance, immediately after construction
// and dependency injection, before the bean is used by any caller.
// All subsequent calls to getIdempotencyKey() return this.idempotencyKey without re-evaluating.
@Component
@RequestScope
public class BillingContext {
private final HttpServletRequest request;
private final String idempotencyKey;
public BillingContext(HttpServletRequest request) {
this.request = request;
// Initialize in constructor — request is already injected.
// For @RequestScope, HttpServletRequest injection works because Spring wraps the
// HttpServletRequest in its own proxy that delegates to the current request.
// The constructor runs on the first access to the @RequestScope proxy, which is
// guaranteed to be from a request-handling thread with an active request context.
String userId = request.getParameter("userId");
String period = request.getParameter("billingPeriod");
this.idempotencyKey = (userId != null && period != null)
? userId + ":" + period + ":" + UUID.randomUUID()
: UUID.randomUUID().toString();
}
public String getIdempotencyKey() {
return this.idempotencyKey; // final field — immutable — returns UUID_A on every call
}
}
Because idempotencyKey is a final field initialized in the constructor, getIdempotencyKey() has no way to return a different value on a second call. The @RequestScope guarantee then becomes sufficient: the same bean instance returns the same final field value across all calls within the request, including all @Retryable retries.
Integration test that exposes the non-memoized bug
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
@AutoConfigureWireMock(port = 0)
class BillingContextMemoizationTest {
@Autowired
private MockMvc mockMvc;
@BeforeEach
void setupStripe503ThenSuccess() {
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("memo-retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("first-done"));
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("memo-retry")
.whenScenarioStateIs("first-done")
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_memo\",\"object\":\"charge\",\"amount\":4900}")));
}
@Test
void requestScopedBillingContextMemoizesKeyAcrossRetries() throws Exception {
// Execute a real HTTP request so @RequestScope is properly scoped.
mockMvc.perform(MockMvcRequestBuilders
.post("/charge?userId=user-007&billingPeriod=2026-Q4")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"amountCents\": 4900}"))
.andExpect(status().isOk());
List<LoggedRequest> stripeRequests = WireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(stripeRequests).hasSize(2);
// The key must be identical across retries.
// If BillingContext.getIdempotencyKey() is not memoized, the set will have 2 elements.
Set<String> keys = stripeRequests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(keys).hasSize(1); // fails on non-memoized implementation
// Also assert that the key format matches what BillingContext is supposed to generate.
String key = keys.iterator().next();
assertThat(key).startsWith("user-007:2026-Q4:");
assertThat(key).matches("user-007:2026-Q4:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
}
}
This test uses MockMvc (not billingService.chargeCustomer() directly) so that Spring activates the full request lifecycle including @RequestScope bean creation and destruction. Calling the service method directly from a test without going through MockMvc does not bind a request context, which means the @RequestScope proxy would throw — the bug would manifest differently than in production.
Comparison: the three failure modes side by side
| Mode | Execution thread | Where UUID.randomUUID() is called |
Why UUID_B is generated |
Fix |
|---|---|---|---|---|
| 1 | ForkJoinPool.commonPool() thread inside supplyAsync() |
Inside the async lambda, in the ScopeNotActiveException catch block |
@Retryable re-invokes the outer method → new lambda object → catch block re-evaluates UUID.randomUUID() |
Compute key in the CALLER before calling the @Retryable method; pass as a stable parameter; no @RequestScope access inside the lambda |
| 2 | Scheduler thread (no HTTP request context) | Inside the @Retryable method body, in the null-context else-branch |
@Retryable re-invokes the method → getRequestAttributes() still null → else-branch re-evaluates UUID.randomUUID() |
Generate key in the scheduler BEFORE calling the @Retryable method; use content-hash key (user + period + amount) for crash-safe idempotency |
| 3 | HTTP request thread (request context active) | Inside @RequestScope BillingContext.getIdempotencyKey() |
@Retryable calls getIdempotencyKey() again → method not memoized → UUID.randomUUID() re-evaluates even on the same bean instance |
Memoize the key in a final field initialized in the constructor, or in a lazily-initialized field with a null-guard; the @RequestScope bean instance is stable per request — use that stability to cache the key |
All three modes share one structural fact: UUID.randomUUID() is a stateless function that generates a new value on every call. The bug in each mode is that a developer places this call in a location that their mental model treats as “once per billing operation” but that actually executes once per @Retryable attempt. The counter-intuitive part is different in each mode:
- Mode 1: The counter-intuitive part is that
@Retryablecreates a new lambda on each retry, not that it replays the previous lambda. Lambdas are objects; their bodies re-execute when called. - Mode 2: The counter-intuitive part is that the
if/elseblock in the method body is not a one-time initializer — it re-executes on every method re-invocation, which@Retryabletriggers multiple times. - Mode 3: The counter-intuitive part is that bean instance identity (the
@RequestScopeguarantee) and method return value stability (the memoization guarantee) are independent properties. One does not imply the other.
Key generation strategy for the two invocation contexts
Billing services that run in both HTTP request contexts and background job contexts need a key generation strategy that is stable across @Retryable retries in both contexts. Two complementary patterns:
Content-hash keys (preferred for background jobs): "bkg:" + userId + ":" + billingPeriod + ":" + amountCents. This key is deterministic — the same inputs produce the same string without any random component. It survives scheduler restarts, multiple JVM instances, and database-driven retry queues. When Stripe receives this key on a second attempt, it returns the cached result from the first attempt. The limitation is that the key encodes a specific charge amount: if the amount changes between billing cycles, the key must incorporate a version or timestamp.
UUID keys computed at the task-planning layer (for request-driven billing): Generate UUID.randomUUID() once, when the billing task is first created (e.g., when the API request is received and validated), persist it to a database or return it to the client as part of the request acknowledgment, and then pass the persisted UUID to the @Retryable-annotated billing method. This decouples key generation from the retry loop entirely: the retry loop never generates a new UUID because it always receives the persisted one as a parameter.
The pattern that eliminates all three failure modes: generate the idempotency key once, in a scope that
@Retryablecannot re-enter, and pass it as a parameter to the@Retryablemethod. The@Retryablemethod’s signature should include the idempotency key as a required parameter, making it impossible to call without a pre-existing key.
Keybrake and @RequestScope billing patterns
Keybrake enforces idempotency at the proxy layer: when an agent calls proxy.keybrake.com/stripe/v1/charges with a vault_key, Keybrake extracts the Idempotency-Key header, logs it against the vault key in the audit table, and compares it on repeat calls within the configured window. If two calls arrive with the same vault_key but different Idempotency-Key values within the charge window, Keybrake flags the second call as a potential duplicate — it can either block it (if the policy has dedup: strict) or pass it through with a X-Keybrake-Dedup: warn header.
The three @RequestScope failure modes described here would all trigger a Keybrake dedup warning on the second Stripe attempt: the audit log would show vault_key: vk_xxx, idempotency_key_1: UUID_A, idempotency_key_2: UUID_B, status: DEDUP_WARN. This provides an audit trail for debugging billing discrepancies without requiring changes to the application code. The application still sends two different keys; Keybrake records both and surfaces the mismatch.
Detect idempotency key drift before it becomes a double charge
Keybrake logs every Stripe API call made by your agents — including the idempotency key on each attempt — and flags when two calls with the same vault key use different idempotency keys within the charge window. No code changes required in your agent.
Summary
Spring’s @RequestScope is a sound pattern for carrying stable per-request billing context, but it introduces three distinct failure modes when combined with @Retryable and Stripe:
- Mode 1 —
UUID.randomUUID()in aScopeNotActiveExceptioncatch block inside aCompletableFuture.supplyAsync()lambda inside a@Retryablemethod: the lambda is re-created on each@Retryableattempt, the catch block re-evaluates,UUID_Bis generated,ch_Bis created alongside committedch_A. Fix: compute the key in the caller before the@Retryableboundary and pass it as a parameter. - Mode 2 —
RequestContextHolder.getRequestAttributes()null-check withUUID.randomUUID()fallback inside the@Retryablemethod body, in a background job where no request context is ever active: the else-branch re-evaluates on every@Retryableattempt. Fix: generate the key in the background job caller using a content-hash formula and pass it as a parameter; use content-hash keys, not UUID keys, for background jobs. - Mode 3 —
@RequestScopeBillingContext.getIdempotencyKey()not memoized: callsUUID.randomUUID()on every invocation;@Retryablecalls the method again on retry;UUID_Breturned. Fix: memoize the key in afinalfield initialized in the constructor or in a lazily-initialized null-guard field; the@RequestScopebean instance is stable per request — use the bean’s instance lifetime to enforce key stability.
The general rule that covers all three: UUID.randomUUID() should never appear in any code path that @Retryable can re-enter. Generate the idempotency key once, in a scope that precedes the first @Retryable-intercepted method call, and pass it as a stable parameter.
Audit every Stripe call from your agents
Keybrake proxies your agent’s Stripe calls, logs every idempotency key, and flags drift across retries — before a double charge hits your customer’s card.
Related posts in this series
- Spring WebMVC Async and Stripe Integration —
DeferredResultexecutor threads,MODE_INHERITABLETHREADLOCALin thread pools, and@Async/@Retryableinteraction - Spring Boot
@Cacheableand@RetryableStripe Integration — AOP advisor ordering, cache miss on exception, and SpEL UUID key expression evaluation - Spring Security OAuth2 and Stripe Integration —
SecurityContextHolderpropagation, token refresh retry, and@Transactional+@Retryableordering - Helidon SE
FaultTolerance.builder()and Stripe Integration — programmatic retry chain composition, per-attempt virtual thread timeout interrupts, and asyncCompletionStagere-invocation - Helidon MicroProfile Fault Tolerance and Stripe Integration — CDI interceptor priority ordering,
@Timeout+@Retryvirtual thread interrupts, and JPAOptimisticLockExceptionretries