diff --git a/libs/network/connectivity/public/build.gradle.kts b/libs/network/connectivity/public/build.gradle.kts index 3defebff8..81092157f 100644 --- a/libs/network/connectivity/public/build.gradle.kts +++ b/libs/network/connectivity/public/build.gradle.kts @@ -14,6 +14,9 @@ dependencies { implementation(libs.bundles.kotlinx.serialization) implementation(libs.bundles.hilt) + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.junit) androidTestImplementation(libs.androidx.test.runner) diff --git a/libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt b/libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt index d98dd8ad2..529ae4732 100644 --- a/libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt +++ b/libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt @@ -10,6 +10,7 @@ import kotlin.time.TimeSource suspend fun retryable( maxRetries: Int = 3, delayDuration: Duration = 2.seconds, + retryIf: (Throwable) -> Boolean = { true }, onRetry: (Int) -> Unit = { currentAttempt -> trace( message = "Retrying call", @@ -33,7 +34,8 @@ suspend fun retryable( while (currentAttempt < maxRetries) { val result = try { call() - } catch (e: Exception) { + } catch (e: Throwable) { + if (!retryIf(e)) throw e trace( message = "Attempt $currentAttempt failed with exception: ${e.message}", error = e, @@ -55,4 +57,56 @@ suspend fun retryable( onError(startTime) return null +} + +/** + * Like [retryable] but rethrows the last exception when retries are exhausted + * instead of returning null, guaranteeing a non-null return on success. + */ +suspend fun retryableOrThrow( + maxRetries: Int = 3, + delayDuration: Duration = 2.seconds, + retryIf: (Throwable) -> Boolean = { true }, + onRetry: (Int) -> Unit = { currentAttempt -> + trace( + message = "Retrying call", + metadata = { + "count" to currentAttempt + }, + type = TraceType.Process, + ) + }, + onError: (startTime: TimeSource.Monotonic.ValueTimeMark) -> Unit = { startTime -> + trace( + "Failed to get a success after $maxRetries attempts in ${startTime.elapsedNow().inWholeMilliseconds} ms", + type = TraceType.Error + ) + }, + call: suspend () -> T, +): T { + var currentAttempt = 0 + var lastException: Throwable? = null + val startTime = TimeSource.Monotonic.markNow() + + while (currentAttempt < maxRetries) { + try { + return call() + } catch (e: Throwable) { + if (!retryIf(e)) throw e + lastException = e + trace( + message = "Attempt $currentAttempt failed with exception: ${e.message}", + error = e, + type = TraceType.Error + ) + currentAttempt++ + if (currentAttempt < maxRetries) { + onRetry(currentAttempt) + delay(delayDuration.inWholeMilliseconds) + } + } + } + + onError(startTime) + throw lastException!! } \ No newline at end of file diff --git a/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/RetryTest.kt b/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/RetryTest.kt new file mode 100644 index 000000000..3b8402eac --- /dev/null +++ b/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/RetryTest.kt @@ -0,0 +1,140 @@ +package com.getcode.utils.network + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class RetryTest { + + // region retryable + + @Test + fun `retryable succeeds on first attempt`() = runTest { + val result = retryable(delayDuration = 1.milliseconds) { "ok" } + assertEquals("ok", result) + } + + @Test + fun `retryable retries and succeeds on later attempt`() = runTest { + var attempts = 0 + val result = retryable(maxRetries = 3, delayDuration = 1.milliseconds) { + attempts++ + if (attempts < 3) throw RuntimeException("fail") + "ok" + } + assertEquals("ok", result) + assertEquals(3, attempts) + } + + @Test + fun `retryable returns null when all retries exhausted`() = runTest { + val result = retryable(maxRetries = 2, delayDuration = 1.milliseconds) { + throw RuntimeException("fail") + } + assertNull(result) + } + + @Test + fun `retryable with retryIf false rethrows immediately`() = runTest { + var attempts = 0 + assertFailsWith { + retryable( + maxRetries = 3, + delayDuration = 1.milliseconds, + retryIf = { it is IllegalStateException }, + ) { + attempts++ + throw IllegalArgumentException("not retryable") + } + } + assertEquals(1, attempts) + } + + @Test + fun `retryable with retryIf true retries matching exceptions`() = runTest { + var attempts = 0 + val result = retryable( + maxRetries = 3, + delayDuration = 1.milliseconds, + retryIf = { it is IllegalStateException }, + ) { + attempts++ + if (attempts < 2) throw IllegalStateException("retryable") + "ok" + } + assertEquals("ok", result) + assertEquals(2, attempts) + } + + // endregion + + // region retryableOrThrow + + @Test + fun `retryableOrThrow succeeds on first attempt`() = runTest { + val result = retryableOrThrow(delayDuration = 1.milliseconds) { "ok" } + assertEquals("ok", result) + } + + @Test + fun `retryableOrThrow retries and succeeds on later attempt`() = runTest { + var attempts = 0 + val result = retryableOrThrow(maxRetries = 3, delayDuration = 1.milliseconds) { + attempts++ + if (attempts < 2) throw RuntimeException("fail") + "ok" + } + assertEquals("ok", result) + assertEquals(2, attempts) + } + + @Test + fun `retryableOrThrow rethrows last exception when retries exhausted`() = runTest { + val exception = assertFailsWith { + retryableOrThrow(maxRetries = 2, delayDuration = 1.milliseconds) { + throw RuntimeException("always fails") + } + } + assertEquals("always fails", exception.message) + } + + @Test + fun `retryableOrThrow with retryIf false rethrows immediately`() = runTest { + var attempts = 0 + assertFailsWith { + retryableOrThrow( + maxRetries = 3, + delayDuration = 1.milliseconds, + retryIf = { it is IllegalStateException }, + ) { + attempts++ + throw IllegalArgumentException("not retryable") + } + } + assertEquals(1, attempts) + } + + @Test + fun `retryableOrThrow with retryIf true retries then rethrows on exhaustion`() = runTest { + var attempts = 0 + val exception = assertFailsWith { + retryableOrThrow( + maxRetries = 2, + delayDuration = 1.milliseconds, + retryIf = { it is IllegalStateException }, + ) { + attempts++ + throw IllegalStateException("attempt $attempts") + } + } + assertEquals(2, attempts) + assertEquals("attempt 2", exception.message) + } + + // endregion +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt index dc95adae4..ada8df51d 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt @@ -18,8 +18,10 @@ import com.getcode.opencode.solana.intents.IntentType import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey import com.getcode.utils.ErrorUtils +import com.getcode.utils.network.retryableOrThrow import kotlinx.coroutines.CoroutineScope import kotlinx.datetime.Instant +import kotlin.time.Duration.Companion.seconds import javax.inject.Inject internal class InternalTransactionRepository @Inject constructor( @@ -29,13 +31,17 @@ internal class InternalTransactionRepository @Inject constructor( scope: CoroutineScope, intent: IntentType, owner: Ed25519.KeyPair - ): Result = service.submitIntent(scope, intent, owner) - .onFailure { - // Expected race: pre-claim check passes but the gift card is claimed - // before the intent is submitted. Not a bug — skip Bugsnag reporting. - if (it is SubmitIntentError.StaleState && it.isGiftCardAlreadyClaimed) return@onFailure - ErrorUtils.handleError(it) + ): Result = runCatching { + retryableOrThrow( + maxRetries = 3, + delayDuration = 1.seconds, + retryIf = { it is SubmitIntentError.StaleState && it.isRaceCondition }, + ) { + service.submitIntent(scope, intent, owner).getOrThrow() } + }.onFailure { + ErrorUtils.handleError(it) + } override suspend fun getIntentMetadata( intentId: PublicKey, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt index 922d2a646..533d075dc 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/ReceiveGiftCardTransactor.kt @@ -14,6 +14,7 @@ import com.getcode.opencode.model.accounts.GiftCardAccount import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token import com.getcode.opencode.providers.TokenMetadataProvider +import com.getcode.opencode.model.core.errors.SubmitIntentError import com.getcode.utils.CodeServerError import com.getcode.utils.NotifiableError import com.getcode.utils.timedTraceSuspend @@ -146,9 +147,13 @@ internal class ReceiveGiftCardTransactor( onStep("intent") Result.success(token to amount) }, - onFailure = { + onFailure = { error -> onStep("intent") - logAndFail(it) + if (error is SubmitIntentError.StaleState && error.isGiftCardAlreadyClaimed) { + Result.failure(error) + } else { + logAndFail(error) + } } ) } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt index 9e57aefbd..547b03b45 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/errors/Errors.kt @@ -123,6 +123,8 @@ sealed class SubmitIntentError( }), NotifiableError data class StaleState(private val reasons: List) : SubmitIntentError(message = reasons.joinToString()), NotifiableError { + val isRaceCondition: Boolean + get() = reasons.any { it.startsWith("race detected:") } val isGiftCardAlreadyClaimed: Boolean get() = reasons.any { it.contains("gift card balance has already been claimed") } } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt index a24246de0..64942cefc 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt @@ -171,6 +171,30 @@ class SubmitIntentErrorTest { assertFalse(error.isGiftCardAlreadyClaimed) } + @Test + fun staleStateWithRaceDetectedIsRaceCondition() { + val error = SubmitIntentError.typed( + buildError( + SubmitIntentResponse.Error.Code.STALE_STATE, + reasonStrings = listOf("race detected: cached balance version is stale") + ) + ) + assertIs(error) + assertTrue(error.isRaceCondition) + } + + @Test + fun staleStateWithOtherReasonIsNotRaceCondition() { + val error = SubmitIntentError.typed( + buildError( + SubmitIntentResponse.Error.Code.STALE_STATE, + reasonStrings = listOf("intent already exists") + ) + ) + assertIs(error) + assertFalse(error.isRaceCondition) + } + @Test fun otherWrausesCause() { val cause = RuntimeException("root cause")