diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 046e34cdf4f..0a56dc8b57a 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Optimize Price API performance by deduplicating concurrent API calls ([#7811](https://github.com/MetaMask/core/pull/7811)) + - Add in-flight promise caching for `fetchSupportedNetworks()` to prevent duplicate concurrent requests + - Update `fetchTokenPrices()` and `fetchExchangeRates()` to only refresh supported networks/currencies when no cached value exists + ## [99.2.0] ### Added diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts index f00735fccd9..18085643b66 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts @@ -1,6 +1,6 @@ import { KnownCaipNamespace } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import nock from 'nock'; +import nock, { isDone } from 'nock'; import { useFakeTimers } from 'sinon'; import { @@ -1987,6 +1987,39 @@ describe('CodefiTokenPricesServiceV2', () => { expect(result).toStrictEqual(mockResponse); }); + + it('deduplicates concurrent requests to the same endpoint', async () => { + const mockResponse = { + fullSupport: ['eip155:1'], + partialSupport: { + spotPricesV2: ['eip155:1', 'eip155:56'], + spotPricesV3: ['eip155:1', 'eip155:42161'], + }, + }; + // Only set up the mock to respond once + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .reply(200, mockResponse); + + // Make 5 concurrent calls + const promises = [ + fetchSupportedNetworks(), + fetchSupportedNetworks(), + fetchSupportedNetworks(), + fetchSupportedNetworks(), + fetchSupportedNetworks(), + ]; + + const results = await Promise.all(promises); + + // All promises should resolve to the same response + results.forEach((result) => { + expect(result).toStrictEqual(mockResponse); + }); + + // Verify no pending mocks (i.e., only one request was made) + expect(isDone()).toBe(true); + }); }); describe('getSupportedNetworks', () => { diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts index 9496053cab2..8b6854ea412 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -367,6 +367,12 @@ type SupportedNetworksResponse = { */ let lastFetchedSupportedNetworks: SupportedNetworksResponse | null = null; +/** + * In-flight promise to prevent concurrent requests to the supported networks endpoint. + */ +let runningSupportedNetworksRequest: Promise | null = + null; + /** * Converts a CAIP-2 chain ID (e.g., 'eip155:1') to a hex chain ID (e.g., '0x1'). * @@ -384,32 +390,47 @@ function caipChainIdToHex(caipChainId: string): Hex | null { /** * Fetches the list of supported networks from the API. * Falls back to the hardcoded list if the fetch fails. + * Deduplicates concurrent requests by returning the same promise if a fetch is already in progress. * * @returns The supported networks response. */ export async function fetchSupportedNetworks(): Promise { - try { - const url = `${BASE_URL_V2}/supportedNetworks`; - const response = await handleFetch(url, { - headers: { 'Cache-Control': 'no-cache' }, - }); + // If a fetch is already in progress, return the same promise + if (runningSupportedNetworksRequest) { + return runningSupportedNetworksRequest; + } - if ( - response && - typeof response === 'object' && - 'fullSupport' in response && - 'partialSupport' in response - ) { - lastFetchedSupportedNetworks = response as SupportedNetworksResponse; - return lastFetchedSupportedNetworks; - } + // Start a new fetch and cache the promise + runningSupportedNetworksRequest = + (async (): Promise => { + try { + const url = `${BASE_URL_V2}/supportedNetworks`; + const response = await handleFetch(url, { + headers: { 'Cache-Control': 'no-cache' }, + }); + + if ( + response && + typeof response === 'object' && + 'fullSupport' in response && + 'partialSupport' in response + ) { + lastFetchedSupportedNetworks = response as SupportedNetworksResponse; + return lastFetchedSupportedNetworks; + } - // Invalid response format, fall back to hardcoded list - return getSupportedNetworksFallback(); - } catch { - // On any error, fall back to the hardcoded list - return getSupportedNetworksFallback(); - } + // Invalid response format, fall back to hardcoded list + return getSupportedNetworksFallback(); + } catch { + // On any error, fall back to the hardcoded list + return getSupportedNetworksFallback(); + } finally { + // Clear the in-flight promise once the request completes + runningSupportedNetworksRequest = null; + } + })(); + + return runningSupportedNetworksRequest; } /** @@ -453,6 +474,7 @@ function getSupportedNetworksFallback(): SupportedNetworksResponse { */ export function resetSupportedNetworksCache(): void { lastFetchedSupportedNetworks = null; + runningSupportedNetworksRequest = null; } /** @@ -687,8 +709,10 @@ export class CodefiTokenPricesServiceV2 // Refresh supported networks in background (non-blocking) // This ensures the list stays fresh during normal polling // Note: fetchSupportedNetworks handles errors internally and always resolves - // eslint-disable-next-line @typescript-eslint/no-floating-promises - fetchSupportedNetworks(); + if (!lastFetchedSupportedNetworks) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchSupportedNetworks(); + } // Get dynamically fetched supported chain IDs for V3 const supportedChainIdsV3 = getSupportedChainIdsV3AsHex(); @@ -791,8 +815,10 @@ export class CodefiTokenPricesServiceV2 // Refresh supported currencies in background (non-blocking) // This ensures the list stays fresh during normal polling // Note: fetchSupportedCurrencies handles errors internally and always resolves - // eslint-disable-next-line @typescript-eslint/no-floating-promises - fetchSupportedCurrencies(); + if (!lastFetchedCurrencies) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchSupportedCurrencies(); + } const url = new URL(`${BASE_URL_V1}/exchange-rates`); url.searchParams.append('baseCurrency', baseCurrency);