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
16import java.io.IOException
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")) }) {
25 return client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
29 fun returnsValueOnFirstAttempt() = runTest {
31 val result = withRetryInternal { calls++; "ok" }
32 assertEquals("ok", result)
33 assertEquals(1, calls)
37 fun retriesTransientFailureThenSucceeds() = runTest {
39 val result = withRetryInternal(initialDelayMs = 0) {
41 if (calls < 3) throw IOException("boom")
44 assertEquals("ok", result)
45 assertEquals(3, calls)
49 fun throwsAfterExhaustingAttempts() = runTest {
51 val error = runCatching {
52 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
54 throw IOException("always")
57 assertTrue("expected IOException, got $error", error is IOException)
58 assertEquals(3, calls)
62 fun clientErrorIsNotRetried() = runTest {
63 val notFound = httpError(HttpStatusCode.NotFound)
64 assertTrue("expected ClientRequestException, got $notFound", notFound is ClientRequestException)
66 val error = runCatching {
67 withRetryInternal(initialDelayMs = 0) {
72 assertEquals("4xx must not be retried", 1, calls)
73 assertTrue(error is ClientRequestException)
77 fun serverErrorIsRetried() = runTest {
78 val serverError = httpError(HttpStatusCode.InternalServerError)
79 assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
82 withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
87 assertEquals("5xx must be retried", 3, calls)
91 fun cancellationIsRethrownImmediately() = runTest {
93 val error = runCatching {
94 withRetryInternal(initialDelayMs = 0) {
96 throw CancellationException("cancel")
99 assertEquals("cancellation must not be retried", 1, calls)
100 assertTrue("expected CancellationException, got $error", error is CancellationException)
104 fun maxAttemptsOneDoesNotRetry() = runTest {
106 val error = runCatching {
107 withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
109 throw IOException("boom")
111 }.exceptionOrNull()!!
112 assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
113 assertTrue(error is IOException)
117 fun succeedsOnLastAllowedAttempt() = runTest {
119 val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
121 if (calls < 3) throw IOException("transient")
124 assertEquals("ok", result)
125 assertEquals(3, calls)
129 fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
131 val start = testScheduler.currentTime
133 withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
135 throw IOException("always")
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)
145 fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
146 val delays = mutableListOf<Long>()
148 var lastTime = testScheduler.currentTime
150 withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
152 val now = testScheduler.currentTime
153 if (calls > 1) delays.add(now - lastTime)
155 throw IOException("always")
158 // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
159 assertEquals(listOf(50L, 100L, 200L), delays)
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.
168 val start = testScheduler.currentTime
170 withRetryInternal(maxAttempts = 2, initialDelayMs = 100L, jitterFactor = 0.2) {
171 throw IOException("always")
174 val elapsed = testScheduler.currentTime - start
175 assertTrue("elapsed $elapsed should be >= 80", elapsed >= 80)
176 assertTrue("elapsed $elapsed should be <= 120", elapsed <= 120)
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