Ktor, Exposed, and Stripe Integration: How Suspend Retry Functions Re-invoke Lambda Bodies, newSuspendedTransaction + While-Loop Retry Regenerates UUID Above the Transaction Boundary, and Flow.retryWhen{} Batch Billing Creates Duplicate Charges
Ktor is built coroutines-first: route handlers are suspend functions, the HttpClient API is fully coroutine-native, and the Exposed SQL library’s newSuspendedTransaction integrates transparently into coroutine scopes. When teams add retry logic in a Ktor service — via a common suspend retry HOF, a manual while-loop around an Exposed transaction, or Kotlin’s Flow.retryWhen{} in a batch route handler — the Stripe idempotency hazard appears in forms that are specific to Ktor’s idioms and different from the three modes covered in the general Ktor + Stripe post. In all three modes here, the retry mechanism re-executes a scope that contains UUID.randomUUID(), generating UUID_B on the retry attempt and creating charge ch_B alongside an already-committed ch_A.
These modes are distinct from the original Ktor post (which covered HttpRequestRetry’s modifyRequest running only on retry attempts, manual retry loops re-invoking the billing suspend function at call site, and Application.launch{} replica concurrency). The modes here focus on three Ktor application-layer patterns: the ecosystem’s idiomatic suspend retry HOF, Exposed’s newSuspendedTransaction combined with a while-loop retry, and Kotlin’s cold Flow with retryWhen{} used in a Ktor route handler for batch billing. They are structurally related to the patterns covered in the Kotlin Coroutines + @Transactional post (Arrow retry{}, Spring @Retryable on suspend functions, Flow.retryWhen{} in Spring context) but appear in a pure Ktor + Exposed stack without Spring.
Background: Ktor’s retry landscape and why idempotency is a recurring concern
Unlike Spring Boot, Ktor does not ship a first-party retry annotation or interceptor for service-layer methods. Teams working with Ktor typically implement retry in one of three ways:
- A custom suspend retry HOF: a
suspend fun retry(maxAttempts, block)utility that calls the block lambda on each attempt, catching specified exceptions and applying a backoff delay. This pattern is so common that it appears in nearly every Ktor service codebase that makes any outbound HTTP calls to payment or messaging APIs. - A manual while-loop or for-loop: direct imperative retry logic around the Exposed transaction and the Ktor
HttpClientcall, without abstracting into a HOF. Common in teams that prefer explicit control over retry behavior. - Kotlin Flows with
retryWhen{}: used when the billing logic is expressed as a reactive pipeline — batch processing viaasFlow()andflatMapMergeis a natural Ktor route handler pattern for concurrent batch billing.
In all three approaches, the same question arises: where is the idempotency key computed relative to the retry boundary? The idempotency key must be the same on every attempt for Stripe to deduplicate correctly. UUID.randomUUID() generates a new value on every call. If UUID.randomUUID() is inside any scope that re-executes per retry attempt, a transient Stripe error will generate UUID_B on the retry, and Stripe will process it as a new billing intent, committing ch_B alongside the already-committed ch_A.
Ktor’s coroutine-first architecture makes this especially easy to get wrong: because coroutines look like sequential code, the retry mechanism (whether HOF, while-loop, or retryWhen{}) does not visually announce itself as “re-executing this code block.” A developer reading val idempotencyKey = UUID.randomUUID().toString() inside a retry block reasons: “This line is just a variable declaration — it runs once.” It runs once per attempt — which is the same as running once if there are no retries, but N times if there are N−1 retries.
Mode 1: Ktor suspend retry HOF — the block lambda is called again per attempt — UUID inside the block generates UUID_B — ch_B
Because Ktor has no built-in retry mechanism for service-layer code, most Ktor teams write a utility function:
// Common Ktor utility — suspend retry HOF
// Every team that makes retry outbound calls ends up writing something like this.
suspend fun <T> retryStripe(
maxAttempts: Int = 3,
initialDelayMs: Long = 300L,
block: suspend () -> T
): T {
var lastException: Exception? = null
repeat(maxAttempts) { attempt ->
try {
return block()
} catch (e: StripeException) {
lastException = e
if (attempt < maxAttempts - 1) {
delay(initialDelayMs * (attempt + 1).toLong())
}
}
}
throw lastException!!
}
The function is straightforward: call block(), catch StripeException, delay with linear backoff, call block() again. The important detail is that block is a suspend lambda — calling block() is exactly like calling a function. Every statement inside the lambda body executes on each call. There is no mechanism that “resumes” a previous execution of the lambda; the retry HOF creates a fresh invocation of the lambda for each attempt:
// BillingService.kt — unsafe mode 1: UUID inside retryStripe{} block lambda
class BillingService(
private val httpClient: HttpClient,
private val stripeSecretKey: String
) {
suspend fun chargeCustomer(
customerId: String,
amountCents: Long
): String {
return retryStripe(maxAttempts = 3) {
// retryStripe() calls this block lambda on each retry attempt.
// Every statement inside the block re-executes per call, including
// UUID.randomUUID() — which generates a new value on every call.
// Developer's reasoning: "This is just a val declaration — it runs once."
// Correct for a non-retried call. Wrong here: the block lambda is
// called again by retryStripe() on each attempt.
val idempotencyKey = UUID.randomUUID().toString() // UNSAFE
httpClient.post("https://api.stripe.com/v1/charges") {
header("Idempotency-Key", idempotencyKey)
header("Authorization", "Bearer $stripeSecretKey")
contentType(ContentType.Application.FormUrlEncoded)
setBody(Parameters.build {
append("amount", amountCents.toString())
append("currency", "usd")
append("customer", customerId)
}.formUrlEncode())
}.body<StripeChargeResponse>().id
}
}
}
The call sequence on a transient network failure:
retryStripe(maxAttempts = 3) { block }starts.repeat(3)begins iteration 0.block()is called for attempt 1.- Inside the block lambda:
UUID.randomUUID()generates UUID_A. The KtorHttpClientmakes the POST to Stripe withIdempotency-Key: UUID_A. - Stripe receives the request. Stripe processes it and commits charge ch_A to its datastore. Before the HTTP response arrives, a network connection reset occurs. Ktor’s
HttpClientthrowsIOException(wrapped or caught asStripeExceptionby the application’s Stripe wrapper layer). - The exception propagates out of the block lambda back to
retryStripe(). The catch block setslastExceptionand callsdelay(300). - Attempt 2 (iteration 1):
block()is called again. The block lambda begins executing from the top.UUID.randomUUID()generates UUID_B — a fresh random value, unrelated to UUID_A. TheHttpClientmakes the POST withIdempotency-Key: UUID_B. - Stripe receives the request with UUID_B. Stripe looks up UUID_B in its idempotency store and finds no prior record (ch_A was stored under UUID_A; UUID_B is unknown to Stripe). Stripe processes the request as a new billing intent and commits charge ch_B.
- Attempt 2 succeeds.
retryStripe()returns ch_B’s charge ID. The caller sees one successful return value: ch_B. ch_A exists in Stripe but was never recorded in the application’s database. The customer has been charged twice.
The developer’s mental model failure in Mode 1: “This is a val declaration at the top of the lambda. It’s not inside a loop. It’s not inside a nested function. It runs once.” This reasoning is correct about Kotlin semantics for a single execution of the lambda body. It is incorrect about what retryStripe() does: it calls the lambda again. Calling a lambda body again means every statement inside it executes again — including val idempotencyKey = UUID.randomUUID().toString(). The val keyword does not make the binding persist across lambda invocations; it simply means the binding is read-only within a single execution. On the next block() call, a new binding named idempotencyKey is created with a new value.
The fix: content-hash key computed outside the retry block
Moving the key outside the retryStripe{} block but inside chargeCustomer() makes it stable for all attempts:
// BillingService.kt — safe mode 1: content-hash key outside the retry block
class BillingService(
private val httpClient: HttpClient,
private val stripeSecretKey: String
) {
suspend fun chargeCustomer(
customerId: String,
amountCents: Long,
billingPeriod: String // e.g. "2026-10" — passed by the billing job
): String {
// Content-hash key: deterministic function of billing period + customer + amount.
// Computed once when chargeCustomer() is called.
// retryStripe()'s block lambda captures it from the enclosing function scope.
// On each retry, the block lambda reads the already-computed value — UUID_A each time.
val idempotencyKey = "charge:$billingPeriod:$customerId:$amountCents"
return retryStripe(maxAttempts = 3) {
httpClient.post("https://api.stripe.com/v1/charges") {
header("Idempotency-Key", idempotencyKey) // stable closure capture
header("Authorization", "Bearer $stripeSecretKey")
contentType(ContentType.Application.FormUrlEncoded)
setBody(Parameters.build {
append("amount", amountCents.toString())
append("currency", "usd")
append("customer", customerId)
}.formUrlEncode())
}.body<StripeChargeResponse>().id
}
}
}
With this placement, attempt 1 sends Idempotency-Key: charge:2026-10:cus_001:9900. If Stripe committed ch_A before the network reset, attempt 2 sends the same key. Stripe recognizes the key and returns ch_A’s response without creating ch_B. No duplicate charge.
The nested-call trap: UUID at function scope is not safe against outer retry callers
Moving UUID to chargeCustomer()’s function scope protects against retryStripe()’s inner retries. It does not protect against an outer retry loop that calls chargeCustomer() multiple times:
// Outer billing job — retries chargeCustomer() on failure
suspend fun runBillingJob(customers: List<Customer>, billingPeriod: String) {
customers.forEach { customer ->
var jobAttempts = 0
while (jobAttempts < 3) {
jobAttempts++
try {
// Each call to chargeCustomer() re-executes the entire function body.
// If the safe variant above uses UUID.randomUUID() at function scope
// (not the content-hash variant), UUID regenerates per call here.
billingService.chargeCustomer(customer.id, customer.amountCents, billingPeriod)
break
} catch (e: StripeException) {
if (jobAttempts >= 3) throw e
delay(5000L * jobAttempts)
}
}
}
}
Each outer chargeCustomer() call is a fresh invocation: the function body re-executes from the top, UUID.randomUUID() at function scope generates UUID_B on the outer retry’s second call. The outer retry loop is itself a retry mechanism, and the function body is its retry unit. Content-hash keys — computed from billingPeriod, customerId, and amountCents — are deterministic and return the same value on every call with the same inputs, regardless of how many layers of retry surround the function.
Mode 2: Ktor + Exposed newSuspendedTransaction{} + while-loop retry — UUID inside the loop body generates UUID_B despite being “above the transaction”
Exposed’s newSuspendedTransaction() is the coroutine-friendly variant of Exposed’s blocking transaction{}. It suspends the coroutine rather than blocking the thread, making it safe to use in Ktor route handlers and services running on Ktor’s coroutine dispatchers. A common Ktor billing service pattern wraps the Exposed transaction and the Stripe call together in a single transaction block, with a while-loop at the outer scope for retry:
// BillingService.kt — unsafe mode 2: UUID inside while-loop body, above transaction
class BillingService(
private val database: Database,
private val httpClient: HttpClient,
private val stripeSecretKey: String
) {
suspend fun billCustomer(customerId: String, amountCents: Long) {
var attempts = 0
var lastError: Exception? = null
while (attempts < 3) {
attempts++
// Developer moves UUID "outside the newSuspendedTransaction{} block" —
// reasoning: "The transaction block is what fails and retries. UUID is
// at the outer while-loop scope, above the transaction. It's stable."
// BUG: the while-loop body is also the retry unit.
// val inside a Kotlin while-loop body is a new binding per iteration.
// Every iteration of the while loop re-evaluates the right-hand side:
// UUID.randomUUID() generates UUID_B on iteration 2.
val idempotencyKey = UUID.randomUUID().toString() // UNSAFE
try {
newSuspendedTransaction(Dispatchers.IO, database) {
// Exposed transaction: record billing attempt, call Stripe, record result.
val attemptId = BillingAttempts.insertAndGetId {
it[this.customerId] = customerId
it[idempotency] = idempotencyKey
it[status] = "pending"
}.value
val response = callStripe(customerId, amountCents, idempotencyKey)
BillingAttempts.update({ BillingAttempts.id eq attemptId }) {
it[chargeId] = response.id
it[status] = "succeeded"
}
}
return // success — exit the while loop
} catch (e: Exception) {
lastError = e
if (attempts < 3) delay(1000L * attempts)
}
}
throw lastError!!
}
private suspend fun callStripe(
customerId: String,
amountCents: Long,
idempotencyKey: String
): StripeChargeResponse {
return httpClient.post("https://api.stripe.com/v1/charges") {
header("Idempotency-Key", idempotencyKey)
header("Authorization", "Bearer $stripeSecretKey")
contentType(ContentType.Application.FormUrlEncoded)
setBody(Parameters.build {
append("amount", amountCents.toString())
append("currency", "usd")
append("customer", customerId)
}.formUrlEncode())
}.body()
}
}
The call sequence on a transient Stripe network failure:
- Iteration 1:
attempts = 1.val idempotencyKey = UUID.randomUUID().toString()executes — UUID_A generated.newSuspendedTransaction()starts transaction TX-1.BillingAttempts.insertAndGetId{}inserts a “pending” record.callStripe()makes the HTTP call withIdempotency-Key: UUID_A. Stripe commits ch_A. Before the HTTP response arrives, the connection drops.callStripe()throwsIOException. - The exception propagates through
newSuspendedTransaction()— TX-1 rolls back (the “pending” insert is rolled back). The exception propagates to the while-loop’s catch block.lastError = e.delay(1000)suspends the coroutine for 1 second. - Iteration 2:
attempts = 2. The while-loop body begins executing from the top.val idempotencyKey = UUID.randomUUID().toString()executes again — this is a newvalbinding for iteration 2’s execution of the while-loop body.UUID.randomUUID()generates UUID_B.callStripe()is called withIdempotency-Key: UUID_B. - Stripe receives UUID_B. Stripe has no record of UUID_B (ch_A was committed under UUID_A). Stripe processes the request and commits ch_B. The customer has been charged twice: ch_A from iteration 1 (pre-network-drop) and ch_B from iteration 2.
The developer’s mental model failure in Mode 2: “I moved UUID above the newSuspendedTransaction{} block. The transaction is what restarts. UUID is at the outer scope, not inside the transaction.” This reasoning identifies the wrong retry unit. Both the while-loop and the transaction block restart on failure. On a network exception from callStripe(), the transaction rolls back and the while-loop iterates. UUID is inside the while-loop body — “above the transaction” and “inside the retry loop” are not mutually exclusive. A Kotlin val declaration inside a while-loop body creates a new binding on every iteration; it is not hoisted to the enclosing function scope. The developer correctly identifies the transaction as a source of re-execution but misses that the while loop is also a source of re-execution.
Progression of failed partial fixes
When developers work through this bug, they often go through a sequence of partially correct fixes:
Attempt 1 — move UUID outside the transaction, inside the loop (the broken code above): “UUID is above the transaction block now.” Still inside the loop. Fails.
Attempt 2 — move UUID above the while loop, inside the function:
suspend fun billCustomer(customerId: String, amountCents: Long) {
// Moved above the while loop — stable for this function's retry loop.
val idempotencyKey = UUID.randomUUID().toString()
var attempts = 0
while (attempts < 3) {
attempts++
try {
newSuspendedTransaction(Dispatchers.IO, database) { /* ... */ }
return
} catch (e: Exception) { /* ... */ }
}
}
This is correct for the while-loop within billCustomer(): UUID is at function scope, computed once when billCustomer() is called, captured by the newSuspendedTransaction{} block via closure. The while-loop’s retry iterations all use UUID_A. However, if billCustomer() is called from an outer retry mechanism that re-invokes the function on failure, the function body re-executes on each outer retry call and UUID.randomUUID() generates UUID_B on the second outer call. The function-scope placement fixes the inner retry but not any outer retry that calls the function again.
The fix that works at all nesting depths — content-hash key as a parameter:
// BillingService.kt — safe mode 2: content-hash key passed as parameter
class BillingService(
private val database: Database,
private val httpClient: HttpClient,
private val stripeSecretKey: String
) {
// idempotencyKey is passed from the caller, computed from stable billing parameters.
// The function does not generate the key — it receives it.
// Safe regardless of whether the caller has a retry loop that re-invokes this function.
suspend fun billCustomer(
customerId: String,
amountCents: Long,
idempotencyKey: String // e.g. "charge:2026-10:cus_001:9900"
) {
var attempts = 0
var lastError: Exception? = null
while (attempts < 3) {
attempts++
try {
newSuspendedTransaction(Dispatchers.IO, database) {
val attemptId = BillingAttempts.insertAndGetId {
it[this.customerId] = customerId
it[idempotency] = idempotencyKey
it[status] = "pending"
}.value
val response = callStripe(customerId, amountCents, idempotencyKey)
BillingAttempts.update({ BillingAttempts.id eq attemptId }) {
it[chargeId] = response.id
it[status] = "succeeded"
}
}
return
} catch (e: Exception) {
lastError = e
if (attempts < 3) delay(1000L * attempts)
}
}
throw lastError!!
}
}
// Caller: billing job with content-hash key
suspend fun runBillingJob(customers: List<Customer>, billingPeriod: String) {
customers.forEach { customer ->
// Content-hash key: deterministic, computed at job scope.
// Same value if the outer job retries billCustomer() on failure.
val key = "charge:$billingPeriod:${customer.id}:${customer.amountCents}"
billingService.billCustomer(customer.id, customer.amountCents, key)
}
}
Exposed transaction + Stripe call inside newSuspendedTransaction and runBlocking anti-pattern
One subtlety with Exposed + Ktor: Ktor’s HttpClient is a coroutine API — it uses suspend functions. Exposed’s newSuspendedTransaction(Dispatchers.IO) runs its block on Dispatchers.IO via a coroutine dispatcher, so the block is a coroutine and can call other suspend functions. This means the Stripe httpClient.post() call can be made directly inside newSuspendedTransaction{} without runBlocking{}. Teams that use runBlocking{} inside newSuspendedTransaction{} to call the Ktor HttpClient are introducing potential thread starvation in the Dispatchers.IO thread pool: runBlocking{} blocks the IO thread while waiting for the coroutine inside to complete, reducing available IO threads for other operations. The correct pattern is to call the Ktor HttpClient’s suspend function directly from inside the newSuspendedTransaction{} block.
A second Exposed + Stripe consideration: if the Stripe call succeeds but the subsequent DB write fails (a unique constraint on idempotency_key, for example), the Exposed transaction rolls back. On retry, the while-loop attempts to insert a new “pending” record and re-call Stripe with the same idempotency key. Stripe deduplicates and returns ch_A’s response. The DB write is retried in a fresh transaction. This is the correct behavior, and it depends on the idempotency key being stable across retries — which is only guaranteed if the key is a content-hash, not a UUID.randomUUID().
Mode 3: Ktor route handler + Kotlin Flow.retryWhen{} batch billing — flow{} builder re-executes per re-collection — UUID inside the builder generates UUID_B — ch_B — blast radius scales with batch size
Ktor route handlers are suspend functions, and Kotlin’s Flow API integrates naturally with the coroutine scope provided by Ktor’s routing DSL. A common pattern for batch billing in a Ktor route handler: receive a list of customers, process them concurrently using asFlow() + flatMapMerge, and respond with results. Per-customer retry is added via retryWhen{} on each customer’s individual flow{}:
// Ktor route handler — unsafe mode 3: UUID inside flow{} builder
fun Route.billingRoutes(httpClient: HttpClient, stripeSecretKey: String) {
post("/api/billing/batch") {
val batchRequest = call.receive<BillingBatchRequest>()
val chargeIds: List<String> = coroutineScope {
batchRequest.customers.asFlow()
.map { customer ->
// Creates a new cold Flow per customer.
// flow{} builder lambda does NOT execute here — this is just
// creating a Flow object with the builder lambda attached.
flow {
// This builder lambda executes when the flow is collected.
// retryWhen{} triggers re-collection on matching exceptions.
// Each re-collection executes this builder lambda from the top.
// UUID.randomUUID() inside the builder generates UUID_B on re-collection.
val idempotencyKey = UUID.randomUUID().toString() // UNSAFE
val response = httpClient.post("https://api.stripe.com/v1/charges") {
header("Idempotency-Key", idempotencyKey)
header("Authorization", "Bearer $stripeSecretKey")
contentType(ContentType.Application.FormUrlEncoded)
setBody(Parameters.build {
append("amount", customer.amountCents.toString())
append("currency", "usd")
append("customer", customer.id)
}.formUrlEncode())
}.body<StripeChargeResponse>()
emit(response.id)
}.retryWhen { cause, attempt ->
// Retry up to 3 times on IO exceptions
cause is IOException && attempt < 3
}
}
.flatMapMerge(concurrency = 5) { it } // collect up to 5 customer flows concurrently
.toList()
}
call.respond(HttpStatusCode.OK, mapOf("charge_ids" to chargeIds))
}
}
The collection sequence for one customer’s flow on a transient failure:
flatMapMerge(concurrency = 5)begins collecting from the outerasFlow()map. For each emitted customer, it starts collecting from the customer’s inner flow (theflow { ... }.retryWhen { ... }). Starting collection on a coldflow{}meansretryWhen{}’s upstream collector callscollect{}on theflow{}— which triggers theflow{}builder lambda for attempt 1.- Inside the builder lambda for customer X:
UUID.randomUUID()generates UUID_A. TheHttpClientposts to Stripe withIdempotency-Key: UUID_A. Stripe commits ch_A. A network reset occurs before the response arrives. TheHttpClientthrowsIOException. - The exception propagates from inside the
flow{}builder toretryWhen{}. The predicatecause is IOException && attempt < 3evaluates totrue(first exception,attempt = 0).retryWhen{}triggers re-collection of the upstreamflow{}. - Re-collection (retry attempt 1):
retryWhen{}internally callscollect{}on the upstreamflow{}again. Kotlin’s coldflow{}executes its builder lambda again from the beginning.UUID.randomUUID()generates UUID_B. TheHttpClientposts withIdempotency-Key: UUID_B. Stripe receives UUID_B, finds no record (ch_A is stored under UUID_A), and commits ch_B. Customer X is charged twice.
Blast radius: scales with the number of customers whose first attempt fails
The batch context makes the blast radius analysis important. Consider a batch of 20 customers processed with flatMapMerge(concurrency = 5). If 4 customers encounter a transient network failure on their first attempt:
- 16 customers succeed on attempt 1: UUID_A per customer, ch_A per customer. No duplicates.
- 4 customers fail on attempt 1: UUID_A1–A4 per customer, ch_A1–A4 committed in Stripe before the failure.
retryWhen{}re-collects each failing customer’s flow. UUID_B1–B4 generated on re-collection. ch_B1–B4 committed. 4 customers are charged twice.
The blast radius is proportional to the number of first-attempt failures times the number of retryWhen{} retries that trigger after Stripe has already committed the charge. In a billing job during peak infrastructure unreliability (a downstream proxy restart, a transient DNS failure), all 20 customers might fail on attempt 1 simultaneously, leading to 20 duplicate charges before the retries start returning.
The fix: UUID outside the flow{} builder
The key must be computed before the flow{} builder lambda is created — either in the outer map{} lambda (which runs once per customer per map invocation) or as a content-hash key derived from the customer’s parameters:
// Ktor route handler — safe mode 3: content-hash key outside flow{} builder
fun Route.billingRoutes(httpClient: HttpClient, stripeSecretKey: String) {
post("/api/billing/batch") {
val batchRequest = call.receive<BillingBatchRequest>()
val billingPeriod = batchRequest.billingPeriod // e.g. "2026-10"
val chargeIds: List<String> = coroutineScope {
batchRequest.customers.asFlow()
.map { customer ->
// Content-hash key computed in the map{} lambda — once per customer
// per map invocation. NOT inside the flow{} builder.
// The flow{} builder captures it via closure.
val idempotencyKey = "charge:$billingPeriod:${customer.id}:${customer.amountCents}"
flow {
// idempotencyKey captured from the map{} lambda scope.
// retryWhen{} re-collects this flow{} builder, which re-executes this lambda.
// But idempotencyKey is read from the closure — not re-computed.
// UUID_A on attempt 1, UUID_A on re-collection. Stripe deduplicates correctly.
val response = httpClient.post("https://api.stripe.com/v1/charges") {
header("Idempotency-Key", idempotencyKey)
header("Authorization", "Bearer $stripeSecretKey")
contentType(ContentType.Application.FormUrlEncoded)
setBody(Parameters.build {
append("amount", customer.amountCents.toString())
append("currency", "usd")
append("customer", customer.id)
}.formUrlEncode())
}.body<StripeChargeResponse>()
emit(response.id)
}.retryWhen { cause, attempt ->
cause is IOException && attempt < 3
}
}
.flatMapMerge(concurrency = 5) { it }
.toList()
}
call.respond(HttpStatusCode.OK, mapOf("charge_ids" to chargeIds))
}
}
With this placement: the map{} lambda runs once per customer when asFlow() emits the customer element. idempotencyKey is computed once in the map{} lambda scope. The flow{} builder captures it via closure. When retryWhen{} re-collects the flow{}, the builder re-executes — but it reads idempotencyKey from the already-computed closure, not from a new UUID.randomUUID() call. UUID_A on all retry re-collections. Stripe deduplicates.
Ktor route coroutine context and coroutineScope{} cancellation
Ktor route handlers execute within a coroutine scope tied to the HTTP call lifecycle. If the client connection drops mid-batch (the client that submitted the billing batch request), Ktor cancels the route handler’s coroutine scope. The coroutineScope{} inside the route handler is a structured concurrency scope — cancellation propagates to all child coroutines, including the flatMapMerge’s concurrent collection coroutines. A customer whose Stripe charge was committed mid-collection when the cancellation occurs will have ch_A in Stripe but no DB billing record. This is a separate concern from the idempotency key failure, but it illustrates why billing jobs are typically better structured as background jobs (Ktor background tasks or a Quartz/scheduled task) rather than route handlers that tie the billing computation lifetime to the HTTP connection lifetime.
The idempotency key fix (content-hash key) helps here too: if a background retry job later re-processes a customer whose Stripe call was interrupted by a route-level cancellation, the content-hash key produces the same value as the initial attempt, and Stripe returns the already-committed charge without creating a duplicate.
Comparison: three Ktor retry modes vs. retry mechanism vs. re-execution unit vs. UUID position
| Mode | Retry mechanism | Re-execution unit | UUID position (unsafe) | Developer misconception |
|---|---|---|---|---|
| 1 | Suspend retry HOF (retryStripe{}) |
Block lambda body | Inside retry block | “This is just a val declaration — it runs once” |
| 2 | While-loop around newSuspendedTransaction{} |
While-loop body (per iteration) | Inside while-loop body, above newSuspendedTransaction{} |
“UUID is above the transaction — it’s at the outer scope” |
| 3 | Kotlin Flow.retryWhen{} |
flow{} builder lambda body (per re-collection) |
Inside flow{} builder |
“UUID is inside the flow{} builder, not inside the retry lambda” |
The progression of misidentified retry units: Mode 1’s developer misidentifies the retry HOF block as “just a val declaration that runs once.” Mode 2’s developer correctly identifies the transaction block as a retry unit but misses that the while-loop is also a retry unit. Mode 3’s developer correctly identifies the retryWhen{} operator as a retry trigger but misidentifies the scope that re-executes (it’s the flow{} builder that re-executes, not the retryWhen{} predicate lambda). All three misidentifications lead to the same outcome: UUID inside a scope that re-executes per retry, UUID_B, ch_B.
Comparison with the modes in the general Ktor + Stripe post
The original Ktor post covered three modes from Ktor’s HTTP client configuration layer:
- HttpRequestRetry
modifyRequestrunning only on retry attempts: UUID inmodifyRequestmeans the initial request has no key, each retry has a fresh UUID. A plugin-configuration-layer failure. - Manual retry loops calling the billing suspend function at call site: UUID at billing function entry regenerates per call. A call-site-layer failure.
- Application.launch{} per Kubernetes replica: UUID per replica for the same customer, three pods, three charges. A deployment topology failure.
The three modes in this post are application-layer failures — they occur inside the service code, not in the HTTP client configuration or deployment topology. Mode 1 uses the Ktor service layer’s common retry HOF. Mode 2 uses Exposed’s coroutine transaction API with a service-layer while-loop. Mode 3 uses the Ktor route handler’s batch processing pattern with Flow operators. The HttpRequestRetry plugin behavior from the original post is orthogonal to these modes — it is possible to have both a correctly configured HttpRequestRetry (no modifyRequest with UUID) and a service-layer suspend retry HOF with UUID inside its block lambda, and the service-layer failure still occurs independently.
Content-hash key as the unified fix across all Ktor retry patterns
The single principle: the idempotency key must be a deterministic function of the billing intent — not a random value. A content-hash key derived from the billing period, customer ID, and amount produces the same string on every call with the same inputs, regardless of how many retry layers surround the computation:
// Kotlin content-hash idempotency key — works across all three Ktor modes
fun billingIdempotencyKey(
billingPeriod: String, // e.g. "2026-10" — same for all retries in the same period
customerId: String, // stable customer identifier from your DB
amountCents: Long // billing amount — same for all retries of the same intent
): String = "charge:$billingPeriod:$customerId:$amountCents"
Mode 1’s retry HOF block re-calls billingIdempotencyKey() on attempt 2 but receives the same string: charge:2026-10:cus_001:9900. Mode 2’s while-loop re-evaluates the key expression on iteration 2 but gets the same string. Mode 3’s retryWhen{} re-collects the flow{}, which uses the key captured from outside the builder — the same string. Stripe’s idempotency store matches on the string value and returns the already-committed charge without processing a new one.
Billing period inclusion is essential for recurring billing jobs: "charge:2026-10:cus_001:9900" and "charge:2026-11:cus_001:9900" are different keys, producing distinct charges for October and November. Without the billing period, retrying a November billing job after partial failure would return October’s charge response for some customers — Stripe would treat it as a duplicate of the October intent. Add the billing period to the key, and every period’s billing job generates its own namespace of idempotency keys, independent from prior periods.
For teams who prefer cryptographic key formats: sha256("$billingPeriod:$customerId:$amountCents").take(32) using Java’s MessageDigest (or Kotlin’s kotlin.crypto or Bouncy Castle) produces a compact fixed-length key with no special-character concerns from customer ID formats. The choice between a human-readable composite key and a compact hash is a matter of observability preference — both are correct for Stripe’s idempotency guarantee.
Test patterns for all three modes
Mode 1: WireMock + Ktor HttpClient + coroutine test
// BillingServiceRetryHofTest.kt
class BillingServiceRetryHofTest {
private val wireMock = WireMockServer(wireMockConfig().dynamicPort())
@BeforeEach fun start() { wireMock.start() }
@AfterEach fun stop() { wireMock.stop() }
@Test
fun `suspend retry HOF does not generate new idempotency key on retry`() = runTest {
// WireMock: first POST to /v1/charges returns 500, second returns 200
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(serverError().withBody("""{"error":{"type":"api_error","message":"connection error"}}"""))
.willSetStateTo("retried"))
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry")
.whenScenarioStateIs("retried")
.willReturn(okJson("""{"id":"ch_test001","object":"charge","amount":9900,"status":"succeeded"}""")))
val httpClient = HttpClient(CIO) {
defaultRequest { url("http://localhost:${wireMock.port}") }
}
val service = BillingService(httpClient, stripeSecretKey = "sk_test")
val chargeId = service.chargeCustomer(
customerId = "cus_001",
amountCents = 9900L,
billingPeriod = "2026-10"
)
assertThat(chargeId).isEqualTo("ch_test001")
// Verify all Idempotency-Key headers sent to Stripe
val requests = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")))
assertThat(requests).hasSize(2) // one 500, one 200
val keys = requests.map { it.getHeader("Idempotency-Key") }
// Both attempts must carry the same idempotency key
assertThat(keys[0]).isEqualTo(keys[1])
// Key must be the deterministic content-hash, not a random UUID
assertThat(keys[0]).isEqualTo("charge:2026-10:cus_001:9900")
}
@Test
fun `suspend retry HOF with UUID inside block fails idempotency test`() = runTest {
// Same WireMock setup.
// UnsafeBillingService uses UUID.randomUUID() inside retryStripe{} block.
// keys[0] will be e.g. "550e8400-e29b-41d4-a716-446655440000"
// keys[1] will be e.g. "c36b5929-b30c-4e2f-bef6-abcdef012345"
// assertThat(keys[0]).isEqualTo(keys[1]) FAILS — confirms the bug exists.
// This test is the regression gate: pass with content-hash, fail with UUID.
}
}
Mode 2: WireMock + Exposed in-memory H2 + while-loop retry test
// BillingServiceTransactionRetryTest.kt
class BillingServiceTransactionRetryTest {
private val wireMock = WireMockServer(wireMockConfig().dynamicPort())
private lateinit var database: Database
@BeforeEach
fun setup() {
wireMock.start()
// In-memory H2 for Exposed testing
database = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
transaction(database) {
SchemaUtils.create(BillingAttempts)
}
}
@AfterEach fun teardown() {
wireMock.stop()
transaction(database) { SchemaUtils.drop(BillingAttempts) }
}
@Test
fun `while-loop retry sends same idempotency key on all attempts`() = runTest {
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("tx-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(serverError())
.willSetStateTo("retried"))
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("tx-retry")
.whenScenarioStateIs("retried")
.willReturn(okJson("""{"id":"ch_ok","object":"charge","status":"succeeded"}""")))
val httpClient = HttpClient(CIO) {
defaultRequest { url("http://localhost:${wireMock.port}") }
}
val service = BillingService(database, httpClient, "sk_test")
val key = "charge:2026-10:cus_001:9900"
service.billCustomer("cus_001", 9900L, key)
val requests = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")))
assertThat(requests).hasSize(2)
val keys = requests.map { it.getHeader("Idempotency-Key") }
assertThat(keys[0]).isEqualTo(keys[1])
assertThat(keys[0]).isEqualTo(key)
}
}
Mode 3: WireMock + Ktor route handler + testApplication{}
// BatchBillingRouteTest.kt
class BatchBillingRouteTest {
private val wireMock = WireMockServer(wireMockConfig().dynamicPort())
@BeforeEach fun start() { wireMock.start() }
@AfterEach fun stop() { wireMock.stop() }
@Test
fun `batch billing Flow retryWhen sends same idempotency key on re-collection`() = testApplication {
// Customer 1: Stripe 500 on first attempt, 200 on second
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.withRequestBody(containing("cus_001"))
.inScenario("batch-c1")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(serverError())
.willSetStateTo("retried"))
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.withRequestBody(containing("cus_001"))
.inScenario("batch-c1")
.whenScenarioStateIs("retried")
.willReturn(okJson("""{"id":"ch_001","object":"charge","status":"succeeded"}""")))
// Customer 2: Stripe 200 on first attempt (no retry needed)
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.withRequestBody(containing("cus_002"))
.willReturn(okJson("""{"id":"ch_002","object":"charge","status":"succeeded"}""")))
application {
val stripeClient = HttpClient(CIO) {
defaultRequest { url("http://localhost:${wireMock.port}") }
}
routing { billingRoutes(stripeClient, "sk_test") }
}
val response = client.post("/api/billing/batch") {
contentType(ContentType.Application.Json)
setBody("""
{
"billing_period": "2026-10",
"customers": [
{"id": "cus_001", "amount_cents": 9900},
{"id": "cus_002", "amount_cents": 4900}
]
}
""".trimIndent())
}
assertThat(response.status).isEqualTo(HttpStatusCode.OK)
// Verify cus_001 was retried but with the same idempotency key
val cus001Requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")).withRequestBody(containing("cus_001"))
)
assertThat(cus001Requests).hasSize(2) // one 500 + one 200
val keys = cus001Requests.map { it.getHeader("Idempotency-Key") }
assertThat(keys[0]).isEqualTo(keys[1])
assertThat(keys[0]).isEqualTo("charge:2026-10:cus_001:9900")
// cus_002 was not retried — one request, one key
val cus002Requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")).withRequestBody(containing("cus_002"))
)
assertThat(cus002Requests).hasSize(1)
assertThat(cus002Requests[0].getHeader("Idempotency-Key"))
.isEqualTo("charge:2026-10:cus_002:4900")
}
}
The test pattern across all three modes is structurally identical: WireMock scenario with a 5xx on the first attempt and 200 on the second, capturing all Idempotency-Key headers sent to Stripe, and asserting equality across all attempts. An unsafe implementation fails the equality assertion because UUID.randomUUID() generates a distinct value per call. A content-hash implementation passes because the hash function returns the same string for the same inputs. The assertion failure message from the unsafe variant is explicit: expected: "550e8400-e29b-41d4-a716-446655440000" but was: "c36b5929-b30c-4e2f-bef6-abcdef012345" — two distinct UUIDs, confirming that two distinct billing intents were sent to Stripe, one per retry attempt.
Ktor’s HttpRequestRetry plugin vs. service-layer retry: orthogonal failure surfaces
The three modes in this post exist in the service layer — in the business logic code that decides when and how to retry a billing operation. Ktor’s HttpRequestRetry plugin operates at the HTTP client layer, retrying the HTTP request when Stripe returns a retryable HTTP status code (429, 503, 500). These two retry surfaces are orthogonal:
- A correctly configured
HttpRequestRetryplugin (without UUID inmodifyRequest) does not eliminate the service-layer failures in Modes 1, 2, and 3. If the service layer’s retry logic re-callschargeCustomer()with a new UUID, theHttpRequestRetryplugin’s correct behavior within each individualhttpClient.post()call is irrelevant — the service layer is already sending a new billing intent before theHttpClienteven has a chance to retry at the HTTP level. - Conversely, an incorrectly configured
HttpRequestRetryplugin (UUID inmodifyRequest, as covered in the original Ktor post) creates failures even when the service layer correctly uses a content-hash key. ThemodifyRequestcallback overwrites the stable key with a fresh UUID on each HTTP-level retry attempt.
Both layers need to be correct for end-to-end idempotency. The service layer must use a content-hash key computed outside any retry scope. The HttpRequestRetry plugin must not overwrite the key in modifyRequest. A Keybrake-proxied key (a vault key routing through the proxy.keybrake.com/stripe/v1/charges endpoint) adds a third enforcement layer: the proxy enforces that requests with the same billing intent are deduplicated at the network level, independent of what the application layer sends, providing a safety net for both classes of failure.
The unified conceptual model
Across all retry mechanisms in all JVM frameworks — Spring @Retryable, Micronaut @Retryable, Quarkus Mutiny onFailure().retry(), Arrow retry{}, Kotlin Flow retryWhen{}, and the Ktor patterns here — the same principle applies:
A retry mechanism re-executes a unit of work. Any statement inside that unit re-executes on each retry attempt.
UUID.randomUUID()inside that unit generates a new UUID per attempt — UUID_B — which Stripe processes as a new billing intent, creating ch_B alongside any already-committed ch_A.
The unit differs:
- Suspend retry HOF: the block lambda is the unit (Mode 1 here)
- While-loop or for-loop: the loop body iteration is the unit (Mode 2 here)
- Kotlin
Flow.retryWhen{}: theflow{}builder lambda is the unit (Mode 3 here) - Arrow
retry{ Schedule }: the block lambda is the unit (Mode 1 in the Kotlin Coroutines post) - Spring
@Retryableon asuspend fun: the method body is the unit viaproceed()re-invocation (Mode 2 in the Kotlin Coroutines post) - Reactor
retryWhen(): the cold publisher’s subscription factory is the unit (covered in the Spring WebClient post)
The fix is always the same: replace UUID.randomUUID() with a content-hash function whose output depends only on stable billing parameters. Place the computation outside all retry units. Pass it as a parameter or closure capture into the retry scope. Any retry mechanism can then safely re-execute its unit without generating a new billing intent at Stripe.
Keybrake: put the brakes on your agent’s keys
A scoped API-key proxy for Stripe, Twilio, and Resend — with per-vendor spend caps, endpoint allowlists, per-call audit log, and one-click revoke. Built for teams running autonomous agents against production SaaS APIs.