Updated on 2026-08-14
This commit is contained in:
parent
0ca6412e31
commit
d1191ec881
5 changed files with 255 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
55
core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt
Normal file
55
core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Retryer>(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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Retryer>(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<Retryer>(relaxUnitFun = true)
|
||||
val retryer2 = mockk<Retryer>(relaxUnitFun = true)
|
||||
val retryer3 = mockk<Retryer>(relaxUnitFun = true)
|
||||
|
||||
// Act
|
||||
retryerPool + retryer1 + retryer2 + retryer3
|
||||
|
||||
// Assert
|
||||
coVerifyOrder {
|
||||
retryer1.launch()
|
||||
retryer2.launch()
|
||||
retryer3.launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue