git.iko.soy/opencode-android
on 598c306 help sign in
179 lines 6.3 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.ServerResponseException
8import io.ktor.client.request.get
9import io.ktor.http.HttpStatusCode
10import io.ktor.http.headersOf
11import kotlinx.coroutines.CancellationException
12import kotlinx.coroutines.test.runTest
13import org.junit.Assert.assertEquals
14import org.junit.Assert.assertTrue
15import org.junit.Test
16import java.io.IOException
18class WithRetryTest {
20 /** Produce the real exception Ktor throws for a status when expectSuccess = true. */
21 private suspend fun httpError(status: HttpStatusCode): Throwable {
22 val client = HttpClient(MockEngine { respond("", status, headersOf("Content-Length", "0")) }) {
23 expectSuccess = true
24 }
25 return client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
26 }
28 @Test
29 fun returnsValueOnFirstAttempt() = runTest {
30 var calls = 0
31 val result = withRetryInternal { calls++; "ok" }
32 assertEquals("ok", result)
33 assertEquals(1, calls)
34 }
36 @Test
37 fun retriesTransientFailureThenSucceeds() = runTest {
38 var calls = 0
39 val result = withRetryInternal(initialDelayMs = 0) {
40 calls++
41 if (calls < 3) throw IOException("boom")
42 "ok"
43 }
44 assertEquals("ok", result)
45 assertEquals(3, calls)
46 }
48 @Test
49 fun throwsAfterExhaustingAttempts() = runTest {
50 var calls = 0
51 val error = runCatching {
52 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
53 calls++
54 throw IOException("always")
55 }
56 }.exceptionOrNull()!!
57 assertTrue("expected IOException, got $error", error is IOException)
58 assertEquals(3, calls)
59 }
61 @Test
62 fun clientErrorIsNotRetried() = runTest {
63 val notFound = httpError(HttpStatusCode.NotFound)
64 assertTrue("expected ClientRequestException, got $notFound", notFound is ClientRequestException)
65 var calls = 0
66 val error = runCatching {
67 withRetryInternal(initialDelayMs = 0) {
68 calls++
69 throw notFound
70 }
71 }.exceptionOrNull()!!
72 assertEquals("4xx must not be retried", 1, calls)
73 assertTrue(error is ClientRequestException)
74 }
76 @Test
77 fun serverErrorIsRetried() = runTest {
78 val serverError = httpError(HttpStatusCode.InternalServerError)
79 assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
80 var calls = 0
81 runCatching {
82 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
83 calls++
84 throw serverError
85 }
86 }
87 assertEquals("5xx must be retried", 3, calls)
88 }
90 @Test
91 fun cancellationIsRethrownImmediately() = runTest {
92 var calls = 0
93 val error = runCatching {
94 withRetryInternal(initialDelayMs = 0) {
95 calls++
96 throw CancellationException("cancel")
97 }
98 }.exceptionOrNull()
99 assertEquals("cancellation must not be retried", 1, calls)
100 assertTrue("expected CancellationException, got $error", error is CancellationException)
101 }
103 @Test
104 fun maxAttemptsOneDoesNotRetry() = runTest {
105 var calls = 0
106 val error = runCatching {
107 withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
108 calls++
109 throw IOException("boom")
110 }
111 }.exceptionOrNull()!!
112 assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
113 assertTrue(error is IOException)
114 }
116 @Test
117 fun succeedsOnLastAllowedAttempt() = runTest {
118 var calls = 0
119 val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
120 calls++
121 if (calls < 3) throw IOException("transient")
122 "ok"
123 }
124 assertEquals("ok", result)
125 assertEquals(3, calls)
126 }
128 @Test
129 fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
130 var calls = 0
131 val start = testScheduler.currentTime
132 runCatching {
133 withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
134 calls++
135 throw IOException("always")
136 }
137 }
138 // Delays: attempt 1→2 waits 100ms (100 * 2^0), attempt 2→3 waits 200ms
139 // (100 * 2^1). Total virtual time elapsed = 300ms.
140 assertEquals(3, calls)
141 assertEquals(300L, testScheduler.currentTime - start)
142 }
144 @Test
145 fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
146 val delays = mutableListOf<Long>()
147 var calls = 0
148 var lastTime = testScheduler.currentTime
149 runCatching {
150 withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
151 calls++
152 val now = testScheduler.currentTime
153 if (calls > 1) delays.add(now - lastTime)
154 lastTime = now
155 throw IOException("always")
156 }
157 }
158 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
159 assertEquals(listOf(50L, 100L, 200L), delays)
160 }
162 @Test
163 fun symmetricJitterStaysWithinBounds() = runTest {
164 // With jitterFactor = 0.2, the delay should be baseDelay ± 20%.
165 // baseDelay = 100, so delay ∈ [80, 120]. Run many times to verify
166 // the symmetric jitter never exceeds the bounds.
167 repeat(100) {
168 val start = testScheduler.currentTime
169 runCatching {
170 withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
171 throw IOException("always")
172 }
173 }
174 val elapsed = testScheduler.currentTime - start
175 assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
176 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
177 }
178 }