Spring Cloud Gateway and Stripe Integration: How GlobalFilter, ModifyRequestBodyGatewayFilterFactory, and Reactor contextWrite Generate New Idempotency Keys on Every Retry
Spring Cloud Gateway’s RetryGatewayFilterFactory re-executes the complete filter chain — all GatewayFilter instances and all GlobalFilter instances — on every retry attempt. An idempotency key generator placed anywhere in that chain generates a new UUID on each attempt. This is structurally distinct from the Spring Boot and Spring WebFlux patterns covered in the Spring Boot Stripe post and Spring WebFlux Stripe post, which cover service-layer @Retryable and WebClient reactive retry. SCG’s bug manifests at the gateway proxy layer: the filter chain is the retry unit, not the business method. Three SCG-specific failure modes follow: a GlobalFilter that calls UUID.randomUUID() in its filter() body, a ModifyRequestBodyGatewayFilterFactory rewrite function that injects a UUID into the proxied request body, and a Reactor contextWrite() call that generates a UUID per subscription — once per retry in SCG’s reactive execution model.
How RetryGatewayFilterFactory re-executes the filter chain
Spring Cloud Gateway routes an inbound request through an ordered list of filters: first all GlobalFilter beans ordered by their Ordered value, then the per-route GatewayFilter instances configured in the route definition. The terminal filter in the chain proxies the request to the backend via NettyRoutingFilter (or WebClientHttpRoutingFilter for the WebClient-based transport).
When a route includes a Retry gateway filter (spring.cloud.gateway.routes[0].filters: [Retry=3] or a programmatic RetryGatewayFilterFactory.apply(config)), SCG wraps the downstream chain in a retry operator. The retry is implemented as a Reactor Mono.retryWhen() on the downstream chain’s Mono<Void>. When the downstream chain terminates with a retriable exception or status code, Reactor re-subscribes to the publisher — which means the full chain from NettyRoutingFilter (or WebClientHttpRoutingFilter) upward is re-executed.
A key detail: the retry does not just re-send the proxied HTTP request. It re-subscribes to the reactive chain that starts with the route filter that precedes the routing filter. Whether a GlobalFilter re-executes depends on where in the filter ordering the RetryGatewayFilterFactory-wrapped chain starts its re-subscription. In SCG’s implementation, the retry wraps the filter chain including all GatewayFilter instances for the route and all GlobalFilter beans. Every filter that produces a Mono via chain.filter(exchange) and whose downstream produces a retriable failure will be re-subscribed. For a GlobalFilter that returns chain.filter(mutatedExchange), the retry causes GlobalFilter.filter() to be called again with the same original exchange reference (since the retry is on the downstream from that filter’s perspective, not on its own invocation) — but any code that runs before the chain.filter() call in filter() runs again, because the reactive chain re-subscribes.
The practical consequence: any UUID.randomUUID() call placed before or during a chain.filter(exchange) call in a filter that participates in SCG’s retry chain will produce a new UUID on each retry attempt.
Failure mode 1: GlobalFilter generates UUID per filter() invocation — RetryGatewayFilterFactory re-invokes filter() on each retry — UUID_B on first retry
A developer building a billing proxy on Spring Cloud Gateway adds a GlobalFilter to intercept inbound agent billing requests and inject a Stripe idempotency key before the request is forwarded downstream:
// StripeBillingGlobalFilter.java — UNSAFE: UUID.randomUUID() in filter() body.
// RetryGatewayFilterFactory re-executes the filter chain on retry.
// filter() is called once per retry attempt.
// UUID_B generated on first retry after Stripe committed ch_A during attempt 1.
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.UUID;
@Component
public class StripeBillingGlobalFilter implements GlobalFilter, Ordered {
@Override
public int getOrder() {
return -100; // runs before routing
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// WRONG: UUID.randomUUID() called every time filter() is invoked.
// RetryGatewayFilterFactory invokes the full chain — including this filter —
// on each retry attempt. UUID_B on attempt 2.
String idempotencyKey = UUID.randomUUID().toString();
ServerHttpRequest mutated = exchange.getRequest().mutate()
.header("Idempotency-Key", idempotencyKey)
.build();
return chain.filter(exchange.mutate().request(mutated).build());
}
}
The route is configured with a Retry filter:
# application.yml — route with Retry gateway filter.
spring:
cloud:
gateway:
routes:
- id: stripe_billing_route
uri: https://api.stripe.com
predicates:
- Path=/billing/charge
filters:
- Retry=3 # RetryGatewayFilterFactory: retry up to 3 times
- name: RequestRateLimiter # rate limiter on top of retry
On a billing request that hits a transient 503 from Stripe (connection timeout, Stripe infrastructure blip):
- SCG receives the inbound request.
StripeBillingGlobalFilter.filter()runs.UUID.randomUUID()→ UUID_A. Request forwarded tohttps://api.stripe.com/v1/chargeswithIdempotency-Key: UUID_A. Stripe receives the request and commits ch_A before returning a 503. RetryGatewayFilterFactorydetects the 503, determines it is retriable (default retriable status codes include 503), and re-executes the filter chain.StripeBillingGlobalFilter.filter()runs again.UUID.randomUUID()→ UUID_B. Request forwarded withIdempotency-Key: UUID_B. Stripe creates ch_B.
The customer is now charged twice.
Subtler variant: exchange.getAttributes().putIfAbsent() appears to guard against re-evaluation but is undermined by the mutated exchange
A developer who reads about the retry risk adds a guard using the exchange attribute map to cache the generated key:
// STILL UNSAFE: putIfAbsent guard looks correct but can fail with a mutated exchange.
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
exchange.getAttributes().putIfAbsent("IDEMPOTENCY_KEY", UUID.randomUUID().toString());
String idempotencyKey = exchange.getAttribute("IDEMPOTENCY_KEY");
ServerHttpRequest mutated = exchange.getRequest().mutate()
.header("Idempotency-Key", idempotencyKey)
.build();
return chain.filter(exchange.mutate().request(mutated).build());
}
The intent is sound: putIfAbsent stores UUID_A on the first invocation and returns UUID_A on subsequent ones. However, exchange.mutate().request(mutated).build() creates a new ServerWebExchange instance via DefaultServerWebExchangeBuilder. In Spring Framework 5.x, the mutated exchange shares the same attribute map reference as the parent exchange — so the guard works correctly under normal conditions.
Where it fails: when RetryGatewayFilterFactory itself calls exchange.mutate() to rebuild the request for the retry (for example, to strip or reset a request body cache, or when combined with ModifyRequestBodyGatewayFilterFactory which replaces the request body reference), the retry exchange passed back to the filter chain is a new DefaultServerWebExchange. Depending on the Spring Cloud Gateway version and the specific retry path, this new exchange may share the original attribute map or start with a fresh one. In SCG versions prior to 3.1.x, the NettyRoutingFilter’s retry logic via ReactorNettyWebSocketClient creates a new exchange for each attempt. The putIfAbsent guard’s effectiveness depends on implementation details of the SCG version in use — it is not a reliable pattern.
Fix: compute idempotency key outside the reactive chain — store in exchange attributes at chain entry
The reliable fix is to derive the idempotency key deterministically from stable request attributes — customer ID, billing period, a route-specific namespace — using a content hash. The key is computed synchronously before any reactive operator and stored in the exchange attribute map at the earliest filter in the chain:
// StripeBillingGlobalFilter.java — SAFE: deterministic content-hash idempotency key.
// Key computed from stable request attributes (no UUID.randomUUID() = no re-evaluation).
// Same (customerId, billingPeriod) produces the same key on every retry attempt.
@Component
public class StripeBillingGlobalFilter implements GlobalFilter, Ordered {
private static final String IDEMPOTENCY_KEY_ATTR = "stripe.idempotency-key";
@Override
public int getOrder() { return -100; }
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// Compute once from stable request metadata.
// putIfAbsent prevents re-computation on retry even if the exchange object differs,
// as long as the attribute map is shared — but the content hash is idempotent
// regardless, so re-computation would produce the same value.
exchange.getAttributes().computeIfAbsent(IDEMPOTENCY_KEY_ATTR, k -> {
String customerId = exchange.getRequest().getHeaders()
.getFirst("X-Customer-Id");
String billingPeriod = exchange.getRequest().getHeaders()
.getFirst("X-Billing-Period");
return stableKey(customerId, billingPeriod);
});
String idempotencyKey = exchange.getAttribute(IDEMPOTENCY_KEY_ATTR);
ServerHttpRequest mutated = exchange.getRequest().mutate()
.header("Idempotency-Key", idempotencyKey)
.build();
return chain.filter(exchange.mutate().request(mutated).build());
}
private static String stableKey(String customerId, String billingPeriod) {
String input = customerId + ":" + billingPeriod + ":scg-billing";
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash).substring(0, 32);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e); // SHA-256 always available
}
}
}
Because stableKey(customerId, billingPeriod) is a pure function, re-computation on retry — even if the exchange attribute map is not shared — produces the same value. UUID_A and UUID_B are gone; there is only one possible key value for a given (customerId, billingPeriod) pair.
Failure mode 2: ModifyRequestBodyGatewayFilterFactory rewrite function generates UUID — body re-read on every retry — UUID_B per attempt
A developer who builds the Stripe billing proxy using SCG’s ModifyRequestBodyGatewayFilterFactory to transform the request body before forwarding it to Stripe encounters a different variant of the same root cause. The SCG documentation for RetryGatewayFilterFactory notes that request body caching is required when retrying routes with a request body — the body is a consumed stream and cannot be re-read without caching. ModifyRequestBodyGatewayFilterFactory handles this automatically: it buffers the transformed body and makes it available for re-reads on retry.
What the documentation does not emphasize: the rewrite function is called each time the body is consumed, including on each retry attempt. If the rewrite function generates a UUID, each retry invocation produces a new UUID:
// BillingRouteConfig.java — UNSAFE: UUID.randomUUID() in ModifyRequestBodyGatewayFilterFactory rewrite function.
// The rewrite function is called each time the transformed body is consumed.
// RetryGatewayFilterFactory causes the body to be re-consumed on each retry.
// UUID_B on retry attempt 2.
import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyRequestBodyGatewayFilterFactory;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BillingRouteConfig {
@Bean
public RouteLocator routes(
RouteLocatorBuilder builder,
ModifyRequestBodyGatewayFilterFactory modifyRequestBodyFactory,
ObjectMapper objectMapper) {
return builder.routes()
.route("stripe_billing", r -> r
.path("/billing/charge")
.filters(f -> f
.retry(config -> config
.setRetries(3)
.setStatuses(HttpStatus.SERVICE_UNAVAILABLE))
.filter(modifyRequestBodyFactory.apply(config -> config
.setInClass(JsonNode.class)
.setOutClass(JsonNode.class)
.setRewriteFunction(JsonNode.class, JsonNode.class,
// WRONG: UUID.randomUUID() fires each time the body is read.
// RetryGatewayFilterFactory causes the body to be re-read per retry.
(exchange, body) -> {
ObjectNode modified = objectMapper.createObjectNode();
modified.setAll((ObjectNode) body);
modified.put("idempotency_key", UUID.randomUUID().toString());
return Mono.just(modified);
}))))
.uri("https://api.stripe.com"))
.build();
}
}
The sequence of events on a transient 503:
- Inbound request arrives.
ModifyRequestBodyGatewayFilterFactory’s rewrite function fires.UUID.randomUUID()→ UUID_A. Modified body with"idempotency_key": "UUID_A"is passed downstream. Stripe receives the body with UUID_A, commits ch_A, and returns 503 (infrastructure error after commit — this is the standard Stripe-commits-before-503 failure mode). RetryGatewayFilterFactoryintercepts the 503 and re-executes the downstream chain.- The routing filter must send the request body again. SCG reads the body from the cached buffer that
ModifyRequestBodyGatewayFilterFactorymaintains for retry. But “reading from the cache” in SCG’s implementation triggers the rewrite function again on the cached bytes — the factory’s design re-applies the transformation on each body read, not just the first. - The rewrite function fires again.
UUID.randomUUID()→ UUID_B. Modified body with"idempotency_key": "UUID_B"is forwarded. Stripe creates ch_B.
Subtler variant: developer tests with Retry=0 — no retry, no re-read, rewrite function fires once — test passes — retry added in production config
The developer who writes tests for the billing route uses a test application configuration with Retry=0 (no retry) because the test backend always responds successfully. The rewrite function fires once per test request. The test verifies that the idempotency key appears in the forwarded body, that it is a valid UUID, and that Stripe’s mock API returns the expected charge response. All assertions pass.
When the production configuration enables Retry=3 for transient failures, the retry path is never covered by the test suite. The first time a 503 occurs in production, the rewrite function fires with UUID_B, and a second charge is created.
The standard integration test pattern for this route — using WireMock as the Stripe backend configured to fail on attempt 1 and succeed on attempt 2 — would catch the bug immediately: WireMock would receive two POST requests with different idempotency_key values in the body, and an explicit assertion that both bodies contain the same key value would fail. But a test configured with Retry=0 or that only covers the happy path cannot expose this failure mode.
Fix: compute idempotency key before the rewrite function — store in exchange attributes — read from there inside the lambda
The rewrite function lambda can safely read from the exchange attribute map because the exchange reference is captured in the closure. If the idempotency key was placed in the exchange attributes before the ModifyRequestBodyGatewayFilterFactory filter executes (for example, by a GlobalFilter with a lower order number that runs first), the rewrite function reads a stable value regardless of how many times it is invoked:
// BillingRouteConfig.java — SAFE: idempotency key pre-computed in exchange attributes.
// IdempotencyKeyGlobalFilter (order -200) runs before ModifyRequestBodyGatewayFilterFactory.
// Rewrite function reads the pre-computed key — no UUID.randomUUID() inside the lambda.
@Bean
public RouteLocator routes(
RouteLocatorBuilder builder,
ModifyRequestBodyGatewayFilterFactory modifyRequestBodyFactory,
ObjectMapper objectMapper) {
return builder.routes()
.route("stripe_billing", r -> r
.path("/billing/charge")
.filters(f -> f
.retry(config -> config
.setRetries(3)
.setStatuses(HttpStatus.SERVICE_UNAVAILABLE))
.filter(modifyRequestBodyFactory.apply(config -> config
.setInClass(JsonNode.class)
.setOutClass(JsonNode.class)
.setRewriteFunction(JsonNode.class, JsonNode.class,
(exchange, body) -> {
// SAFE: read pre-computed stable key from exchange attributes.
// Key was computed by IdempotencyKeyGlobalFilter before this filter.
// Same value on every retry attempt — no UUID.randomUUID() here.
String idempotencyKey = exchange.getAttribute(
IdempotencyKeyGlobalFilter.IDEMPOTENCY_KEY_ATTR);
ObjectNode modified = objectMapper.createObjectNode();
modified.setAll((ObjectNode) body);
modified.put("idempotency_key", idempotencyKey);
return Mono.just(modified);
}))))
.uri("https://api.stripe.com"))
.build();
}
// IdempotencyKeyGlobalFilter.java — order -200, runs before ModifyRequestBodyGatewayFilterFactory.
// Computes stable content-hash idempotency key from request headers.
// Stores in exchange attribute map for downstream filters to read.
@Component
public class IdempotencyKeyGlobalFilter implements GlobalFilter, Ordered {
public static final String IDEMPOTENCY_KEY_ATTR = "stripe.idempotency-key";
@Override
public int getOrder() { return -200; } // runs before ModifyRequestBodyGatewayFilterFactory
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
exchange.getAttributes().computeIfAbsent(IDEMPOTENCY_KEY_ATTR, k -> {
String customerId = exchange.getRequest().getHeaders()
.getFirst("X-Customer-Id");
String billingPeriod = exchange.getRequest().getHeaders()
.getFirst("X-Billing-Period");
return stableKey(customerId, billingPeriod);
});
return chain.filter(exchange);
}
static String stableKey(String customerId, String billingPeriod) {
String input = customerId + ":" + billingPeriod + ":scg-billing";
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash).substring(0, 32);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
The IdempotencyKeyGlobalFilter runs once before the route filter chain because its getOrder() is lower (earlier in the pipeline) than ModifyRequestBodyGatewayFilterFactory’s filter. On the first request, computeIfAbsent computes and stores the stable key. The rewrite function lambda reads the stored value — the same value on every retry invocation.
An important subtlety: computeIfAbsent is idempotent here because stableKey() is a pure function. Even if the exchange attribute map were replaced on retry (which it is not in standard SCG), recomputing the key produces the same result. The safety guarantee comes from the deterministic hash, not from the caching mechanism.
Failure mode 3: UUID.randomUUID() inside Reactor contextWrite() — SCG retry re-subscribes to the filter chain Mono — contextWrite() runs per subscription — UUID_B per retry
A developer building a more sophisticated SCG billing filter uses Reactor’s subscriber context to propagate the idempotency key through the reactive pipeline instead of the exchange attribute map. The motivation is to make the key available in downstream reactive operators without threading it through method parameters:
// StripeBillingGlobalFilter.java — UNSAFE: UUID.randomUUID() inside contextWrite().
// contextWrite() is a reactive assembly-time operator that executes per subscription.
// RetryGatewayFilterFactory re-subscribes to the filter chain Mono on each retry.
// UUID.randomUUID() inside contextWrite() re-evaluates on every retry attempt.
@Component
public class StripeBillingGlobalFilter implements GlobalFilter, Ordered {
@Override
public int getOrder() { return -100; }
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return chain.filter(exchange)
// WRONG: contextWrite() lambda executes per subscription.
// SCG retry re-subscribes to chain.filter(exchange) on each attempt.
// UUID.randomUUID() generates UUID_B on the first retry subscription.
.contextWrite(ctx -> ctx.put("stripe.idempotency-key", UUID.randomUUID().toString()));
}
}
Downstream filters read the idempotency key from the subscriber context via Mono.deferContextual() or contextView():
// StripeRoutingFilter.java — reads idempotency key from Reactor context.
// context.get("stripe.idempotency-key") retrieves UUID_A on attempt 1, UUID_B on attempt 2.
@Component
public class StripeRoutingFilter implements GlobalFilter, Ordered {
@Override
public int getOrder() { return -50; } // runs after StripeBillingGlobalFilter
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return Mono.deferContextual(ctx -> {
String idempotencyKey = ctx.get("stripe.idempotency-key");
ServerHttpRequest mutated = exchange.getRequest().mutate()
.header("Idempotency-Key", idempotencyKey)
.build();
return chain.filter(exchange.mutate().request(mutated).build());
});
}
}
The bug is in how Reactor’s contextWrite() executes relative to subscriptions. contextWrite(ctx -> ctx.put(key, value)) is an operator that modifies the subscriber context when the Mono is subscribed to. It does not run at assembly time (when filter() is called and the operator chain is constructed). It runs at subscription time (when subscribe() is called on the assembled chain). In SCG’s retry model, each retry attempt corresponds to a new subscription to the downstream chain. Each new subscription triggers contextWrite(), which executes the lambda, which calls UUID.randomUUID() — UUID_B on the first retry subscription, UUID_C on the second, and so on.
Subtler variant: Mono.defer() wrapper around contextWrite() — developer assumes defer makes the computation lazy-but-once — Mono.defer() is lazy-per-subscription, not lazy-with-cache
A developer who has encountered Reactor’s assembly-vs-subscription distinction adds a Mono.defer() wrapper, thinking it will make the computation “lazy until needed but computed only once”:
// STILL UNSAFE: Mono.defer() re-evaluates the supplier lambda per subscription.
// Each retry creates a new subscription to the deferred Mono.
// UUID.randomUUID() inside the defer() supplier fires on every retry.
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return Mono.defer(() -> {
String idempotencyKey = UUID.randomUUID().toString();
return chain.filter(exchange)
.contextWrite(ctx -> ctx.put("stripe.idempotency-key", idempotencyKey));
});
}
Mono.defer(supplier) creates a new publisher per subscription by calling the supplier function each time a subscriber subscribes. It is the reactive analogue of lazy evaluation, not of caching. If the supplier generates a UUID, each subscription produces a new UUID. In SCG’s retry model, each retry creates a new subscription to the filter chain, triggering the defer supplier, triggering UUID.randomUUID() — the same bug as without defer, with extra indirection.
The analogue the developer might be reaching for is Mono.fromSupplier(supplier).cache(): a supplier-backed Mono that caches its result after the first subscription. But caching does not compose correctly with SCG’s filter chain lifecycle — a cached Mono that lives for the duration of the application will return UUID_A for every request from any customer, not just within one retry sequence.
How contextWrite() placement relative to the routing filter determines which subscriptions trigger it
It is worth clarifying how contextWrite() placement affects retry behavior. Reactor context propagates from downstream to upstream: a contextWrite() at the end of a chain affects all operators upstream of it when they subscribe. In SCG’s filter model, chain.filter(exchange) represents the rest of the filter chain downstream of the current filter.
When RetryGatewayFilterFactory wraps the downstream chain and retries on failure, the retry creates a new subscription to the wrapped downstream Mono. The exact boundary of what is re-subscribed depends on where in the chain the RetryGatewayFilterFactory filter sits. Filters with a higher order number (farther from the routing filter) are part of the retried portion of the chain. A contextWrite() in a filter that is within the retried portion runs per retry subscription.
The developer who adds contextWrite() in a filter with a very low order number (runs before everything else) might assume it runs only once per inbound request. This is true when there is no retry. With RetryGatewayFilterFactory active and the filter within the retried chain boundary, it runs per retry.
Fix: compute idempotency key synchronously in filter() body — store in exchange attributes — use contextWrite only to read from attributes if context propagation is required
// StripeBillingGlobalFilter.java — SAFE: synchronous key computation, exchange attribute storage.
// No UUID.randomUUID() in any reactive operator or lambda.
// contextWrite() propagates the key from the exchange attributes to the Reactor context
// for downstream operators — but the value is computed once synchronously.
@Component
public class StripeBillingGlobalFilter implements GlobalFilter, Ordered {
private static final String IDEMPOTENCY_KEY_ATTR = "stripe.idempotency-key";
@Override
public int getOrder() { return -100; }
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// SAFE: synchronous computation, runs each time filter() is invoked.
// Content hash — deterministic for a given (customerId, billingPeriod) pair.
// Re-computation on retry produces the same value.
String customerId = exchange.getRequest().getHeaders().getFirst("X-Customer-Id");
String billingPeriod = exchange.getRequest().getHeaders().getFirst("X-Billing-Period");
String idempotencyKey = stableKey(customerId, billingPeriod);
// Store in exchange attributes for GatewayFilters to read directly.
exchange.getAttributes().put(IDEMPOTENCY_KEY_ATTR, idempotencyKey);
// Optionally: propagate through Reactor context for downstream reactive operators
// that cannot access the exchange reference.
// The lambda captures the synchronously computed idempotencyKey string,
// not UUID.randomUUID(). Same value on every contextWrite() execution.
return chain.filter(exchange)
.contextWrite(ctx -> ctx.put(IDEMPOTENCY_KEY_ATTR, idempotencyKey));
}
private static String stableKey(String customerId, String billingPeriod) {
String input = customerId + ":" + billingPeriod + ":scg-billing";
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash).substring(0, 32);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
The key insight: the contextWrite() lambda ctx -> ctx.put(IDEMPOTENCY_KEY_ATTR, idempotencyKey) captures the local variable idempotencyKey computed synchronously in filter(). The lambda itself runs per subscription (per retry), but all it does is write the already-computed string into the context. There is no UUID.randomUUID() inside the lambda — the value was determined before the reactive chain was even assembled for this request. Whether the lambda runs once or three times, it writes the same stable content-hash value.
How these three patterns interact in a real SCG billing route
In practice, an SCG billing gateway might use all three mechanisms simultaneously: a GlobalFilter for shared setup, ModifyRequestBodyGatewayFilterFactory for per-route body transformation, and Reactor context for cross-cutting propagation. If the idempotency key is generated correctly in one place but incorrectly in another, the downstream filter that makes the Stripe call uses whichever key it receives — and if that happens to be the one generated by the contextWrite lambda on retry, UUID_B reaches Stripe.
The filter execution order in SCG determines which key wins. A GlobalFilter at order -200 that writes the stable content-hash key to the exchange attributes will be overwritten if a route-level GatewayFilter at a higher order reads UUID from the exchange attributes and re-injects a freshly generated one into the Idempotency-Key request header. Tracing the exact key value through a multi-filter chain with retries requires understanding both the filter ordering and which filters are within the RetryGatewayFilterFactory’s retry boundary.
The simplest defensible architecture is a single IdempotencyKeyGlobalFilter at the lowest order (runs first) that computes and stores the stable key, combined with a rule that no other filter in the chain calls UUID.randomUUID() for billing idempotency purposes. All downstream filters read from the exchange attribute map. The Idempotency-Key header is added exactly once, in the routing filter or a dedicated AddRequestHeaderGatewayFilterFactory filter that reads the pre-computed value from exchange attributes.
Testing SCG billing filters for retry idempotency
SCG routes can be tested using @SpringBootTest with an embedded server and WireMock as the downstream backend. The test must configure a route that includes the Retry gateway filter and verify that all retry requests carry an identical Idempotency-Key header:
// BillingRouteRetryTest.java — integration test for SCG retry idempotency.
// WireMock is the Stripe backend: fails on attempt 1, succeeds on attempt 2.
// Test captures Idempotency-Key from both requests and asserts they are identical.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"spring.cloud.gateway.routes[0].id=stripe_billing",
"spring.cloud.gateway.routes[0].uri=http://localhost:${wiremock.server.port}",
"spring.cloud.gateway.routes[0].predicates[0]=Path=/billing/charge",
"spring.cloud.gateway.routes[0].filters[0]=Retry=3",
"spring.cloud.gateway.routes[0].filters[1]=ModifyRequestBody=..."
})
@AutoConfigureWireMock(port = 0)
class BillingRouteRetryTest {
@Autowired
WebTestClient webTestClient;
@Test
void retryAttemptsCarryIdenticalIdempotencyKey() {
// WireMock: fail first attempt with 503, succeed on second.
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("stripe-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("retried"));
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("stripe-retry")
.whenScenarioStateIs("retried")
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_test\",\"status\":\"succeeded\"}")));
webTestClient.post()
.uri("/billing/charge")
.header("X-Customer-Id", "cus_123")
.header("X-Billing-Period", "2026-09")
.bodyValue("{\"amount\": 2999}")
.exchange()
.expectStatus().isOk();
// Capture all requests received by WireMock.
List<LoggedRequest> requests = WireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests).hasSize(2);
String keyAttempt1 = requests.get(0).getHeader("Idempotency-Key");
String keyAttempt2 = requests.get(1).getHeader("Idempotency-Key");
// CRITICAL assertion: both retry attempts must carry the same key.
// If GlobalFilter, ModifyRequestBody rewrite function, or contextWrite()
// calls UUID.randomUUID() per filter invocation, this assertion fails:
// keyAttempt1 = "UUID_A", keyAttempt2 = "UUID_B".
assertThat(keyAttempt2)
.as("Retry attempt must carry the same Idempotency-Key as the first attempt")
.isEqualTo(keyAttempt1);
// Confirm the key is a valid 32-character hex SHA-256 prefix.
assertThat(keyAttempt1)
.matches("[0-9a-f]{32}");
}
}
This test fails immediately on any of the three buggy patterns described above. For failure mode 1, StripeBillingGlobalFilter generates UUID_A on attempt 1 and UUID_B on attempt 2 — keyAttempt1 != keyAttempt2. For failure mode 2, the ModifyRequestBodyGatewayFilterFactory rewrite function produces different idempotency_key values in the body on each attempt (though you would need to inspect the request body, not just the header, for that variant). For failure mode 3, contextWrite() puts UUID_A on the first subscription and UUID_B on the retry subscription — the downstream filter that reads the context produces two different headers.
An additional test verifies that the key is deterministic for a given (customerId, billingPeriod) pair across multiple requests:
// Determinism test: two requests with the same customer + billing period
// must produce the same Idempotency-Key, regardless of request timing.
@Test
void idempotencyKeyIsDeterministicForSameCustomerAndPeriod() {
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"id\":\"ch_test\",\"status\":\"succeeded\"}")));
// First request.
webTestClient.post()
.uri("/billing/charge")
.header("X-Customer-Id", "cus_456")
.header("X-Billing-Period", "2026-09")
.bodyValue("{\"amount\": 4999}")
.exchange()
.expectStatus().isOk();
// Second request — same customer, same period.
webTestClient.post()
.uri("/billing/charge")
.header("X-Customer-Id", "cus_456")
.header("X-Billing-Period", "2026-09")
.bodyValue("{\"amount\": 4999}")
.exchange()
.expectStatus().isOk();
List<LoggedRequest> requests = WireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests).hasSize(2);
// Both requests must carry the same Idempotency-Key.
// A UUID.randomUUID()-based generator would produce different keys for each request.
// A content-hash generator produces the same key for the same (customer, period).
assertThat(requests.get(1).getHeader("Idempotency-Key"))
.isEqualTo(requests.get(0).getHeader("Idempotency-Key"));
}
This second test catches a class of bug that the retry test does not: a generator that is “stable within one retry sequence” but “random per request.” For example, an AtomicReference initialized in the filter bean that is set on the first filter invocation and cleared after the chain completes. On the first request it is stable across retries. On the second request it generates UUID_B, which Stripe treats as a new charge. For a billing system where the agent may call the billing endpoint multiple times for the same customer in the same period (network timeout from the agent’s side, not from Stripe’s side), this is a real second-charge scenario.
SCG as a billing proxy layer in agent architectures
Spring Cloud Gateway is a natural fit for centralizing the API governance layer in an agent-based system. Instead of embedding Stripe API key management and spend cap enforcement in every service that needs to bill, a single SCG instance proxies all billing-adjacent traffic. Agents call the gateway; the gateway enforces policies and forwards to Stripe. This architecture makes it straightforward to add cross-cutting concerns like request logging, rate limiting, and idempotency key injection in one place rather than every calling service.
The double-charge failure modes described above are the operational risk that appears when this architecture is built without understanding SCG’s retry semantics. An agent that sends a billing request and receives a 503 from the gateway will retry. If the gateway forwards the retry to Stripe with UUID_B, and Stripe committed ch_A on the first attempt, the agent has charged the customer twice without ever knowing it. The agent’s retry logic is correct at its layer; the bug is in the gateway filter chain that generates a new UUID on each forwarded attempt.
The fix architecture — IdempotencyKeyGlobalFilter at order -200, content-hash key stored in exchange attributes, all downstream filters reading from attributes — implements the idempotency governance pattern at the gateway layer. The agents do not need to generate or manage Stripe idempotency keys; the gateway enforces that every billing request to Stripe carries a key that is stable for the (customerId, billingPeriod) pair, regardless of how many times the gateway’s retry mechanism retries the downstream request.
Vault key spend cap as a financial backstop
Keybrake’s vault keys operate at the layer below SCG: between SCG and Stripe. An SCG route configured to proxy billing requests through Keybrake’s proxy at proxy.keybrake.com/stripe/v1/charges carries a vault_key_xxx instead of the real Stripe key. Keybrake enforces the policy attached to the vault key before forwarding to Stripe: {"vendor": "stripe", "daily_usd_cap": expected_total * 1.10, "allowed_endpoints": ["/v1/charges"]}.
If the SCG billing filter has a UUID re-evaluation bug that creates ch_B alongside ch_A for every billing retry, the daily spend cap on the vault key detects the anomaly at the Keybrake proxy layer. The first day the retry path is exercised in production, Keybrake observes total charges exceeding expected_total * 1.10 and rejects subsequent charge requests. The financial blast radius is bounded by the cap, not by how many customers the agent attempted to bill before someone noticed the double charges in the Stripe dashboard.
The spend cap does not fix the idempotency key bug — it stops the damage from scaling. A billing run against 10,000 customers where each billing request has a 2% chance of hitting a 503 would produce ~200 double charges without spend cap enforcement. With a cap set at expected_daily_revenue * 1.10, the first ~200 double charges are absorbed within the cap variance, but the cap prevents the scenario from reaching 2,000 or 20,000 double charges if the retry logic enters a pathological state.
The combination of correct idempotency keys (stable content-hash, computed in IdempotencyKeyGlobalFilter), a database-level pre-flight guard (INSERT ... ON CONFLICT DO NOTHING on (customer_id, billing_period)) in the downstream billing service, and a vault key daily spend cap provides three independent layers of defense at three different points in the stack. The SCG filter layer, the application database layer, and the Keybrake proxy layer each independently prevent the double-charge scenario from reaching customers.
Summary: which SCG patterns are safe and which are not
| Pattern | UUID.randomUUID() location | Safe under Retry? | Why |
|---|---|---|---|
GlobalFilter.filter() body |
Before chain.filter() |
No | Retry re-invokes filter() — UUID_B per attempt |
GlobalFilter with stableKey() |
Content hash in filter() body |
Yes | Hash of stable inputs is the same on every re-invocation |
ModifyRequestBodyGatewayFilterFactory rewrite function body |
Inside the rewrite lambda | No | Rewrite function fires per body read — retry re-reads body |
ModifyRequestBodyGatewayFilterFactory reads from exchange attributes |
Pre-computed by upstream filter | Yes | Exchange attribute value computed once before retry boundary |
contextWrite(ctx -> ctx.put(key, UUID.randomUUID())) |
Inside contextWrite lambda |
No | contextWrite runs per subscription — retry = new subscription |
contextWrite(ctx -> ctx.put(key, precomputedKey)) |
Pre-computed in filter() body (stable) |
Yes | Lambda captures stable string — same value on every execution |
Mono.defer(() -> { UUID; chain.filter() }) |
Inside defer supplier |
No | defer is lazy-per-subscription, not lazy-with-cache |
The invariant is: any code path that generates a UUID must not be inside a reactive operator whose subscription boundary coincides with SCG’s retry unit. contextWrite(), Mono.defer(), and any operator that is re-subscribed on retry are unsafe locations for UUID.randomUUID(). The exchange filter() method body is also an unsafe location when the filter is within the retried portion of the chain and the idempotency key is generated randomly rather than deterministically.
The safe location is: synchronous, deterministic computation in the filter() method body before the reactive chain is assembled, stored in the exchange attribute map, and read by all downstream filters via exchange.getAttribute().
Keybrake adds a spend cap beneath your SCG filter chain
SCG’s RetryGatewayFilterFactory re-executes filters, and even correct idempotency key logic has edge cases. A Keybrake vault key with daily_usd_cap: expected_daily_revenue * 1.10 sits between your SCG instance and Stripe — if a billing retry generates ch_B alongside ch_A for any reason, the cap stops the damage from scaling before the Stripe dashboard becomes the first alert.