Updated on 2026-08-14

This commit is contained in:
Tangem 2024-06-11 12:51:04 +04:00
parent 80ec72c173
commit e8c5c35e56
11 changed files with 89 additions and 44 deletions

View file

@ -33,12 +33,8 @@ internal class ApiResponseCallDelegate<T : Any>(
}
override fun onFailure(call: Call<T>, t: Throwable) {
val e = if (t.isNetworkException()) {
ApiResponseError.NetworkException
} else {
ApiResponseError.UnknownException(t)
}
val safeResponse = apiError<T>(e)
val error = t.toApiError()
val safeResponse = apiError<T>(error)
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
}

View file

@ -73,7 +73,10 @@ sealed class ApiResponseError : Exception() {
}
/** Represents a network error, typically when there's no connectivity. */
object NetworkException : ApiResponseError()
data object NetworkException : ApiResponseError()
/** Represents a timeout error, typically when the server takes too long to respond. */
data object TimeoutException : ApiResponseError()
/**
* Represents an unexpected exception that doesn't fall into one of the other categories.

View file

@ -1,8 +1,11 @@
package com.tangem.datasource.api.common.response
import kotlinx.coroutines.TimeoutCancellationException
import retrofit2.Response
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.concurrent.TimeoutException
import javax.net.ssl.SSLHandshakeException
internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
@ -23,10 +26,14 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
}
}
internal fun Throwable.isNetworkException(): Boolean = when (this) {
internal fun Throwable.toApiError(): ApiResponseError = when (this) {
is ConnectException,
is UnknownHostException,
is SSLHandshakeException,
-> true
else -> false
-> ApiResponseError.NetworkException
is TimeoutException,
is TimeoutCancellationException,
is SocketTimeoutException,
-> ApiResponseError.TimeoutException
else -> ApiResponseError.UnknownException(cause = this)
}

View file

@ -4,7 +4,9 @@ import arrow.core.raise.Raise
import arrow.core.raise.recover
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.time.Duration
/**
* A wrapper around the [Raise] interface specific for [ApiResponseError]. It provides utility functions to
@ -28,6 +30,28 @@ value class ApiResponseRaise(
}
}
/**
* Attempts to execute an API call safely, providing error handling and a timeout.
*
* @param T The return type of the API call and the function.
* @param timeoutMillis The timeout in milliseconds for the API call. Default is 30 seconds.
* @param call The API call block to execute.
* @param onError A function to handle errors and return a fallback value of type [T].
*
* @return The result of the API call or the fallback value provided by [onError] if an error occurs.
*/
suspend inline fun <T> safeApiCallWithTimeout(
timeoutMillis: Duration = with(Duration) { 30.seconds },
crossinline call: suspend ApiResponseRaise.() -> T,
crossinline onError: suspend (ApiResponseError) -> T,
): T = safeApiCall(
call = {
withTimeoutOrNull(timeoutMillis) { call() }
?: raise(ApiResponseError.TimeoutException)
},
onError = onError,
)
/**
* Attempts to execute an API call safely, providing error handling.
*
@ -40,12 +64,10 @@ value class ApiResponseRaise(
suspend inline fun <T> safeApiCall(
crossinline call: suspend ApiResponseRaise.() -> T,
crossinline onError: suspend (ApiResponseError) -> T,
): T {
return recover(
block = { call(ApiResponseRaise(raise = this)) },
recover = {
Timber.w(it, "Unable to perform safe API call")
onError(it)
},
)
}
): T = recover(
block = { call(ApiResponseRaise(raise = this)) },
recover = {
Timber.w(it, "Unable to perform safe API call")
onError(it)
},
)

View file

@ -249,7 +249,7 @@ internal class DefaultCurrenciesRepository(
launch(dispatchers.io) {
combine(
getMultiCurrencyWalletCurrencies(userWallet),
getMultiCurrencyWalletCurrencies(userWallet).distinctUntilChanged(),
isMultiCurrencyWalletCurrenciesFetching.map { it.getOrElse(userWallet.walletId) { false } },
) { currencies, isFetching ->
send(currencies, isStillLoading = isFetching)

View file

@ -1,6 +1,6 @@
package com.tangem.data.tokens.repository
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.api.safeApiCallWithTimeout
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.tokens.utils.QuotesConverter
import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter
@ -91,30 +91,30 @@ internal class DefaultQuotesRepository(
if (expiredCurrenciesIds.isEmpty()) return
quotesFetchedForAppCurrency = appCurrencyId
fetchQuotes(expiredCurrenciesIds, appCurrencyId)
}
}
private suspend fun fetchQuotes(rawCurrenciesIds: Set<String>, appCurrencyId: String) {
val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies(rawCurrenciesIds)
val response = safeApiCall(
val response = safeApiCallWithTimeout(
call = {
val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",")
tangemTechApi.getQuotes(appCurrencyId, coinIds).bind()
},
onError = {
onError = { error ->
cacheRegistry.invalidate(rawCurrenciesIds.map(::getQuoteCacheKey))
null
throw error
},
)
if (response != null) {
val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
response,
replacementIdsResult.idsFiltered,
)
quotesStore.store(updatedResponse)
}
val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
response,
replacementIdsResult.idsFiltered,
)
quotesStore.store(updatedResponse)
}
private suspend fun filterExpiredCurrenciesIds(

View file

@ -83,13 +83,16 @@ sealed class Lce<out E : Any, out C : Any> {
}
/**
* Returns the content of this [Lce] if it's a [Lce.Content] or partial content if it's a [Lce.Loading],
* `null` if it's a [Lce.Error].
* Returns the content of this [Lce] if it's a [Lce.Content] or partial content if it's a [Lce.Loading]
* and [isPartialContentAccepted] is `true`, `null` if it's a [Lce.Error].
*
* @param isPartialContentAccepted A flag indicating whether partial content should be accepted
* and returned. Default is `true`.
*
* @return The content of this [Lce] or `null`.
*/
fun getOrNull(): C? = fold(
ifLoading = ::identity,
fun getOrNull(isPartialContentAccepted: Boolean = true): C? = fold(
ifLoading = { if (isPartialContentAccepted) it else null },
ifContent = ::identity,
ifError = { null },
)

View file

@ -10,13 +10,13 @@ sealed class TotalFiatBalance {
* Represents the loading state of the fiat balance.
* This state indicates that the fiat balance is currently being retrieved or calculated.
*/
object Loading : TotalFiatBalance()
data object Loading : TotalFiatBalance()
/**
* Represents the failure state of the fiat balance.
* This state indicates that an attempt to retrieve or calculate the fiat balance has failed.
*/
object Failed : TotalFiatBalance()
data object Failed : TotalFiatBalance()
/**
* Represents the successfully loaded state of the fiat balance.

View file

@ -122,6 +122,9 @@ internal class CurrenciesStatusesLceOperations(
val quotes = recover({ maybeQuotes?.bind()?.toNonEmptySetOrNull() }) {
quotesRetrievingFailed = true
null
}?.ifEmpty {
quotesRetrievingFailed = true
null
}
currencies.map { currency ->

View file

@ -63,7 +63,14 @@ internal abstract class BasicTokenListSubscriber(
transform = { maybeTokenList, maybeAppCurrency ->
val tokenList = maybeTokenList.getOrElse(
ifLoading = { maybeContent ->
maybeContent ?: return@combine
val isRefreshing = stateHolder.getWalletState(userWallet.walletId)
?.pullToRefreshConfig
?.isRefreshing
?: false
maybeContent
?.takeIf { !isRefreshing }
?: return@combine
},
ifError = { e ->
Timber.e("Failed to load token list: $e")

View file

@ -53,9 +53,8 @@ internal class MultiWalletTokenListSubscriber(
updateSortingIfNeeded(maybeTokenList)
}
private suspend fun updateSortingIfNeeded(maybeTokenList: Lce<TokenListError, TokenList>) {
val tokenList = maybeTokenList.getOrNull() ?: return
if (!checkNeedSorting(tokenList)) return
private suspend fun updateSortingIfNeeded(maybeTokenList: Lce<*, TokenList>) {
val tokenList = getTokenList(maybeTokenList) ?: return
applyTokenListSortingUseCase(
userWalletId = userWallet.walletId,
@ -65,9 +64,14 @@ internal class MultiWalletTokenListSubscriber(
)
}
private fun checkNeedSorting(tokenList: TokenList): Boolean {
return tokenList.totalFiatBalance !is TotalFiatBalance.Loading &&
tokenList.sortedBy == TokenList.SortType.BALANCE
private fun getTokenList(lce: Lce<*, TokenList>): TokenList? {
val tokenList = lce.getOrNull(isPartialContentAccepted = false)
?: return null
return tokenList.takeIf {
tokenList.totalFiatBalance is TotalFiatBalance.Loaded &&
tokenList.sortedBy == TokenList.SortType.BALANCE
}
}
private fun getCurrenciesIds(tokenList: TokenList): List<CryptoCurrency.ID> {