Updated on 2026-08-14
This commit is contained in:
parent
3088258c74
commit
50c633e873
11 changed files with 119 additions and 33 deletions
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.utils.retryer
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.pow
|
||||
import kotlin.random.Random
|
||||
|
|
@ -14,7 +15,7 @@ import kotlin.random.Random
|
|||
*/
|
||||
class Retryer(
|
||||
private val attempt: Int,
|
||||
private val block: suspend () -> Boolean,
|
||||
private val block: suspend (Int) -> Boolean,
|
||||
) {
|
||||
|
||||
init {
|
||||
|
|
@ -24,13 +25,24 @@ class Retryer(
|
|||
/**
|
||||
* Launches the retry mechanism, executing the block up to the specified number of attempts.
|
||||
* If the block returns true, the retrying stops.
|
||||
*
|
||||
* @throws CancellationException if the coroutine is cancelled during execution.
|
||||
* @throws Error if the block throws an Error.
|
||||
*/
|
||||
suspend fun launch() {
|
||||
repeat(attempt) {
|
||||
val timeInMillis = calculateDelay(iteration = it)
|
||||
repeat(attempt) { iteration ->
|
||||
val timeInMillis = calculateDelay(iteration = iteration)
|
||||
delay(timeInMillis)
|
||||
|
||||
val result = block()
|
||||
val result = try {
|
||||
block(iteration)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Error) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
if (result) return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package com.tangem.utils.retryer
|
|||
|
||||
import com.google.common.truth.Truth
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
|
@ -58,17 +58,20 @@ class RetryerTest {
|
|||
fun `retryer should be repeated until block returns true`() = runTest {
|
||||
// Arrange
|
||||
val attempt = 5
|
||||
val block = mockk<() -> Boolean>()
|
||||
val block = mockk<suspend (Int) -> Boolean>()
|
||||
val retryer = Retryer(attempt = attempt, block = block)
|
||||
|
||||
coEvery { block() } returnsMany List(attempt) { it == attempt - 2 } // returns true on the 4th call
|
||||
coEvery { block(any()) } returnsMany List(attempt) { it == attempt - 2 } // returns true on the 4th call
|
||||
|
||||
// Act
|
||||
retryer.launch()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = attempt - 1) {
|
||||
block.invoke()
|
||||
coVerifyOrder {
|
||||
block.invoke(0)
|
||||
block.invoke(1)
|
||||
block.invoke(2)
|
||||
block.invoke(3)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,17 +79,17 @@ class RetryerTest {
|
|||
fun `retryer should be called once`() = runTest {
|
||||
// Arrange
|
||||
val attempt = 5
|
||||
val block = mockk<() -> Boolean>()
|
||||
val block = mockk<suspend (Int) -> Boolean>()
|
||||
val retryer = Retryer(attempt = attempt, block = block)
|
||||
|
||||
coEvery { block() } returns true // for the first attempt
|
||||
coEvery { block(1) } returns true // for the first attempt
|
||||
|
||||
// Act
|
||||
retryer.launch()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) {
|
||||
block.invoke()
|
||||
coVerifyOrder {
|
||||
block.invoke(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,17 +97,40 @@ class RetryerTest {
|
|||
fun `retryer should throw after all attempts failed`() = runTest {
|
||||
// Arrange
|
||||
val attempt = 3
|
||||
val block = mockk<() -> Boolean>()
|
||||
val block = mockk<suspend (Int) -> Boolean>()
|
||||
val retryer = Retryer(attempt = attempt, block = block)
|
||||
|
||||
coEvery { block() } returnsMany List(attempt) { false }
|
||||
coEvery { block(any()) } returnsMany List(attempt) { false }
|
||||
|
||||
// Act
|
||||
retryer.launch()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = attempt) {
|
||||
block.invoke()
|
||||
coVerifyOrder {
|
||||
block.invoke(0)
|
||||
block.invoke(1)
|
||||
block.invoke(2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retryer should handle exceptions in block and continue retrying`() = runTest {
|
||||
// Arrange
|
||||
val attempt = 4
|
||||
val block = mockk<suspend (Int) -> Boolean>()
|
||||
val retryer = Retryer(attempt = attempt, block = block)
|
||||
|
||||
coEvery { block(any()) } throws IllegalStateException("Test Exception") andThenMany List(attempt - 1) { false }
|
||||
|
||||
// Act
|
||||
retryer.launch()
|
||||
|
||||
// Assert
|
||||
coVerifyOrder {
|
||||
block.invoke(0)
|
||||
block.invoke(1)
|
||||
block.invoke(2)
|
||||
block.invoke(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -182,7 +182,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
|
|||
|
||||
store(userWalletId = userWalletId, response = accountsResponseWithTokens)
|
||||
|
||||
userTokensSaver.push(
|
||||
userTokensSaver.pushWithRetryer(
|
||||
userWalletId = userWalletId,
|
||||
response = accountsResponseWithTokens.toUserTokensResponse(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
|
|||
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag)
|
||||
|
||||
pushWalletAccounts(userWalletId, accountDTOs)
|
||||
userTokensSaver.push(userWalletId, userTokensResponse)
|
||||
userTokensSaver.pushWithRetryer(userWalletId, userTokensResponse)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ internal class DefaultAccountsCRUDRepository(
|
|||
return
|
||||
}
|
||||
|
||||
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
|
||||
userTokensSaver.pushWithRetryer(userWalletId = userWalletId, response = response.toUserTokensResponse())
|
||||
}
|
||||
|
||||
override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option<Int> = option {
|
||||
|
|
|
|||
|
|
@ -80,12 +80,11 @@ internal class DefaultMainAccountTokensMigration(
|
|||
|
||||
store.updateData { updatedResponse }
|
||||
|
||||
userTokensSaver.push(
|
||||
val userTokensResponse = updatedResponse.toUserTokensResponse()
|
||||
userTokensSaver.pushWithRetryer(
|
||||
userWalletId = userWalletId,
|
||||
response = updatedResponse.toUserTokensResponse(),
|
||||
response = userTokensResponse,
|
||||
onFailSend = {
|
||||
// TODO: save failed state to retry later
|
||||
// [REDACTED_JIRA]
|
||||
val exception = IllegalStateException("Failed to push updated tokens after migration")
|
||||
Timber.e(exception)
|
||||
raise(exception)
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ class FetchWalletAccountsErrorHandlerTest {
|
|||
userTokensResponseStore.getSyncOrNull(userWalletId = any())
|
||||
defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any())
|
||||
pushWalletAccounts(any(), any())
|
||||
userTokensSaver.push(userWalletId = any(), response = any())
|
||||
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any())
|
||||
storeWalletAccounts(any(), any())
|
||||
}
|
||||
}
|
||||
|
|
@ -127,7 +127,7 @@ class FetchWalletAccountsErrorHandlerTest {
|
|||
|
||||
// Assert
|
||||
coVerify {
|
||||
userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse())
|
||||
userTokensSaver.pushWithRetryer(userWalletId, response = savedAccountsResponse.toUserTokensResponse())
|
||||
tangemTechApi.createWallet(OnlyWalletIdBody(userWalletId.stringValue))
|
||||
eTagsStore.store(userWalletId, ETagsStore.Key.WalletAccounts, eTagValue)
|
||||
pushWalletAccounts(userWalletId, listOf(accountDTO))
|
||||
|
|
@ -191,7 +191,7 @@ class FetchWalletAccountsErrorHandlerTest {
|
|||
|
||||
coVerify(inverse = true) {
|
||||
pushWalletAccounts(any(), any())
|
||||
userTokensSaver.push(userWalletId = any(), response = any())
|
||||
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
private val accountsResponseStore = mockk<AccountsResponseStore>()
|
||||
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
|
||||
|
||||
private val userTokensSaver = mockk<UserTokensSaver>(relaxed = true)
|
||||
private val userTokensSaver = mockk<UserTokensSaver>(relaxUnitFun = true)
|
||||
|
||||
private val migration = DefaultMainAccountTokensMigration(
|
||||
accountsResponseStoreFactory = accountsResponseStoreFactory,
|
||||
userTokensSaver = userTokensSaver,
|
||||
|
|
@ -62,7 +63,7 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
coVerify(inverse = true) {
|
||||
accountsResponseStoreFactory.create(any())
|
||||
accountsResponseStore.data
|
||||
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
|
||||
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
|
||||
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +111,7 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
|
||||
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +146,7 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
|
||||
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +203,7 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
accountsResponseStoreFactory.create(userWalletId)
|
||||
accountsResponseStore.data
|
||||
accountsResponseStore.updateData(any())
|
||||
userTokensSaver.push(
|
||||
userTokensSaver.pushWithRetryer(
|
||||
userWalletId = userWalletId,
|
||||
response = migratedResponse.toUserTokensResponse(),
|
||||
onFailSend = any(),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import com.tangem.datasource.local.token.UserTokensResponseStore
|
|||
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.retryer.Retryer
|
||||
import com.tangem.utils.retryer.RetryerPool
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
class UserTokensSaver(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
|
|
@ -16,6 +19,7 @@ class UserTokensSaver(
|
|||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val addressesEnricher: UserTokensResponseAddressesEnricher,
|
||||
private val accountsFeatureToggles: AccountsFeatureToggles,
|
||||
private val pushTokensRetryerPool: RetryerPool,
|
||||
) {
|
||||
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
|
||||
|
||||
|
|
@ -58,6 +62,23 @@ class UserTokensSaver(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun pushWithRetryer(
|
||||
userWalletId: UserWalletId,
|
||||
response: UserTokensResponse,
|
||||
useEnricher: Boolean = true,
|
||||
onFailSend: () -> Unit = {},
|
||||
) {
|
||||
push(
|
||||
userWalletId = userWalletId,
|
||||
response = response,
|
||||
useEnricher = useEnricher,
|
||||
onFailSend = {
|
||||
pushTokensRetryerPool + createPushTokensRetryer(userWalletId, response)
|
||||
onFailSend()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserTokensResponse.applyCompatibility(): UserTokensResponse {
|
||||
return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this)
|
||||
}
|
||||
|
|
@ -86,4 +107,24 @@ class UserTokensSaver(
|
|||
private fun UserTokensResponse.enrichByAccountId(userWalletId: UserWalletId): UserTokensResponse {
|
||||
return UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, response = this)
|
||||
}
|
||||
|
||||
private fun createPushTokensRetryer(userWalletId: UserWalletId, response: UserTokensResponse): Retryer {
|
||||
return Retryer(attempt = 3) { iteration ->
|
||||
var isSuccess = true
|
||||
|
||||
push(
|
||||
userWalletId = userWalletId,
|
||||
response = response,
|
||||
onFailSend = {
|
||||
Timber.e(
|
||||
"Retryer: Failed to push updated tokens on attempt ${iteration + 1} for $userWalletId",
|
||||
)
|
||||
|
||||
isSuccess = false
|
||||
},
|
||||
)
|
||||
|
||||
isSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,10 +16,13 @@ import com.tangem.domain.demo.models.DemoConfig
|
|||
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
|
||||
import com.tangem.domain.wallets.repository.WalletsRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.retryer.RetryerPool
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -76,6 +79,9 @@ internal object DataCommonModule {
|
|||
dispatchers = dispatchers,
|
||||
addressesEnricher = addressesEnricher,
|
||||
accountsFeatureToggles = accountsFeatureToggles,
|
||||
pushTokensRetryerPool = RetryerPool(
|
||||
coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class UserTokensSaverTest {
|
|||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
addressesEnricher = enricher,
|
||||
accountsFeatureToggles = accountsFeatureToggles,
|
||||
pushTokensRetryerPool = mockk(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue