Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ class CoinGeckoRepository implements CexRepository {
Future<List<CexCoin>>? _coinListInFlight;
Set<String>? _cachedFiatCurrencies;

/// Tracks when the last coin list fetch failed, to prevent API request spam.
/// When a fetch fails (e.g. due to rate limiting), we avoid retrying until
/// the cooldown period has elapsed.
DateTime? _lastCoinListFailure;
static const _coinListFailureCooldown = Duration(minutes: 5);

/// Fetches the CoinGecko market data.
///
/// Returns a list of [CoinMarketData] objects containing the market data.
Expand All @@ -57,14 +63,35 @@ class CoinGeckoRepository implements CexRepository {
if (_cachedCoinList != null) {
return _cachedCoinList!;
}

// Prevent API spam: don't retry if we recently failed.
// Without this guard, every price request triggers supports() which calls
// getCoinList(), and each failed call immediately retries the API — causing
// a request storm that exhausts rate limits.
if (_lastCoinListFailure != null) {
final elapsed = DateTime.now().difference(_lastCoinListFailure!);
if (elapsed < _coinListFailureCooldown) {
throw StateError(
'CoinGecko coin list fetch is in cooldown after a recent failure '
'(${(_coinListFailureCooldown - elapsed).inSeconds}s remaining)',
);
}
_lastCoinListFailure = null;
}

if (_coinListInFlight != null) {
return _coinListInFlight!;
}
_coinListInFlight = _fetchCoinListInternal()
.then((list) {
_cachedCoinList = list;
_lastCoinListFailure = null;
return list;
})
.catchError((Object error, StackTrace stackTrace) {
_lastCoinListFailure = DateTime.now();
Error.throwWithStackTrace(error, stackTrace);
})
.whenComplete(() {
_coinListInFlight = null;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,109 @@ void main() {
});
});

group('getCoinList failure cooldown', () {
test(
'should not retry API call during cooldown after failure',
() async {
var callCount = 0;
when(() => mockProvider.fetchCoinList()).thenAnswer((_) async {
callCount++;
throw Exception('API error');
});
when(
() => mockProvider.fetchSupportedVsCurrencies(),
).thenAnswer((_) async => ['usd']);

// Enable memoization for this test
final memoizedRepo = CoinGeckoRepository(
coinGeckoProvider: mockProvider,
);

// First call should hit the API and fail
expect(memoizedRepo.getCoinList(), throwsA(isA<Exception>()));
await Future<void>.delayed(Duration.zero);
expect(callCount, equals(1));

// Second call should throw StateError (cooldown) without hitting API
expect(memoizedRepo.getCoinList(), throwsA(isA<StateError>()));
await Future<void>.delayed(Duration.zero);
expect(callCount, equals(1)); // Still 1 - no new API call
},
);

test(
'should return false from supports() during cooldown without API call',
() async {
when(() => mockProvider.fetchCoinList()).thenThrow(
Exception('rate limit'),
);
when(
() => mockProvider.fetchSupportedVsCurrencies(),
).thenAnswer((_) async => ['usd']);

final memoizedRepo = CoinGeckoRepository(
coinGeckoProvider: mockProvider,
);

final assetId = AssetId(
id: 'bitcoin',
name: 'Bitcoin',
symbol: AssetSymbol(assetConfigId: 'BTC', coinGeckoId: 'bitcoin'),
chainId: AssetChainId(chainId: 0),
derivationPath: null,
subClass: CoinSubClass.utxo,
);

// First supports() call triggers the API failure
final result1 = await memoizedRepo.supports(
assetId,
FiatCurrency.usd,
PriceRequestType.currentPrice,
);
expect(result1, isFalse);

// Second supports() call should return false without new API call
final result2 = await memoizedRepo.supports(
assetId,
FiatCurrency.usd,
PriceRequestType.currentPrice,
);
expect(result2, isFalse);

// Provider should only have been called once
verify(() => mockProvider.fetchCoinList()).called(1);
},
);

test('should cache result on success and not call API again', () async {
when(() => mockProvider.fetchCoinList()).thenAnswer(
(_) async => [
const CexCoin(
id: 'bitcoin',
symbol: 'btc',
name: 'Bitcoin',
currencies: {},
),
],
);
when(
() => mockProvider.fetchSupportedVsCurrencies(),
).thenAnswer((_) async => ['usd']);

final memoizedRepo = CoinGeckoRepository(
coinGeckoProvider: mockProvider,
);

final result1 = await memoizedRepo.getCoinList();
final result2 = await memoizedRepo.getCoinList();

expect(result1.length, equals(1));
expect(result2.length, equals(1));
// API should only be called once
verify(() => mockProvider.fetchCoinList()).called(1);
});
});

group('_mapFiatCurrencyToCoingecko mapping verification', () {
setUp(() {
// Mock the coin list response
Expand Down
Loading
Loading