Kotlin Flow, Exposed, and Stripe Integration: How Exposed’s Built-in Deadlock Retry Re-executes the transaction{} Block, Flow.retry{} Re-collects Cold flow{} Builders, and Flow.flatMapLatest{} Cancellation Leaves Committed Stripe Charges Without Database Records

Exposed, JetBrains’ idiomatic Kotlin SQL library, retries its transaction{} block automatically when the database reports a serialization failure. That retry is silent — no annotation, no configuration flag, no visible loop. For most code, this is a helpful invariant. For code that calls Stripe inside a transaction{} block, it is a hidden source of duplicate charges that looks nothing like the annotation-based retry bugs in Spring or Micronaut.

This post covers three Kotlin Flow and Exposed Stripe integration failure modes that are structurally distinct from the Ktor suspend retry HOF + newSuspendedTransaction + Flow.retryWhen{} post, the Kotlin coroutines Arrow retry{} + Spring @Retryable post, and the Micronaut coroutines post. The modes here are specific to Exposed’s transaction semantics, Kotlin cold-flow lifecycle, and the Flow operator flatMapLatest{}.

Background: Exposed transactions, cold flows, and the Stripe idempotency contract

Exposed’s transaction{} DSL opens a JDBC connection, sets autocommit off, runs the lambda body, and either commits or rolls back. A lesser-known behavior: Exposed wraps the lambda execution in a retry loop for a specific class of database exceptions. When the database raises an exception whose SQLState begins with 40 — serialization failures and deadlock detections — Exposed catches the exception and re-runs the lambda body from the beginning, up to DEFAULT_REPETITION_ATTEMPTS times (currently 3 in the Exposed source). This is controlled by the repetitionAttempts parameter on transaction(), which defaults to the database-level setting.

This behavior is documented, but the documentation is easy to miss because it reads like a database-level reliability feature rather than an application-level retry that re-executes every statement in the block. Developers who understand that their Stripe call is inside a transaction{} block often assume the Exposed retry is safe because they think of it as “the transaction retrying, not the application code.” The transaction and the application code are the same lambda. The retry re-runs both.

Kotlin’s Flow is a cold asynchronous data stream. A flow{} builder does not execute until the flow is collected. Every collection is a fresh execution of the builder block. The Flow.retry{} and Flow.retryWhen{} operators re-collect the upstream flow on failure. For a cold flow{}, re-collection means re-executing the builder block from the first line. This is the same fundamental mechanism as Mono.retryWhen() in Project Reactor and Uni.onFailure().retry() in Mutiny — re-subscription to a cold stream is re-execution — but the Kotlin surface syntax makes it easy to miss because flow{} blocks look like coroutine lambda bodies rather than publisher specifications.

Stripe’s idempotency key contract: Stripe deduplicates based on the Idempotency-Key header. If the same key is used twice within 24 hours for the same endpoint, Stripe returns the cached result of the first successful request. If a different key is used for what is economically the same charge, Stripe processes it as a new charge. The contract is per-key, not per-customer, not per-amount. Two requests with different keys for the same customer at the same amount on the same day are two charges.

Mode 1: Exposed’s built-in deadlock retry re-executes the transaction{} block — UUID inside the block generates UUID_B on the automatic database-layer retry — ch_B

Exposed’s automatic retry for serialization failures is enabled by default for databases configured with isolation levels that report SQLState 40001 or 40P01 (PostgreSQL deadlock detected). In a production billing system running concurrent charge jobs — two jobs that both update the same customer’s billing record and then charge Stripe — deadlocks are plausible. When they happen, Exposed silently re-runs the entire transaction{} lambda.

// BillingService.kt — unsafe mode 1 (UUID inside Exposed transaction{} block)
class BillingService(private val stripeGateway: StripeGateway) {

    fun chargeCustomer(customerId: String, amountCents: Long, billingPeriod: String): String {
        // Developer's reasoning:
        //   "I'm using Exposed's transaction{} block for atomicity — if the DB write
        //    fails, the Stripe charge is also rolled back. I'm not calling retry()
        //    anywhere explicitly; my code doesn't have a retry loop."
        //
        // The problem: Exposed retries transaction{} automatically on SQLState 40001
        // (serialization failure) and 40P01 (PostgreSQL deadlock detected).
        // The DEFAULT_REPETITION_ATTEMPTS is 3.
        // Every statement in the lambda body re-executes on each attempt.
        //
        // In a concurrent billing system where two jobs update the same customer
        // billing record, deadlocks occur. Exposed catches the deadlock exception,
        // waits, and re-runs the lambda. UUID inside the lambda generates UUID_B.

        return transaction {
            val idempotencyKey = UUID.randomUUID().toString()  // UUID_B on deadlock retry

            val params = PaymentIntentCreateParams.builder()
                .setAmount(amountCents)
                .setCurrency("usd")
                .setCustomer(customerId)
                .setConfirm(true)
                .build()

            val paymentIntentId = stripeGateway.createPaymentIntent(params, idempotencyKey)
            // Attempt 1: UUID_A sent to Stripe. Stripe processes the charge.
            // Response arrives. DB insert follows.
            // But: concurrent job also updates this customer's billing record.
            // PostgreSQL: deadlock detected (40P01). Exposed catches the exception.
            // ROLLBACK issued — DB write undone.
            // Exposed retries: lambda re-executes from line 1.
            // Attempt 2: UUID_B generated. Stripe processes a second charge.
            // ch_B created alongside ch_A. Customer charged twice.

            BillingRecords.insert {
                it[BillingRecords.customerId] = customerId
                it[BillingRecords.stripeId] = paymentIntentId
                it[BillingRecords.period] = billingPeriod
                it[BillingRecords.amountCents] = amountCents
            }

            paymentIntentId
        }
    }
}

The developer’s mental model has two accurate components and one fatal flaw. Accurate: Exposed’s transaction block provides atomicity between the DB write and any other DB writes in the block. Accurate: the developer has not written a retry loop. Fatal flaw: Exposed has a retry loop. It is not written by the developer; it is in Exposed’s transaction() implementation. The developer’s absence of an explicit retry construct does not prevent the implicit one from firing.

Why the deadlock retry fires on Stripe-after-DB patterns specifically

The deadlock window is widest when the transaction holds a DB row lock long enough for a concurrent transaction to acquire a conflicting lock. The pattern “charge Stripe first, then write to DB inside transaction{};” maximizes this window: the transaction has locked the row (or is about to), makes a synchronous HTTP call to Stripe that may take 200–800ms, and holds the lock throughout. Concurrent billing jobs that follow the same pattern for different customers whose billing records share a table-level lock (MyISAM, or a full-table scan on an unindexed column in InnoDB) will deadlock during the Stripe call window.

The safer pattern — from a deadlock-window perspective — is to perform the Stripe call outside the transaction and write the result inside a short transaction. This shortens the lock-holding window to the duration of a single INSERT, dramatically reducing the deadlock probability. It also makes the UUID placement obvious: the UUID is computed before the transaction opens, so no retry path (Exposed automatic or otherwise) can regenerate it.

// BillingService.kt — fixed mode 1 (Stripe call outside transaction, content-hash key)
class BillingService(private val stripeGateway: StripeGateway) {

    fun chargeCustomer(customerId: String, amountCents: Long, billingPeriod: String): String {
        // Content-hash key computed outside the transaction{} block.
        // No retry mechanism — Exposed's automatic or manual — can regenerate it.
        val idempotencyKey = contentHashKey(customerId, billingPeriod, amountCents)

        val params = PaymentIntentCreateParams.builder()
            .setAmount(amountCents)
            .setCurrency("usd")
            .setCustomer(customerId)
            .setConfirm(true)
            .build()

        // Stripe call outside the transaction — no DB lock held during the HTTP call.
        // Short deadlock window: only the INSERT below holds a lock.
        val paymentIntentId = stripeGateway.createPaymentIntent(params, idempotencyKey)

        transaction {
            // Short transaction: one INSERT, no HTTP calls.
            // If Exposed retries this transaction on a serialization failure,
            // the Stripe call is NOT re-executed — it already completed above.
            // Stripe deduplicates on idempotencyKey — even if the INSERT retry
            // somehow triggered a Stripe re-check, the response would be the cached result.
            BillingRecords.insertIgnore {
                it[BillingRecords.customerId] = customerId
                it[BillingRecords.stripeId] = paymentIntentId
                it[BillingRecords.period] = billingPeriod
                it[BillingRecords.amountCents] = amountCents
            }
        }

        return paymentIntentId
    }

    private fun contentHashKey(customerId: String, billingPeriod: String, amountCents: Long): String {
        val input = "$customerId|$billingPeriod|$amountCents"
        return MessageDigest.getInstance("SHA-256")
            .digest(input.toByteArray(Charsets.UTF_8))
            .joinToString("") { "%02x".format(it) }
    }
}

Note the use of insertIgnore for the DB record. If the Exposed automatic retry fires on the short INSERT transaction and the first attempt already committed (which would be unusual for a simple INSERT but can happen with certain PostgreSQL configurations), the second attempt’s INSERT is a no-op rather than a duplicate record. The insertIgnore pattern — or a unique constraint on (customerId, period) with a corresponding ON CONFLICT DO NOTHING — makes the DB write idempotent in the same way that the content-hash key makes the Stripe call idempotent.

Configuring Exposed’s repetition attempts

The transaction() function accepts a repetitionAttempts parameter. Setting it to 0 disables the automatic retry entirely:

// Opt out of Exposed's automatic deadlock retry for transactions containing Stripe calls
transaction(repetitionAttempts = 0) {
    // Stripe call inside — Exposed will NOT retry on serialization failure.
    // Handle the exception at the call site if retry is needed,
    // with a content-hash key computed outside this block.
    val paymentIntentId = stripeGateway.createPaymentIntent(params, idempotencyKey)
    BillingRecords.insert { ... }
}

Alternatively, set a project-wide default in the Exposed Database configuration: the defaultRepetitionAttempts property on DatabaseConfig controls the default for all transactions. Setting it to 0 globally, then opting specific transactions into deadlock retry explicitly, makes the behavior visible and auditable. Any transaction that can tolerate deadlock retry — because it contains only idempotent operations — opts in; any transaction containing non-idempotent side effects like Stripe calls defaults to no retry.

Mode 2: Kotlin Flow.retry{} re-collects a cold flow{} builder — UUID inside the builder generates UUID_B per re-collection — ch_B — Exposed transaction rolled back for attempt 1 while ch_A is committed in Stripe

Kotlin’s flow{} builder creates a cold flow. It does not execute until a terminal operator collects it. The Flow.retry() and Flow.retryWhen() operators, on receiving a failure from the upstream flow, re-collect the upstream from the beginning. For a cold flow{}, this means re-executing the builder block. The behavior is identical to Mono.retryWhen() in Project Reactor re-subscribing to a cold Mono.fromCallable() (covered in the Spring WebClient post), but the Kotlin syntax — a plain-looking lambda body — makes it visually less obvious that the block re-executes on retry.

// BillingFlowService.kt — unsafe mode 2 (UUID inside cold flow{} builder with retry{})
class BillingFlowService(
    private val stripeGateway: StripeGateway,
    private val customers: List
) {

    fun billingFlow(): Flow = customers.asFlow()
        .flatMapMerge(concurrency = 5) { customer ->
            // flow{} creates a cold flow. This builder block does not execute
            // until the flow is collected. On Flow.retry{} re-collection,
            // this builder block re-executes from the first statement.

            flow {
                // Developer's reasoning:
                //   "UUID is computed here, at the start of the per-customer flow.
                //    It runs before the Stripe call. It's a val — it won't change
                //    within a single execution of this block."
                //
                // The reasoning about "within a single execution" is correct.
                // The problem is that retry{} triggers a second execution.
                // On re-collection, this is a second execution of the block.
                // val uuid is a new binding — UUID.randomUUID() returns UUID_B.

                val idempotencyKey = UUID.randomUUID().toString()  // UUID_B on re-collection

                val result = transaction {
                    val params = PaymentIntentCreateParams.builder()
                        .setAmount(customer.amountCents)
                        .setCurrency("usd")
                        .setCustomer(customer.stripeId)
                        .setConfirm(true)
                        .build()

                    val piId = stripeGateway.createPaymentIntent(params, idempotencyKey)
                    // Transaction attempt 1:
                    //   UUID_A → Stripe → pi_A committed in Stripe.
                    //   Network timeout receiving Stripe response.
                    //   Exception propagates. Transaction rolls back DB writes.
                    //   (pi_A is committed in Stripe regardless of DB rollback.)
                    BillingRecords.insert {
                        it[BillingRecords.customerId] = customer.id
                        it[BillingRecords.stripeId] = piId
                    }
                    piId
                }

                emit(BillingResult(customer.id, result))
            }.retry(3) { e ->
                // retry{} catches the exception from the transaction{} block.
                // It re-collects the upstream flow — re-executes the flow{} builder.
                // UUID_B is generated. A new Stripe charge is initiated.
                // pi_B committed in Stripe alongside pi_A.
                e is StripeConnectException || e is ExposedSQLException
            }
        }
}

The developer is correct that val idempotencyKey does not change “within a single execution of this block.” That statement is true for any val in any Kotlin context — val is immutable. The insight the statement misses is that retry{} triggers more than one execution. Each re-collection is a new execution. Each new execution re-evaluates UUID.randomUUID() and assigns the result to a new val idempotencyKey binding. Two executions, two UUIDs, two Stripe charges.

Interaction with Exposed transaction rollback: the “two separate failures” scenario

The combination of Exposed transaction rollback and Flow.retry{} produces a scenario that is particularly hard to reconstruct from logs. Consider the execution trace for a network timeout after Stripe commits:

  1. Attempt 1: transaction{} opens. UUID_A computed. Stripe call sent. Stripe commits pi_A (200 response). Network timeout before response reaches the application. StripeConnectException thrown inside the transaction{} lambda. Exposed catches the exception. The transaction is not a serialization failure, so Exposed does not retry the transaction — it propagates the exception. transaction{} rolls back — the DB INSERT for pi_A is undone. The exception propagates out of the flow{} builder block, out of the inner flow, and reaches retry{}.
  2. retry{} re-collects the upstream. The flow{} builder re-executes. UUID_B computed. New transaction{} opens. Stripe call sent with UUID_B. Stripe has no record of UUID_B — pi_B is created and committed. DB INSERT for pi_B committed. emit() called. The collector receives the result.
  3. Final state: pi_A exists in Stripe (customer charged once via pi_A). pi_B exists in Stripe (customer charged a second time via pi_B). The DB has one record for pi_B. The DB has no record for pi_A — it was rolled back. The application considers the operation successful after attempt 2.

Detecting this from application logs requires correlation across three layers: the Stripe dashboard (shows two charges for the same customer at the same amount within a short window), the application logs (shows a StripeConnectException retry for the customer), and the database (shows one record for pi_B, no record for pi_A). None of these layers individually indicates that a duplicate charge occurred. The Stripe dashboard shows two charges that are superficially consistent with two billing events. The application log shows a transient retry that appears to have succeeded. The DB shows one clean record.

The fix: content-hash key outside the flow{} builder

Moving the UUID computation outside the flow{} builder block puts it in the flatMapMerge{} lambda body, which executes once per customer emission from the upstream customers.asFlow(). The flatMapMerge{} lambda is not re-collected by retry{} — only the inner flow{} is re-collected. The key computed in flatMapMerge{} is captured by the flow{} closure and reused on every re-collection of the inner flow.

// BillingFlowService.kt — fixed mode 2 (content-hash key outside flow{} builder)
fun billingFlow(): Flow = customers.asFlow()
    .flatMapMerge(concurrency = 5) { customer ->
        // Content-hash key computed here — in the flatMapMerge{} lambda body.
        // flatMapMerge{} executes once per customer emission.
        // retry{} re-collects the inner flow{}, not the flatMapMerge{} lambda.
        // The key is captured by the flow{} closure — same value on every re-collection.
        val idempotencyKey = contentHashKey(
            customer.id,
            customer.billingPeriod,
            customer.amountCents
        )

        flow {
            // idempotencyKey captured from the outer lambda — stable across retries.
            val result = transaction(repetitionAttempts = 0) {
                val params = PaymentIntentCreateParams.builder()
                    .setAmount(customer.amountCents)
                    .setCurrency("usd")
                    .setCustomer(customer.stripeId)
                    .setConfirm(true)
                    .build()

                val piId = stripeGateway.createPaymentIntent(params, idempotencyKey)
                // Attempt 1: key = hash("cus_A|2026-10|5000") = "a3f7..."
                // Network timeout. Exception. transaction{} rolls back.
                // retry{} re-collects flow{}.
                // Attempt 2: key = hash("cus_A|2026-10|5000") = "a3f7..." (same inputs).
                // Stripe: key already processed — returns cached response for pi_A.
                // No pi_B. Customer charged exactly once.
                BillingRecords.insertIgnore {
                    it[BillingRecords.customerId] = customer.id
                    it[BillingRecords.stripeId] = piId
                }
                piId
            }
            emit(BillingResult(customer.id, result))
        }.retry(3) { e -> e is StripeConnectException }
    }

The repetitionAttempts = 0 on the transaction{} is belt-and-suspenders against the Mode 1 issue: even if a serialization failure occurs, Exposed will not silently re-run the Stripe call. The exception propagates to retry{}, which re-collects the flow with the stable key. Every retry attempt at every layer uses the same key.

Mode 3: Flow.flatMapLatest{} cancellation leaves a committed Stripe charge with no database record — next upstream emission triggers a new charge with UUID_B — ch_A and ch_B both exist in Stripe — DB has zero records

Flow.flatMapLatest{} is designed to cancel the previous inner flow when the upstream emits a new value. This makes it useful for UI patterns (cancel the previous search query when the user types a new character) and event-driven systems (cancel a stale operation when a fresher trigger arrives). Applied to billing, it introduces a failure mode with no analog in the annotation-based or manual-retry patterns: cancellation of an in-flight Stripe charge whose HTTP request has already been received and committed by Stripe.

// BillingReactiveService.kt — unsafe mode 3 (flatMapLatest + Stripe charge)
class BillingReactiveService(
    private val billingTriggers: SharedFlow,
    private val stripeGateway: StripeGateway
) {

    // billingTriggers emits whenever a billing event occurs (webhook, scheduled trigger,
    // payment retry request, etc.)
    //
    // Developer's intent: process each billing trigger. If a new trigger arrives
    // while processing the previous one, cancel the stale operation and handle
    // the new trigger. This prevents stacked billing operations for the same customer
    // when multiple triggers arrive in quick succession.
    //
    // Developer's reasoning about safety:
    //   "If the previous operation is cancelled, it means it was still in progress —
    //    nothing was committed. Cancellation before completion = safe to retry."
    //
    // This reasoning is correct for pure Kotlin coroutine operations that have not
    // yet sent a side effect. It is incorrect for Stripe charges: once the HTTP
    // request reaches Stripe, Stripe processes and commits the charge independently
    // of what happens to the coroutine on the caller side.

    val billingResults: Flow = billingTriggers
        .flatMapLatest { trigger ->
            flow {
                val idempotencyKey = UUID.randomUUID().toString()  // New UUID per trigger

                // The Stripe HTTP call is dispatched to the network.
                // At this point, the request may already be in Stripe's hands.
                // If a new billingTrigger emission arrives AFTER the HTTP request
                // is sent but BEFORE the response is received, flatMapLatest{} cancels
                // this inner flow. The coroutine receives CancellationException.
                // The HTTP socket may be closed. But Stripe has already committed ch_A.
                val paymentIntentId = stripeGateway.createPaymentIntent(
                    buildParams(trigger),
                    idempotencyKey
                )

                // If the coroutine is cancelled here (between Stripe commit and DB write),
                // the transaction below never executes. ch_A exists in Stripe, DB is empty.

                transaction {
                    BillingRecords.insert {
                        it[BillingRecords.triggerId] = trigger.id
                        it[BillingRecords.stripeId] = paymentIntentId
                        it[BillingRecords.customerId] = trigger.customerId
                    }
                }

                emit(BillingResult(trigger.customerId, paymentIntentId))
            }
        }

    // When the next billingTrigger emission arrives:
    //   flatMapLatest{} starts a new inner flow with a NEW UUID.
    //   UUID_B is generated. Stripe has no record of UUID_B.
    //   Stripe processes ch_B alongside ch_A.
    //   This time the coroutine runs to completion — DB record for ch_B committed.
    //   Final state: ch_A in Stripe (no DB record), ch_B in Stripe (DB record exists).
    //   Net: customer charged twice, DB shows one charge.
}

The developer’s reasoning — “cancellation before completion means nothing was committed” — would be correct for a pure in-memory computation or a database operation where the transaction atomically commits everything or commits nothing. It is incorrect for Stripe because Stripe is not part of the application transaction. The HTTP network boundary is not a transaction boundary in the database sense. A request that travels across the network and reaches Stripe’s servers is a committed side effect from Stripe’s perspective, regardless of what happens on the caller side afterward.

The cancellation window and why it is not negligible

The cancellation window — the interval between the HTTP request leaving the application and the HTTP response arriving — is the Stripe round-trip time: typically 150–600ms for PaymentIntent creation under normal load. During this window, any new emission to billingTriggers will cancel the in-flight charge coroutine.

In production billing systems, rapid successive trigger emissions occur in several real scenarios:

None of these are edge cases in production. Any billing system with flatMapLatest{} processing live billing triggers is exposed to this failure mode during normal operation.

Why coroutine cancellation does not stop the Stripe HTTP request

When flatMapLatest{} cancels the inner flow coroutine, it sends a CancellationException to the coroutine via cooperative cancellation. At the next suspension point, the coroutine checks for cancellation and throws. For network I/O, the suspension point is the await() or receive() call waiting for the HTTP response. The cancellation closes the local connection — the socket on the application side.

Closing the local socket does not retract the HTTP request already sent to Stripe. The TCP stream carrying the request bytes has already been delivered. Stripe’s servers have received the complete request, parsed it, validated the idempotency key, and initiated (or completed) the charge. Stripe’s commitment is not contingent on the caller staying connected to receive the response. The charge is in Stripe’s ledger. The RST or FIN on the caller’s TCP connection is a transport event, not a charge cancellation event.

Stripe’s documentation on idempotent requests addresses this directly: if you disconnect before receiving a response, you should retry with the same idempotency key to determine whether the request succeeded. The implication is that the request may have succeeded even if you did not receive the response. flatMapLatest{}, by discarding the in-flight coroutine and starting a new one with a new UUID, does the exact opposite: it retries with a different key, guaranteeing a new charge.

The fix: separate the charge from the flow control

The root problem is that flatMapLatest{} is the wrong operator for billing operations. flatMapLatest{} is designed for operations where cancelling the stale one is safe — UI queries, real-time updates, speculative prefetches. Billing charges are not safe to cancel once dispatched to the network. The fix is to decouple the trigger processing from the charge execution:

// BillingReactiveService.kt — fixed mode 3 (decouple trigger dedup from charge execution)
class BillingReactiveService(
    private val billingTriggers: SharedFlow,
    private val stripeGateway: StripeGateway,
    private val billingQueue: Channel  // RENDEZVOUS or CONFLATED channel
) {

    // Deduplication layer — safe to use flatMapLatest{} here because no Stripe call yet
    val triggerDedup: Flow = billingTriggers
        .flatMapLatest { trigger ->
            // No Stripe call here. Just pass the trigger through with deduplication.
            // If a new trigger arrives, this inner flow is cancelled — no side effect.
            flowOf(trigger).onStart { delay(50) }  // 50ms debounce window
        }

    // Charge execution layer — processes deduplicated triggers sequentially
    // (or with bounded concurrency via flatMapMerge, never flatMapLatest)
    val billingResults: Flow = triggerDedup
        .flatMapMerge(concurrency = 3) { trigger ->  // NOT flatMapLatest
            flow {
                // Content-hash key: stable across any retry of this trigger.
                val idempotencyKey = contentHashKey(
                    trigger.customerId,
                    trigger.billingPeriod,
                    trigger.amountCents
                )
                // This coroutine is NOT cancelled by subsequent trigger emissions —
                // flatMapMerge runs inner flows concurrently but does not cancel them.
                // Once dispatched, the charge runs to completion.
                val paymentIntentId = stripeGateway.createPaymentIntent(
                    buildParams(trigger),
                    idempotencyKey
                )
                transaction {
                    BillingRecords.insertIgnore {
                        it[BillingRecords.triggerId] = trigger.id
                        it[BillingRecords.stripeId] = paymentIntentId
                        it[BillingRecords.customerId] = trigger.customerId
                    }
                }
                emit(BillingResult(trigger.customerId, paymentIntentId))
            }
        }
}

The deduplication step uses flatMapLatest{} for the debounce, but at a point where no Stripe call has been made — the cancellation of a stale inner flow at this layer has no side effects. The charge execution step uses flatMapMerge{}, which allows concurrency but does not cancel in-flight inner flows when new values arrive. Concurrency bounded to 3 prevents unbounded parallel charge execution while still allowing multiple customers’ charges to proceed in parallel.

The content-hash key provides the second layer of safety: if the same trigger is somehow processed twice (by the deduplication layer missing a duplicate), both attempts use the same key, and Stripe deduplicates the second attempt against the first. The combination of flatMapMerge (no cancellation) and content-hash key (Stripe-layer deduplication) makes the charge execution idempotent across all failure modes.

Comparison table

Mode Retry mechanism Re-execution unit UUID position in unsafe code Developer misconception
1 Exposed automatic deadlock retry (DEFAULT_REPETITION_ATTEMPTS) Entire transaction{} lambda body Inside transaction{} block, before Stripe call “I’m not retrying Stripe — the database is retrying a concurrency conflict”
2 Flow.retry{} re-collecting cold flow{} Entire flow{} builder block per re-collection Inside flow{} builder, before transaction{} “UUID is a val — it doesn’t change within a single execution of this block”
3 flatMapLatest{} cancels in-flight coroutine, starts new one New inner flow execution per upstream emission Inside inner flow{} builder, first line “Cancellation before completion = nothing was committed”

Testing: kotlinx.coroutines.test + WireMock patterns

All three modes require test setups that exercise the specific re-execution path rather than the happy-path flow. For Mode 1, the test must trigger a database serialization failure. For Mode 2, the test must observe multiple Stripe request bodies. For Mode 3, the test must inject a new upstream emission during the Stripe call round-trip.

// Mode 1 test: verify Exposed deadlock retry does NOT create duplicate charges
// (requires content-hash key in place — test verifies the fix, not the bug)
@Test
fun `Exposed deadlock retry uses same idempotency key — no duplicate charge`() {
    // WireMock: first Stripe request → success
    stubFor(post(urlPathEqualTo("/v1/payment_intents"))
        .willReturn(aResponse().withStatus(200)
            .withBody("""{"id":"pi_A","status":"succeeded"}""")))

    // Simulate a deadlock on first DB insert by forcing Exposed to retry:
    // Use an H2 in-memory DB with SERIALIZABLE isolation and concurrent writes
    // that trigger a serialization failure.
    // Here we test the key-stability property directly.

    val service = BillingService(stripeGateway)
    service.chargeCustomer("cus_test", 5000, "2026-10")

    // Verify only one Stripe request was made (deadlock retry doesn't reach Stripe
    // if the fix is in place — Stripe call is outside transaction{})
    verify(exactly(1), postRequestedFor(urlPathEqualTo("/v1/payment_intents")))
}

// Mode 2 test: verify Flow.retry{} uses same idempotency key on re-collection
@Test
fun `Flow retry uses content-hash key — second Stripe call uses same key as first`() = runTest {
    var requestCount = 0

    stubFor(post(urlPathEqualTo("/v1/payment_intents"))
        .inScenario("retry")
        .whenScenarioStateIs(STARTED)
        .willReturn(aResponse().withStatus(500)
            .withBody("""{"error":{"type":"api_connection_error"}}"""))
        .willSetStateTo("succeeded"))

    stubFor(post(urlPathEqualTo("/v1/payment_intents"))
        .inScenario("retry")
        .whenScenarioStateIs("succeeded")
        .willReturn(aResponse().withStatus(200)
            .withBody("""{"id":"pi_A","status":"succeeded"}""")))

    val results = mutableListOf()
    billingFlowService.billingFlow().toList(results)

    // Extract Idempotency-Key headers from all captured requests
    val requests = findAll(postRequestedFor(urlPathEqualTo("/v1/payment_intents")))
    val keys = requests.map { it.getHeader("Idempotency-Key") }

    // Both requests must use the same key — Stripe deduplicates correctly
    assertEquals(2, keys.size)
    assertEquals(keys[0], keys[1],
        "Retry attempt must use same Idempotency-Key as first attempt")
}

// Mode 3 test: verify flatMapMerge does not cancel in-flight charges
@Test
fun `flatMapMerge does not cancel charge on second trigger — no duplicate`() = runTest {
    val triggers = MutableSharedFlow(replay = 0, extraBufferCapacity = 10)

    stubFor(post(urlPathEqualTo("/v1/payment_intents"))
        .willReturn(aResponse()
            .withFixedDelay(200)  // 200ms delay — trigger second emission during this window
            .withStatus(200)
            .withBody("""{"id":"pi_A","status":"succeeded"}""")))

    val job = launch { billingReactiveService.billingResults.collect() }

    triggers.emit(BillingTrigger("t1", "cus_A", "2026-10", 5000))
    delay(50)  // Emit second trigger while first charge is in-flight
    triggers.emit(BillingTrigger("t2", "cus_A", "2026-10", 5000))

    delay(500)  // Allow both to complete
    job.cancel()

    // flatMapMerge allows both — both complete — but content-hash key deduplicates
    val requests = findAll(postRequestedFor(urlPathEqualTo("/v1/payment_intents")))
    val keys = requests.map { it.getHeader("Idempotency-Key") }

    // With content-hash key: same customer + period + amount → same key → Stripe deduplicates
    assertEquals(keys[0], keys[1])
}

Keybrake: enforce idempotency at the proxy layer regardless of application code

All three failure modes in this post are variants of the same root cause: application code generates a new idempotency key when it retries or re-executes a Stripe call. The mechanisms are different — Exposed automatic deadlock retry, cold flow re-collection, coroutine cancellation — but the outcome is the same: UUID_B sent to Stripe, ch_B created alongside ch_A.

Content-hash keys fix the application-layer root cause. But in a system with multiple services, multiple developers, and multiple retry paths, enforcing the “compute the key before the retry scope” rule across every Stripe call is an auditing problem. New code ships without review; content-hash keys get accidentally replaced with UUID.randomUUID() during refactors; the Exposed automatic retry is not visible to reviewers reading the service code.

Keybrake is a scoped API-key proxy that sits between your application and Stripe. When you route your Stripe calls through Keybrake instead of directly to api.stripe.com, Keybrake can enforce idempotency at the proxy layer: it normalizes idempotency keys to deterministic hashes based on the request body, detects when a key changes across retries for the same logical charge, and either deduplicates or alerts — depending on your policy. This catches the class of bug described in this post regardless of which layer generates the retry, without requiring code changes in every service that calls Stripe.

Put the brakes on your agent’s Stripe calls

Join the waitlist for Keybrake — scoped API keys, spend caps, and audit log for the SaaS APIs your agents call.