/
eventually.kt
162 lines (143 loc) · 5.76 KB
/
eventually.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
package io.kotest.assertions.timing
import io.kotest.assertions.SuspendingProducer
import io.kotest.assertions.failure
import io.kotest.assertions.until.Interval
import io.kotest.assertions.until.fixed
import kotlinx.coroutines.delay
import kotlin.reflect.KClass
import kotlin.time.Duration
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import kotlin.time.milliseconds
/**
* Runs a function until it doesn't throw as long as the specified duration hasn't passed
*/
suspend fun <T> eventually(duration: Duration, f: SuspendingProducer<T>): T =
eventually(EventuallyConfig(duration = duration, exceptionClass = Throwable::class), f = f)
suspend fun <T : Any> eventually(
duration: Duration,
interval: Interval,
f: SuspendingProducer<T>
): T = eventually(EventuallyConfig(duration, interval), f = f)
suspend fun <T> eventually(
duration: Duration,
interval: Interval,
predicate: EventuallyPredicate<T>,
f: SuspendingProducer<T>
): T = eventually(EventuallyConfig(duration = duration, interval), predicate = predicate, f = f)
suspend fun <T> eventually(
duration: Duration,
interval: Interval,
listener: EventuallyListener<T>,
f: SuspendingProducer<T>
): T = eventually(EventuallyConfig(duration = duration, interval), listener = listener, f = f)
@Deprecated(
"""
Use eventually with an interval, using Duration based poll is deprecated.
To convert an existing duration to an interval you can Duration.fixed(), Duration.exponential(), or Duration.fibonacci().
""",
ReplaceWith(
"eventually(duration, interval = poll.fixed(), f = f)",
"io.kotest.assertions.until.fixed"
)
)
suspend fun <T> eventually(duration: Duration, poll: Duration, f: SuspendingProducer<T>): T =
eventually(EventuallyConfig(duration = duration, interval = poll.fixed(), exceptionClass = Throwable::class), f = f)
/**
* Runs a function until it doesn't throw the specified exception as long as the specified duration hasn't passed
*/
suspend fun <T> eventually(duration: Duration, exceptionClass: KClass<out Throwable>, f: SuspendingProducer<T>): T =
eventually(EventuallyConfig(duration = duration, exceptionClass = exceptionClass), f = f)
/**
* Runs a function until the following constraints are eventually met:
* the optional [predicate] must be satisfied, defaults to true
* the optional [duration] has not passed now, defaults to [Duration.INFINITE]
* the number of iterations does not exceed the optional [retries], defaults to [Int.MAX_VALUE]
*
* [eventually] will catch the specified optional [exceptionClass] and (or when not specified) [AssertionError], defaults to [Throwable]
* [eventually] will delay the specified [interval] between iterations, defaults to 25 [milliseconds]
* [eventually] will pass the resulting value and state (see [EventuallyState]) into the optional [listener]
*/
suspend fun <T> eventually(
duration: Duration = Duration.INFINITE,
interval: Interval = 25.milliseconds.fixed(),
predicate: EventuallyPredicate<T> = EventuallyPredicate { true },
listener: EventuallyListener<T> = EventuallyListener { },
retries: Int = Int.MAX_VALUE,
exceptionClass: KClass<out Throwable>? = null,
f: SuspendingProducer<T>
): T = eventually(EventuallyConfig(duration, interval, retries, exceptionClass), predicate, listener, f)
/**
* Runs a function until it doesn't throw and the result satisfies the predicate as long as the specified duration hasn't passed
* and uses [EventuallyConfig] to control the duration, interval, listener, retries, and exceptionClass.
*/
suspend fun <T> eventually(
config: EventuallyConfig,
predicate: EventuallyPredicate<T> = EventuallyPredicate { true },
listener: EventuallyListener<T> = EventuallyListener { },
f: SuspendingProducer<T>,
): T {
val start = TimeSource.Monotonic.markNow()
val end = start.plus(config.duration)
var times = 0
var firstError: Throwable? = null
var lastError: Throwable? = null
while (end.hasNotPassedNow() && times < config.retries) {
try {
val result = f()
listener.onEval(EventuallyState(result, start, end, times, firstError, lastError))
if (predicate.test(result)) {
return result
}
} catch (e: Throwable) {
if (AssertionError::class.isInstance(e) || config.exceptionClass?.isInstance(e) == true) {
if (firstError == null) {
firstError = e
} else {
lastError = e
}
} else {
throw e
}
}
times++
delay(config.interval.next(times))
}
val message = StringBuilder().apply {
appendLine("Eventually block failed after ${config.duration}; attempted $times time(s); ${config.interval} delay between attempts")
if (firstError != null) {
appendLine("The first error was caused by: ${firstError.message}")
appendLine(firstError.stackTraceToString())
}
if (lastError != null) {
appendLine("The last error was caused by: ${lastError.message}")
appendLine(lastError.stackTraceToString())
}
}
throw failure(message.toString())
}
data class EventuallyConfig(
val duration: Duration = Duration.INFINITE,
val interval: Interval = 25.milliseconds.fixed(),
val retries: Int = Int.MAX_VALUE,
val exceptionClass: KClass<out Throwable>? = null,
) {
init {
require(retries > 0) { "Retries should not be less than one" }
require(!duration.isNegative()) { "Duration cannot be negative" }
}
}
data class EventuallyState<T>(
val result: T,
val start: TimeMark,
val end: TimeMark,
val iteration: Int,
val firstError: Throwable?,
val thisError: Throwable?,
)
fun interface EventuallyPredicate<T> {
fun test(result: T): Boolean
}
fun interface EventuallyListener<T> {
fun onEval(state: EventuallyState<T>)
}