Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-06 13:43:21 +04:00
parent 0ca6412e31
commit d1191ec881
5 changed files with 255 additions and 0 deletions

View file

@ -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)

View 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
}
}

View file

@ -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
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}