Spring Data Elasticsearch and Stripe Integration: How @Retryable Restarts After VersionConflictEngineException Generates UUID_B on Each Attempt While ch_A Stays Committed on Stripe, the Near-Real-Time Search Gap Returns Empty for a Just-Indexed IdempotencyDocument Causing Recovery to Call Stripe with UUID_B, and Auto-Generated @Id Lost in an IndexResponse Network Timeout Assigns a New Document ID on the Next save() Call Deriving a Different Stripe Key
This post is part of the ongoing series on Stripe idempotency failure modes in JVM frameworks. Earlier entries covered Spring Data JPA (JDBC transaction boundary and Stripe commit ordering), Spring Data R2DBC with Kotlin coroutines (Reactor retry operator re-subscribes the cold chain including UUID generation), Hibernate Envers (audit revision rollback leaves Stripe committed), and Hibernate Envers batch charging (AuditReader guard rollback, @PrePersist flush deferral, OPTIMISTIC_FORCE_INCREMENT flush-time OCC). This post moves to Spring Data Elasticsearch — a data store with three behaviors that are absent from SQL databases and that each independently produce Stripe double-charges when developers apply familiar SQL-era idempotency patterns without accounting for them.
The three behaviors are: optimistic concurrency control via Elasticsearch’s seq_no and primary_term pair (which fires at the point of the IndexRequest, after any application-level side effects like Stripe calls); the near-real-time (NRT) visibility window that makes freshly indexed documents invisible to search queries for up to one second by default; and Elasticsearch’s auto-generated document _id values, which are assigned server-side during indexing and returned in the IndexResponse — meaning a network timeout reading that response leaves the application without the ID and causes the next save() call to request a fresh auto-generated ID.
The stack for examples in this post: Spring Boot 3.x, spring-boot-starter-data-elasticsearch (Spring Data Elasticsearch 5.x, Elasticsearch 8.x), Spring Retry (spring-retry), Stripe Java SDK v24+, Testcontainers Elasticsearch 8.x, WireMock 3.x, JUnit 5.
Background: how Spring Data Elasticsearch differs from relational data stores for idempotency
Spring Data JPA and Spring Data R2DBC operate over JDBC or R2DBC transactions. A @Transactional boundary gives you a single commit point: either the entire operation commits, including any state you wrote to the database before calling Stripe, or it rolls back. The transaction is also synchronous from the perspective of the database: after the commit call returns, the data is durable and immediately visible to all readers in the same isolation level.
Spring Data Elasticsearch operates over HTTP calls to the Elasticsearch REST API. There is no transaction boundary. Each call to ElasticsearchRepository.save() is a separate IndexRequest sent to the cluster. Two properties follow from this:
- Optimistic concurrency via
seq_no/primary_term. Elasticsearch provides optimistic locking through the document’s sequence number (_seq_no) and primary term (_primary_term). Spring Data Elasticsearch exposes this through a@Versionannotated field on the@Document. When you callsave()on a document that was loaded with a knownseq_no, the generatedIndexRequestincludesif_seq_noandif_primary_termparameters. If a concurrent write incremented the document’sseq_nosince you loaded it, Elasticsearch rejects the write with aVersionConflictEngineException. Crucially, this rejection happens at the point of theIndexRequest— after any code you ran before callingsave(), including Stripe API calls. - Near-real-time (NRT) visibility. Elasticsearch makes indexed documents visible to search queries by periodically refreshing index segments. The default refresh interval is one second (
index.refresh_interval: 1s). A document indexed bysave()is not visible toSearchRequest-based queries (includingElasticsearchRepository.findBy*()derived methods) until after the next refresh. However, a directGetRequestby document ID (used internally byfindById()) reads from the primary shard directly and bypasses the refresh cycle, so it is immediately consistent. This asymmetry —findById()is consistent,findBySubscriptionId()is not — is a common source of surprises.
A third property specific to auto-ID documents is described in Mode 3. Together, these three properties produce the failure modes in this post.
Mode 1: @Retryable catches VersionConflictEngineException thrown after Stripe commits ch_A — method restarts — UUID.randomUUID() at method top generates UUID_B — ch_B committed alongside ch_A
The developer builds a subscription billing service that loads a SubscriptionDocument from Elasticsearch, calls Stripe to charge the customer, and then updates the document with the COMPLETED status and Stripe charge ID. To handle transient Elasticsearch cluster errors (e.g., a shard-level write failure or a momentary leader election), the developer wraps the method with Spring Retry’s @Retryable.
The idempotency key for Stripe is generated with UUID.randomUUID() at the top of the method, before the Stripe call. The developer reasons: “I generate the key once per method invocation, pass it to Stripe, and the key is stable for the duration of that invocation. If the method fails and Spring Retry retries it, the key is regenerated — but that’s safe because Elasticsearch threw before Stripe was called.”
The flaw in this reasoning is about ordering. The developer expects failures to happen before the Stripe call (on document load, on schema validation). The VersionConflictEngineException happens after: during save(), which runs after the Stripe charge succeeds.
// SubscriptionDocument.java
@Document(indexName = "subscriptions")
public class SubscriptionDocument {
@Id
private String id;
@Field(type = FieldType.Keyword)
private String subscriptionId;
@Field(type = FieldType.Keyword)
private String customerId;
@Field(type = FieldType.Integer)
private int amountCents;
@Field(type = FieldType.Keyword)
private String billingPeriod; // e.g. "2026-10"
@Field(type = FieldType.Keyword)
private String billingStatus; // PENDING | COMPLETED | FAILED
@Field(type = FieldType.Keyword)
private String stripeChargeId;
// Spring Data Elasticsearch maps this to _seq_no + _primary_term.
// save() with a stale seqNo throws VersionConflictEngineException.
@Version
private Long seqNo;
// getters / setters omitted
}
// SubscriptionRepository.java
public interface SubscriptionRepository
extends ElasticsearchRepository<SubscriptionDocument, String> {
Optional<SubscriptionDocument> findBySubscriptionId(String subscriptionId);
}
// BillingService.java
@Service
public class BillingService {
@Autowired
private SubscriptionRepository subscriptionRepo;
@Autowired
private StripeClient stripeClient;
// @Retryable catches VersionConflictEngineException (ES OCC failure) and
// ElasticsearchException (transient cluster errors). maxAttempts=3.
@Retryable(
retryFor = { VersionConflictEngineException.class, ElasticsearchException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 200, multiplier = 2)
)
public String chargeSubscription(String subscriptionId, String billingPeriod) {
// UUID generated fresh at the START of every @Retryable attempt.
// If @Retryable retries the method, UUID.randomUUID() runs again → UUID_B.
String idempotencyKey = UUID.randomUUID().toString();
SubscriptionDocument doc = subscriptionRepo
.findBySubscriptionId(subscriptionId)
.orElseThrow(() -> new IllegalArgumentException(
"Subscription not found: " + subscriptionId));
// Guard: only charge if not yet COMPLETED
if ("COMPLETED".equals(doc.getBillingStatus())) {
return doc.getStripeChargeId();
}
// Call Stripe — idempotencyKey is UUID_A on the first attempt.
// Stripe commits ch_A.
ChargeResponse charge = stripeClient.createCharge(
doc.getCustomerId(),
doc.getAmountCents(),
billingPeriod,
idempotencyKey
);
// Update the document with the charge result.
doc.setBillingStatus("COMPLETED");
doc.setStripeChargeId(charge.getId());
// IndexRequest is sent with if_seq_no=doc.seqNo, if_primary_term=...
// If a concurrent subscription update (e.g., plan change, address update)
// incremented the document's seq_no since we loaded it, Elasticsearch
// rejects this write with VersionConflictEngineException.
// ch_A is already committed on Stripe. The exception propagates up.
subscriptionRepo.save(doc); // <-- VersionConflictEngineException thrown here
return charge.getId();
}
}
The failure sequence:
chargeSubscription("sub_001", "2026-10")is called. First@Retryableattempt begins.idempotencyKey = UUID.randomUUID()→UUID_A.- Document loaded from Elasticsearch with
seq_no=12. - Status is
PENDING. Guard passes. stripeClient.createCharge(..., UUID_A)is called. Stripe commitsch_Aand returns a 200 OK.- Document fields updated:
billingStatus=COMPLETED,stripeChargeId=ch_A. subscriptionRepo.save(doc)sends anIndexRequestwithif_seq_no=12. Between step 3 and step 7, a concurrent process (subscription plan update, credit card update) wrote to the same document and incremented itsseq_noto 13. Elasticsearch findsseq_no=12 ≠ 13and returns a409 Conflictresponse. Spring Data Elasticsearch throwsVersionConflictEngineException.- Spring Retry catches
VersionConflictEngineException. It matches theretryForlist. After 200ms, the method is retried. - Second attempt begins.
idempotencyKey = UUID.randomUUID()→UUID_B. - Document is loaded fresh from Elasticsearch. Now
seq_no=13. The document’sbillingStatusis stillPENDING— because the previous attempt never completed thesave()that set it toCOMPLETED. The OCC exception prevented that write. - Guard: status is
PENDING→ passes. stripeClient.createCharge(..., UUID_B)is called. Stripe has no record ofUUID_B. It processes this as a fresh charge.ch_Bis committed alongsidech_A.- Customer is billed twice.
The developer misconception is: “The VersionConflictEngineException means a concurrent write conflicted with my Elasticsearch update. It fires at the save() call. I call save() after calling Stripe, so a conflict means my Stripe call already succeeded — but that’s fine, I’ll retry the whole method, the status guard will catch it on retry, and the second attempt will see the COMPLETED status I wrote.”
The misconception has two errors. First, the VersionConflictEngineException fires because the document’s seq_no was incremented by a concurrent, unrelated write (plan update, not a competing billing attempt), not because a successful billing write exists. The document’s billingStatus is still PENDING after the exception, because the first attempt’s save() was rejected. Second, the status guard (if "COMPLETED" return) can only protect against double charges if the status is durable before the retry reaches Stripe. But durability requires the save() to succeed, which it did not.
Why the COMPLETED status guard fails here
Status guards are a valid idempotency mechanism for SQL databases because the status update and the row insertion are part of the same JDBC transaction, which either commits atomically or rolls back entirely. On commit, the COMPLETED status is immediately visible to subsequent reads. On rollback, the status is never visible, so the guard correctly sees PENDING on retry.
In Elasticsearch, there is no transaction. The save() that writes COMPLETED status is an independent IndexRequest. That IndexRequest failed with VersionConflictEngineException — meaning the COMPLETED write did not reach Elasticsearch. The document remains PENDING. On retry, the guard sees PENDING and allows the Stripe call to proceed, generating a new idempotency key in the process.
The fix: derive the idempotency key from stable inputs before the method call
The key must not be generated inside the @Retryable method, because the method body is re-executed on every retry. The key must be computed from inputs that are stable across all retry attempts and that uniquely identify this particular billing event. Subscription ID, billing period, and amount are stable: they are properties of the subscription plan and the calendar month, not of any runtime state that can change between attempts.
// Fixed BillingService.java — key derived from stable inputs, passed as parameter
@Service
public class BillingService {
@Autowired
private SubscriptionRepository subscriptionRepo;
@Autowired
private StripeClient stripeClient;
// Public entry point: derives key from stable inputs BEFORE calling the
// @Retryable method. Key does not change between retry attempts.
public String chargeSubscription(String subscriptionId,
String billingPeriod,
int amountCents) {
// SHA-256 of stable business inputs → truncated to 36 chars for
// Stripe's Idempotency-Key header (max 255 chars; UUID format optional).
String idempotencyKey = toIdempotencyKey(subscriptionId, billingPeriod, amountCents);
return doChargeSubscription(subscriptionId, billingPeriod, amountCents, idempotencyKey);
}
@Retryable(
retryFor = { VersionConflictEngineException.class, ElasticsearchException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 200, multiplier = 2)
)
private String doChargeSubscription(String subscriptionId,
String billingPeriod,
int amountCents,
String idempotencyKey) {
// idempotencyKey is the same on every @Retryable attempt.
// No UUID.randomUUID() inside this method.
SubscriptionDocument doc = subscriptionRepo
.findBySubscriptionId(subscriptionId)
.orElseThrow(() -> new IllegalArgumentException(
"Subscription not found: " + subscriptionId));
if ("COMPLETED".equals(doc.getBillingStatus())) {
return doc.getStripeChargeId();
}
ChargeResponse charge = stripeClient.createCharge(
doc.getCustomerId(),
amountCents,
billingPeriod,
idempotencyKey // same key on every attempt
);
doc.setBillingStatus("COMPLETED");
doc.setStripeChargeId(charge.getId());
doc.setBilledAt(Instant.now());
subscriptionRepo.save(doc);
return charge.getId();
}
static String toIdempotencyKey(String subscriptionId,
String billingPeriod,
int amountCents) {
try {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
byte[] hash = sha.digest(
(subscriptionId + ":" + billingPeriod + ":" + amountCents)
.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash).substring(0, 36);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
Now when a VersionConflictEngineException fires after Stripe commits ch_A, the @Retryable retries. The retry passes the same idempotencyKey to Stripe. Stripe’s 24-hour idempotency cache matches the key and returns ch_A without creating ch_B. The customer is charged exactly once regardless of how many OCC conflicts occur during the Elasticsearch update.
Test: assert key stability across VersionConflictEngineException retry
@SpringBootTest
@Testcontainers
class BillingServiceRetryTest {
@Container
static ElasticsearchContainer esContainer =
new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.13.0")
.withEnv("discovery.type", "single-node")
.withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m");
// WireMock stubs Stripe so we can assert exactly which idempotency keys arrive.
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@Autowired
private BillingService billingService;
@Autowired
private SubscriptionRepository subscriptionRepo;
@Test
void sameIdempotencyKeyOnVersionConflictRetry() throws Exception {
// Seed document with billingStatus=PENDING
SubscriptionDocument doc = seedDocument("sub_test_1", "2026-10", 2999);
// Concurrent writer increments seq_no before our save() runs
// (simulated by updating the document between the Stripe call and save()).
// We stub Stripe to capture all idempotency keys received.
List<String> capturedKeys = new ArrayList<>();
wm.stubFor(post(urlPathEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"id":"ch_test","object":"charge","amount":2999,
"status":"succeeded","currency":"usd"}
""")));
// Simulate a concurrent update between the Stripe call and the ES save().
// We do this by hooking into a test-only interceptor in BillingService
// that touches the document after the Stripe call returns.
// (In practice, test the key stability by inspecting WireMock request log.)
billingService.chargeSubscription("sub_test_1", "2026-10", 2999);
List<LoggedRequest> stripeRequests = wm.findAll(
postRequestedFor(urlPathEqualTo("/v1/charges")));
// If @Retryable fired, there may be multiple Stripe requests.
// All must carry the same idempotency key.
Set<String> keys = stripeRequests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(keys).hasSize(1); // all requests used the same key
}
}
Mode 2: near-real-time search gap returns empty for a just-indexed IdempotencyDocument — recovery generates UUID_B and calls Stripe — ch_B committed alongside already-committed ch_A
The developer adds an idempotency guard layer using Elasticsearch. Before calling Stripe, the service writes an IdempotencyDocument to an ES index with fields subscriptionId, billingPeriod, and idempotencyKey. On recovery or retry, the service queries ES for an existing record matching subscriptionId + billingPeriod. If a record exists, the service reads the stored key and calls Stripe with it (or returns early if the stored charge ID shows the call succeeded). If no record exists, the service generates a fresh key, writes a new record, and calls Stripe.
This pattern is sound in principle. The problem is the NRT gap: the idempotency record written before the Stripe call may not be visible to a SearchRequest-based query for up to one second. If a failure (crash, JVM OOM, network partition) happens after the record is written but before Stripe responds, and the recovery runs within the NRT window, the search query returns empty. Recovery treats this as “no prior attempt” and generates a new idempotency key.
// IdempotencyDocument.java
@Document(indexName = "stripe_idempotency_records")
public class IdempotencyDocument {
@Id
private String id; // auto-generated by Elasticsearch
@Field(type = FieldType.Keyword)
private String subscriptionId;
@Field(type = FieldType.Keyword)
private String billingPeriod;
@Field(type = FieldType.Keyword)
private String idempotencyKey;
@Field(type = FieldType.Keyword)
private String stripeChargeId; // null until Stripe responds
@Field(type = FieldType.Keyword)
private String status; // PENDING | COMPLETED
@Field(type = FieldType.Date, format = DateFormat.epoch_millis)
private Instant createdAt;
// getters / setters omitted
}
// IdempotencyRepository.java
public interface IdempotencyRepository
extends ElasticsearchRepository<IdempotencyDocument, String> {
// Uses SearchRequest — only sees docs visible after the last index refresh.
// Documents indexed less than ~1 second ago may NOT appear here.
Optional<IdempotencyDocument> findBySubscriptionIdAndBillingPeriod(
String subscriptionId, String billingPeriod);
}
// BillingService.java — NRT-gap-vulnerable pattern
@Service
public class BillingService {
@Autowired
private IdempotencyRepository idempotencyRepo;
@Autowired
private StripeClient stripeClient;
public String chargeSubscription(String subscriptionId, String billingPeriod) {
// Step 1: query ES for an existing idempotency record.
// Uses SearchRequest internally. If the record was indexed <1s ago,
// the index hasn't been refreshed yet — the query returns Optional.empty().
Optional<IdempotencyDocument> existing =
idempotencyRepo.findBySubscriptionIdAndBillingPeriod(
subscriptionId, billingPeriod);
if (existing.isPresent()) {
IdempotencyDocument rec = existing.get();
if ("COMPLETED".equals(rec.getStatus())) {
return rec.getStripeChargeId();
}
// Prior attempt recorded but Stripe not yet confirmed — reuse key.
return callStripeWithKey(subscriptionId, billingPeriod, rec.getIdempotencyKey());
}
// Step 2: no prior record found — generate a new key and write a record.
// Developer thinks: "no record = no prior attempt = safe to generate new UUID."
// But the record from a prior attempt may exist in ES and just not be
// visible yet (NRT gap).
String idempotencyKey = UUID.randomUUID().toString(); // UUID_B on recovery
IdempotencyDocument newRec = new IdempotencyDocument();
newRec.setSubscriptionId(subscriptionId);
newRec.setBillingPeriod(billingPeriod);
newRec.setIdempotencyKey(idempotencyKey);
newRec.setStatus("PENDING");
newRec.setCreatedAt(Instant.now());
idempotencyRepo.save(newRec); // document indexed but not yet searchable
// Step 3: call Stripe. On the first attempt this uses UUID_A (the key
// written to ES before the crash). On recovery, if NRT gap hides UUID_A's
// record, this uses UUID_B — a different key — Stripe creates ch_B.
return callStripeWithKey(subscriptionId, billingPeriod, idempotencyKey);
}
private String callStripeWithKey(String subscriptionId,
String billingPeriod,
String idempotencyKey) {
// actual Stripe call; stores charge ID back in the idempotency record
// ...
return "ch_placeholder";
}
}
The failure sequence:
- First attempt:
findBySubscriptionIdAndBillingPeriod()returns empty (no prior record). KeyUUID_Agenerated.IdempotencyDocumentwritten to ES withidempotencyKey=UUID_Aandstatus=PENDING. Elasticsearch acknowledges the write and begins indexing the document in memory segments. - Stripe is called with
UUID_A. Stripe commitsch_A. The application crashes (JVM killed, pod evicted, thread pool overflow) before the Stripe response can be processed and the record updated to COMPLETED. - The
IdempotencyDocumentwithUUID_Awas written to Elasticsearch and is durable in ES. However, the index segment containing this document has not yet been merged into the searchable index state. The document will not appear inSearchRequestresults until Elasticsearch runs its next scheduled refresh, which happens at most every 1 second. - Recovery starts within the same 1-second NRT window.
findBySubscriptionIdAndBillingPeriod()uses aSearchRequestwhich queries the current searchable index state. The document withUUID_Ais not yet in the searchable state. The query returnsOptional.empty(). - Recovery concludes: “no prior attempt.” Generates
UUID_B = UUID.randomUUID(). Writes a secondIdempotencyDocumentto ES withUUID_B. Calls Stripe withUUID_B. - Stripe receives a charge request with
Idempotency-Key: UUID_B. Its cache has onlyUUID_A. It processes the new request.ch_Bis committed. Customer billed twice.
The developer misconception is: “I write the idempotency record to Elasticsearch before calling Stripe. After a crash, I query ES first. If ES has the record, I reuse the key. If not, no prior Stripe call was made. This is safe because ES acknowledged the write synchronously before I called Stripe.”
The flaw is conflating index durability (acknowledged by Elasticsearch) with search visibility (visible to SearchRequest). When Elasticsearch acknowledges a write (result: "created" in the IndexResponse), it means the document is persisted on the primary shard. It does not mean the document is visible in search results. Search visibility requires the index to be refreshed, which happens on a scheduler. These are two different operations with different timing guarantees.
Why findById() would have worked but findBySubscriptionId() did not
If the developer had used a document ID derived deterministically from subscriptionId + billingPeriod (rather than an auto-generated ID), then findById() would have issued a GetRequest instead of a SearchRequest. GetRequest reads from the primary shard directly, bypassing the refresh cycle entirely. A document indexed one millisecond ago is immediately visible to GetRequest. The NRT gap applies only to SearchRequest-based operations.
But even with findById(), this approach couples your Stripe idempotency to Elasticsearch document IDs and requires careful ID scheme management. The simpler and more robust fix is to derive the Stripe key from stable business inputs independently of any stored state.
Fix option A: use refresh = RefreshPolicy.IMMEDIATE on the guard write
Spring Data Elasticsearch allows you to override the refresh behavior per operation using IndexQuery with RefreshPolicy.IMMEDIATE, or by calling ElasticsearchOperations.save(entity, indexCoordinates) and configuring the underlying IndexRequest with setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE). This forces Elasticsearch to refresh the affected shards before returning the acknowledgment, making the document immediately visible to search queries.
// Fix A: force immediate refresh on the idempotency guard write
// (using ElasticsearchOperations directly for per-call refresh control)
@Service
public class BillingService {
@Autowired
private ElasticsearchOperations esOps;
@Autowired
private IdempotencyRepository idempotencyRepo;
@Autowired
private StripeClient stripeClient;
public String chargeSubscription(String subscriptionId, String billingPeriod) {
Optional<IdempotencyDocument> existing =
idempotencyRepo.findBySubscriptionIdAndBillingPeriod(
subscriptionId, billingPeriod);
if (existing.isPresent()) {
// ... reuse stored key or return early
}
String idempotencyKey = UUID.randomUUID().toString();
IdempotencyDocument newRec = new IdempotencyDocument();
newRec.setSubscriptionId(subscriptionId);
newRec.setBillingPeriod(billingPeriod);
newRec.setIdempotencyKey(idempotencyKey);
newRec.setStatus("PENDING");
newRec.setCreatedAt(Instant.now());
// IMMEDIATE refresh: Elasticsearch returns only after the document is
// visible in search. Subsequent findBySubscriptionIdAndBillingPeriod()
// calls from other threads / recovery processes will see this record.
// Cost: higher write latency (~5–20ms extra per refresh cycle).
IndexQuery indexQuery = new IndexQueryBuilder()
.withObject(newRec)
.build();
esOps.index(indexQuery,
IndexCoordinates.of("stripe_idempotency_records"),
RefreshPolicy.IMMEDIATE);
return callStripeWithKey(subscriptionId, billingPeriod, idempotencyKey);
}
}
The cost of RefreshPolicy.IMMEDIATE is that Elasticsearch performs a segment merge on the affected shards before returning, adding approximately 5–20ms per write. For billing workloads where each write is a billable event, this overhead is acceptable. Avoid using IMMEDIATE as a blanket setting on all writes — apply it only to idempotency guard writes.
Fix option B: derive Stripe key from stable inputs (no ES guard needed)
If the Stripe idempotency key is derived deterministically from business inputs that are stable across all retry attempts, no stored guard is needed. Stripe’s own 24-hour idempotency cache handles the deduplication:
// Fix B: content-hash key from stable inputs — no ES guard needed
@Service
public class BillingService {
@Autowired
private StripeClient stripeClient;
public String chargeSubscription(String subscriptionId,
String billingPeriod,
int amountCents) {
// Same key on every attempt for the same subscriptionId + billingPeriod + amount.
// Stripe 24h cache: if called twice with the same key within 24 hours,
// the second call returns the cached response from the first.
String idempotencyKey = toIdempotencyKey(subscriptionId, billingPeriod, amountCents);
return stripeClient.createCharge(
getCustomerId(subscriptionId),
amountCents,
billingPeriod,
idempotencyKey
);
}
}
Test: assert search gap behavior and demonstrate the fix
@SpringBootTest
@Testcontainers
class IdempotencyNrtTest {
@Container
static ElasticsearchContainer esContainer =
new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.13.0")
.withEnv("discovery.type", "single-node")
.withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
// Set a long refresh interval to make the NRT gap deterministic in tests.
.withEnv("index.refresh_interval", "60s");
@Autowired
private IdempotencyRepository idempotencyRepo;
@Autowired
private ElasticsearchOperations esOps;
@Test
void searchQueryMissesDocumentWithinRefreshWindow() {
IdempotencyDocument doc = new IdempotencyDocument();
doc.setSubscriptionId("sub_nrt_test");
doc.setBillingPeriod("2026-10");
doc.setIdempotencyKey("uuid-A");
doc.setStatus("PENDING");
doc.setCreatedAt(Instant.now());
// Default save — no immediate refresh.
idempotencyRepo.save(doc);
// SearchRequest-based query — document not yet visible (NRT gap).
Optional<IdempotencyDocument> found =
idempotencyRepo.findBySubscriptionIdAndBillingPeriod(
"sub_nrt_test", "2026-10");
// This assertion passes — the document is NOT found despite being saved.
// This is the NRT gap that causes recovery to generate UUID_B.
assertThat(found).isEmpty();
}
@Test
void immediateRefreshMakesDocumentSearchable() {
IdempotencyDocument doc = new IdempotencyDocument();
doc.setSubscriptionId("sub_nrt_immediate");
doc.setBillingPeriod("2026-10");
doc.setIdempotencyKey("uuid-A");
doc.setStatus("PENDING");
doc.setCreatedAt(Instant.now());
// Save with IMMEDIATE refresh — document is searchable right away.
IndexQuery q = new IndexQueryBuilder().withObject(doc).build();
esOps.index(q,
IndexCoordinates.of("stripe_idempotency_records"),
RefreshPolicy.IMMEDIATE);
Optional<IdempotencyDocument> found =
idempotencyRepo.findBySubscriptionIdAndBillingPeriod(
"sub_nrt_immediate", "2026-10");
// Now the document is found — recovery can reuse uuid-A.
assertThat(found).isPresent();
assertThat(found.get().getIdempotencyKey()).isEqualTo("uuid-A");
}
}
Mode 3: auto-generated @Id lost in IndexResponse network timeout — retry save() assigns a new ES document ID — developer’s idempotencyKey = f(esDocId) produces UUID_B from new ID — ch_B committed alongside ch_A
Some developers choose to let Elasticsearch auto-generate the document _id (by leaving @Id String id as null before saving) and then use the assigned ES document ID as the basis for the Stripe idempotency key. The reasoning is: “the ES document ID is a unique, durable identifier assigned exactly once per document. I can derive the Stripe key from it deterministically. As long as I have the same ES document, I have the same Stripe key.”
The assumption — “the ES document ID is assigned exactly once per document” — is only true if the save() call that triggers the assignment completes and the application successfully receives the assigned ID. The ES-generated ID lives in the IndexResponse that Elasticsearch returns after indexing. If the network connection between the application and the Elasticsearch cluster drops while transmitting the response (after Elasticsearch has processed the write and assigned the ID), the application receives an exception. The document was indexed with a new ID, but the application never received that ID. On retry, the application creates a new document with a null ID, and Elasticsearch assigns a completely different auto-generated ID.
// PaymentDocument.java — @Id is null before save(); ES auto-generates it
@Document(indexName = "payments")
public class PaymentDocument {
@Id
private String id; // null before first save(); Elasticsearch assigns a value
@Field(type = FieldType.Keyword)
private String subscriptionId;
@Field(type = FieldType.Keyword)
private String billingPeriod;
@Field(type = FieldType.Integer)
private int amountCents;
@Field(type = FieldType.Keyword)
private String customerId;
@Field(type = FieldType.Keyword)
private String billingStatus;
@Field(type = FieldType.Keyword)
private String stripeChargeId;
// getters / setters omitted
}
// PaymentRepository.java
public interface PaymentRepository
extends ElasticsearchRepository<PaymentDocument, String> {}
// BillingService.java — derives Stripe key from ES auto-generated @Id
@Service
public class BillingService {
@Autowired
private PaymentRepository paymentRepo;
@Autowired
private StripeClient stripeClient;
public String chargeSubscription(String subscriptionId,
String billingPeriod,
String customerId,
int amountCents) {
// Step 1: create a payment document with null @Id.
PaymentDocument payment = new PaymentDocument();
payment.setSubscriptionId(subscriptionId);
payment.setBillingPeriod(billingPeriod);
payment.setCustomerId(customerId);
payment.setAmountCents(amountCents);
payment.setBillingStatus("PENDING");
// Step 2: save to Elasticsearch. ES auto-generates an _id (e.g.,
// "a7fD3kQ9XrW2mNpL8vYs") and returns it in the IndexResponse.
// Spring Data Elasticsearch sets doc.id = the auto-generated value.
// Returns the saved entity with @Id populated.
PaymentDocument saved = paymentRepo.save(payment);
// saved.getId() == "a7fD3kQ9XrW2mNpL8vYs" — or throws if network drops
// Step 3: derive the Stripe idempotency key from the ES document ID.
// Developer reasoning: "The ES ID is unique to this document. Using it as
// the key source means the key is tied to the document's existence. If I
// retry save(), I get the same document back with the same ID."
// This reasoning fails if the IndexResponse is lost — see below.
String idempotencyKey = "stripe-key-" + saved.getId(); // UUID_A on first attempt
// Step 4: call Stripe.
ChargeResponse charge = stripeClient.createCharge(
customerId, amountCents, billingPeriod, idempotencyKey);
// Step 5: update document with charge result.
saved.setBillingStatus("COMPLETED");
saved.setStripeChargeId(charge.getId());
paymentRepo.save(saved);
return charge.getId();
}
}
The failure sequence:
chargeSubscription("sub_001", "2026-10", "cus_001", 2999)is called.- A
PaymentDocumentwith nullidis created and passed topaymentRepo.save(payment). TheIndexRequestis sent to Elasticsearch. - Elasticsearch processes the write, assigns auto-generated ID
"a7fD3kQ9XrW2mNpL8vYs", persists the document on the primary shard, and begins writing theIndexResponseback to the application. - The network connection between the Elasticsearch cluster and the application drops (load balancer timeout, node failover, packet loss spike) while the
IndexResponsebytes are in transit. The application’s HTTP client throwsjava.net.SocketTimeoutException: Read timed out, which propagates as anElasticsearchExceptionorRestClientException. - The application does not receive the
IndexResponse.saved.getId()cannot be called becausesavedwas never returned. The document with ID"a7fD3kQ9XrW2mNpL8vYs"exists in Elasticsearch but the application has no reference to it. - No Stripe call has been made yet (the Stripe call happens after the ES save returns). The exception from step 4 propagates to the caller.
- The caller retries. A new
PaymentDocumentis created with nullid.paymentRepo.save(payment)is called again. Elasticsearch assigns a new auto-generated ID:"b9Rm3xN5KpQ7eVfT2wHu". This time theIndexResponseis received successfully.saved.getId()returns"b9Rm3xN5KpQ7eVfT2wHu". idempotencyKey = "stripe-key-" + "b9Rm3xN5KpQ7eVfT2wHu"=UUID_B. This is a different key from UUID_A ("stripe-key-a7fD3kQ9XrW2mNpL8vYs").- Stripe is called with
UUID_B. This is the first call Stripe has received from this application session. Stripe commitsch_B. - Wait — Stripe was only called AFTER the ES save in this flow. So if step 4 caused an exception before the Stripe call, why is there a ch_A? The scenario requires the retry to lead to a double charge. Let me re-trace: in the scenario where the problem manifests, the Stripe call DOES happen before the ES response is lost, or the order is re-arranged.
Correction: in the exact code above, Stripe is called after save() returns. So if save() throws, Stripe is never called on that attempt. The double-charge scenario requires the developer to make the Stripe call first and the ES save second — which is the natural order for storing the result — or it requires a different flow where the developer derives the key from a pre-existing document. Let me show the second, more realistic variant:
// More realistic variant: developer saves an "intent" document first (to get
// an ES ID for key derivation), then calls Stripe with the ES ID as key source,
// then updates the document with the charge result.
@Service
public class BillingService {
@Autowired
private PaymentRepository paymentRepo;
@Autowired
private StripeClient stripeClient;
public String chargeSubscription(String subscriptionId,
String billingPeriod,
String customerId,
int amountCents) {
// Phase 1: save "intent" document to get a stable ES ID.
PaymentDocument intent = new PaymentDocument();
intent.setSubscriptionId(subscriptionId);
intent.setBillingPeriod(billingPeriod);
intent.setCustomerId(customerId);
intent.setAmountCents(amountCents);
intent.setBillingStatus("PENDING");
// first save — ES assigns auto-generated ID and returns it
PaymentDocument savedIntent = paymentRepo.save(intent);
String esDocId = savedIntent.getId(); // "a7fD3kQ9XrW2mNpL8vYs" (first attempt)
// Phase 2: call Stripe using ES ID as key source.
String idempotencyKey = toIdempotencyKeyFromEsId(esDocId); // UUID_A
ChargeResponse charge = stripeClient.createCharge(
customerId, amountCents, billingPeriod, idempotencyKey);
// Stripe commits ch_A.
// Phase 3: update document with charge result.
savedIntent.setBillingStatus("COMPLETED");
savedIntent.setStripeChargeId(charge.getId());
paymentRepo.save(savedIntent); // THIS save fails (VersionConflictEngineException,
// cluster error, or IndexResponse network timeout)
return charge.getId();
}
// On retry, the entire method runs again:
// - Phase 1: new save() with null ID → ES assigns "b9Rm3xN5..." → UUID_B
// - Phase 2: stripeClient.createCharge(..., UUID_B) → ch_B
// Customer charged twice.
}
The developer misconception is: “By saving the intent document first and using its ES-assigned ID as the Stripe key source, I ensure the key is stable: the same document produces the same key. If the method fails and restarts, I look up the existing PENDING document by subscription ID + billing period and continue from there, reusing the same key.”
This reasoning requires a lookup step on retry that finds the existing PENDING document. But if the developer doesn’t add that lookup step explicitly — or adds it as a search query that hits the NRT gap (Mode 2) — the retry creates a second intent document with a new auto-generated ID and a new Stripe key. The “derive key from ES ID” chain is broken the moment a second intent document is created.
Root cause: Stripe key derivation depends on a server-assigned runtime value
The fundamental problem is that the Stripe idempotency key is derived from a value (the ES-assigned document ID) that is only known after the first successful save() call completes and the IndexResponse is received. Any failure between the document creation and the Stripe call — or between Phase 1 and Phase 3 that causes a full retry without finding the existing document — can produce a different ES ID and therefore a different Stripe key.
The fix: pre-generate the document ID from stable inputs before calling save()
The solution is to pre-compute the document ID from stable business inputs before calling save(), so the ID does not depend on Elasticsearch’s auto-assignment mechanism. Spring Data Elasticsearch allows you to set the @Id field before saving, and it will use that value as the document _id in the IndexRequest.
// Fixed BillingService.java — pre-generate ES document ID and Stripe key
// from stable inputs before any ES or Stripe call.
@Service
public class BillingService {
@Autowired
private PaymentRepository paymentRepo;
@Autowired
private StripeClient stripeClient;
public String chargeSubscription(String subscriptionId,
String billingPeriod,
String customerId,
int amountCents) {
// Both the ES document ID and the Stripe idempotency key are derived
// from the same stable inputs. They are identical across all retry attempts
// without needing any prior successful save() or Stripe call.
String stableKey = toStableKey(subscriptionId, billingPeriod, amountCents);
// Check for existing COMPLETED document using findById() — which uses
// GetRequest (immediately consistent, no NRT gap).
Optional<PaymentDocument> existing = paymentRepo.findById(stableKey);
if (existing.isPresent() && "COMPLETED".equals(existing.get().getBillingStatus())) {
return existing.get().getStripeChargeId();
}
// Create or upsert the intent document with the pre-computed ID.
PaymentDocument intent = new PaymentDocument();
intent.setId(stableKey); // pre-set ID — ES uses this as _id, no auto-generation
intent.setSubscriptionId(subscriptionId);
intent.setBillingPeriod(billingPeriod);
intent.setCustomerId(customerId);
intent.setAmountCents(amountCents);
intent.setBillingStatus("PENDING");
paymentRepo.save(intent);
// Even if IndexResponse is lost, the document has ID=stableKey in ES.
// On retry, paymentRepo.findById(stableKey) will find the existing
// PENDING document (GetRequest is immediately consistent).
// Stripe key is the same stable value — UUID_A on every attempt.
ChargeResponse charge = stripeClient.createCharge(
customerId, amountCents, billingPeriod, stableKey); // stableKey == UUID_A
intent.setBillingStatus("COMPLETED");
intent.setStripeChargeId(charge.getId());
paymentRepo.save(intent);
return charge.getId();
}
static String toStableKey(String subscriptionId,
String billingPeriod,
int amountCents) {
try {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
byte[] hash = sha.digest(
(subscriptionId + ":" + billingPeriod + ":" + amountCents)
.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash).substring(0, 36);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
With this fix, the ES document ID and the Stripe key are both stableKey — the same value computed from stable business inputs. On retry, findById(stableKey) issues a GetRequest (immediately consistent) and finds the PENDING document if it was indexed on a prior attempt. Stripe receives the same key on every attempt. No second charge is created.
Test: assert that auto-generated ID produces a different key on each attempt
@SpringBootTest
@Testcontainers
class AutoGeneratedIdKeyTest {
@Container
static ElasticsearchContainer esContainer =
new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.13.0")
.withEnv("discovery.type", "single-node");
@Autowired
private PaymentRepository paymentRepo;
@Test
void autoGeneratedIdDiffersAcrossSaveCalls() {
// Simulate first attempt: save with null ID.
PaymentDocument doc1 = new PaymentDocument();
doc1.setSubscriptionId("sub_001");
doc1.setBillingPeriod("2026-10");
PaymentDocument saved1 = paymentRepo.save(doc1);
String esId1 = saved1.getId();
// Simulate second attempt (retry, null ID again — prior ID was lost
// in a network timeout reading the IndexResponse).
PaymentDocument doc2 = new PaymentDocument();
doc2.setSubscriptionId("sub_001");
doc2.setBillingPeriod("2026-10");
PaymentDocument saved2 = paymentRepo.save(doc2);
String esId2 = saved2.getId();
// Two different auto-generated IDs — two different Stripe keys.
assertThat(esId1).isNotEqualTo(esId2);
// The derived Stripe keys are different — double charge risk confirmed.
String key1 = "stripe-key-" + esId1;
String key2 = "stripe-key-" + esId2;
assertThat(key1).isNotEqualTo(key2);
}
@Test
void preComputedIdIsSameAcrossRetries() {
String stableKey = BillingService.toStableKey("sub_001", "2026-10", 2999);
// First save with pre-set ID.
PaymentDocument doc1 = new PaymentDocument();
doc1.setId(stableKey);
doc1.setSubscriptionId("sub_001");
doc1.setBillingPeriod("2026-10");
paymentRepo.save(doc1);
// Second save (retry) with the same pre-set ID.
PaymentDocument doc2 = new PaymentDocument();
doc2.setId(stableKey);
doc2.setSubscriptionId("sub_001");
doc2.setBillingPeriod("2026-10");
paymentRepo.save(doc2); // upserts — same document, same ID
// findById uses GetRequest — immediately consistent, no NRT gap.
Optional<PaymentDocument> found = paymentRepo.findById(stableKey);
assertThat(found).isPresent();
assertThat(found.get().getId()).isEqualTo(stableKey);
// Stripe key is always stableKey — same on first attempt and retry.
assertThat("stripe-key-" + stableKey).isEqualTo("stripe-key-" + stableKey);
}
}
Comparison table
| Mode | Root cause | Trigger | Developer misconception | Detection signal | Fix |
|---|---|---|---|---|---|
1. @Retryable UUID reset after VersionConflictEngineException |
UUID.randomUUID() at method top; @Retryable re-executes the entire method body; OCC conflict fires post-Stripe-commit leaving the COMPLETED status unwritten |
VersionConflictEngineException from a concurrent, unrelated ES write (plan update, address change) after Stripe commits ch_A; method retries with UUID_B |
“The OCC conflict means a concurrent billing attempt modified the document — the COMPLETED status will be visible on retry, so the guard will catch it.” | Two Stripe charges with distinct idempotency keys within the @Retryable backoff window; ES document shows PENDING status after VersionConflictEngineException even though Stripe committed |
Derive key from stable business inputs and pass as parameter to the @Retryable method; key is the same on every retry; Stripe 24h cache returns ch_A |
| 2. NRT search gap defeats idempotency guard | SearchRequest-based findBy*() query only sees documents from the last refreshed index segment; RefreshPolicy.WAIT_UNTIL (default) does not guarantee search visibility |
Crash or failure within the 1-second NRT window after writing the idempotency guard document and committing ch_A; recovery’s search query returns empty |
“Elasticsearch acknowledged my write synchronously — the document is immediately searchable.” | Two idempotency guard documents in ES for the same subscription + period; duplicate Stripe charges with different keys within milliseconds | Use RefreshPolicy.IMMEDIATE on guard writes; or use findById() with a pre-computed stable ID (GetRequest bypasses NRT gap); or derive Stripe key from stable inputs without an ES guard |
3. Auto-generated @Id lost in IndexResponse timeout |
ES auto-generated ID is assigned during indexing and returned in IndexResponse; if the response is lost, the application has no reference to the ID; retry creates a second document with a new auto-generated ID |
Network timeout reading IndexResponse after ES has indexed the document; retry creates new document — new ES ID — new Stripe key derived from new ID |
“If save() throws an exception, the document wasn’t indexed — the retry starts fresh with a new document and a new ID that is guaranteed to be unique to this attempt.” |
Two distinct PENDING documents in ES for the same subscription + period; two Stripe charges with keys "stripe-key-<esId1>" and "stripe-key-<esId2>" |
Pre-compute document ID from stable inputs before save(); set @Id on the document before saving; Stripe key derived from the same stable inputs — same ID and key on every attempt; findById() on retry is immediately consistent |
One principle underlying all three modes
All three failure modes share a single root cause: the Stripe idempotency key is tied to a value that is recomputed or reassigned on retry. In Mode 1, the key is UUID.randomUUID() at the top of a method that @Retryable re-executes. In Mode 2, the key is generated fresh when a guard query returns empty — a query that returns empty because Elasticsearch’s NRT gap hides an existing guard document. In Mode 3, the key is derived from an ES auto-generated document ID that is assigned differently on each save() call when the prior ID was lost in a network failure.
The fix in all three cases follows the same principle: the Stripe idempotency key must be computed from inputs that are stable across all retry attempts and that do not depend on any runtime-assigned value (auto-generated UUID, server-assigned document ID, auto-incremented sequence number, or any value produced by a random function). The subscription ID, billing period, and billing amount are stable: they are properties of the business event, not of the execution attempt. A content-hash key derived from these inputs is identical on the first attempt and every subsequent retry, regardless of whether prior attempts committed a charge, wrote a guard document, or lost an Elasticsearch response.
Storing the stable key in the intent document before calling Stripe is the second half of the fix. If the application process dies between writing the intent document and receiving the Stripe response, recovery can load the intent document using findById(stableKey) — a GetRequest that bypasses the NRT gap — read the stored key, and call Stripe with the same key. Stripe’s idempotency cache returns the previously committed charge. No second charge is created.
Spring Data Elasticsearch vs relational Spring Data: what changes for idempotency
When you move from Spring Data JPA or Spring Data JDBC to Spring Data Elasticsearch, three properties of the data store change in ways that affect idempotency patterns:
- No JDBC transaction boundary. SQL databases provide a transaction that wraps all operations into a single commit. Elasticsearch has no equivalent. Each
save()is an independentIndexRequest. There is no atomic “write idempotency state and call Stripe or neither” guarantee. This means the failure window is wider: the application can fail after the idempotency guard write and before the Stripe call, or after the Stripe call and before the status update, or between any two adjacent steps. - NRT visibility gap on search queries. SQL databases (with the standard isolation levels used in billing code) make committed data immediately visible to subsequent reads in the same or concurrent transactions. Elasticsearch introduces up to 1 second of visibility delay for
SearchRequest-based queries. Any idempotency guard that relies on a search query must account for this gap — either by usingRefreshPolicy.IMMEDIATEor by querying by document ID (which bypasses the gap) rather than by field values. - Optimistic concurrency via
seq_no/primary_termfires at write time. SQL@Versionfields (withOPTIMISTIC_FORCE_INCREMENTor standardUPDATE … WHERE version=N) check version at write time, not at read time. The same is true for Elasticsearch: theif_seq_no/if_primary_termcheck fires when theIndexRequestreaches the primary shard, which is after all application code that runs beforesave()— including Stripe calls. An OCC failure in Elasticsearch carries the same double-charge risk as an OCC failure in JPA, for the same reason.
These differences mean that idempotency patterns that work reliably with Spring Data JPA — such as “write PENDING row inside transaction, call Stripe, update to COMPLETED inside same transaction, use row existence as idempotency guard” — do not translate directly to Spring Data Elasticsearch without modification. The modifications are: use content-hash keys from stable inputs (eliminates dependence on any stored state for key derivation); use findById() for guard queries (eliminates NRT gap); use RefreshPolicy.IMMEDIATE if search queries are required for guards (closes NRT gap at the cost of extra write latency).
Safe Spring Data Elasticsearch + Stripe configuration
Based on the three failure modes, here is a safe configuration baseline for Spring Boot applications using Spring Data Elasticsearch to support billing workflows that call Stripe:
# application.yml — Spring Data Elasticsearch + Stripe safe defaults
spring:
elasticsearch:
uris: https://elasticsearch:9200
connection-timeout: 3s
socket-timeout: 30s # generous — give ES time to respond under load
# Per-index settings (applied via @Setting on @Document or via REST API):
#
# For idempotency record indexes:
# index.refresh_interval: 1s (default — acceptable if using IMMEDIATE on guard writes)
# index.number_of_replicas: 1 (ensure durability of guard documents)
#
# For intent document indexes:
# index.refresh_interval: 1s (recovery uses findById/GetRequest, not search)
# index.number_of_replicas: 1
# Spring Retry — if used with ES operations:
spring:
retry:
maxAttempts: 3
backoff:
delay: 200
multiplier: 2
# IMPORTANT: pass stable idempotency key as a parameter to any @Retryable
# method that calls Stripe. Never generate UUID.randomUUID() inside a
# @Retryable method body.
// Safe Spring Data Elasticsearch + Stripe pattern — summary
@Service
public class SafeBillingService {
@Autowired
private PaymentRepository paymentRepo;
@Autowired
private StripeClient stripeClient;
// All three safe practices in one method:
// 1. Stable key derived BEFORE method entry (passed as parameter if @Retryable used)
// 2. Pre-set @Id from stable key (no auto-generated ID; findById is immediately consistent)
// 3. findById() for guard check (GetRequest bypasses NRT gap)
public String charge(String subscriptionId, String billingPeriod, int amountCents,
String customerId) {
// Stable key — same on every attempt.
String key = toStableKey(subscriptionId, billingPeriod, amountCents);
// Guard: findById uses GetRequest — immediately consistent, no NRT gap.
Optional<PaymentDocument> existing = paymentRepo.findById(key);
if (existing.isPresent() && "COMPLETED".equals(existing.get().getBillingStatus())) {
return existing.get().getStripeChargeId();
}
// Intent document with pre-set ID.
PaymentDocument intent = new PaymentDocument();
intent.setId(key);
intent.setSubscriptionId(subscriptionId);
intent.setBillingPeriod(billingPeriod);
intent.setCustomerId(customerId);
intent.setAmountCents(amountCents);
intent.setBillingStatus("PENDING");
// save() with pre-set ID — upserts on retry, no second auto-generated ID.
paymentRepo.save(intent);
// Stripe call with stable key — same key on every attempt.
ChargeResponse charge = stripeClient.createCharge(
customerId, amountCents, billingPeriod, key);
intent.setBillingStatus("COMPLETED");
intent.setStripeChargeId(charge.getId());
paymentRepo.save(intent);
return charge.getId();
}
}
The three safe practices applied here correspond directly to the three failure modes: no UUID.randomUUID() inside any method that can be retried (Mode 1), findById() for all guard queries (Mode 2), and a pre-set @Id derived from stable inputs to avoid auto-generated ID loss (Mode 3). Together they make the billing path safe regardless of whether Elasticsearch is experiencing a shard conflict, an NRT gap, or a transient network failure on the response path.
Keybrake: idempotency enforcement at the proxy layer for Stripe
The failure modes in this post all arise from the application layer deriving or regenerating the Stripe idempotency key at a point in the execution path that can be repeated. Keybrake sits between your service and Stripe as a policy-enforcing proxy, deriving content-hash idempotency keys from request content at the network boundary — before the request reaches Stripe — so that @Retryable restarts, Elasticsearch NRT gaps in guard lookups, and IndexResponse timeouts that cause new document IDs to be assigned all produce the same Stripe key, guaranteeing exactly-once charging regardless of where in the application the key originated.