diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/retry/StreamRetryPolicy.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/retry/StreamRetryPolicy.kt index 84018bc8..1fe8395d 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/model/retry/StreamRetryPolicy.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/retry/StreamRetryPolicy.kt @@ -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]`. @@ -75,6 +75,7 @@ private constructor( * retry 1 → 250 ms * retry 2 → 500 ms * retry 3 → 1 000 ms + * retry 4 → 2 000 ms * … * ``` * @@ -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. * diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/model/retry/StreamRetryPolicyTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/model/retry/StreamRetryPolicyTest.kt index bf98ecb3..ad8ffbff 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/model/retry/StreamRetryPolicyTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/model/retry/StreamRetryPolicyTest.kt @@ -59,17 +59,18 @@ 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 @@ -77,11 +78,28 @@ class StreamRetryPolicyTest { 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) @@ -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() + val quadraticDelays = mutableListOf() + val exponentialDelays = mutableListOf() + 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`() { + 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 // ======================================== @@ -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) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/processing/StreamRetryProcessorImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/processing/StreamRetryProcessorImplTest.kt index 4af4cdfb..35e25360 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/processing/StreamRetryProcessorImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/processing/StreamRetryProcessorImplTest.kt @@ -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)