Skip to content

Commit 717b430

Browse files
committed
feat(network/connectivity): add retryIf predicate and retryableOrThrow to retryable
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 0c7526f commit 717b430

3 files changed

Lines changed: 197 additions & 0 deletions

File tree

libs/network/connectivity/public/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ dependencies {
1414
implementation(libs.bundles.kotlinx.serialization)
1515
implementation(libs.bundles.hilt)
1616

17+
testImplementation(kotlin("test"))
18+
testImplementation(libs.kotlinx.coroutines.test)
19+
1720
androidTestImplementation(libs.androidx.junit)
1821
androidTestImplementation(libs.junit)
1922
androidTestImplementation(libs.androidx.test.runner)

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +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 },
1314
onRetry: (Int) -> Unit = { currentAttempt ->
1415
trace(
1516
message = "Retrying call",
@@ -34,6 +35,7 @@ suspend fun <T> retryable(
3435
val result = try {
3536
call()
3637
} catch (e: Exception) {
38+
if (!retryIf(e)) throw e
3739
trace(
3840
message = "Attempt $currentAttempt failed with exception: ${e.message}",
3941
error = e,
@@ -55,4 +57,56 @@ suspend fun <T> retryable(
5557

5658
onError(startTime)
5759
return null
60+
}
61+
62+
/**
63+
* Like [retryable] but rethrows the last exception when retries are exhausted
64+
* instead of returning null, guaranteeing a non-null return on success.
65+
*/
66+
suspend fun <T> retryableOrThrow(
67+
maxRetries: Int = 3,
68+
delayDuration: Duration = 2.seconds,
69+
retryIf: (Exception) -> Boolean = { true },
70+
onRetry: (Int) -> Unit = { currentAttempt ->
71+
trace(
72+
message = "Retrying call",
73+
metadata = {
74+
"count" to currentAttempt
75+
},
76+
type = TraceType.Process,
77+
)
78+
},
79+
onError: (startTime: TimeSource.Monotonic.ValueTimeMark) -> Unit = { startTime ->
80+
trace(
81+
"Failed to get a success after $maxRetries attempts in ${startTime.elapsedNow().inWholeMilliseconds} ms",
82+
type = TraceType.Error
83+
)
84+
},
85+
call: suspend () -> T,
86+
): T {
87+
var currentAttempt = 0
88+
var lastException: Exception? = null
89+
val startTime = TimeSource.Monotonic.markNow()
90+
91+
while (currentAttempt < maxRetries) {
92+
try {
93+
return call()
94+
} catch (e: Exception) {
95+
if (!retryIf(e)) throw e
96+
lastException = e
97+
trace(
98+
message = "Attempt $currentAttempt failed with exception: ${e.message}",
99+
error = e,
100+
type = TraceType.Error
101+
)
102+
currentAttempt++
103+
if (currentAttempt < maxRetries) {
104+
onRetry(currentAttempt)
105+
delay(delayDuration.inWholeMilliseconds)
106+
}
107+
}
108+
}
109+
110+
onError(startTime)
111+
throw lastException!!
58112
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package com.getcode.utils.network
2+
3+
import kotlinx.coroutines.ExperimentalCoroutinesApi
4+
import kotlinx.coroutines.test.runTest
5+
import org.junit.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertFailsWith
8+
import kotlin.test.assertNull
9+
import kotlin.time.Duration.Companion.milliseconds
10+
11+
@OptIn(ExperimentalCoroutinesApi::class)
12+
class RetryTest {
13+
14+
// region retryable
15+
16+
@Test
17+
fun `retryable succeeds on first attempt`() = runTest {
18+
val result = retryable(delayDuration = 1.milliseconds) { "ok" }
19+
assertEquals("ok", result)
20+
}
21+
22+
@Test
23+
fun `retryable retries and succeeds on later attempt`() = runTest {
24+
var attempts = 0
25+
val result = retryable(maxRetries = 3, delayDuration = 1.milliseconds) {
26+
attempts++
27+
if (attempts < 3) throw RuntimeException("fail")
28+
"ok"
29+
}
30+
assertEquals("ok", result)
31+
assertEquals(3, attempts)
32+
}
33+
34+
@Test
35+
fun `retryable returns null when all retries exhausted`() = runTest {
36+
val result = retryable(maxRetries = 2, delayDuration = 1.milliseconds) {
37+
throw RuntimeException("fail")
38+
}
39+
assertNull(result)
40+
}
41+
42+
@Test
43+
fun `retryable with retryIf false rethrows immediately`() = runTest {
44+
var attempts = 0
45+
assertFailsWith<IllegalArgumentException> {
46+
retryable(
47+
maxRetries = 3,
48+
delayDuration = 1.milliseconds,
49+
retryIf = { it is IllegalStateException },
50+
) {
51+
attempts++
52+
throw IllegalArgumentException("not retryable")
53+
}
54+
}
55+
assertEquals(1, attempts)
56+
}
57+
58+
@Test
59+
fun `retryable with retryIf true retries matching exceptions`() = runTest {
60+
var attempts = 0
61+
val result = retryable(
62+
maxRetries = 3,
63+
delayDuration = 1.milliseconds,
64+
retryIf = { it is IllegalStateException },
65+
) {
66+
attempts++
67+
if (attempts < 2) throw IllegalStateException("retryable")
68+
"ok"
69+
}
70+
assertEquals("ok", result)
71+
assertEquals(2, attempts)
72+
}
73+
74+
// endregion
75+
76+
// region retryableOrThrow
77+
78+
@Test
79+
fun `retryableOrThrow succeeds on first attempt`() = runTest {
80+
val result = retryableOrThrow(delayDuration = 1.milliseconds) { "ok" }
81+
assertEquals("ok", result)
82+
}
83+
84+
@Test
85+
fun `retryableOrThrow retries and succeeds on later attempt`() = runTest {
86+
var attempts = 0
87+
val result = retryableOrThrow(maxRetries = 3, delayDuration = 1.milliseconds) {
88+
attempts++
89+
if (attempts < 2) throw RuntimeException("fail")
90+
"ok"
91+
}
92+
assertEquals("ok", result)
93+
assertEquals(2, attempts)
94+
}
95+
96+
@Test
97+
fun `retryableOrThrow rethrows last exception when retries exhausted`() = runTest {
98+
val exception = assertFailsWith<RuntimeException> {
99+
retryableOrThrow(maxRetries = 2, delayDuration = 1.milliseconds) {
100+
throw RuntimeException("always fails")
101+
}
102+
}
103+
assertEquals("always fails", exception.message)
104+
}
105+
106+
@Test
107+
fun `retryableOrThrow with retryIf false rethrows immediately`() = runTest {
108+
var attempts = 0
109+
assertFailsWith<IllegalArgumentException> {
110+
retryableOrThrow(
111+
maxRetries = 3,
112+
delayDuration = 1.milliseconds,
113+
retryIf = { it is IllegalStateException },
114+
) {
115+
attempts++
116+
throw IllegalArgumentException("not retryable")
117+
}
118+
}
119+
assertEquals(1, attempts)
120+
}
121+
122+
@Test
123+
fun `retryableOrThrow with retryIf true retries then rethrows on exhaustion`() = runTest {
124+
var attempts = 0
125+
val exception = assertFailsWith<IllegalStateException> {
126+
retryableOrThrow(
127+
maxRetries = 2,
128+
delayDuration = 1.milliseconds,
129+
retryIf = { it is IllegalStateException },
130+
) {
131+
attempts++
132+
throw IllegalStateException("attempt $attempts")
133+
}
134+
}
135+
assertEquals(2, attempts)
136+
assertEquals("attempt 2", exception.message)
137+
}
138+
139+
// endregion
140+
}

0 commit comments

Comments
 (0)