git.iko.soy/opencode-android
on d4cde6a help sign in
221 lines 8.1 kB history raw
1package soy.iko.opencode.data.network
3import io.ktor.client.HttpClient
4import io.ktor.client.engine.mock.MockEngine
5import io.ktor.client.engine.mock.respond
6import io.ktor.client.plugins.ClientRequestException
7import io.ktor.client.plugins.RedirectResponseException
8import io.ktor.client.plugins.ServerResponseException
9import io.ktor.client.request.get
10import io.ktor.http.HttpStatusCode
11import io.ktor.http.headersOf
12import kotlinx.coroutines.CancellationException
13import kotlinx.coroutines.test.runTest
14import org.junit.Assert.assertEquals
15import org.junit.Assert.assertTrue
16import org.junit.Test
17import java.io.IOException
19@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
20class WithRetryTest {
22 /** Produce the real exception Ktor throws for a status when expectSuccess = true. */
23 private suspend fun httpError(status: HttpStatusCode): Throwable {
24 val client = HttpClient(MockEngine { respond("", status, headersOf("Content-Length", "0")) }) {
25 expectSuccess = true
26 }
27 return client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
28 }
30 @Test
31 fun returnsValueOnFirstAttempt() = runTest {
32 var calls = 0
33 val result = withRetryInternal { calls++; "ok" }
34 assertEquals("ok", result)
35 assertEquals(1, calls)
36 }
38 @Test
39 fun retriesTransientFailureThenSucceeds() = runTest {
40 var calls = 0
41 val result = withRetryInternal(initialDelayMs = 0) {
42 calls++
43 if (calls < 3) throw IOException("boom")
44 "ok"
45 }
46 assertEquals("ok", result)
47 assertEquals(3, calls)
48 }
50 @Test
51 fun throwsAfterExhaustingAttempts() = runTest {
52 var calls = 0
53 val error = runCatching {
54 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
55 calls++
56 throw IOException("always")
57 }
58 }.exceptionOrNull()!!
59 assertTrue("expected IOException, got $error", error is IOException)
60 assertEquals(3, calls)
61 }
63 @Test
64 fun clientErrorIsNotRetried() = runTest {
65 val notFound = httpError(HttpStatusCode.NotFound)
66 assertTrue("expected ClientRequestException, got $notFound", notFound is ClientRequestException)
67 var calls = 0
68 val error = runCatching {
69 withRetryInternal(initialDelayMs = 0) {
70 calls++
71 throw notFound
72 }
73 }.exceptionOrNull()!!
74 assertEquals("4xx must not be retried", 1, calls)
75 assertTrue(error is ClientRequestException)
76 }
78 @Test
79 fun serverErrorIsRetried() = runTest {
80 val serverError = httpError(HttpStatusCode.InternalServerError)
81 assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
82 var calls = 0
83 runCatching {
84 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
85 calls++
86 throw serverError
87 }
88 }
89 assertEquals("5xx must be retried", 3, calls)
90 }
92 @Test
93 fun cancellationIsRethrownImmediately() = runTest {
94 var calls = 0
95 val error = runCatching {
96 withRetryInternal(initialDelayMs = 0) {
97 calls++
98 throw CancellationException("cancel")
99 }
100 }.exceptionOrNull()
101 assertEquals("cancellation must not be retried", 1, calls)
102 assertTrue("expected CancellationException, got $error", error is CancellationException)
103 }
105 @Test
106 fun maxAttemptsOneDoesNotRetry() = runTest {
107 var calls = 0
108 val error = runCatching {
109 withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
110 calls++
111 throw IOException("boom")
112 }
113 }.exceptionOrNull()!!
114 assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
115 assertTrue(error is IOException)
116 }
118 @Test
119 fun succeedsOnLastAllowedAttempt() = runTest {
120 var calls = 0
121 val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
122 calls++
123 if (calls < 3) throw IOException("transient")
124 "ok"
125 }
126 assertEquals("ok", result)
127 assertEquals(3, calls)
128 }
130 @Test
131 fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
132 var calls = 0
133 val start = testScheduler.currentTime
134 runCatching {
135 withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
136 calls++
137 throw IOException("always")
138 }
139 }
140 // Delays: attempt 1→2 waits 100ms (100 * 2^0), attempt 2→3 waits 200ms
141 // (100 * 2^1). Total virtual time elapsed = 300ms.
142 assertEquals(3, calls)
143 assertEquals(300L, testScheduler.currentTime - start)
144 }
146 @Test
147 fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
148 val delays = mutableListOf<Long>()
149 var calls = 0
150 var lastTime = testScheduler.currentTime
151 runCatching {
152 withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
153 calls++
154 val now = testScheduler.currentTime
155 if (calls > 1) delays.add(now - lastTime)
156 lastTime = now
157 throw IOException("always")
158 }
159 }
160 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
161 assertEquals(listOf(50L, 100L, 200L), delays)
162 }
164 @Test
165 fun symmetricJitterStaysWithinBounds() = runTest {
166 // With jitterFactor = 0.2, the delay should be baseDelay ± 20%.
167 // baseDelay = 100, so delay ∈ [80, 120]. Run many times to verify
168 // the symmetric jitter never exceeds the bounds.
169 repeat(100) {
170 val start = testScheduler.currentTime
171 runCatching {
172 withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
173 throw IOException("always")
174 }
175 }
176 val elapsed = testScheduler.currentTime - start
177 assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
178 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
179 }
180 }
182 @Test
183 fun redirectErrorIsNotRetried() = runTest {
184 // RedirectResponseException is thrown when expectSuccess=true and the status is 3xx.
185 // We use 300 (MultipleChoices) since MockEngine follows 301/302 by default.
186 val client = HttpClient(MockEngine {
187 respond("", HttpStatusCode.MultipleChoices, headersOf("Content-Length", "0"))
188 }) {
189 expectSuccess = true
190 followRedirects = false
191 }
192 val redirectError = client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
193 assertTrue("expected RedirectResponseException, got $redirectError", redirectError is RedirectResponseException)
194 var calls = 0
195 val error = runCatching {
196 withRetryInternal(initialDelayMs = 0) {
197 calls++
198 throw redirectError
199 }
200 }.exceptionOrNull()!!
201 assertEquals("3xx must not be retried", 1, calls)
202 assertTrue(error is RedirectResponseException)
203 }
205 @Test
206 fun jitteredBackoffDoublesWithAttempt() {
207 // With no jitter, backoff = initial * 2^(attempt-1)
208 assertEquals(100L, jitteredBackoff(1, 100L, 0.0))
209 assertEquals(200L, jitteredBackoff(2, 100L, 0.0))
210 assertEquals(400L, jitteredBackoff(3, 100L, 0.0))
211 assertEquals(800L, jitteredBackoff(4, 100L, 0.0))
212 }
214 @Test
215 fun jitteredBackoffClampsToMaxDelay() {
216 // At high attempts, the delay should be clamped to the configured max.
217 val maxDelay = NetworkConfig.retryMaxDelayMs
218 val delay = jitteredBackoff(40, 100L, 0.0)
219 assertTrue("delay $delay should be clamped to max $maxDelay", delay <= maxDelay)
220 }