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
17import java.io.IOException
19@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
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")) }) {
27 return client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
31 fun returnsValueOnFirstAttempt() = runTest {
33 val result = withRetryInternal { calls++; "ok" }
34 assertEquals("ok", result)
35 assertEquals(1, calls)
39 fun retriesTransientFailureThenSucceeds() = runTest {
41 val result = withRetryInternal(initialDelayMs = 0) {
43 if (calls < 3) throw IOException("boom")
46 assertEquals("ok", result)
47 assertEquals(3, calls)
51 fun throwsAfterExhaustingAttempts() = runTest {
53 val error = runCatching {
54 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
56 throw IOException("always")
59 assertTrue("expected IOException, got $error", error is IOException)
60 assertEquals(3, calls)
64 fun clientErrorIsNotRetried() = runTest {
65 val notFound = httpError(HttpStatusCode.NotFound)
66 assertTrue("expected ClientRequestException, got $notFound", notFound is ClientRequestException)
68 val error = runCatching {
69 withRetryInternal(initialDelayMs = 0) {
74 assertEquals("4xx must not be retried", 1, calls)
75 assertTrue(error is ClientRequestException)
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)
87 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
92 assertEquals("429 must be retried like a transient failure", 3, calls)
96 fun serverErrorIsRetried() = runTest {
97 val serverError = httpError(HttpStatusCode.InternalServerError)
98 assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
101 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
106 assertEquals("5xx must be retried", 3, calls)
110 fun cancellationIsRethrownImmediately() = runTest {
112 val error = runCatching {
113 withRetryInternal(initialDelayMs = 0) {
115 throw CancellationException("cancel")
118 assertEquals("cancellation must not be retried", 1, calls)
119 assertTrue("expected CancellationException, got $error", error is CancellationException)
123 fun maxAttemptsOneDoesNotRetry() = runTest {
125 val error = runCatching {
126 withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
128 throw IOException("boom")
130 }.exceptionOrNull()!!
131 assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
132 assertTrue(error is IOException)
136 fun succeedsOnLastAllowedAttempt() = runTest {
138 val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
140 if (calls < 3) throw IOException("transient")
143 assertEquals("ok", result)
144 assertEquals(3, calls)
148 fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
150 val start = testScheduler.currentTime
152 withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
154 throw IOException("always")
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)
164 fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
165 val delays = mutableListOf<Long>()
167 var lastTime = testScheduler.currentTime
169 withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
171 val now = testScheduler.currentTime
172 if (calls > 1) delays.add(now - lastTime)
174 throw IOException("always")
177 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
178 assertEquals(listOf(50L, 100L, 200L), delays)
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.
187 val start = testScheduler.currentTime
189 withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
190 throw IOException("always")
193 val elapsed = testScheduler.currentTime - start
194 assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
195 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
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"))
207 followRedirects = false
209 val redirectError = client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
210 assertTrue("expected RedirectResponseException, got $redirectError", redirectError is RedirectResponseException)
212 val error = runCatching {
213 withRetryInternal(initialDelayMs = 0) {
217 }.exceptionOrNull()!!
218 assertEquals("3xx must not be retried", 1, calls)
219 assertTrue(error is RedirectResponseException)
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))
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)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list