Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ private constructor(
/**
* Creates an **exponential back-off** policy.
*
* Delay grows according to:
* Delay doubles on every retry:
* ```text
* nextDelay = prevDelay + retryIndex × backoffStepMillis
* nextDelay = backoffStepMillis × 2^(retryIndex - 1)
* ```
*
* then clamped to `[backoffStepMillis, maxBackoffMillis]`.
Expand All @@ -75,6 +75,7 @@ private constructor(
* retry 1 → 250 ms
* retry 2 → 500 ms
* retry 3 → 1 000 ms
* retry 4 → 2 000 ms
* …
* ```
*
Expand All @@ -100,14 +101,72 @@ private constructor(
maxBackoffMills = maxBackoffMillis,
initialDelayMillis = initialDelayMillis,
giveUpFunction = giveUp,
nextBackOffDelayFunction = { retry, prev ->
(prev + retry * backoffStepMillis)
.coerceAtMost(maxBackoffMillis)
.coerceIn(backoffStepMillis, maxBackoffMillis)
nextBackOffDelayFunction = { retry, _ ->
val shift = (retry - 1).coerceIn(0, Long.SIZE_BITS - 1)
// Largest step that still fits under the cap once shifted; comparing
// against it keeps `shl` from overflowing on high retry counts.
val largestShiftableStep = maxBackoffMillis shr shift
if (backoffStepMillis > largestShiftableStep) {
maxBackoffMillis
} else {
backoffStepMillis shl shift
}
},
)
.also { it.requireValid() }

/**
* Creates a **quadratic back-off** policy.
*
* Each retry adds a growing increment to the previous delay:
* ```text
* nextDelay = prevDelay + retryIndex × backoffStepMillis
* ```
*
* then clamped to `[backoffStepMillis, maxBackoffMillis]`. Driven from an
* [initialDelayMillis] of `0`, the delays are the triangular numbers scaled by the step —
* `backoffStepMillis × n(n + 1) / 2`.
*
* Asymptotically this is the gentler curve, but over the range a retry loop actually covers
* it is the **steeper** of the two. At a 100 ms step it runs `100, 300, 600, 1000` against
* [exponential]'s `100, 200, 400, 800`; doubling only overtakes it at retry 5, and with the
* default `maxRetries = 5` the processor never computes that retry. Expect longer waits
* than [exponential], not shorter.
*
* Example with defaults:
* ```
* attempt 1 → 0 ms
* retry 1 → 250 ms
* retry 2 → 750 ms
* retry 3 → 1 500 ms
* retry 4 → 2 500 ms
* …
* ```
*
* Unlike [exponential], this reads the previous delay, so the curve depends on the retry
* loop feeding each delay back in — which `StreamRetryProcessor` does.
*
* Parameter semantics match [exponential].
*/
public fun quadratic(
@IntRange(from = 1) minRetries: Int = 1,
@IntRange(from = 1) maxRetries: Int = 5,
@IntRange(from = 0) backoffStepMillis: Long = 250,
@IntRange(from = 0) maxBackoffMillis: Long = 15_000,
@IntRange(from = 0) initialDelayMillis: Long = 0,
giveUp: (Int, Throwable) -> Boolean = { retry, _ -> retry > maxRetries },
): StreamRetryPolicy =
custom(
minRetries = minRetries,
maxRetries = maxRetries,
minBackoffMills = backoffStepMillis,
maxBackoffMills = maxBackoffMillis,
initialDelayMillis = initialDelayMillis,
giveUp = giveUp,
) { retry, prev ->
prev + retry * backoffStepMillis
}

/**
* Creates a **linear back-off** policy.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,29 +59,47 @@ class StreamRetryPolicyTest {
fun `exponential backoff delay calculation increases exponentially`() {
val policy = StreamRetryPolicy.exponential(backoffStepMillis = 100, initialDelayMillis = 0)

// First retry: prev=0 + retry=1 * 100 = 100
// Retry n waits 100 * 2^(n-1)
val delay1 = policy.nextBackOffDelayFunction(1, 0)
assertEquals(100, delay1)

// Second retry: prev=100 + retry=2 * 100 = 300
val delay2 = policy.nextBackOffDelayFunction(2, delay1)
assertEquals(300, delay2)
assertEquals(200, delay2)

// Third retry: prev=300 + retry=3 * 100 = 600
val delay3 = policy.nextBackOffDelayFunction(3, delay2)
assertEquals(600, delay3)
assertEquals(400, delay3)

val delay4 = policy.nextBackOffDelayFunction(4, delay3)
assertEquals(800, delay4)
}

@Test
fun `exponential backoff delay is capped at maxBackoffMillis`() {
val policy =
StreamRetryPolicy.exponential(backoffStepMillis = 1000, maxBackoffMillis = 3000)

// Should exceed max: 0 + 10 * 1000 = 10000, but capped at 3000
// Should exceed max: 1000 * 2^9 = 512_000, but capped at 3000
val delay = policy.nextBackOffDelayFunction(10, 0)
assertEquals(3000, delay)
}

@Test
fun `exponential backoff delay does not overflow on very high retry counts`() {
val policy =
StreamRetryPolicy.exponential(backoffStepMillis = 1000, maxBackoffMillis = 30_000)

assertEquals(30_000, policy.nextBackOffDelayFunction(64, 0))
assertEquals(30_000, policy.nextBackOffDelayFunction(Int.MAX_VALUE, 0))
}

@Test
fun `exponential backoff delay ignores the previous delay`() {
val policy = StreamRetryPolicy.exponential(backoffStepMillis = 100)

assertEquals(400, policy.nextBackOffDelayFunction(3, 0))
assertEquals(400, policy.nextBackOffDelayFunction(3, 12_345))
}

@Test
fun `exponential backoff delay is clamped to minimum`() {
val policy = StreamRetryPolicy.exponential(backoffStepMillis = 100)
Expand All @@ -99,6 +117,126 @@ class StreamRetryPolicyTest {
assertTrue(policy.giveUpFunction(4, error)) // retry 4 > maxRetries 3
}

// ========================================
// Quadratic Factory Function
// ========================================

@Test
fun `quadratic creates policy with correct defaults`() {
val policy = StreamRetryPolicy.quadratic()

assertEquals(1, policy.minRetries)
assertEquals(5, policy.maxRetries)
assertEquals(250, policy.minBackoffMills)
assertEquals(15_000, policy.maxBackoffMills)
assertEquals(0, policy.initialDelayMillis)
}

@Test
fun `quadratic creates policy with custom parameters`() {
val policy =
StreamRetryPolicy.quadratic(
minRetries = 2,
maxRetries = 10,
backoffStepMillis = 500,
maxBackoffMillis = 30_000,
initialDelayMillis = 100,
)

assertEquals(2, policy.minRetries)
assertEquals(10, policy.maxRetries)
assertEquals(500, policy.minBackoffMills)
assertEquals(30_000, policy.maxBackoffMills)
assertEquals(100, policy.initialDelayMillis)
}

@Test
fun `quadratic backoff delay follows the triangular numbers`() {
val policy = StreamRetryPolicy.quadratic(backoffStepMillis = 100, initialDelayMillis = 0)

// Fed back through the retry loop, retry n waits 100 * n(n+1)/2
val delay1 = policy.nextBackOffDelayFunction(1, 0)
assertEquals(100, delay1)

val delay2 = policy.nextBackOffDelayFunction(2, delay1)
assertEquals(300, delay2)

val delay3 = policy.nextBackOffDelayFunction(3, delay2)
assertEquals(600, delay3)

val delay4 = policy.nextBackOffDelayFunction(4, delay3)
assertEquals(1000, delay4)
}

@Test
fun `quadratic backoff stays above exponential until they cross at retry 5`() {
val step = 100L
val linear = StreamRetryPolicy.linear(backoffStepMillis = step)
val quadratic = StreamRetryPolicy.quadratic(backoffStepMillis = step)
val exponential = StreamRetryPolicy.exponential(backoffStepMillis = step)

val linearDelays = mutableListOf<Long>()
val quadraticDelays = mutableListOf<Long>()
val exponentialDelays = mutableListOf<Long>()
for (retry in 1..5) {
linearDelays += linear.nextBackOffDelayFunction(retry, linearDelays.lastOrNull() ?: 0)
quadraticDelays +=
quadratic.nextBackOffDelayFunction(retry, quadraticDelays.lastOrNull() ?: 0)
exponentialDelays +=
exponential.nextBackOffDelayFunction(retry, exponentialDelays.lastOrNull() ?: 0)
}

assertEquals(listOf(100L, 200L, 300L, 400L, 500L), linearDelays)
assertEquals(listOf(100L, 300L, 600L, 1000L, 1500L), quadraticDelays)
assertEquals(listOf(100L, 200L, 400L, 800L, 1600L), exponentialDelays)

// Quadratic is the steeper curve over retries 2..4 and only loses to doubling at retry 5.
// The default maxRetries = 5 stops the processor before it computes retry 5, so callers
// on defaults always see the quadratic delays as the longer ones.
for (retry in 2..4) {
assertTrue(quadraticDelays[retry - 1] > exponentialDelays[retry - 1])
}
assertTrue(exponentialDelays[4] > quadraticDelays[4])
assertTrue(quadraticDelays.drop(1).zip(linearDelays.drop(1)).all { it.first > it.second })
}

@Test
fun `quadratic backoff delay is capped at maxBackoffMillis`() {
val policy = StreamRetryPolicy.quadratic(backoffStepMillis = 1000, maxBackoffMillis = 3000)

// Should exceed max: 0 + 10 * 1000 = 10_000, but capped at 3000
val delay = policy.nextBackOffDelayFunction(10, 0)
assertEquals(3000, delay)
}

@Test
fun `quadratic backoff delay saturates at the cap on high retry counts`() {
Comment thread
aleksandar-apostolov marked this conversation as resolved.
val policy =
StreamRetryPolicy.quadratic(backoffStepMillis = 1000, maxBackoffMillis = 30_000)

// From a zero previous delay, so this pins growth into the cap at an extreme retry index
// rather than restating that the cap is a fixed point.
assertEquals(30_000, policy.nextBackOffDelayFunction(1000, 0))
assertEquals(30_000, policy.nextBackOffDelayFunction(Int.MAX_VALUE, 0))
}

@Test
fun `quadratic backoff delay is clamped to minimum`() {
val policy = StreamRetryPolicy.quadratic(backoffStepMillis = 100)

// Delay should never be less than backoffStepMillis
val delay = policy.nextBackOffDelayFunction(1, 0)
assertTrue(delay >= 100)
}

@Test
fun `quadratic giveUp function respects maxRetries`() {
val policy = StreamRetryPolicy.quadratic(maxRetries = 3)

val error = RuntimeException("test")
assertTrue(policy.giveUpFunction(4, error)) // retry 4 > maxRetries 3
}

// ========================================
// Linear Factory Function
// ========================================
Expand Down Expand Up @@ -252,6 +390,31 @@ class StreamRetryPolicyTest {
StreamRetryPolicy.exponential(initialDelayMillis = -100)
}

@Test(expected = IllegalArgumentException::class)
fun `quadratic throws when minRetries is zero`() {
StreamRetryPolicy.quadratic(minRetries = 0)
}

@Test(expected = IllegalArgumentException::class)
fun `quadratic throws when maxRetries is less than minRetries`() {
StreamRetryPolicy.quadratic(minRetries = 10, maxRetries = 5)
}

@Test(expected = IllegalArgumentException::class)
fun `quadratic throws when minBackoffMills is negative`() {
StreamRetryPolicy.quadratic(backoffStepMillis = -50)
}

@Test(expected = IllegalArgumentException::class)
fun `quadratic throws when maxBackoffMillis is less than minBackoffMills`() {
StreamRetryPolicy.quadratic(backoffStepMillis = 2000, maxBackoffMillis = 1000)
}

@Test(expected = IllegalArgumentException::class)
fun `quadratic throws when initialDelayMillis is negative`() {
StreamRetryPolicy.quadratic(initialDelayMillis = -500)
}

@Test(expected = IllegalArgumentException::class)
fun `linear throws when minRetries is zero`() {
StreamRetryPolicy.linear(minRetries = 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,32 @@ class StreamRetryProcessorImplTest {
assertEquals(100, dispatcher.scheduler.currentTime)
}

@Test
fun `exponential policy doubles the elapsed delay between attempts`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = TestScope(dispatcher)

val policy =
StreamRetryPolicy.exponential(
minRetries = 1,
maxRetries = 5,
backoffStepMillis = 250,
initialDelayMillis = 0,
)

val counter = AtomicInteger()
val job =
scope.async { retry.retry(policy) { counter.incrementAndGet().also { error("Boom") } } }

dispatcher.scheduler.advanceUntilIdle()

assertTrue(job.await().isFailure)
assertEquals(5, counter.get())
// Four waits between five attempts: 250 + 500 + 1000 + 2000.
// The pre-fix policy accumulated instead, giving 250 + 750 + 1500 + 2500 = 5000.
assertEquals(3750, dispatcher.scheduler.currentTime)
}

@Test
fun `returns failure after exhausting maxRetries`() = runTest {
val policy = StreamRetryPolicy.linear(maxRetries = 2, minRetries = 1)
Expand Down
Loading