Skip to content

Commit 99b0c01

Browse files
committed
fix(transactions): retry submitIntent on server-side stale state race
Bugsnag reports SubmitIntentError.StaleState errors with reason "race detected: cached balance version is stale". This is a server-side optimistic version lock — the client sends no balance version. When concurrent intents advance the version before the DB transaction commits, the check fails and the transaction rolls back (no nonces consumed). - Add isRaceCondition property to StaleState, matching the "race detected:" prefix — the only StaleState variant safe to blindly retry - Wrap submitIntent in retryableOrThrow with retryIf narrowed to race conditions only (other StaleState reasons like stale exchange rates, closed accounts, or claimed gift cards require client-side state refresh and would fail identically on retry) - Move gift-card-claimed Bugsnag suppression from InternalTransactionRepository to ReceiveGiftCardTransactor where the context-specific handling belongs Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 717b430 commit 99b0c01

5 files changed

Lines changed: 50 additions & 13 deletions

File tree

libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import kotlin.time.TimeSource
1010
suspend fun <T> retryable(
1111
maxRetries: Int = 3,
1212
delayDuration: Duration = 2.seconds,
13-
retryIf: (Exception) -> Boolean = { true },
13+
retryIf: (Throwable) -> Boolean = { true },
1414
onRetry: (Int) -> Unit = { currentAttempt ->
1515
trace(
1616
message = "Retrying call",
@@ -34,7 +34,7 @@ suspend fun <T> retryable(
3434
while (currentAttempt < maxRetries) {
3535
val result = try {
3636
call()
37-
} catch (e: Exception) {
37+
} catch (e: Throwable) {
3838
if (!retryIf(e)) throw e
3939
trace(
4040
message = "Attempt $currentAttempt failed with exception: ${e.message}",
@@ -66,7 +66,7 @@ suspend fun <T> retryable(
6666
suspend fun <T> retryableOrThrow(
6767
maxRetries: Int = 3,
6868
delayDuration: Duration = 2.seconds,
69-
retryIf: (Exception) -> Boolean = { true },
69+
retryIf: (Throwable) -> Boolean = { true },
7070
onRetry: (Int) -> Unit = { currentAttempt ->
7171
trace(
7272
message = "Retrying call",
@@ -85,13 +85,13 @@ suspend fun <T> retryableOrThrow(
8585
call: suspend () -> T,
8686
): T {
8787
var currentAttempt = 0
88-
var lastException: Exception? = null
88+
var lastException: Throwable? = null
8989
val startTime = TimeSource.Monotonic.markNow()
9090

9191
while (currentAttempt < maxRetries) {
9292
try {
9393
return call()
94-
} catch (e: Exception) {
94+
} catch (e: Throwable) {
9595
if (!retryIf(e)) throw e
9696
lastException = e
9797
trace(

services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import com.getcode.opencode.solana.intents.IntentType
1818
import com.getcode.solana.keys.Mint
1919
import com.getcode.solana.keys.PublicKey
2020
import com.getcode.utils.ErrorUtils
21+
import com.getcode.utils.network.retryableOrThrow
2122
import kotlinx.coroutines.CoroutineScope
2223
import kotlinx.datetime.Instant
24+
import kotlin.time.Duration.Companion.seconds
2325
import javax.inject.Inject
2426

2527
internal class InternalTransactionRepository @Inject constructor(
@@ -29,13 +31,17 @@ internal class InternalTransactionRepository @Inject constructor(
2931
scope: CoroutineScope,
3032
intent: IntentType,
3133
owner: Ed25519.KeyPair
32-
): Result<IntentType> = service.submitIntent(scope, intent, owner)
33-
.onFailure {
34-
// Expected race: pre-claim check passes but the gift card is claimed
35-
// before the intent is submitted. Not a bug — skip Bugsnag reporting.
36-
if (it is SubmitIntentError.StaleState && it.isGiftCardAlreadyClaimed) return@onFailure
37-
ErrorUtils.handleError(it)
34+
): Result<IntentType> = runCatching {
35+
retryableOrThrow(
36+
maxRetries = 3,
37+
delayDuration = 1.seconds,
38+
retryIf = { it is SubmitIntentError.StaleState && it.isRaceCondition },
39+
) {
40+
service.submitIntent(scope, intent, owner).getOrThrow()
3841
}
42+
}.onFailure {
43+
ErrorUtils.handleError(it)
44+
}
3945

4046
override suspend fun getIntentMetadata(
4147
intentId: PublicKey,

services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import com.getcode.opencode.model.accounts.GiftCardAccount
1414
import com.getcode.opencode.model.financial.LocalFiat
1515
import com.getcode.opencode.model.financial.Token
1616
import com.getcode.opencode.providers.TokenMetadataProvider
17+
import com.getcode.opencode.model.core.errors.SubmitIntentError
1718
import com.getcode.utils.CodeServerError
1819
import com.getcode.utils.NotifiableError
1920
import com.getcode.utils.timedTraceSuspend
@@ -146,9 +147,13 @@ internal class ReceiveGiftCardTransactor(
146147
onStep("intent")
147148
Result.success(token to amount)
148149
},
149-
onFailure = {
150+
onFailure = { error ->
150151
onStep("intent")
151-
logAndFail(it)
152+
if (error is SubmitIntentError.StaleState && error.isGiftCardAlreadyClaimed) {
153+
Result.failure(error)
154+
} else {
155+
logAndFail(error)
156+
}
152157
}
153158
)
154159
}

services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ sealed class SubmitIntentError(
123123
}), NotifiableError
124124
data class StaleState(private val reasons: List<String>) :
125125
SubmitIntentError(message = reasons.joinToString()), NotifiableError {
126+
val isRaceCondition: Boolean
127+
get() = reasons.any { it.startsWith("race detected:") }
126128
val isGiftCardAlreadyClaimed: Boolean
127129
get() = reasons.any { it.contains("gift card balance has already been claimed") }
128130
}

services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,30 @@ class SubmitIntentErrorTest {
171171
assertFalse(error.isGiftCardAlreadyClaimed)
172172
}
173173

174+
@Test
175+
fun staleStateWithRaceDetectedIsRaceCondition() {
176+
val error = SubmitIntentError.typed(
177+
buildError(
178+
SubmitIntentResponse.Error.Code.STALE_STATE,
179+
reasonStrings = listOf("race detected: cached balance version is stale")
180+
)
181+
)
182+
assertIs<SubmitIntentError.StaleState>(error)
183+
assertTrue(error.isRaceCondition)
184+
}
185+
186+
@Test
187+
fun staleStateWithOtherReasonIsNotRaceCondition() {
188+
val error = SubmitIntentError.typed(
189+
buildError(
190+
SubmitIntentResponse.Error.Code.STALE_STATE,
191+
reasonStrings = listOf("intent already exists")
192+
)
193+
)
194+
assertIs<SubmitIntentError.StaleState>(error)
195+
assertFalse(error.isRaceCondition)
196+
}
197+
174198
@Test
175199
fun otherWrausesCause() {
176200
val cause = RuntimeException("root cause")

0 commit comments

Comments
 (0)