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 serverErrorIsRetried() = runTest {
80 val serverError = httpError(HttpStatusCode.InternalServerError)
81 assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
84 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
89 assertEquals("5xx must be retried", 3, calls)
93 fun cancellationIsRethrownImmediately() = runTest {
95 val error = runCatching {
96 withRetryInternal(initialDelayMs = 0) {
98 throw CancellationException("cancel")
101 assertEquals("cancellation must not be retried", 1, calls)
102 assertTrue("expected CancellationException, got $error", error is CancellationException)
106 fun maxAttemptsOneDoesNotRetry() = runTest {
108 val error = runCatching {
109 withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
111 throw IOException("boom")
113 }.exceptionOrNull()!!
114 assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
115 assertTrue(error is IOException)
119 fun succeedsOnLastAllowedAttempt() = runTest {
121 val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
123 if (calls < 3) throw IOException("transient")
126 assertEquals("ok", result)
127 assertEquals(3, calls)
131 fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
133 val start = testScheduler.currentTime
135 withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
137 throw IOException("always")
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)
147 fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
148 val delays = mutableListOf<Long>()
150 var lastTime = testScheduler.currentTime
152 withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
154 val now = testScheduler.currentTime
155 if (calls > 1) delays.add(now - lastTime)
157 throw IOException("always")
160 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
161 assertEquals(listOf(50L, 100L, 200L), delays)
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.
170 val start = testScheduler.currentTime
172 withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
173 throw IOException("always")
176 val elapsed = testScheduler.currentTime - start
177 assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
178 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
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"))
190 followRedirects = false
192 val redirectError = client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
193 assertTrue("expected RedirectResponseException, got $redirectError", redirectError is RedirectResponseException)
195 val error = runCatching {
196 withRetryInternal(initialDelayMs = 0) {
200 }.exceptionOrNull()!!
201 assertEquals("3xx must not be retried", 1, calls)
202 assertTrue(error is RedirectResponseException)
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))
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)
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