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 // 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)
87 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
92 assertEquals("429 must be retried like a transient failure", 3, calls)
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)
103 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
108 assertEquals("408 must be retried like a transient failure", 3, calls)
112 fun serverErrorIsRetried() = runTest {
113 val serverError = httpError(HttpStatusCode.InternalServerError)
114 assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
117 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
122 assertEquals("5xx must be retried", 3, calls)
126 fun cancellationIsRethrownImmediately() = runTest {
128 val error = runCatching {
129 withRetryInternal(initialDelayMs = 0) {
131 throw CancellationException("cancel")
134 assertEquals("cancellation must not be retried", 1, calls)
135 assertTrue("expected CancellationException, got $error", error is CancellationException)
139 fun maxAttemptsOneDoesNotRetry() = runTest {
141 val error = runCatching {
142 withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
144 throw IOException("boom")
146 }.exceptionOrNull()!!
147 assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
148 assertTrue(error is IOException)
152 fun succeedsOnLastAllowedAttempt() = runTest {
154 val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
156 if (calls < 3) throw IOException("transient")
159 assertEquals("ok", result)
160 assertEquals(3, calls)
164 fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
166 val start = testScheduler.currentTime
168 withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
170 throw IOException("always")
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)
180 fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
181 val delays = mutableListOf<Long>()
183 var lastTime = testScheduler.currentTime
185 withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
187 val now = testScheduler.currentTime
188 if (calls > 1) delays.add(now - lastTime)
190 throw IOException("always")
193 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
194 assertEquals(listOf(50L, 100L, 200L), delays)
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.
203 val start = testScheduler.currentTime
205 withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
206 throw IOException("always")
209 val elapsed = testScheduler.currentTime - start
210 assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
211 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
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"))
223 followRedirects = false
225 val redirectError = client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
226 assertTrue("expected RedirectResponseException, got $redirectError", redirectError is RedirectResponseException)
228 val error = runCatching {
229 withRetryInternal(initialDelayMs = 0) {
233 }.exceptionOrNull()!!
234 assertEquals("3xx must not be retried", 1, calls)
235 assertTrue(error is RedirectResponseException)
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))
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)
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