From 50c633e873fdf360eabf91608fa69016fd892eb8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 7 Nov 2025 13:54:47 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../java/com/tangem/utils/retryer/Retryer.kt | 20 +++++-- .../com/tangem/utils/retryer/RetryerTest.kt | 52 ++++++++++++++----- .../fetcher/DefaultWalletAccountsFetcher.kt | 2 +- .../FetchWalletAccountsErrorHandler.kt | 2 +- .../DefaultAccountsCRUDRepository.kt | 2 +- .../DefaultMainAccountTokensMigration.kt | 7 ++- .../FetchWalletAccountsErrorHandlerTest.kt | 6 +-- .../DefaultMainAccountTokensMigrationTest.kt | 13 ++--- .../data/common/currency/UserTokensSaver.kt | 41 +++++++++++++++ .../tangem/data/common/di/DataCommonModule.kt | 6 +++ .../common/currency/UserTokensSaverTest.kt | 1 + 11 files changed, 119 insertions(+), 33 deletions(-) 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 index 854877ddc3..bada167774 100644 --- a/core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt +++ b/core/utils/src/main/java/com/tangem/utils/retryer/Retryer.kt @@ -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 } 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 index c1e6a6eb83..d4baffeb7c 100644 --- a/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerTest.kt +++ b/core/utils/src/test/kotlin/com/tangem/utils/retryer/RetryerTest.kt @@ -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 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 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 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 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) } } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt index 42a7783cc1..a57f70b03e 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/DefaultWalletAccountsFetcher.kt @@ -182,7 +182,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor( store(userWalletId = userWalletId, response = accountsResponseWithTokens) - userTokensSaver.push( + userTokensSaver.pushWithRetryer( userWalletId = userWalletId, response = accountsResponseWithTokens.toUserTokensResponse(), ) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 8b51092c49..2a5d98b24a 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -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) } } diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index b8877392d3..2bae8a091f 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -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 = option { diff --git a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt index 2000100c0e..93b3735d63 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/tokens/DefaultMainAccountTokensMigration.kt @@ -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) diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index dae6705e1c..5598bdd163 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -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()) } } diff --git a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt index b0bd00999e..5e59689b19 100644 --- a/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/token/DefaultMainAccountTokensMigrationTest.kt @@ -30,7 +30,8 @@ class DefaultMainAccountTokensMigrationTest { private val accountsResponseStore = mockk() private val accountsResponseStoreFlow = MutableStateFlow(value = null) - private val userTokensSaver = mockk(relaxed = true) + private val userTokensSaver = mockk(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(), diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt index e0bfb846d8..bcac2b6715 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt @@ -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 + } + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index ed6a6d9545..b3f4e454ab 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -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), + ), ) } diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt index 66e4d28c01..1b5bdab260 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt @@ -30,6 +30,7 @@ class UserTokensSaverTest { dispatchers = TestingCoroutineDispatcherProvider(), addressesEnricher = enricher, accountsFeatureToggles = accountsFeatureToggles, + pushTokensRetryerPool = mockk(), ) @BeforeEach