fix: transparently retry TronGrid calls on HTTP 429 instead of surfacing it to the user

TronGrid's public (no API key) endpoint rate-limits aggressively under normal, human-paced
usage - confirmed live during send-flow testing, where every Confirm tap (each re-running
fee estimation + broadcast) started hitting bare 429s after only a few attempts, with no
way through except retrying by hand until one happened to land outside the rate-limit window.

Added retryOn429 (exponential backoff, same shape as the existing test-only helper in
TronBalancesIntegrationTest) at the RealTronGridApi level so every call - fee estimation,
broadcast, balance reads - retries transparently. Broadcast is safe to retry on 429
specifically since it means TronGrid rejected the request before processing it, not that
the transaction may already be in flight.
This commit is contained in:
2026-07-12 04:54:58 -07:00
parent bc9087136c
commit 04c0c41a2d
@@ -14,6 +14,8 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUn
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.Trc20TransferAbi
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.delay
import retrofit2.HttpException
import java.math.BigInteger
/**
@@ -95,6 +97,24 @@ class RealTronGridApi(
private val retrofitApi: RetrofitTronGridApi
) : TronGridApi {
// TronGrid's public (no API key) endpoint rate-limits aggressively - confirmed live to return a bare HTTP
// 429 under normal, human-paced usage (not just load testing) once a handful of requests land in a short
// window. Without this, a 429 on any call in the send flow (fee estimation re-runs on every keystroke,
// broadcast, etc.) surfaced straight to the user as a raw error dialog, and the only way through was to
// keep tapping Confirm until a request happened to land outside the rate-limit window. Retrying here means
// every TronGrid call gets this transparently, not just the ones a caller remembered to wrap.
private suspend fun <T> retryOn429(maxAttempts: Int = 4, block: suspend () -> T): T {
repeat(maxAttempts - 1) { attempt ->
try {
return block()
} catch (e: HttpException) {
if (e.code() != 429) throw e
delay(1_000L * (attempt + 1))
}
}
return block()
}
override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance {
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO
@@ -127,7 +147,7 @@ class RealTronGridApi(
amount = amountSun.toLongExactOrThrow("amount")
)
val response = retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request)
val response = retryOn429 { retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request) }
return response.requireConstructed()
}
@@ -146,7 +166,7 @@ class RealTronGridApi(
parameter = parameterHex
)
return retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request)
return retryOn429 { retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request) }
}
override suspend fun triggerSmartContract(
@@ -165,7 +185,7 @@ class RealTronGridApi(
feeLimit = feeLimitSun.toLongExactOrThrow("feeLimit")
)
val response = retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request)
val response = retryOn429 { retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request) }
if (response.result?.result != true) {
throw TronApiException(response.result?.message ?: response.result?.code ?: "triggersmartcontract failed without a message")
@@ -188,7 +208,10 @@ class RealTronGridApi(
signature = listOf(signatureHex)
)
val response = retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request)
// Safe to retry on 429 specifically: a 429 means TronGrid rejected the request before processing it
// (rate limit), not that the transaction may have already been broadcast - unlike a timeout, it can't
// cause a double-send.
val response = retryOn429 { retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request) }
if (response.result != true) {
throw TronApiException(response.decodeErrorMessage())
@@ -198,18 +221,18 @@ class RealTronGridApi(
}
override suspend fun getChainParameters(baseUrl: String): Map<String, Long> {
return retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters"))
return retryOn429 { retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters")) }
.chainParameter
.associate { it.key to it.value }
}
override suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse {
return retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex))
return retryOn429 { retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex)) }
}
private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount(
url = accountUrl(baseUrl, address)
).data?.firstOrNull()
private suspend fun fetchAccountData(baseUrl: String, address: String) = retryOn429 {
retrofitApi.getAccount(url = accountUrl(baseUrl, address))
}.data?.firstOrNull()
private fun accountUrl(baseUrl: String, address: String): String {
return "${baseUrl.trimEnd('/')}/v1/accounts/$address"