@@ -19,6 +19,8 @@ import soy.iko.opencode.data.model.UpdateSessionRequest
1919import io.ktor.client.HttpClient
2020import io.ktor.client.call.body
2121import io.ktor.client.plugins.ClientRequestException
22+import io.ktor.client.plugins.ResponseException
23+import io.ktor.client.plugins.ServerResponseException
2224import io.ktor.client.request.delete
2325import io.ktor.client.request.get
2426import io.ktor.client.request.header
@@ -238,28 +240,51 @@ internal suspend fun <T> withRetryInternal(
238240 return block()
239241 } catch (c: kotlinx.coroutines.CancellationException) {
240242 throw c
241− } catch (t: Exception) {
243+ } catch (t: ClientRequestException) {
244+ // 429 (Too Many Requests) is transient — retry with backoff.
245+ // All other 4xx are non-retryable.
246+ if (t.response.status.value != 429) throw t
242247 lastError = t
243− if (t is ClientRequestException) {
244− // 429 (Too Many Requests) is transient — retry with backoff.
245− if (t.response.status.value != 429) throw t
248+ if (attempt < maxAttempts) {
249+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
246250 }
251+ } catch (t: ServerResponseException) {
252+ // 5xx server errors are retryable with backoff.
253+ lastError = t
247254 if (attempt < maxAttempts) {
248− // Guard against Int overflow from the shift and Long overflow from
249− // the multiplication at high attempt counts.
250− val shift = (attempt - 1).coerceAtMost(30)
251− val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
252− // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
253− // shorter, sometimes longer — spreads concurrent client retries and
254− // prevents thundering-herd reconnection storms.
255− val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
256− delay((baseDelay + jitter).coerceAtLeast(0))
255+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
256+ }
257+ } catch (t: ResponseException) {
258+ // 3xx (redirect limit exceeded) is non-retryable.
259+ throw t
260+ } catch (t: Exception) {
261+ lastError = t
262+ if (attempt < maxAttempts) {
263+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
257264 }
258265 }
259266 }
260267 throw lastError ?: IllegalStateException("withRetry failed without error")
261268}
262269
270+/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
271+ * so the backoff formula is unit-testable without an HTTP server. */
272+internal fun jitteredBackoff(
273+ attempt: Int,
274+ initialDelayMs: Long,
275+ jitterFactor: Double,
276+): Long {
277+ // Guard against Int overflow from the shift and Long overflow from
278+ // the multiplication at high attempt counts.
279+ val shift = (attempt - 1).coerceAtMost(30)
280+ val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
281+ // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
282+ // shorter, sometimes longer — spreads concurrent client retries and
283+ // prevents thundering-herd reconnection storms.
284+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
285+ return (baseDelay + jitter).coerceAtLeast(0)
286+}
287+
263288/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
264289private fun encode(segment: String): String =
265290 URLEncoder.encode(segment, "UTF-8").replace("+", "%20")