diff --git a/core/utils/build.gradle.kts b/core/utils/build.gradle.kts index 4a731b1f09..48e632eff4 100644 --- a/core/utils/build.gradle.kts +++ b/core/utils/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(deps.jodatime) // endregion + testImplementation(deps.test.coroutine) testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.truth) diff --git a/core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt b/core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt new file mode 100644 index 0000000000..854877ddc3 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt @@ -0,0 +1,55 @@ +package com.tangem.utils.retryer + +import kotlinx.coroutines.delay +import kotlin.math.pow +import kotlin.random.Random + +/** + * Utility class to retry a suspending block of code a specified number of times with exponential backoff and jitter. + * + * @param attempt The number of attempts to retry the block. + * @param block The suspending block of code to be executed. It should return true if successful, false otherwise. + * +[REDACTED_AUTHOR] + */ +class Retryer( + private val attempt: Int, + private val block: suspend () -> Boolean, +) { + + init { + require(attempt > 0) { "Retryer.attempt should be greater than 0" } + } + + /** + * Launches the retry mechanism, executing the block up to the specified number of attempts. + * If the block returns true, the retrying stops. + */ + suspend fun launch() { + repeat(attempt) { + val timeInMillis = calculateDelay(iteration = it) + delay(timeInMillis) + + val result = block() + + if (result) return + } + } + + /** + * Calculates the delay before the next retry attempt using exponential backoff with jitter. + * delay = BASE * 2^iteration +- JITTER + * + * @param iteration The current iteration number (0-based). + * @return The calculated delay in milliseconds. + */ + private fun calculateDelay(iteration: Int): Long { + return (BASE_DELAY * 2.0.pow(iteration.toDouble()) + Random.nextLong(-JITTER, JITTER)).toLong() + } + + private companion object { + + const val BASE_DELAY = 1000L // 1 second + const val JITTER = 500L // 0.5 second + } +} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/retryer/RetryerPool.kt b/core/utils/src/main/java/com/tangem/utils/retryer/RetryerPool.kt new file mode 100644 index 0000000000..6aeb28dbd4 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/retryer/RetryerPool.kt @@ -0,0 +1,36 @@ +package com.tangem.utils.retryer + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +/** + * Utility class to manage and launch multiple [Retryer] instances one by one. + * + * @param coroutineScope The [CoroutineScope] in which the retryers will be launched. + * +[REDACTED_AUTHOR] + */ +class RetryerPool(private val coroutineScope: CoroutineScope) { + + private val queue = Channel(Channel.UNLIMITED) + + init { + coroutineScope.launch { + for (retryer in queue) { + retryer.launch() + } + } + } + + /** + * Adds a [Retryer] to the pool and launches it one by one. + * + * @param retryer The [Retryer] instance to be added and launched. + * @return The current [RetryerPool] instance for chaining. + */ + operator fun plus(retryer: Retryer): RetryerPool { + queue.trySend(retryer) + return this + } +} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerPoolTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerPoolTest.kt new file mode 100644 index 0000000000..c752032279 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerPoolTest.kt @@ -0,0 +1,53 @@ +package com.tangem.utils.retryer + +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RetryerPoolTest { + + private val coroutineScope = CoroutineScope(SupervisorJob() + TestingCoroutineDispatcherProvider().default) + + @Test + fun `retryer pool plus operator adds retryer to the pool`() { + // Arrange + val retryerPool = RetryerPool(coroutineScope = coroutineScope) + val retryer = mockk(relaxUnitFun = true) + + // Act + retryerPool + retryer + + // Assert + coVerify(exactly = 1) { + retryer.launch() + } + } + + @Test + fun `multiple retryers added to the pool are launched`() { + // Arrange + val retryerPool = RetryerPool(coroutineScope = coroutineScope) + val retryer1 = mockk(relaxUnitFun = true) + val retryer2 = mockk(relaxUnitFun = true) + val retryer3 = mockk(relaxUnitFun = true) + + // Act + retryerPool + retryer1 + retryer2 + retryer3 + + // Assert + coVerifyOrder { + retryer1.launch() + retryer2.launch() + retryer3.launch() + } + } +} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerTest.kt new file mode 100644 index 0000000000..c1e6a6eb83 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerTest.kt @@ -0,0 +1,110 @@ +package com.tangem.utils.retryer + +import com.google.common.truth.Truth +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RetryerTest { + + @Test + fun `retryer with zero attempts should throw exception`() { + // Act + val actual = runCatching { + Retryer(attempt = 0) { false } + } + .exceptionOrNull() + + // Assert + val expected = IllegalArgumentException("Retryer.attempt should be greater than 0") + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `retryer with negative attempts should throw exception`() { + // Act + val actual = runCatching { + Retryer(attempt = -5) { false } + } + .exceptionOrNull() + + // Assert + val expected = IllegalArgumentException("Retryer.attempt should be greater than 0") + Truth.assertThat(actual).isInstanceOf(expected::class.java) + Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `retryer with positive attempts should be created successfully`() { + // Act + val actual = runCatching { + Retryer(attempt = 3) { false } + } + .getOrNull() + + // Assert + Truth.assertThat(actual).isNotNull() + } + + @Test + fun `retryer should be repeated until block returns true`() = runTest { + // Arrange + val attempt = 5 + val block = mockk<() -> Boolean>() + val retryer = Retryer(attempt = attempt, block = block) + + coEvery { block() } returnsMany List(attempt) { it == attempt - 2 } // returns true on the 4th call + + // Act + retryer.launch() + + // Assert + coVerify(exactly = attempt - 1) { + block.invoke() + } + } + + @Test + fun `retryer should be called once`() = runTest { + // Arrange + val attempt = 5 + val block = mockk<() -> Boolean>() + val retryer = Retryer(attempt = attempt, block = block) + + coEvery { block() } returns true // for the first attempt + + // Act + retryer.launch() + + // Assert + coVerify(exactly = 1) { + block.invoke() + } + } + + @Test + fun `retryer should throw after all attempts failed`() = runTest { + // Arrange + val attempt = 3 + val block = mockk<() -> Boolean>() + val retryer = Retryer(attempt = attempt, block = block) + + coEvery { block() } returnsMany List(attempt) { false } + + // Act + retryer.launch() + + // Assert + coVerify(exactly = attempt) { + block.invoke() + } + } +} \ No newline at end of file