Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions libs/network/connectivity/public/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import kotlin.time.TimeSource
suspend fun <T> retryable(
maxRetries: Int = 3,
delayDuration: Duration = 2.seconds,
retryIf: (Throwable) -> Boolean = { true },
onRetry: (Int) -> Unit = { currentAttempt ->
trace(
message = "Retrying call",
Expand All @@ -33,7 +34,8 @@ suspend fun <T> 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,
Expand All @@ -55,4 +57,56 @@ suspend fun <T> 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 <T> 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!!
}
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> {
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<RuntimeException> {
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<IllegalArgumentException> {
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<IllegalStateException> {
retryableOrThrow(
maxRetries = 2,
delayDuration = 1.milliseconds,
retryIf = { it is IllegalStateException },
) {
attempts++
throw IllegalStateException("attempt $attempts")
}
}
assertEquals(2, attempts)
assertEquals("attempt 2", exception.message)
}

// endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -29,13 +31,17 @@ internal class InternalTransactionRepository @Inject constructor(
scope: CoroutineScope,
intent: IntentType,
owner: Ed25519.KeyPair
): Result<IntentType> = 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<IntentType> = 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ sealed class SubmitIntentError(
}), NotifiableError
data class StaleState(private val reasons: List<String>) :
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") }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubmitIntentError.StaleState>(error)
assertTrue(error.isRaceCondition)
}

@Test
fun staleStateWithOtherReasonIsNotRaceCondition() {
val error = SubmitIntentError.typed(
buildError(
SubmitIntentResponse.Error.Code.STALE_STATE,
reasonStrings = listOf("intent already exists")
)
)
assertIs<SubmitIntentError.StaleState>(error)
assertFalse(error.isRaceCondition)
}

@Test
fun otherWrausesCause() {
val cause = RuntimeException("root cause")
Expand Down
Loading