git.iko.soy/opencode-android
on 59feb55 help sign in
238 lines 8.8 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 // 429 (Too Many Requests) is the only 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 serverErrorIsRetried() = runTest {
97 val serverError = httpError(HttpStatusCode.InternalServerError)
98 assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
99 var calls = 0
100 runCatching {
101 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
102 calls++
103 throw serverError
104 }
105 }
106 assertEquals("5xx must be retried", 3, calls)
107 }
109 @Test
110 fun cancellationIsRethrownImmediately() = runTest {
111 var calls = 0
112 val error = runCatching {
113 withRetryInternal(initialDelayMs = 0) {
114 calls++
115 throw CancellationException("cancel")
116 }
117 }.exceptionOrNull()
118 assertEquals("cancellation must not be retried", 1, calls)
119 assertTrue("expected CancellationException, got $error", error is CancellationException)
120 }
122 @Test
123 fun maxAttemptsOneDoesNotRetry() = runTest {
124 var calls = 0
125 val error = runCatching {
126 withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
127 calls++
128 throw IOException("boom")
129 }
130 }.exceptionOrNull()!!
131 assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
132 assertTrue(error is IOException)
133 }
135 @Test
136 fun succeedsOnLastAllowedAttempt() = runTest {
137 var calls = 0
138 val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
139 calls++
140 if (calls < 3) throw IOException("transient")
141 "ok"
142 }
143 assertEquals("ok", result)
144 assertEquals(3, calls)
145 }
147 @Test
148 fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
149 var calls = 0
150 val start = testScheduler.currentTime
151 runCatching {
152 withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
153 calls++
154 throw IOException("always")
155 }
156 }
157 // Delays: attempt 1→2 waits 100ms (100 * 2^0), attempt 2→3 waits 200ms
158 // (100 * 2^1). Total virtual time elapsed = 300ms.
159 assertEquals(3, calls)
160 assertEquals(300L, testScheduler.currentTime - start)
161 }
163 @Test
164 fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
165 val delays = mutableListOf<Long>()
166 var calls = 0
167 var lastTime = testScheduler.currentTime
168 runCatching {
169 withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
170 calls++
171 val now = testScheduler.currentTime
172 if (calls > 1) delays.add(now - lastTime)
173 lastTime = now
174 throw IOException("always")
175 }
176 }
177 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
178 assertEquals(listOf(50L, 100L, 200L), delays)
179 }
181 @Test
182 fun symmetricJitterStaysWithinBounds() = runTest {
183 // With jitterFactor = 0.2, the delay should be baseDelay ± 20%.
184 // baseDelay = 100, so delay ∈ [80, 120]. Run many times to verify
185 // the symmetric jitter never exceeds the bounds.
186 repeat(100) {
187 val start = testScheduler.currentTime
188 runCatching {
189 withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
190 throw IOException("always")
191 }
192 }
193 val elapsed = testScheduler.currentTime - start
194 assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
195 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
196 }
197 }
199 @Test
200 fun redirectErrorIsNotRetried() = runTest {
201 // RedirectResponseException is thrown when expectSuccess=true and the status is 3xx.
202 // We use 300 (MultipleChoices) since MockEngine follows 301/302 by default.
203 val client = HttpClient(MockEngine {
204 respond("", HttpStatusCode.MultipleChoices, headersOf("Content-Length", "0"))
205 }) {
206 expectSuccess = true
207 followRedirects = false
208 }
209 val redirectError = client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
210 assertTrue("expected RedirectResponseException, got $redirectError", redirectError is RedirectResponseException)
211 var calls = 0
212 val error = runCatching {
213 withRetryInternal(initialDelayMs = 0) {
214 calls++
215 throw redirectError
216 }
217 }.exceptionOrNull()!!
218 assertEquals("3xx must not be retried", 1, calls)
219 assertTrue(error is RedirectResponseException)
220 }
222 @Test
223 fun jitteredBackoffDoublesWithAttempt() {
224 // With no jitter, backoff = initial * 2^(attempt-1)
225 assertEquals(100L, jitteredBackoff(1, 100L, 0.0))
226 assertEquals(200L, jitteredBackoff(2, 100L, 0.0))
227 assertEquals(400L, jitteredBackoff(3, 100L, 0.0))
228 assertEquals(800L, jitteredBackoff(4, 100L, 0.0))
229 }
231 @Test
232 fun jitteredBackoffClampsToMaxDelay() {
233 // At high attempts, the delay should be clamped to the configured max.
234 val maxDelay = NetworkConfig.retryMaxDelayMs
235 val delay = jitteredBackoff(40, 100L, 0.0)
236 assertTrue("delay $delay should be clamped to max $maxDelay", delay <= maxDelay)
237 }