Kotlin Coroutines, @Transactional, and Stripe Integration: How Arrow retry{} Re-invokes Suspend Function Bodies, Spring @Retryable + @Transactional on suspend Functions, and Flow.retryWhen{} Re-collection Generate New Idempotency Keys
Kotlin Coroutines flatten callback-based and reactive code into sequential-looking suspend functions, removing much of the explicit subscription and re-subscription mental overhead of Reactor’s cold-publisher model. When teams add retry logic — via Arrow Resilience’s retry{}, Spring’s @Retryable on suspend functions, or Kotlin’s Flow.retryWhen{} — the same idempotency hazard that affects Java reactive frameworks reappears in a different syntax. Each retry mechanism re-executes a scope that contains UUID.randomUUID(), generating UUID_B on the retry attempt, sending a key Stripe has never seen, and creating charge ch_B alongside the already-committed ch_A.
These failure modes are structurally related to those covered in the Spring Boot @Transactional + WebClient reactive retry post (Reactor retryWhen() re-subscription and @Retryable on reactive methods), the Quarkus reactive @Transactional post (Mutiny onFailure().retry() and SmallRye FT @Retry interceptor ordering), and the Micronaut Data reactive @Transactional post (Micronaut @Retryable on Mono<T> and onErrorResume() recovery). The Kotlin coroutine variants differ in syntax — there are no publishers, no subscriptions, and no subscribe callbacks in the developer-visible API — but the underlying failure is the same: a scope that contains UUID.randomUUID() re-executes per retry. The three modes covered here focus on the Kotlin-specific retry surfaces: Arrow’s functional-retry block, Spring Retry’s AOP interception of coroutine methods, and Kotlin’s cold Flow operator chain.
Background: how Kotlin Coroutine retry mechanisms differ from Reactor — and why idempotency remains the same problem
In Reactor, the idempotency problem is commonly framed as a “cold publisher re-subscription” issue: Mono.fromCallable() is cold — its callable runs on every subscription — retryWhen() re-subscribes — UUID.randomUUID() inside the callable generates UUID_B. Developers who have internalized this framing sometimes conclude that Kotlin Coroutines are exempt: “There’s no publisher, no cold stream, no subscription. I just call a suspend fun. It’s like calling a regular function.” This reasoning is partially correct: Kotlin Coroutines do not have an explicit publisher-subscriber contract in their basic form. The hazard comes from the retry mechanism, not from cold-stream semantics.
The general principle: any retry mechanism works by re-executing a unit of work on failure. Whether that unit is a RetryCallback lambda (Spring Retry’s RetryTemplate), a method body (Spring’s @Retryable), a reactive operator chain (Reactor’s retryWhen()), or a suspend block (Arrow’s retry{}), the execution unit re-runs on each retry attempt. UUID.randomUUID() inside that unit generates a new value on each run. The syntax differs — Kotlin suspend functions vs. Java lambdas vs. Reactor operators — but the consequence is identical: UUID_B sent to Stripe, ch_B created alongside ch_A.
Kotlin Coroutines have three retry surfaces that interact with Stripe idempotency:
- Arrow Resilience
retry{}: a higher-order suspend function that calls its block lambda repeatedly on failure. The block is the retry unit. Every line inside the block re-executes per call, includingUUID.randomUUID(). - Spring
@Retryableon asuspendfunction: Spring Retry’s AOP interceptor wraps the method and callsproceed()on retry. Forsuspendfunctions,proceed()re-invokes the coroutine method — a new activation frame, all local variables reinitialize. The method body is the retry unit. - Kotlin
Flow.retryWhen{}: a flow operator that re-collects the upstream coldFlowon matching exceptions. Kotlin’s coldflow{}builder re-executes its lambda on every collection.UUID.randomUUID()inside the builder generates UUID_B on the first retry’s re-collection.
Mode 1: Arrow Resilience retry{ Schedule } — the block lambda is called again per retry — UUID inside the block generates UUID_B — ch_B
Arrow Resilience’s retry() is a higher-order suspend function with a signature similar to:
// Arrow Resilience — simplified signature
suspend fun <A> Schedule<Throwable, *>.retry(block: suspend () -> A): A
The implementation calls block(), catches exceptions matching the Schedule predicate, applies the schedule’s delay, and calls block() again. The block is a suspend lambda — a suspend lambda is a function value, and calling it again means executing every statement inside it again. There is no “resuming a suspended coroutine” happening here. The coroutine that ran attempt 1’s block has completed (or failed) by the time Arrow starts attempt 2. Arrow starts a fresh coroutine invocation of the lambda for attempt 2.
// BillingService.kt — unsafe mode 1: UUID inside Arrow retry{} block
@Service
class BillingService {
suspend fun chargeCustomer(customerId: String, amountCents: Long): String {
return Schedule.recurs<Throwable>(2).retry {
// Arrow retry{} re-invokes this block lambda on each retry attempt.
// Every statement inside this block re-executes: UUID, params, Charge.create().
// Developer's reasoning: "This isn't a Reactor cold stream — I'm not
// 're-subscribing to a publisher.' There's no subscription concern."
// The concern is different: the block lambda is called again.
val idempotencyKey = UUID.randomUUID().toString() // UNSAFE
val params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build()
val options = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()
// Stripe.java SDK — blocking call, runs on the coroutine dispatcher thread.
// In practice, wrap in withContext(Dispatchers.IO) for JVM thread pool.
Charge.create(params, options).id
}
}
}
The call sequence on a transient Stripe network failure:
Schedule.recurs(2).retry { block }starts. Arrow calls the block lambda for attempt 1.- Inside the block:
UUID.randomUUID()generates UUID_A.paramsandoptionsare built with UUID_A.Charge.create(params, options)makes the HTTP call to Stripe with idempotency key UUID_A. - Stripe receives the request. Stripe commits charge ch_A to its datastore. Before the HTTP response completes, a network reset occurs. The Stripe Java SDK throws
ApiConnectionException. - The exception propagates out of the block lambda back to Arrow’s retry machinery.
Schedule.recurs(2)matches any exception and allows up to 2 retries. Arrow applies the schedule delay (if any) and calls the block lambda again. - Attempt 2: The block lambda starts executing again as a fresh invocation.
UUID.randomUUID()generates UUID_B — a new random UUID, distinct from UUID_A.paramsandoptionsare rebuilt with UUID_B.Charge.create()makes the HTTP call with UUID_B. - Stripe receives the request with UUID_B. Stripe looks up UUID_B in its idempotency store and finds no matching record (ch_A was keyed under UUID_A; UUID_B is unknown). Stripe processes the request as a new billing intent and commits charge ch_B.
- Attempt 2 succeeds.
retry{}returns the ch_B charge ID. The caller receives ch_B without knowing about ch_A. The customer has been charged twice.
The developer’s mental model failure in Mode 1: “Kotlin Coroutines are not cold streams. Arrow retry{} doesn’t ‘re-subscribe’ to anything — it’s just retrying. The concern in Reactor is that Mono.fromCallable()’s callable re-executes on every subscription, but here there’s no publisher or subscriber involved.” This is correct about the mechanism difference. The idempotency problem in this mode is not framed as cold-stream re-subscription. The problem is: “the retry mechanism calls the block lambda again, and UUID.randomUUID() is inside the block lambda.” The mechanism is different from Reactor; the consequence is identical. Arrow’s retry{} block is a suspend lambda, and calling a suspend lambda is exactly like calling a function: every line in its body executes.
The nested retry nesting trap: UUID at function scope is not safe against all retry layers
After learning about the inner-block hazard, some developers move UUID to the chargeCustomer() function scope, above the retry{} call:
// Partially safer — protects against the inner retry{} block, but not against
// any outer retry loop that re-calls chargeCustomer() itself.
suspend fun chargeCustomer(customerId: String, amountCents: Long): String {
val idempotencyKey = UUID.randomUUID().toString() // At function scope
return Schedule.recurs<Throwable>(2).retry {
// idempotencyKey captured from chargeCustomer() scope via closure.
// Arrow retry{} re-calls this block but NOT chargeCustomer() — so
// idempotencyKey is stable against the inner retry. Good.
val params = ChargeCreateParams.builder()
.setAmount(amountCents).setCurrency("usd").setCustomer(customerId).build()
Charge.create(params, RequestOptions.builder()
.setIdempotencyKey(idempotencyKey).build()).id
}
}
This placement is correct against the inner retry{}: the idempotency key is captured from chargeCustomer()’s scope by the block’s closure and is not re-computed on each Arrow retry. However, if chargeCustomer() is itself called inside a caller-level retry loop, the entire function body re-executes per caller retry:
// Caller-level retry loop — calls chargeCustomer() again on failure.
// chargeCustomer()'s entire body re-executes per call:
// UUID.randomUUID() at chargeCustomer() scope generates UUID_B on caller retry attempt 2.
for (attempt in 1..3) {
try {
chargeCustomer(customerId, amountCents) // re-invokes full function body
break
} catch (e: StripeException) {
if (attempt == 3) throw e
delay(300)
}
}
The nested-retry nesting trap: moving UUID from inside the Arrow retry{} block to the function scope protects against the inner retry but not against an outer retry that re-calls the function. The only placement immune to all retry nesting depths is a deterministic content-hash key computed from stable billing parameters — a key whose output is the same regardless of how many times the surrounding scope re-executes:
// BillingService.kt — safe mode 1: content-hash key stable at all retry depths
@Service
class BillingService {
suspend fun chargeCustomer(
customerId: String,
amountCents: Long,
billingPeriod: String // e.g. "2026-10" — passed by the billing job, same across all retries
): String {
// Content-hash key: deterministic function of billing period + customer ID + amount.
// Same output on every call with the same inputs, regardless of:
// — how many Arrow retry{} retries occur on the inner block
// — how many outer caller retry iterations call chargeCustomer() again
// — whether the billing job restarts between retries
val idempotencyKey = "charge:$billingPeriod:$customerId:$amountCents"
return Schedule.recurs<Throwable>(2).retry {
val params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build()
withContext(Dispatchers.IO) {
Charge.create(params, RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // same value on every retry at every layer
.build()).id
}
}
}
}
With a content-hash key, attempt 1 sends "charge:2026-10:cus_001:9900". If Stripe committed ch_A before the network error, attempt 2 sends the same key. Stripe recognizes the key and returns ch_A without creating ch_B. No duplicate charge.
A note on Schedule.recurs(2) vs Schedule.exponential(100.milliseconds) with and(Schedule.recurs(3)): Arrow’s Schedule combinators compose retry policies. The idempotency key concern applies to all of them equally — the policy controls how many times the block is called and with what delays, not whether UUID.randomUUID() inside the block regenerates. Content-hash keys work regardless of which Arrow schedule is used.
Mode 2: Spring @Retryable + @Transactional on a Kotlin suspend function — proceed() re-invokes the coroutine method — UUID at method scope generates UUID_B — ch_B — plus interceptor ordering in coroutine context
Spring Boot 3.x with Kotlin and spring-boot-starter-data-r2dbc (or spring-boot-starter-data-jpa with the kotlinx-coroutines-reactor bridge) supports @Transactional on Kotlin suspend functions. The infrastructure wraps the coroutine execution in a transaction context element that flows through the coroutine’s coroutine context. Spring Retry 2.x similarly supports @Retryable on suspend functions via AOP interception that understands Kotlin coroutine method signatures.
The AOP interception model for a suspend function: Spring creates a CGLIB proxy subclass of the service class. When the proxy method is called, the AnnotationAwareRetryOperationsInterceptor intercepts. For a suspend function, the AOP framework cooperates with Kotlin’s coroutine machinery via the Continuation parameter (Kotlin’s suspend mechanism compiles to a method with a trailing Continuation<T> parameter). On retry, proceed() produces a new coroutine invocation of the target method — effectively a new call to the suspend fun body. A new activation frame is created. All local variables are uninitialized at entry. UUID.randomUUID() at the top of the suspend function body, outside any lambda, generates UUID_B on the second proceed() call:
// BillingService.kt — unsafe mode 2: UUID at suspend function body scope under @Retryable
@Service
class BillingService(
private val billingRepo: BillingRepository
) {
@Retryable(retryFor = [StripeException::class], maxAttempts = 3, backoff = Backoff(delay = 300))
@Transactional
suspend fun chargeCustomer(customerId: String, amountCents: Long): String {
// At the top of the suspend function body, outside any lambda or nested scope.
// Developer's reasoning: "I'm not inside a retry block, not inside a flow builder,
// not inside any lambda. I'm at suspend function scope. UUID is computed once."
// Wrong under @Retryable: the suspend function body IS the retry unit.
// @Retryable's proceed() calls the suspend function body again on each retry attempt.
val idempotencyKey = UUID.randomUUID().toString() // UNSAFE
val params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build()
val charge = withContext(Dispatchers.IO) {
Charge.create(params, RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build())
}
// @Transactional context flows via coroutine context element.
// billingRepo.save() participates in the transaction.
billingRepo.save(BillingRecord(customerId, charge.id, idempotencyKey))
return charge.id
}
}
The call sequence on a transient Stripe failure:
- The proxy’s
chargeCustomer()is called.AnnotationAwareRetryOperationsInterceptorintercepts. It callsmethodInvocation.proceed()for attempt 1 — this invokes the realchargeCustomer()on the target object as a coroutine. - The suspend function body begins. A new activation frame is created.
val idempotencyKey = UUID.randomUUID().toString()executes — UUID_A generated.paramsbuilt.Charge.create()makes the HTTP call with UUID_A. Stripe commits ch_A.ApiConnectionExceptionthrown before response arrives. - The exception propagates out of the coroutine. The interceptor catches it. Retry policy: 3 max attempts, 300ms backoff. Interceptor waits 300ms and calls
methodInvocation.proceed()again. - Attempt 2:
proceed()invokes the realchargeCustomer()again. A new activation frame is created.val idempotencyKey = UUID.randomUUID().toString()executes — UUID_B generated (freshUUID.randomUUID()call).paramsrebuilt.Charge.create()called with UUID_B. Stripe creates ch_B. Customer charged twice.
The developer’s mental model failure in Mode 2: “UUID is at the top of the suspend fun body, not inside any lambda. In a non-retried method, this would execute once. It should execute once here too.” This is true for a non-retried method. @Retryable changes the invariant: the method body is re-entered on each retry. “Method scope” does not mean “computed once per caller call” under @Retryable — it means “computed once per proceed() invocation.” With 3 proceed() calls in the retry policy, the method body executes 3 times for a single caller invocation of the service method.
Interceptor ordering hazard: @Transactional vs. @Retryable in coroutine context
When both @Retryable and @Transactional are on the same suspend function, Spring’s AOP applies them in priority order. The default behavior in Spring Boot with Kotlin coroutines:
@Retryableinterceptor order:Ordered.LOWEST_PRECEDENCE - 5(Integer.MAX_VALUE - 5). Lower priority = outer interceptor in Spring’s chain.@Transactionalinterceptor order:Ordered.LOWEST_PRECEDENCE(Integer.MAX_VALUE). Even lower priority = inner interceptor.
Default ordering: @Retryable is outer, @Transactional is inner. Each proceed() call from @Retryable enters the @Transactional interceptor, which starts a new transaction for that retry attempt. This is the correct behavior: each retry attempt starts a fresh, clean transaction. A DB failure in attempt 1 rolls back attempt 1’s transaction and does not poison attempt 2.
If the ordering is reversed — by setting @Transactional(order = Int.MAX_VALUE - 6) to make @Transactional outer, or by a framework configuration that changes the default priority — the hazard becomes:
@Transactional(outer) starts a single transaction TX-1 for the entire retry sequence.@Retryable(inner) callsproceed()for each attempt within TX-1’s boundary.- A
DataIntegrityViolationExceptionor any Spring data exception in attempt 1 marks TX-1 rollback-only. - All subsequent
@Retryableretries still issue Stripe charges (ch_A committed in Stripe, ch_B committed in Stripe), but none of thebillingRepo.save()calls succeed — they all execute within the rollback-only TX-1 and are discarded when TX-1 rolls back at the end. Result: multiple Stripe charges, zero DB billing records, no way to reconcile.
In Kotlin coroutine context, @Transactional propagates via a CoroutineContext element (specifically, the transaction context element set up by TransactionalCoroutineUtils or the equivalent in your Spring Data reactive flavor). When @Transactional is outer and wraps a retry sequence, all retry attempts’ coroutines inherit the same rollback-only transaction context element. The service decomposition fix applies here as it does in Quarkus and Micronaut: separate the retry orchestrator from the transactional service into two distinct beans, so each @Retryable attempt enters the @Transactional interceptor fresh:
// Safe mode 2: service decomposition — retry outer, transaction inner, separate beans
@Service
class BillingOrchestrator(private val billingService: TransactionalBillingService) {
// Outer bean: @Retryable, no @Transactional.
// Each proceed() call invokes billingService.chargeAndRecord() as a new call —
// billingService's @Transactional starts a fresh transaction per attempt.
@Retryable(retryFor = [StripeException::class], maxAttempts = 3, backoff = Backoff(delay = 300))
suspend fun chargeCustomer(
customerId: String,
amountCents: Long,
billingPeriod: String
): String {
// Content-hash key computed here — in the retry-unit scope — using deterministic inputs.
// @Retryable re-invokes this method body per proceed(), which re-computes the key.
// But content-hash key from stable inputs produces the same value each time.
val idempotencyKey = "charge:$billingPeriod:$customerId:$amountCents"
return billingService.chargeAndRecord(customerId, amountCents, idempotencyKey)
}
}
@Service
class TransactionalBillingService(private val billingRepo: BillingRepository) {
// Inner bean: @Transactional, no @Retryable.
// Each call from BillingOrchestrator starts a new transaction.
// A DB failure rolls back only this transaction, not a shared outer one.
@Transactional
suspend fun chargeAndRecord(
customerId: String,
amountCents: Long,
idempotencyKey: String
): String {
val params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build()
val charge = withContext(Dispatchers.IO) {
Charge.create(params, RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // passed as parameter — stable
.build())
}
billingRepo.save(BillingRecord(customerId, charge.id, idempotencyKey))
return charge.id
}
}
With this decomposition: if attempt 1’s billingRepo.save() fails with a unique-constraint violation, TransactionalBillingService’s transaction TX-1 rolls back. BillingOrchestrator’s @Retryable catches the exception and calls proceed() again. Attempt 2 calls billingService.chargeAndRecord() again, which starts a fresh transaction TX-2 — no rollback-only state inherited from TX-1. The content-hash idempotency key is the same on attempt 2 as on attempt 1, so Stripe deduplicates correctly if ch_A was committed before the DB failure.
Mode 3: Kotlin Flow.retryWhen{} — cold flow{} builder re-executes on each re-collection — UUID inside the builder generates UUID_B — ch_B
Kotlin’s Flow<T> is cold. The flow { } builder function creates a Flow object, but its block lambda does not execute at creation time. The lambda executes when the flow is collected — when a consumer calls collect{}, first(), single(), toList(), or any other terminal operator. This cold-flow semantics is intentional: it makes flows composable, lazy, and safe to share as values between functions without triggering side effects.
Flow.retryWhen { cause, attempt -> } is a flow operator that re-collects the upstream flow on exceptions matching the predicate. “Re-collecting” means calling collect{} on the upstream again — which, for a cold flow{}, means executing the builder lambda again from the beginning. Every statement inside the flow{} builder re-executes on each retryWhen{} re-collection:
// BillingFlowService.kt — unsafe mode 3: UUID inside flow{} builder
@Service
class BillingFlowService {
fun chargeCustomerFlow(customerId: String, amountCents: Long): Flow<String> =
flow {
// flow{} builder lambda executes on every collection of this flow.
// retryWhen{} triggers re-collection on matching exceptions.
// Developer's reasoning: "I know retryWhen() re-subscribes in Reactor, but
// Kotlin Flows don't have subscriptions — retryWhen is just an exception handler."
// Wrong: Kotlin Flow.retryWhen{} internally re-collects (re-starts) the upstream flow.
// The flow{} builder lambda executes again. UUID.randomUUID() generates UUID_B.
val idempotencyKey = UUID.randomUUID().toString() // UNSAFE: inside flow{} builder
val params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build()
val chargeId = withContext(Dispatchers.IO) {
Charge.create(params, RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build()).id
}
emit(chargeId)
}
.retryWhen { cause, attempt ->
cause is StripeException && attempt < 3
}
}
The collection sequence on a transient Stripe failure:
- A caller collects the flow:
billingFlowService.chargeCustomerFlow(cus, amt).single(). - The
retryWhen{}operator starts collecting from its upstream — which is theflow{}cold flow. This triggers theflow{}builder lambda to execute. - Inside the builder:
UUID.randomUUID()generates UUID_A.paramsandoptionsare built with UUID_A.withContext(Dispatchers.IO) { Charge.create(...) }makes the HTTP call. Stripe commits ch_A.ApiConnectionExceptionthrown before the response arrives. - The exception propagates from inside the
flow{}builder out to theretryWhen{}operator. The predicatecause is StripeException && attempt < 3evaluates totrue(attempt 0).retryWhen{}triggers re-collection of the upstream flow. - Retry (re-collection):
retryWhen{}internally callscollect{}on the upstreamflow{}again. The cold flow’s builder lambda executes again from the beginning.UUID.randomUUID()generates UUID_B.paramsandoptionsare rebuilt with UUID_B.Charge.create()is called with UUID_B. Stripe creates ch_B alongside ch_A. - The re-collection succeeds.
emit(chargeId)emits ch_B’s ID. The caller receives ch_B. The customer has been charged twice.
The developer’s mental model failure in Mode 3: “Kotlin Flows don’t have subscriptions. The terminology ‘re-subscribing to a cold publisher’ is Reactor/RxJava language — it doesn’t apply here. retryWhen{} is just catching exceptions and retrying.” This reasoning is correct about terminology but incorrect about behavior. Kotlin’s cold Flow is equivalent to a cold publisher in the reactive-streams sense: its producer logic runs on every consumer (collection = subscription). Flow.retryWhen{} is equivalent to Reactor’s retryWhen() in this regard: both re-initiate upstream production on each retry. The implementation of Flow.retryWhen{} in kotlinx-coroutines calls upstream.collect{} inside a loop — which for a flow{} cold flow means calling the builder lambda again.
UUID outside the flow{} builder but inside the function returning the flow
The fix placement matters. Consider two variants:
// Variant A — UUID at flow{} builder scope (unsafe):
fun chargeFlow(...): Flow<String> = flow {
val key = UUID.randomUUID().toString() // re-evaluates on every retryWhen re-collection
// ...
}.retryWhen { cause, attempt -> cause is StripeException && attempt < 3 }
// Variant B — UUID outside flow{} builder, inside the function body (safe against retryWhen):
fun chargeFlow(...): Flow<String> {
val key = UUID.randomUUID().toString() // computed once when chargeFlow() is called
return flow {
// key captured via closure from the function scope — not re-evaluated on re-collection
// ...
}.retryWhen { cause, attempt -> cause is StripeException && attempt < 3 }
}
Variant B is safe against retryWhen{}’s re-collection: the function body executes once when chargeFlow() is called by the caller. UUID.randomUUID() runs once. The flow{} builder captures key from the function scope via closure. On each retryWhen{} re-collection, the flow{} builder lambda re-executes, but it reads key from the already-computed closure value — UUID_A both times. Stripe deduplicates correctly.
However, Variant B has the same nested-retry nesting trap as Mode 1: if the caller holds a reference to the Flow returned by chargeFlow() and re-collects it in a retry loop, each re-collection triggers retryWhen{}’s downstream starting again, but the flow{} builder was created once with UUID_A captured. This is fine — the function-scope UUID is stable across multiple collections of the same returned Flow object.
But if the caller creates a new flow by calling chargeFlow() inside a retry loop, each call to chargeFlow() generates a new UUID:
// Still unsafe if chargeFlow() itself is called inside a retry loop:
for (attempt in 1..3) {
try {
chargeFlow(customerId, amountCents).single() // calls chargeFlow() again each iteration
break
} catch (e: StripeException) {
if (attempt == 3) throw e
delay(300)
}
}
The content-hash key is the fix that is safe regardless of where the retry boundary is:
// BillingFlowService.kt — safe mode 3: content-hash key outside flow{} builder
@Service
class BillingFlowService {
fun chargeCustomerFlow(
customerId: String,
amountCents: Long,
billingPeriod: String
): Flow<String> {
// Content-hash key: computed once when the function is called.
// Stable across retryWhen{} re-collections and across any outer retry loops
// that re-call this function — same inputs produce the same key.
val idempotencyKey = "charge:$billingPeriod:$customerId:$amountCents"
return flow {
val params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build()
val chargeId = withContext(Dispatchers.IO) {
Charge.create(params, RequestOptions.builder()
.setIdempotencyKey(idempotencyKey) // captured from function scope — stable
.build()).id
}
emit(chargeId)
}
.retryWhen { cause, attempt ->
cause is StripeException && attempt < 3
}
}
}
Flow + @Transactional and the reactive transaction context interaction
When a @Transactional suspend function returns a Flow<T>, the transaction context behavior depends on whether the transaction manager is coroutine-aware. With Spring Data R2DBC and Kotlin coroutines, @Transactional on a suspend fun returning a Flow<T> binds the transaction to the collection of the returned flow — the transaction starts when the flow is collected, not when the function is called.
This introduces a subtle interaction with retryWhen{}: if retryWhen{} is chained on a flow whose flow{} builder includes both Stripe calls and reactive repository calls, each retryWhen{} re-collection re-executes the builder inside a new transaction context. This is analogous to the Quarkus Panache withTransaction() scenario: each re-collection starts a fresh reactive transaction for the DB operations (correct for DB atomicity) but also re-evaluates UUID.randomUUID() for the Stripe call (incorrect for idempotency). The fix — moving the idempotency key outside the flow{} builder — addresses the Stripe concern without affecting the transaction behavior.
Comparison: three Kotlin coroutine retry modes vs. UUID placement vs. re-execution unit
| Mode | Retry mechanism | Re-execution unit | UUID position (unsafe) | Developer misconception |
|---|---|---|---|---|
| 1 | Arrow retry{ Schedule } |
Block lambda body | Inside retry block | “No cold publisher — no re-subscription concern” |
| 2 | Spring @Retryable on suspend fun |
Suspend function body (proceed() re-invocation) |
At suspend function scope, outside all lambdas | “UUID is at method scope, not inside any lambda” |
| 3 | Kotlin Flow.retryWhen{} |
flow{} builder lambda body |
Inside flow{} builder |
“Kotlin Flows don’t have subscriptions like Reactor” |
All three modes share the same root cause: the idempotency key is computed inside a scope that re-executes per retry attempt. The mechanism differs (block re-invocation, method body re-invocation, flow builder re-execution), and the developer misconception differs (absent publisher semantics, method-scope stability guarantee, absent subscription terminology), but the consequence is identical: UUID_B on retry, ch_B at Stripe.
Content-hash key as the general fix across all three modes
The single fix that works in all three modes is replacing UUID.randomUUID() with a deterministic content-hash function whose output depends only on the billing parameters — billing period, customer ID, amount — not on randomness:
// Kotlin idiomatic content-hash key — works across all three retry modes
private fun idempotencyKey(billingPeriod: String, customerId: String, amountCents: Long): String =
"charge:$billingPeriod:$customerId:$amountCents"
A content-hash key produces the same output for the same inputs regardless of how many times the surrounding scope re-executes. Mode 1’s Arrow retry block re-calls idempotencyKey() on attempt 2 but receives the same string. Mode 2’s @Retryable proceed() re-invokes the method body but the content-hash function at the top of the body produces the same string. Mode 3’s retryWhen{} re-collects the flow but the content-hash key captured from outside the builder is the same string. Stripe’s idempotency store matches on the key string — same key, same response, no ch_B.
Billing period inclusion is important for recurring billing jobs: "charge:2026-10:cus_001:9900" and "charge:2026-11:cus_001:9900" are different keys, producing different charges for October and November — correct deduplication behavior. Without the billing period, October and November charges for the same customer and amount would collide, and Stripe would return October’s charge for November’s billing attempt.
A stronger alternative for teams who prefer cryptographic key formats: SHA-256(billingPeriod + customerId + amountCents.toString()) encoded as hex or base64. This produces a compact fixed-length key, avoids any concern about special characters in customer IDs or billing period strings, and is still deterministic across all retry attempts. The choice between a readable composite string and a hash is a matter of preference and observability — both are correct for Stripe idempotency.
Test patterns for each mode
Mode 1: WireMock + Arrow retry test
// BillingServiceArrowRetryTest.kt
@ExtendWith(WireMockExtension::class)
class BillingServiceArrowRetryTest(
@RegisterExtension val wireMock: WireMockExtension = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build()
) {
@Test
fun `Arrow retry does not generate new idempotency key on retry`() = runTest {
val capturedKeys = mutableListOf<String>()
// WireMock: first call 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\"}}"))
.willSetStateTo("retried"))
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("retry")
.whenScenarioStateIs("retried")
.willReturn(okJson("{\"id\":\"ch_test\",\"object\":\"charge\",...}")))
// Configure Stripe SDK to point at WireMock (in test setup)
Stripe.overrideApiBase("http://localhost:${wireMock.port}")
val service = BillingService()
service.chargeCustomer("cus_001", 9900L, "2026-10")
// Capture all Idempotency-Key headers from all requests
val requests = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")))
val keys = requests.map { it.getHeader("Idempotency-Key") }
// Two requests should have been made (one 500, one 200)
assertThat(keys).hasSize(2)
// Both requests must carry the same idempotency key
assertThat(keys[0]).isEqualTo(keys[1])
// Key must be deterministic content-hash, not a random UUID
assertThat(keys[0]).isEqualTo("charge:2026-10:cus_001:9900")
}
@Test
fun `Arrow retry UUID implementation fails idempotency test`() = runTest {
// Same WireMock setup as above.
// With UUID: keys[0] != keys[1] — test would fail.
// With content-hash: keys[0] == keys[1] — test passes.
// This test serves as the regression gate.
}
}
Mode 2: SpringBootTest + MockK for @Retryable suspend function
// BillingServiceRetryableTest.kt
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class BillingServiceRetryableTest {
@Autowired
lateinit var billingOrchestrator: BillingOrchestrator
@Test
fun `@Retryable suspend function uses same idempotency key on retry`() = runTest {
// WireMock scenario: first /v1/charges call 500, second 200
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("spring-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(serverError().withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("retried"))
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("spring-retry")
.whenScenarioStateIs("retried")
.willReturn(okJson("{ \"id\":\"ch_ok\",\"object\":\"charge\" }")))
billingOrchestrator.chargeCustomer("cus_001", 9900L, "2026-10")
val allRequests = findAll(postRequestedFor(urlEqualTo("/v1/charges")))
val keys = allRequests.map { it.getHeader("Idempotency-Key") }
// Two attempts made (1 failure + 1 success)
assertThat(keys).hasSize(2)
// Same key on both attempts — @Retryable proceed() did not regenerate UUID
assertThat(keys[0]).isEqualTo(keys[1])
}
}
Mode 3: runTest + WireMock for Flow.retryWhen{}
// BillingFlowServiceTest.kt
@ExtendWith(WireMockExtension::class)
class BillingFlowServiceTest(
@RegisterExtension val wireMock: WireMockExtension = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build()
) {
@Test
fun `Flow retryWhen does not generate new idempotency key on re-collection`() = runTest {
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("flow-retry")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(serverError())
.willSetStateTo("retried"))
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("flow-retry")
.whenScenarioStateIs("retried")
.willReturn(okJson("{ \"id\":\"ch_ok\",\"object\":\"charge\" }")))
Stripe.overrideApiBase("http://localhost:${wireMock.port}")
val service = BillingFlowService()
val result = service.chargeCustomerFlow("cus_001", 9900L, "2026-10").single()
assertThat(result).isEqualTo("ch_ok")
val requests = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")))
val keys = requests.map { it.getHeader("Idempotency-Key") }
// retryWhen triggered one re-collection — two total requests
assertThat(keys).hasSize(2)
// Both requests carry the same idempotency key — no UUID_B on re-collection
assertThat(keys[0]).isEqualTo(keys[1])
assertThat(keys[0]).isEqualTo("charge:2026-10:cus_001:9900")
}
@Test
fun `UUID inside flow builder fails idempotency test on retryWhen re-collection`() = runTest {
// Same WireMock setup.
// UnsafeBillingFlowService (UUID inside flow{} builder): keys[0] != keys[1].
// This assertion would fail — confirming the bug.
// SafeBillingFlowService (content-hash outside builder): keys[0] == keys[1].
// Test as regression gate.
}
}
The test patterns are structurally identical across all three modes: WireMock scenario with a 500 on the first attempt and 200 on the second, capturing all Idempotency-Key headers, and asserting that all captured keys are equal. An unsafe implementation (any UUID variant) 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 on every retry attempt. The test does not need to know which retry mechanism is used — it only cares about the key observed at the Stripe HTTP interface.
The single conceptual model across all languages and retry frameworks
The pattern that unifies all the modes in this post (and the related Spring, Quarkus, Micronaut, and Java concurrent posts) is:
A retry mechanism re-executes a scope. Any statement inside that scope re-executes on each retry attempt. If
UUID.randomUUID()is inside that scope, it generates a new UUID per attempt — UUID_B — which is sent to Stripe as a new billing intent, creating ch_B alongside any already-committed ch_A.
The scope varies by mechanism:
- Arrow
retry{}: the block lambda is the scope - Spring
@Retryable: the annotated method body is the scope (viaproceed()re-invocation) - Kotlin
Flow.retryWhen{}: theflow{}builder lambda is the scope (via re-collection) - Java’s
RetryTemplate.execute(RetryCallback): theRetryCallbacklambda is the scope (covered in the Spring CompletableFuture post) - Reactor’s
retryWhen(): the upstream publisher’s factory/subscription is the scope (covered in the Spring WebClient post) - Mutiny’s
onFailure().retry(): the upstream Uni/Multi’s subscription is the scope (covered in the Quarkus post)
The fix is always the same: replace UUID.randomUUID() — whose output changes per call — with a content-hash function — whose output is the same per call given the same inputs — and place it so that its inputs (billing period, customer ID, amount) are stable across all retry attempts and billing job restarts.
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.