Micronaut, Kotlin Coroutines, and Stripe Integration: How @Retryable Compile-Time AOP on suspend Functions, Manual while-Loop Retry in @TransactionalEventListener, and supervisorScope async{} Batch Billing with Outer @Retryable Generate New Idempotency Keys
Micronaut’s compile-time AOP model differs from Spring’s runtime proxy approach in one important implementation detail — KSP-generated interceptors at build time, not CGLIB bytecode at class load. At runtime, the consequence for Stripe idempotency is identical: @Retryable’s interceptor calls proceed() for each retry attempt, each proceed() is a fresh coroutine invocation of the suspend function, all local bindings re-initialize, and UUID.randomUUID() anywhere in the function body generates UUID_B on the second attempt. The compile-time vs. runtime distinction — the detail that Micronaut engineers justifiably take pride in — does not insulate the application from this class of bug.
This post covers three Micronaut Kotlin coroutine failure modes that produce Stripe duplicate charges via new idempotency keys, each structurally distinct from the Kotlin coroutines Arrow retry{} + Spring @Retryable + Flow.retryWhen{} post, the Ktor suspend retry HOF + Exposed newSuspendedTransaction + Flow.retryWhen{} post, and the Micronaut ReactorHttpClient + Reactor retryWhen() post. The modes here are Micronaut-idiomatic: annotation-driven retry on coroutine service methods, event-driven architecture billing with Micronaut’s application event system, and Kotlin coroutine batch parallelism via supervisorScope{}.
Background: Micronaut’s compile-time AOP and why the idempotency hazard is unchanged
Micronaut’s headline differentiator from Spring is “no reflection, no proxies, no CGLIB at runtime.” To achieve this, Micronaut processes annotations at build time: the Kotlin Symbol Processing (KSP) annotation processor — or the older kapt backend — reads @Retryable, @Transactional, @Cacheable, and every other AOP annotation at compile time and generates Java source files implementing the Interceptor interface. The generated classes are compiled alongside your application code. No class loading magic at startup; no dynamic proxy generation at the first ApplicationContext.getBean() call.
At runtime, when a bean method annotated with @Retryable is called, the framework routes the call through the generated interceptor chain. Each interceptor in the chain holds a reference to the next interceptor (or the actual method) via a MethodInvocationContext. Calling context.proceed() advances to the next interceptor or, at the end of the chain, invokes the actual method implementation. For @Retryable, the generated interceptor calls context.proceed(), catches exceptions matching the configured exception types, waits the configured delay, and calls context.proceed() again for the retry attempt.
For a suspend function, context.proceed() invokes the method as a coroutine. Micronaut’s Kotlin coroutine support uses the coroutine continuation mechanism: the interceptor is aware that the method is a suspend function and bridges the coroutine invocation into the interceptor chain. The critical runtime fact: each context.proceed() call on a suspend function starts a new coroutine invocation of that function. The coroutine that ran attempt 1 completed (or failed) before context.proceed() is called again for attempt 2. Attempt 2 is not resuming the suspended coroutine from attempt 1 — it is starting a new coroutine that re-executes the function body from the first statement. Every val and var declared inside the function body is a new binding. UUID.randomUUID() at the top of the function body generates a new UUID.
The compile-time vs. runtime distinction is real and important for startup performance, memory footprint, and native image compatibility. It has no bearing on whether UUID.randomUUID() inside the method body generates the same value or a new value on each retry attempt. The answer is the same as for Spring’s CGLIB proxy: new value per attempt — UUID_B — ch_B.
Mode 1: Micronaut @Retryable on a suspend function — KSP-generated interceptor calls proceed() per retry — fresh coroutine activation frame — UUID at function scope regenerates — UUID_B — ch_B
Micronaut’s @Retryable annotation is part of the micronaut-retry module. The annotation accepts attempts, delay, multiplier, and includes/excludes exception lists. The KSP processor generates an interceptor that implements the retry loop using these parameters.
// BillingService.kt — unsafe mode 1
@Singleton
class BillingService(private val stripeGateway: StripeGateway) {
@Retryable(attempts = "3", delay = "500ms", includes = [StripeConnectException::class])
suspend fun chargeCustomer(customerId: String, amountCents: Long): String {
// Micronaut's @Retryable KSP interceptor calls proceed() on this function body
// for each retry attempt. proceed() is a fresh coroutine invocation.
// All val bindings in this function body re-initialize per proceed() call.
//
// Developer's reasoning: "Micronaut uses compile-time AOP, not a runtime proxy.
// The interceptor is a generated class, not a dynamic proxy. There's no
// re-subscription or cold-stream concern."
//
// The concern is different: proceed() re-invokes the function body. The mechanism
// of interception (compile-time vs. runtime) is orthogonal to whether the function
// body re-executes. It does.
val idempotencyKey = UUID.randomUUID().toString() // UUID_B on attempt 2
val params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build()
return stripeGateway.createCharge(params, idempotencyKey)
// Attempt 1: UUID_A sent to Stripe → ch_A committed.
// Network blip → StripeConnectException.
// @Retryable interceptor: attempt < 3, delay 500ms, call proceed() again.
// Attempt 2: UUID_B sent to Stripe → Stripe has never seen UUID_B → ch_B created.
// ch_A and ch_B both committed. Customer charged twice.
}
}
The developer’s mental model — “Micronaut’s AOP is different from Spring’s, so the same retry bugs don’t apply” — identifies a real architectural difference but draws the wrong conclusion. The difference matters for tooling, native image, GraalVM compatibility, and startup time. It does not change what proceed() does at the instruction level: it invokes the method again. A function body that re-executes re-evaluates every statement in it.
The @Recover interaction: fallback fires after duplicate charges already exist
Micronaut provides @Recover as a companion annotation to @Retryable. A method annotated with @Recover in the same bean is called when all retry attempts have been exhausted without success. Teams sometimes reason that adding @Recover is a safety net — “if the retries all fail, the recover method will handle it.” The @Recover method does fire correctly on retry exhaustion. But by the time it fires, @Retryable has already called proceed() two additional times after the first failure, each time generating a new UUID and potentially committing a new Stripe charge.
// BillingService.kt — @Recover fires too late to prevent duplicate charges
@Singleton
class BillingService(private val stripeGateway: StripeGateway) {
@Retryable(attempts = "3", delay = "500ms", includes = [StripeConnectException::class])
suspend fun chargeCustomer(customerId: String, amountCents: Long): String {
val idempotencyKey = UUID.randomUUID().toString() // UUID regenerates per proceed()
val params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build()
return stripeGateway.createCharge(params, idempotencyKey)
}
// Called when all 3 attempts have been exhausted.
// At this point:
// - Attempt 1 may have committed ch_A (UUID_A) — ch_A exists in Stripe.
// - Attempt 2 committed ch_B (UUID_B) — ch_B exists in Stripe.
// - Attempt 3 committed ch_C (UUID_C) — ch_C exists in Stripe (if network was intermittent).
// @Recover cannot retroactively revoke ch_A, ch_B, or ch_C.
// Its only recourse is to record the failure state in the DB.
@Recover
suspend fun recoverCharge(e: StripeConnectException, customerId: String, amountCents: Long): String {
log.error("All 3 Stripe attempts failed for customer $customerId", e)
// Mark billing attempt as failed in DB — but the DB may already have
// ch_A, ch_B, or ch_C recorded from the attempt handler.
// Customer may have been charged 1, 2, or 3 times.
return "FAILED"
}
}
The timeline for a scenario where attempt 1 succeeds in Stripe but the response is lost (a network interruption after Stripe commits but before the response reaches the caller) and attempt 2 also gets through:
- Attempt 1: UUID_A sent, ch_A committed, response lost,
StripeConnectExceptionthrown. @Retryableintercepts, waits 500ms, callsproceed()for attempt 2.- Attempt 2: UUID_B sent (new UUID from new function body execution), ch_B committed, 200 returned.
- No more exceptions —
@Retryableconsiders the operation successful.@Recoveris NOT called. - Both ch_A and ch_B exist in Stripe. Customer charged twice. No error log. No alert.
In the scenario where all three attempts ultimately fail (Stripe is down for the full retry window), @Recover is called, but any attempt that did reach Stripe before failing (a timeout after Stripe committed but before response delivery) has already produced a committed charge. @Recover cannot undo a committed Stripe charge; it can only handle the failure state in the application layer.
The nested retry trap with @Retryable
A common refactoring path: developer reads about UUID idempotency bugs in coroutines and moves UUID.randomUUID() “above the retry logic.” For Arrow’s retry{} block (covered in the Kotlin coroutines post), moving UUID above the retry{} block in the same function scope does make it stable for that retry. With @Retryable, “above the retry logic” is ambiguous because the retry is implemented by an interceptor that wraps the entire function invocation — there is no “above” within the function scope that is stable across proceed() calls. The entire function body is the retry unit. Moving UUID to the first line of the function body does not help; proceed() re-evaluates the first line.
// Still broken — UUID at the "top of the function" is still inside the proceed() unit
@Retryable(attempts = "3", delay = "500ms")
suspend fun chargeCustomer(customerId: String, amountCents: Long): String {
// "I moved UUID to the very first line, before any retry logic."
// But the @Retryable interceptor calls proceed() which re-enters the function
// from this first line. UUID is still inside the retry unit.
val idempotencyKey = UUID.randomUUID().toString() // Still regenerates per proceed()
return stripeGateway.createCharge(
buildParams(customerId, amountCents),
idempotencyKey
)
}
The only placement that is stable across all @Retryable proceed() calls is a value computed outside the method body and passed in as a parameter, or a deterministic content-hash function whose output is the same given the same inputs regardless of how many times it is called.
// BillingService.kt — fixed mode 1 (content-hash key)
@Singleton
class BillingService(private val stripeGateway: StripeGateway) {
@Retryable(attempts = "3", delay = "500ms", includes = [StripeConnectException::class])
suspend fun chargeCustomer(customerId: String, amountCents: Long, billingPeriod: String): String {
// Content-hash key: SHA-256 of (customerId + billingPeriod + amountCents).
// Calling this function again — via proceed() or via any caller retry —
// returns the same key for the same inputs. Stripe deduplicates on this key.
val idempotencyKey = contentHashKey(customerId, billingPeriod, amountCents)
val params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setCustomer(customerId)
.build()
return stripeGateway.createCharge(params, idempotencyKey)
// Attempt 1: key = sha256("cus_A|2026-10|5000") = "a3f7..." → ch_A committed.
// Network blip → StripeConnectException.
// Attempt 2: key = sha256("cus_A|2026-10|5000") = "a3f7..." (same inputs → same hash).
// Stripe: already processed key "a3f7..." → returns the cached response for ch_A.
// No ch_B. Charge deduplication works.
}
private fun contentHashKey(customerId: String, billingPeriod: String, amountCents: Long): String {
val input = "$customerId|$billingPeriod|$amountCents"
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
return hashBytes.joinToString("") { "%02x".format(it) }
}
}
The billingPeriod parameter is essential for the content-hash approach. Without it, the same customer being charged the same amount in two different billing periods would produce the same hash — Stripe would treat the second month’s charge as a duplicate of the first and return the cached response, effectively billing the customer only once. The billing period scopes the idempotency window to a single billing cycle. For event-driven or job-based billing, the billing job ID or job run timestamp serves the same function.
Mode 2: Manual while-loop retry in a @TransactionalEventListener — Kotlin val inside loop body is a new binding per iteration — UUID_B — ch_B — plus rollback-only transaction complication
Micronaut’s application event system lets services publish and consume application events. A billing pattern that appears frequently in Micronaut event-driven architectures: an order service publishes a PaymentRequestedEvent when an order is confirmed, and a billing service subscribes to that event and initiates the Stripe charge. Micronaut’s @TransactionalEventListener (or @EventListener inside a @Transactional method) wires the listener into the transaction lifecycle of the publisher — the listener fires after the publishing transaction commits, ensuring the event is only processed if the order record is durable.
// BillingEventListener.kt — unsafe mode 2 (manual while-loop retry with UUID inside loop)
@Singleton
class BillingEventListener(
private val stripeGateway: StripeGateway,
private val billingRepository: BillingRepository
) {
@TransactionalEventListener
suspend fun onPaymentRequested(event: PaymentRequestedEvent) {
var attempt = 0
val maxAttempts = 3
// Developer's intent: retry the Stripe call up to 3 times on transient errors.
// Developer's reasoning about UUID placement:
// "UUID is declared inside the while loop, which is not a lambda or a cold stream.
// It's just a Kotlin val declaration inside a block. The while loop is not
// 're-invoking a block lambda' or 're-subscribing to a publisher'.
// It's just a loop."
//
// This reasoning correctly identifies that the while loop is not a lambda or stream.
// It incorrectly concludes that Kotlin val inside a while-loop body is stable.
// A Kotlin val declared inside a while-loop body is a new val binding per iteration.
// The while-loop body is a fresh block entry on every pass of the loop.
// 'val idempotencyKey = UUID.randomUUID()' creates a new UUID on every iteration.
while (attempt < maxAttempts) {
try {
val idempotencyKey = UUID.randomUUID().toString() // New UUID per iteration
val params = ChargeCreateParams.builder()
.setAmount(event.amountCents)
.setCurrency("usd")
.setCustomer(event.stripeCustomerId)
.build()
val chargeId = stripeGateway.createCharge(params, idempotencyKey)
billingRepository.recordCharge(event.orderId, chargeId)
return // Success — exit the while loop
} catch (e: StripeConnectException) {
attempt++
if (attempt >= maxAttempts) throw e
delay(500L * attempt)
// Loop continues → UUID_B on iteration 2 → ch_B → duplicate charge
}
}
}
}
Why Kotlin val inside a while-loop body is not hoisted
In Kotlin, a val declared inside a block (including a while-loop body, a for-loop body, an if branch, or a try block) is scoped to that block. Each entry into the block is a new scope. On the second iteration of the while loop, the block is entered again and the val idempotencyKey binding is created again via a fresh call to UUID.randomUUID().
This is not unique to Kotlin; Java, Go, Python, and virtually every modern language with block scoping behaves the same way. The Java equivalent String idempotencyKey = UUID.randomUUID().toString(); inside a for loop body generates a new UUID on each iteration. Kotlin’s val scoping inside a loop body is not a Kotlin-specific quirk — it is standard block scoping. The bug is a universal “variable declared inside the retry loop” mistake, not a language-specific one.
The developer reasoning that identifies while loop iteration as distinct from lambda re-invocation or cold-stream re-subscription is technically correct as a description of the Kotlin language mechanism. Lambdas and cold streams do involve different execution models. But the consequence for UUID idempotency is the same: the UUID-generating statement re-executes on the second attempt. The surface syntax — while (attempt < maxAttempts) vs. retry { ... } vs. retryWhen { ... } — does not change whether the UUID expression runs once or runs per attempt.
The @TransactionalEventListener rollback complication
Micronaut’s @TransactionalEventListener runs in its own transaction context (configured by the TransactionDefinition — default is REQUIRES_NEW in most frameworks, creating a new transaction independent of the publisher’s committed transaction). When the while-loop exhausts all attempts and re-throws the last exception, the transaction wrapping the @TransactionalEventListener method rolls back. This rollback affects any database writes made inside the listener during the current transaction — in the example above, the billingRepository.recordCharge() call on attempt 1 is rolled back (assuming it was inside the same transaction as the listener).
The rollback correctly prevents a partial DB state from persisting. But it has no effect on Stripe. Stripe charges are external API calls; they are not enrolled in the database transaction. The ch_A committed by Stripe on attempt 1 and ch_B committed by Stripe on attempt 2 remain committed regardless of the database rollback. The DB ends up with no record of either charge. The customer has been charged once or twice (depending on whether attempt 1 reached Stripe before the error). Reconciliation between the DB billing table (showing nothing for this order) and the Stripe dashboard (showing one or two charges) is the operational outcome.
// BillingEventListener.kt — fixed mode 2 (content-hash key outside loop)
@Singleton
class BillingEventListener(
private val stripeGateway: StripeGateway,
private val billingRepository: BillingRepository
) {
@TransactionalEventListener
suspend fun onPaymentRequested(event: PaymentRequestedEvent) {
// Content-hash key computed ONCE, outside the retry loop, from stable inputs.
// The event carries a stable orderId that uniquely identifies this billing intent.
// Including the billing period (month) ensures a future re-billing for the same
// order in a different period gets a distinct key.
val idempotencyKey = contentHashKey(event.orderId, event.billingPeriod, event.amountCents)
var attempt = 0
val maxAttempts = 3
while (attempt < maxAttempts) {
try {
val params = ChargeCreateParams.builder()
.setAmount(event.amountCents)
.setCurrency("usd")
.setCustomer(event.stripeCustomerId)
.build()
val chargeId = stripeGateway.createCharge(params, idempotencyKey)
// idempotencyKey is the same on every iteration — Stripe deduplicates.
billingRepository.recordCharge(event.orderId, chargeId)
return
} catch (e: StripeConnectException) {
attempt++
if (attempt >= maxAttempts) throw e
delay(500L * attempt)
}
}
}
private fun contentHashKey(orderId: String, billingPeriod: String, amountCents: Long): String {
val input = "$orderId|$billingPeriod|$amountCents"
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(input.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}
}
With the content-hash key outside the loop, iteration 2 sends the same key to Stripe as iteration 1. Stripe checks its idempotency key cache, finds a result for this key, and returns the cached response (the Charge object from attempt 1’s successful creation). No second charge is created. If attempt 1 reached Stripe but the response was lost (triggering the loop’s catch branch), attempt 2’s Stripe call also returns the cached result for the same key — the charge from attempt 1 is returned as if the call succeeded. The billingRepository.recordCharge() call then records the original charge ID correctly.
A note on @TransactionalEventListener vs. @EventListener scoping in Micronaut
Micronaut has two primary event listener annotations: @EventListener (fires synchronously in the same thread as the publisher, and in the same transaction if the publisher is transactional and the listener is inside a @Transactional method) and @TransactionalEventListener (deferred until after the publishing transaction commits, then fired in a new transaction). For the UUID idempotency bug, the distinction matters for the rollback complication scenario (whether the DB write in the listener is rolled back alongside failed Stripe attempts), but not for the core bug: UUID inside the retry loop generates a new UUID per iteration regardless of the transaction scope. The fix — move UUID generation outside the loop using a content-hash key — applies equally to both annotation variants.
Mode 3: supervisorScope{} + async{} parallel batch billing with outer @Retryable — proceed() re-invokes the batch method body — all async{} blocks recreated — UUIDs regenerate for already-charged customers — ch_B per customer
Micronaut services that bill multiple customers in a single batch operation frequently use Kotlin coroutines’ structured concurrency primitives to run per-customer Stripe calls in parallel. supervisorScope{} is the standard choice for batch work: unlike coroutineScope{}, a failure in one child async{} job does not automatically cancel sibling jobs. Each customer’s charge attempt is independent; a transient Stripe error for one customer should not prevent the other customers from being processed. After all async{} blocks have been launched, awaitAll() waits for all results and collects any deferred exceptions.
// BatchBillingService.kt — unsafe mode 3 (UUID inside async{}, outer @Retryable)
@Singleton
class BatchBillingService(private val stripeGateway: StripeGateway) {
@Retryable(
attempts = "2",
delay = "2s",
includes = [BatchBillingException::class]
)
suspend fun chargeAll(customers: List): BatchResult {
// supervisorScope{} + async{} launches all Stripe calls concurrently.
// UUID.randomUUID() is inside each async{} block — a unique key per customer.
//
// Developer's reasoning: "Each customer has its own async{} block. The UUID is
// per-customer and per-attempt — that's the correct granularity. I want each
// customer to have an independent idempotency key."
//
// This reasoning is correct about granularity (one key per customer, not one key
// for the whole batch). The problem is what happens when @Retryable fires:
// proceed() re-invokes this entire function body, creating a new supervisorScope{}
// with new async{} blocks for ALL customers — including those already successfully
// charged in attempt 1. The UUIDs inside the new async{} blocks are new.
// Stripe has committed ch_A for customers who succeeded on attempt 1.
// Attempt 2's async{} blocks send UUID_B for those same customers → ch_B → ch_B.
val results = supervisorScope {
customers.map { customer ->
async {
val idempotencyKey = UUID.randomUUID().toString() // New UUID per async{} block launch
val params = ChargeCreateParams.builder()
.setAmount(customer.amountCents)
.setCurrency("usd")
.setCustomer(customer.stripeCustomerId)
.build()
CustomerChargeResult(
customerId = customer.id,
chargeId = stripeGateway.createCharge(params, idempotencyKey)
)
}
}.awaitAll()
}
// If any customer's async{} block threw a non-deferred exception,
// awaitAll() rethrows it here. If we want to inspect individual failures,
// we'd need Deferred>.
// Here we wrap and throw BatchBillingException so @Retryable retries the whole method.
val failures = results.filter { it.chargeId == null }
if (failures.isNotEmpty()) throw BatchBillingException(failures)
return BatchResult(results)
// Scenario: 100 customers. Attempt 1 charges 98 successfully.
// Customer 99 triggers a StripeConnectException inside async{}.
// supervisorScope does not cancel the other 99 async jobs — all 98 succeed.
// awaitAll() re-throws the exception from customer 99.
// BatchBillingException thrown — @Retryable intercepts.
// proceed() called for attempt 2.
// New supervisorScope{} created. New async{} blocks for all 100 customers.
// 98 already-charged customers: UUID_B sent → ch_B each → 98 duplicate charges.
// Customer 99: UUID_B sent (was never charged) → may succeed → ch_B_99 (first charge).
// Customer 100: UUID_B sent → ch_B_100.
// Net: 98 customers charged twice, 2 customers charged once on attempt 2.
}
}
Why the blast radius is proportional to batch progress at failure time
The blast radius of the outer @Retryable retry on a supervisorScope{} batch depends on the ratio of customers that succeeded on attempt 1 before a failure was detected. If a failure occurs early in the batch (most customers haven’t been charged yet), the blast radius of attempt 2 is small. If a failure occurs after 95% of customers have been charged, attempt 2 duplicates 95% of the batch. The supervisorScope{} model, by not cancelling sibling jobs on failure, maximizes the number of customers successfully charged on attempt 1 — which is exactly what you want for normal operation — but it also maximizes the blast radius of an outer @Retryable because more customers have committed charges before the failure propagates to awaitAll().
The outer @Retryable is designed for single-operation idempotency — “retry this charge if it fails.” Applying it to a batch operation that internally processes N independent units breaks the scope assumption: a single retry of the batch method is not a retry of the single failed unit; it is a re-execution of all N units. The retry mechanism and the parallelism model are in conflict.
Fix: per-customer retry with content-hash keys, remove outer @Retryable
The correct fix changes the retry boundary from the batch method to the per-customer operation. Each async{} block gets its own retry logic (or a dedicated retry-capable helper is called for each customer). The content-hash key is computed from stable per-customer inputs outside the retry scope. The outer @Retryable is removed — if individual customer retries fail, the batch method collects and reports per-customer failures without attempting to retry all customers from scratch.
// BatchBillingService.kt — fixed mode 3 (per-customer retry, content-hash keys)
@Singleton
class BatchBillingService(private val stripeGateway: StripeGateway) {
// No @Retryable at the batch level. Retry is per-customer inside each async{} block.
suspend fun chargeAll(customers: List, billingPeriod: String): BatchResult {
val results = supervisorScope {
customers.map { customer ->
async {
// Content-hash key computed outside the per-customer retry scope.
// It is computed once per async{} block launch — but if this
// function's async{} were also inside an outer retry, we'd need
// it outside that too. Here with no outer @Retryable, once per
// async{} block is correct.
val idempotencyKey = contentHashKey(customer.id, billingPeriod, customer.amountCents)
retryCustomerCharge(customer, idempotencyKey)
}
}.awaitAll()
}
return BatchResult(results)
}
// Per-customer retry: only retries the single customer's charge.
// @Retryable at this level is safe: this method charges exactly one customer.
// proceed() re-invokes this method with the same idempotencyKey parameter.
// idempotencyKey is a parameter — it is not a local val that regenerates.
// Stripe deduplicates on the same key across all proceed() calls.
@Retryable(attempts = "3", delay = "500ms", includes = [StripeConnectException::class])
suspend fun retryCustomerCharge(customer: CustomerBillingRecord, idempotencyKey: String): CustomerChargeResult {
val params = ChargeCreateParams.builder()
.setAmount(customer.amountCents)
.setCurrency("usd")
.setCustomer(customer.stripeCustomerId)
.build()
return CustomerChargeResult(
customerId = customer.id,
chargeId = stripeGateway.createCharge(params, idempotencyKey)
)
}
private fun contentHashKey(customerId: String, billingPeriod: String, amountCents: Long): String {
val input = "$customerId|$billingPeriod|$amountCents"
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(input.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}
}
The key design points of the fixed version:
- No outer
@Retryableon the batch method. Batch-level retry is not appropriate when the batch contains heterogeneous units (some succeeded, some failed) because retry would re-execute all units. If the caller needs retry at the batch level, it must filter the customer list to only include those who haven’t been charged yet before re-callingchargeAll. - Per-customer
@Retryableon a dedicated single-customer method. The retry scope is now exactly one customer.proceed()re-invokesretryCustomerCharge, which receivesidempotencyKeyas a parameter. A parameter is passed in from the caller and does not regenerate on re-invocation of the callee. Stripe sees the same key on everyproceed()call. - Content-hash key computed in the
async{}block, outsideretryCustomerCharge. If the key were computed insideretryCustomerCharge, the content-hash function would be called on eachproceed()— but since it is a deterministic function of the same stable inputs, it would return the same value each time. This is also safe. Either placement works. Passing as a parameter makes the test surface cleaner: you can verify the key passed to the gateway without needing to replicate the hash function in the test.
The supervisorScope{} vs. coroutineScope{} nuance for batch billing idempotency
Using coroutineScope{} instead of supervisorScope{} changes the failure semantics: a failure in any child async{} block immediately cancels all sibling coroutines. This means that in a batch of 100 customers, if customer 5 fails, customers 6–100 are cancelled before they reach Stripe. The blast radius of the outer @Retryable retry would then be only 4 (customers 1–4 succeeded before the cancellation). coroutineScope{} minimizes blast radius but also processes fewer customers per attempt, requiring more retries to complete the batch. supervisorScope{} maximizes customers-per-attempt throughput at the cost of higher blast radius on outer retry. Neither scoping choice removes the need for stable idempotency keys — per-customer keys with content hashing are required in both variants.
Comparison table: three Micronaut Kotlin coroutine failure modes
| Mode | Retry mechanism | Re-execution unit | UUID position in unsafe code | Developer misconception | Fix |
|---|---|---|---|---|---|
| 1 | Micronaut @Retryable on suspend fun |
Entire suspend function body (per proceed() call) |
Top of function body (any position inside function) | “Micronaut uses compile-time AOP, not runtime proxy — the retry mechanism is different from Spring’s” | Content-hash key as parameter or computed from stable inputs outside method calls chain |
| 2 | Manual while (attempt < max) loop |
while-loop body (per iteration) |
Inside while-loop body |
“UUID is inside a loop, not a lambda or cold stream — Kotlin val inside a loop isn’t re-evaluated” |
Move UUID generation outside the loop body using content-hash key from stable inputs |
| 3 | Outer @Retryable on batch method + supervisorScope + async{} |
Entire batch method body, including all async{} block launches |
Inside each async{} block |
“Each customer has its own UUID at the right granularity — per-customer, not per-batch” | Per-customer @Retryable on a single-customer method; content-hash key passed as parameter; remove outer @Retryable from batch method |
Test patterns: capturing Idempotency-Key headers across retry attempts
For all three modes, the test strategy is the same: configure WireMock to return a 500 on the first request to /v1/charges and a 200 on subsequent requests, capture all Idempotency-Key headers received, and assert that all captured values are equal. A safe implementation passes this assertion. An unsafe implementation fails because each retry attempt sends a different UUID.
// BillingServiceTest.kt — mode 1 test with @MicronautTest + WireMock
@MicronautTest
class BillingServiceTest {
@Inject
lateinit var billingService: BillingService
@Test
fun `@Retryable on suspend function sends same idempotency key on retry`() = runTest {
val capturedKeys = mutableListOf<String>()
// WireMock scenario: first call returns 500, subsequent calls return 200.
WireMock.stubFor(
WireMock.post(WireMock.urlEqualTo("/v1/charges"))
.inScenario("retry-test")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(
WireMock.aResponse()
.withStatus(500)
.withHeader("Content-Type", "application/json")
.withBody("""{"error":{"type":"api_connection_error"}}""")
)
.willSetStateTo("first-attempt-done")
)
WireMock.stubFor(
WireMock.post(WireMock.urlEqualTo("/v1/charges"))
.inScenario("retry-test")
.whenScenarioStateIs("first-attempt-done")
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""{"id":"ch_test","object":"charge","amount":5000}""")
)
)
billingService.chargeCustomer("cus_test", 5000L, "2026-10")
// Retrieve all requests to /v1/charges and extract Idempotency-Key headers.
val requests = WireMock.findAll(WireMock.postRequestedFor(WireMock.urlEqualTo("/v1/charges")))
requests.forEach { req ->
req.getHeader("Idempotency-Key")?.let { capturedKeys.add(it) }
}
// Exactly 2 requests (1 failure + 1 success).
assertEquals(2, capturedKeys.size)
// Safe implementation: both requests carry the same key.
// Unsafe implementation: capturedKeys[0] != capturedKeys[1] — test fails.
assertEquals(capturedKeys[0], capturedKeys[1],
"Idempotency key must be identical across retry attempts; got: $capturedKeys")
}
}
// BillingEventListenerTest.kt — mode 2 test
@MicronautTest
class BillingEventListenerTest {
@Inject
lateinit var applicationEventPublisher: ApplicationEventPublisher<PaymentRequestedEvent>
@Test
fun `while-loop retry in @TransactionalEventListener sends same idempotency key`() = runTest {
val capturedKeys = mutableListOf<String>()
// WireMock: first call returns 500, second call returns 200.
setupRetryScenario()
applicationEventPublisher.publishEventAsync(
PaymentRequestedEvent(
orderId = "ord_test_123",
stripeCustomerId = "cus_test",
amountCents = 9900L,
billingPeriod = "2026-10"
)
).await()
val requests = WireMock.findAll(WireMock.postRequestedFor(WireMock.urlEqualTo("/v1/charges")))
requests.forEach { req ->
req.getHeader("Idempotency-Key")?.let { capturedKeys.add(it) }
}
assertEquals(2, capturedKeys.size)
assertEquals(capturedKeys[0], capturedKeys[1],
"Idempotency key must match across while-loop iterations; got: $capturedKeys")
}
}
// BatchBillingServiceTest.kt — mode 3 test
@MicronautTest
class BatchBillingServiceTest {
@Inject
lateinit var batchBillingService: BatchBillingService
@Test
fun `per-customer @Retryable sends same idempotency key for same customer on retry`() = runTest {
val billingPeriod = "2026-10"
val customers = listOf(
CustomerBillingRecord(id = "cus_A", stripeCustomerId = "cus_stripe_A", amountCents = 5000L),
CustomerBillingRecord(id = "cus_B", stripeCustomerId = "cus_stripe_B", amountCents = 7500L)
)
// WireMock: for cus_A, first attempt returns 500, second returns 200.
// For cus_B, first attempt succeeds immediately.
setupPerCustomerRetryScenario(customers[0].stripeCustomerId)
batchBillingService.chargeAll(customers, billingPeriod)
// Capture Idempotency-Key headers for requests to /v1/charges.
val requests = WireMock.findAll(WireMock.postRequestedFor(WireMock.urlEqualTo("/v1/charges")))
val keysPerCustomer = requests.groupBy { req ->
// Stripe routes customer charges — we identify by request body customer field.
// Simpler: group by Idempotency-Key prefix (customer ID is embedded in hash input).
// In this test, assert per-customer key stability directly.
req.bodyAsString.substringAfter("\"customer\":\"").substringBefore("\"")
}
// cus_A: 2 requests (1 retry). Keys must match.
val cusAKeys = keysPerCustomer["cus_stripe_A"]?.map { it.getHeader("Idempotency-Key") } ?: emptyList()
assertEquals(2, cusAKeys.size)
assertEquals(cusAKeys[0], cusAKeys[1],
"cus_A idempotency key must be stable across retry; got: $cusAKeys")
// cus_B: 1 request (no retry needed). Just verify a key was sent.
val cusBKeys = keysPerCustomer["cus_stripe_B"]?.map { it.getHeader("Idempotency-Key") } ?: emptyList()
assertEquals(1, cusBKeys.size)
assertNotNull(cusBKeys[0])
}
}
The three tests share the same structural approach: configure WireMock to simulate a transient failure on the first attempt, trigger the retry via the normal application path (direct service call, event publication, or batch invocation), and assert key equality across all requests captured by WireMock. No mocking of the UUID generation itself — the test observes the HTTP interface, not the internal state. This mirrors how Stripe itself observes the keys and is therefore the most accurate regression gate for this class of bug.
Micronaut-specific considerations for each mode
Mode 1: Micronaut @Retryable configuration and Kotlin coroutine support
Micronaut’s @Retryable configuration supports expression language for the attempts, delay, and multiplier values — these can be bound to application configuration properties (attempts = "\${retry.billing.attempts:3}"). The Kotlin coroutine support for @Retryable requires micronaut-kotlin-runtime on the classpath; without it, Micronaut may not correctly bridge suspend function interception and the retry behavior may be undefined. Check that io.micronaut.kotlin:micronaut-kotlin-runtime is in your build.gradle.kts alongside io.micronaut:micronaut-retry.
Micronaut’s @Retryable intercepts at the bean proxy level — it only applies to calls that go through the Micronaut bean context. A suspend fun chargeCustomer calling another method suspend fun internalCharge in the same bean class does not go through the interceptor for internalCharge; self-calls bypass the proxy. If you want per-customer retry to work via @Retryable, the @Retryable-annotated method must be on a separate bean (see the Mode 3 fix: retryCustomerCharge is called via Micronaut dependency injection, not as this.retryCustomerCharge(...) from within the same class).
Mode 2: Micronaut event system, @Async, and coroutine context
Micronaut’s @EventListener fires synchronously by default. Adding @Async to the listener method offloads event processing to a managed thread pool. For suspend function listeners, Micronaut’s coroutine support dispatches the listener on the appropriate dispatcher. In either case — synchronous, @Async, or suspend — the UUID-inside-loop bug manifests identically: the UUID expression runs on every loop iteration regardless of the dispatcher or coroutine context.
The @TransactionalEventListener default transaction propagation in Micronaut is configured by the TransactionDefinition attached to the listener. If the listener uses @Transactional(Transactional.TxType.REQUIRES_NEW) (explicit new transaction), the listener transaction is fully independent of the event publisher’s transaction, and a rollback in the listener does not affect the publisher’s committed state. If the listener uses REQUIRED and participates in the publisher’s transaction (only possible for synchronous listeners fired before the publisher commits), a rollback in the listener rolls back the entire publishing transaction. The UUID bug is orthogonal to this transaction scoping choice; it manifests whenever the retry loop iterates.
Mode 3: supervisorScope in Micronaut services and coroutine dispatcher selection
Micronaut injects coroutine scopes via the @ApplicationContext-managed lifecycle. The default dispatcher for Micronaut coroutine services is the I/O dispatcher (Dispatchers.IO), which is appropriate for Stripe HTTP calls. The supervisorScope{} block inherits the coroutine context of the calling coroutine, including its dispatcher. Switching dispatchers inside the async{} block (async(Dispatchers.IO) { ... } vs. async { ... }) does not affect UUID generation; the UUID bug depends on when the expression runs (per block launch), not which thread runs it.
Micronaut’s structured concurrency model aligns well with Kotlin coroutines. Micronaut beans that are @Singleton and use suspend functions participate in coroutine-aware transaction management when micronaut-data-tx-kotlin is on the classpath. The @Transactional annotation on a suspend fun in a Micronaut Data service uses a coroutine-aware transaction interceptor that propagates the transaction through the coroutine’s continuation chain — not via ThreadLocal, which would break in a coroutine context. This is a correct design for transaction propagation. The Stripe idempotency key bug is orthogonal to transaction propagation correctness: the bug is in the key generation expression, not in how the transaction propagates across coroutines.
The unified model across all Micronaut retry surfaces
All three modes in this post, and all the prior posts in this series, share a single failure pattern:
A retry mechanism re-executes a scope. Any statement inside that scope re-executes per retry attempt.
UUID.randomUUID()inside that scope generates UUID_B on the second attempt, which Stripe treats as a new billing intent, creating ch_B alongside any already-committed ch_A.
The scope varies across mechanisms:
- Micronaut
@Retryableonsuspend fun: thesuspendfunction body is the scope (perproceed() - Manual
whileloop: the loop body is the scope (per iteration) - Outer
@Retryableon batch method: the batch method body is the scope (allasync{}launches perproceed() - Arrow
retry{}block: the block lambda is the scope (covered in the Kotlin coroutines post) - Ktor suspend retry HOF: the HOF’s block lambda is the scope (covered in the Ktor post)
- Reactor
retryWhen(): the upstream publisher’s subscription is the scope (covered in the Spring WebClient post) - Mutiny
onFailure().retry(): the upstream Uni’s subscription is the scope (covered in the Quarkus post)
The fix is always the same: replace UUID.randomUUID() — whose output is new per call — with a deterministic content-hash function — whose output is the same per call given the same inputs. Place the content-hash computation outside the retry scope, or ensure that the stable inputs (customer ID, billing period, amount) are passed to the content-hash function and that the function is called with those inputs on every attempt. Stripe receives the same key on every attempt and deduplicates correctly.
The Micronaut-specific wrinkle is that “outside the retry scope” for @Retryable means “outside the annotated method entirely” — specifically, passed as a parameter from the caller. The caller computes the content-hash key before calling the @Retryable service method, and the service method receives the key as a stable parameter across all proceed() calls. Alternatively, and most robustly: design the key to be a pure function of the business intent inputs that are themselves stable across retries, so that even if the key is computed inside the method via the content-hash function, the same result is produced on every proceed() call.
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.