git.iko.soy/opencode-android
on e66c7ed help sign in
254 lines 9.5 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 tooManyRequestsIsRetried() = runTest {
80 // 408 and 429 are the only transient 4xx that should retry — all other 4xx
81 // surface immediately. Verify the special case isn't silently lost.
82 val rateLimited = httpError(HttpStatusCode.TooManyRequests)
83 assertTrue("expected ClientRequestException, got $rateLimited", rateLimited is ClientRequestException)
84 assertEquals(429, (rateLimited as ClientRequestException).response.status.value)
85 var calls = 0
86 runCatching {
87 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
88 calls++
89 throw rateLimited
90 }
91 }
92 assertEquals("429 must be retried like a transient failure", 3, calls)
93 }
95 @Test
96 fun requestTimeoutIsRetried() = runTest {
97 // 408 (Request Timeout) is transient — the client MAY repeat the request.
98 val timeout = httpError(HttpStatusCode.RequestTimeout)
99 assertTrue("expected ClientRequestException, got $timeout", timeout is ClientRequestException)
100 assertEquals(408, (timeout as ClientRequestException).response.status.value)
101 var calls = 0
102 runCatching {
103 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
104 calls++
105 throw timeout
106 }
107 }
108 assertEquals("408 must be retried like a transient failure", 3, calls)
109 }
111 @Test
112 fun serverErrorIsRetried() = runTest {
113 val serverError = httpError(HttpStatusCode.InternalServerError)
114 assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
115 var calls = 0
116 runCatching {
117 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
118 calls++
119 throw serverError
120 }
121 }
122 assertEquals("5xx must be retried", 3, calls)
123 }
125 @Test
126 fun cancellationIsRethrownImmediately() = runTest {
127 var calls = 0
128 val error = runCatching {
129 withRetryInternal(initialDelayMs = 0) {
130 calls++
131 throw CancellationException("cancel")
132 }
133 }.exceptionOrNull()
134 assertEquals("cancellation must not be retried", 1, calls)
135 assertTrue("expected CancellationException, got $error", error is CancellationException)
136 }
138 @Test
139 fun maxAttemptsOneDoesNotRetry() = runTest {
140 var calls = 0
141 val error = runCatching {
142 withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
143 calls++
144 throw IOException("boom")
145 }
146 }.exceptionOrNull()!!
147 assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
148 assertTrue(error is IOException)
149 }
151 @Test
152 fun succeedsOnLastAllowedAttempt() = runTest {
153 var calls = 0
154 val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
155 calls++
156 if (calls < 3) throw IOException("transient")
157 "ok"
158 }
159 assertEquals("ok", result)
160 assertEquals(3, calls)
161 }
163 @Test
164 fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
165 var calls = 0
166 val start = testScheduler.currentTime
167 runCatching {
168 withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
169 calls++
170 throw IOException("always")
171 }
172 }
173 // Delays: attempt 1→2 waits 100ms (100 * 2^0), attempt 2→3 waits 200ms
174 // (100 * 2^1). Total virtual time elapsed = 300ms.
175 assertEquals(3, calls)
176 assertEquals(300L, testScheduler.currentTime - start)
177 }
179 @Test
180 fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
181 val delays = mutableListOf<Long>()
182 var calls = 0
183 var lastTime = testScheduler.currentTime
184 runCatching {
185 withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
186 calls++
187 val now = testScheduler.currentTime
188 if (calls > 1) delays.add(now - lastTime)
189 lastTime = now
190 throw IOException("always")
191 }
192 }
193 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
194 assertEquals(listOf(50L, 100L, 200L), delays)
195 }
197 @Test
198 fun symmetricJitterStaysWithinBounds() = runTest {
199 // With jitterFactor = 0.2, the delay should be baseDelay ± 20%.
200 // baseDelay = 100, so delay ∈ [80, 120]. Run many times to verify
201 // the symmetric jitter never exceeds the bounds.
202 repeat(100) {
203 val start = testScheduler.currentTime
204 runCatching {
205 withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
206 throw IOException("always")
207 }
208 }
209 val elapsed = testScheduler.currentTime - start
210 assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
211 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
212 }
213 }
215 @Test
216 fun redirectErrorIsNotRetried() = runTest {
217 // RedirectResponseException is thrown when expectSuccess=true and the status is 3xx.
218 // We use 300 (MultipleChoices) since MockEngine follows 301/302 by default.
219 val client = HttpClient(MockEngine {
220 respond("", HttpStatusCode.MultipleChoices, headersOf("Content-Length", "0"))
221 }) {
222 expectSuccess = true
223 followRedirects = false
224 }
225 val redirectError = client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
226 assertTrue("expected RedirectResponseException, got $redirectError", redirectError is RedirectResponseException)
227 var calls = 0
228 val error = runCatching {
229 withRetryInternal(initialDelayMs = 0) {
230 calls++
231 throw redirectError
232 }
233 }.exceptionOrNull()!!
234 assertEquals("3xx must not be retried", 1, calls)
235 assertTrue(error is RedirectResponseException)
236 }
238 @Test
239 fun jitteredBackoffDoublesWithAttempt() {
240 // With no jitter, backoff = initial * 2^(attempt-1)
241 assertEquals(100L, jitteredBackoff(1, 100L, 0.0))
242 assertEquals(200L, jitteredBackoff(2, 100L, 0.0))
243 assertEquals(400L, jitteredBackoff(3, 100L, 0.0))
244 assertEquals(800L, jitteredBackoff(4, 100L, 0.0))
245 }
247 @Test
248 fun jitteredBackoffClampsToMaxDelay() {
249 // At high attempts, the delay should be clamped to the configured max.
250 val maxDelay = NetworkConfig.retryMaxDelayMs
251 val delay = jitteredBackoff(40, 100L, 0.0)
252 assertTrue("delay $delay should be clamped to max $maxDelay", delay <= maxDelay)
253 }