Skip to content

Commit 892e87e

Browse files
committed
Add kotlin-result based retry function in kotlin-retry-result
Closes #23
1 parent 768fcfb commit 892e87e

File tree

7 files changed

+358
-0
lines changed

7 files changed

+358
-0
lines changed

README.md

+36
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,41 @@ fun main() = runBlocking {
115115
}
116116
```
117117

118+
## Integration with [kotlin-result][kotlin-result]
119+
120+
If the code you wish to try returns a `Result<V, E>` instead of throwing an
121+
`Exception`, add the following dependency for access to a Result-based `retry`
122+
function that shares the same policy code:
123+
124+
```groovy
125+
repositories {
126+
mavenCentral()
127+
}
128+
129+
dependencies {
130+
implementation("com.michael-bull.kotlin-retry:kotlin-retry-result:2.0.0")
131+
}
132+
```
133+
134+
Usage:
135+
136+
```kotlin
137+
138+
import com.github.michaelbull.result.Result
139+
import com.github.michaelbull.retry.policy.constantDelay
140+
import com.github.michaelbull.retry.result.retry
141+
142+
fun somethingThatCanFail(): Result<Int, DomainError> = TODO()
143+
144+
val everyTwoSeconds = constantDelay<DomainError>(2000)
145+
146+
fun main() = runBlocking {
147+
val result: Result<Int, DomainError> = retry(everyTwoSeconds) {
148+
somethingThatCanFail()
149+
}
150+
}
151+
```
152+
118153
## Backoff
119154

120155
The examples above retry executions immediately after they fail, however we may
@@ -205,6 +240,7 @@ This project is available under the terms of the ISC license. See the
205240
[retry-policy]: https://github.com/michaelbull/kotlin-retry/blob/master/kotlin-retry/src/commonMain/kotlin/com/github/michaelbull/retry/policy/RetryPolicy.kt
206241
[aws-backoff]: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
207242
[haskell-retry]: http://hackage.haskell.org/package/retry-0.8.0.1/docs/Control-Retry.html
243+
[kotlin-result]: https://github.com/michaelbull/kotlin-result
208244

209245
[badge-android]: http://img.shields.io/badge/-android-6EDB8D.svg?style=flat
210246
[badge-android-native]: http://img.shields.io/badge/support-[AndroidNative]-6EDB8D.svg?style=flat

gradle/libs.versions.toml

+2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
[versions]
22
kotlin = "1.9.22"
33
kotlin-coroutines = "1.8.0"
4+
kotlin-result = "2.0.0"
45
mockk = "1.13.10"
56
versions-plugin = "0.51.0"
67

78
[libraries]
89
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
910
kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" }
1011
kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" }
12+
kotlin-result = { module = "com.michael-bull.kotlin-result:kotlin-result", version.ref = "kotlin-result" }
1113
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
1214

1315
[plugins]

kotlin-retry-result/build.gradle.kts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
description = "Extensions for integrating with kotlin-result."
2+
3+
plugins {
4+
id("kotlin-conventions")
5+
id("publish-conventions")
6+
}
7+
8+
kotlin {
9+
sourceSets {
10+
commonMain {
11+
dependencies {
12+
api(project(":kotlin-retry"))
13+
api(libs.kotlin.result)
14+
implementation(libs.kotlin.coroutines.core)
15+
}
16+
}
17+
18+
commonTest {
19+
dependencies {
20+
implementation(project(":kotlin-retry"))
21+
implementation(libs.kotlin.coroutines.test)
22+
}
23+
}
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.github.michaelbull.retry.result
2+
3+
import com.github.michaelbull.result.Err
4+
import com.github.michaelbull.result.Result
5+
import com.github.michaelbull.result.asErr
6+
import com.github.michaelbull.retry.attempt.Attempt
7+
import com.github.michaelbull.retry.attempt.firstAttempt
8+
import com.github.michaelbull.retry.instruction.ContinueRetrying
9+
import com.github.michaelbull.retry.instruction.RetryInstruction
10+
import com.github.michaelbull.retry.instruction.StopRetrying
11+
import com.github.michaelbull.retry.policy.RetryPolicy
12+
import kotlinx.coroutines.delay
13+
import kotlin.contracts.InvocationKind
14+
import kotlin.contracts.contract
15+
16+
/**
17+
* Calls the specified function [block] and returns its [Result], handling any [Err] returned from the [block] function
18+
* execution retrying the invocation according to [instructions][RetryInstruction] from the [policy].
19+
*/
20+
public suspend inline fun <V, E> retry(policy: RetryPolicy<E>, block: () -> Result<V, E>): Result<V, E> {
21+
contract {
22+
callsInPlace(block, InvocationKind.AT_LEAST_ONCE)
23+
}
24+
25+
var attempt: Attempt? = null
26+
27+
while (true) {
28+
val result = block()
29+
30+
if (result.isOk) {
31+
return result
32+
} else {
33+
if (attempt == null) {
34+
attempt = firstAttempt()
35+
}
36+
37+
val failedAttempt = attempt.failedWith(result.error)
38+
39+
when (val instruction = policy(failedAttempt)) {
40+
StopRetrying -> {
41+
return result.asErr()
42+
}
43+
44+
ContinueRetrying -> {
45+
attempt.retryImmediately()
46+
}
47+
48+
else -> {
49+
val (delayMillis) = instruction
50+
delay(delayMillis)
51+
attempt.retryAfter(delayMillis)
52+
}
53+
}
54+
}
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.github.michaelbull.retry.result
2+
3+
import com.github.michaelbull.result.Err
4+
import com.github.michaelbull.result.Result
5+
import com.github.michaelbull.retry.instruction.RetryInstruction
6+
import com.github.michaelbull.retry.policy.RetryPolicy
7+
import kotlin.contracts.InvocationKind
8+
import kotlin.contracts.contract
9+
10+
/**
11+
* Calls the specified function [block] and returns its [Result], handling any [Err] returned from the [block] function
12+
* execution retrying the invocation according to [instructions][RetryInstruction] from the [policy].
13+
*/
14+
public suspend inline fun <V, E> runRetrying(policy: RetryPolicy<E>, block: () -> Result<V, E>): Result<V, E> {
15+
contract {
16+
callsInPlace(block, InvocationKind.AT_LEAST_ONCE)
17+
}
18+
19+
return retry(policy, block)
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package com.github.michaelbull.retry.result
2+
3+
import com.github.michaelbull.result.Err
4+
import com.github.michaelbull.result.Ok
5+
import com.github.michaelbull.retry.policy.constantDelay
6+
import com.github.michaelbull.retry.policy.continueIf
7+
import com.github.michaelbull.retry.policy.stopAtAttempts
8+
import kotlinx.coroutines.Deferred
9+
import kotlinx.coroutines.ExperimentalCoroutinesApi
10+
import kotlinx.coroutines.async
11+
import kotlinx.coroutines.cancel
12+
import kotlinx.coroutines.delay
13+
import kotlinx.coroutines.launch
14+
import kotlinx.coroutines.test.runTest
15+
import kotlin.coroutines.cancellation.CancellationException
16+
import kotlin.test.Test
17+
import kotlin.test.assertEquals
18+
import kotlin.test.assertFailsWith
19+
import kotlin.test.assertFalse
20+
import kotlin.test.assertTrue
21+
22+
@ExperimentalCoroutinesApi
23+
class RetryTest {
24+
25+
private data class AttemptsError(val attempts: Int)
26+
27+
@Test
28+
fun retryToAttemptLimit() = runTest {
29+
val fiveTimes = stopAtAttempts<AttemptsError>(5)
30+
var attempts = 0
31+
32+
val result = retry(fiveTimes) {
33+
attempts++
34+
35+
if (attempts < 5) {
36+
Err(AttemptsError(attempts))
37+
} else {
38+
Ok(Unit)
39+
}
40+
}
41+
42+
assertEquals(Ok(Unit), result)
43+
assertEquals(5, attempts)
44+
}
45+
46+
@Test
47+
fun retryExhaustingAttemptLimit() = runTest {
48+
val tenTimes = stopAtAttempts<AttemptsError>(10)
49+
var attempts = 0
50+
51+
val result = retry(tenTimes) {
52+
attempts++
53+
54+
if (attempts < 15) {
55+
Err(AttemptsError(attempts))
56+
} else {
57+
Ok(Unit)
58+
}
59+
}
60+
61+
assertEquals(Err(AttemptsError(10)), result)
62+
assertEquals(10, attempts)
63+
}
64+
65+
@Test
66+
fun retryThrowsCancellationException() = runTest {
67+
val tenTimes = stopAtAttempts<Unit>(10)
68+
69+
assertFailsWith<CancellationException> {
70+
retry(tenTimes) {
71+
Ok(Unit).also {
72+
throw CancellationException()
73+
}
74+
}
75+
}
76+
}
77+
78+
@Test
79+
fun retryStopsAfterCancellation() = runTest {
80+
val fiveTimes = stopAtAttempts<Unit>(5)
81+
var attempts = 0
82+
83+
assertFailsWith<CancellationException> {
84+
retry(fiveTimes) {
85+
attempts++
86+
87+
if (attempts == 2) {
88+
throw CancellationException()
89+
} else {
90+
Err(Unit)
91+
}
92+
}
93+
}
94+
95+
assertEquals(2, attempts)
96+
}
97+
98+
@Test
99+
fun retryWithCustomPolicy() = runTest {
100+
val uptoFifteenTimes = continueIf<AttemptsError> { (failure) ->
101+
failure.attempts < 15
102+
}
103+
104+
var attempts = 0
105+
106+
val result = retry(uptoFifteenTimes) {
107+
attempts++
108+
Err(AttemptsError(attempts))
109+
}
110+
111+
assertEquals(Err(AttemptsError(15)), result)
112+
}
113+
114+
@Test
115+
fun cancelRetryFromJob() = runTest {
116+
val every100ms = constantDelay<AttemptsError>(100)
117+
var attempts = 0
118+
119+
val job = backgroundScope.launch {
120+
retry(every100ms) {
121+
attempts++
122+
Err(AttemptsError(attempts))
123+
}
124+
}
125+
126+
testScheduler.advanceTimeBy(350)
127+
testScheduler.runCurrent()
128+
129+
job.cancel()
130+
131+
testScheduler.advanceUntilIdle()
132+
133+
assertTrue(job.isCancelled)
134+
assertEquals(4, attempts)
135+
136+
testScheduler.advanceTimeBy(2000)
137+
testScheduler.runCurrent()
138+
139+
assertTrue(job.isCancelled)
140+
assertEquals(4, attempts)
141+
}
142+
143+
@Test
144+
fun cancelRetryWithinJob() = runTest {
145+
val every20ms = constantDelay<AttemptsError>(20)
146+
var attempts = 0
147+
148+
val job = launch {
149+
retry(every20ms) {
150+
attempts++
151+
152+
if (attempts == 15) {
153+
cancel()
154+
}
155+
156+
Err(AttemptsError(attempts))
157+
}
158+
}
159+
160+
testScheduler.advanceUntilIdle()
161+
162+
assertTrue(job.isCancelled)
163+
assertEquals(15, attempts)
164+
165+
testScheduler.advanceTimeBy(2000)
166+
testScheduler.runCurrent()
167+
168+
assertTrue(job.isCancelled)
169+
assertEquals(15, attempts)
170+
}
171+
172+
@Test
173+
fun cancelRetryWithinChildJob() = runTest {
174+
val every20ms = constantDelay<AttemptsError>(20)
175+
var attempts = 0
176+
177+
lateinit var childJobOne: Deferred<Int>
178+
lateinit var childJobTwo: Deferred<Int>
179+
180+
val parentJob = launch {
181+
retry(every20ms) {
182+
childJobOne = async {
183+
delay(100)
184+
attempts
185+
}
186+
187+
childJobTwo = async {
188+
delay(50)
189+
190+
if (attempts == 15) {
191+
cancel()
192+
}
193+
194+
1
195+
}
196+
197+
attempts = childJobOne.await() + childJobTwo.await()
198+
199+
Err(AttemptsError(attempts))
200+
}
201+
}
202+
203+
testScheduler.advanceUntilIdle()
204+
205+
assertTrue(parentJob.isCancelled)
206+
assertFalse(childJobOne.isCancelled)
207+
assertTrue(childJobTwo.isCancelled)
208+
assertEquals(15, attempts)
209+
210+
testScheduler.advanceTimeBy(2000)
211+
testScheduler.runCurrent()
212+
213+
assertTrue(parentJob.isCancelled)
214+
assertFalse(childJobOne.isCancelled)
215+
assertTrue(childJobTwo.isCancelled)
216+
assertEquals(15, attempts)
217+
}
218+
}

settings.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ dependencyResolutionManagement {
1818
rootProject.name = "kotlin-retry"
1919

2020
include("kotlin-retry")
21+
include("kotlin-retry-result")

0 commit comments

Comments
 (0)