Skip to content
Closed
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 @@ -142,6 +142,7 @@ import PerpsSelectAdjustMarginActionView from '../PerpsSelectAdjustMarginActionV
import PerpsSelectModifyActionView from '../PerpsSelectModifyActionView';
import { createStyles } from './PerpsMarketDetailsView.styles';
import type { PerpsMarketDetailsViewProps } from './PerpsMarketDetailsView.types';
import { useRefetchCandleDataOnError } from '../../hooks/useRefetchCandleDataOnError';

interface MarketDetailsRouteParams {
market: PerpsMarketData;
Expand Down Expand Up @@ -378,13 +379,20 @@ const PerpsMarketDetailsView: React.FC<PerpsMarketDetailsViewProps> = () => {
isLoading: isLoadingHistory,
hasHistoricalData,
fetchMoreHistory,
error: candleError,
} = usePerpsLiveCandles({
symbol: market?.symbol || '',
interval: selectedCandlePeriod,
duration: TimeDuration.YearToDate,
throttleMs: 1000,
});

useRefetchCandleDataOnError({
candleData,
candleError,
fetchMoreHistory,
});

// Get current price from the last candle's close price for chart synchronization
// This ensures the current price line matches the live candle close price exactly
const chartCurrentPrice = useMemo(() => {
Expand Down
224 changes: 224 additions & 0 deletions app/components/UI/Perps/hooks/useRefetchCandleDataOnError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { renderHook, act } from '@testing-library/react-native';
import { useRefetchCandleDataOnError } from './useRefetchCandleDataOnError';
import { CandlePeriod, type CandleData } from '@metamask/perps-controller';

describe('useRefetchCandleDataOnError', () => {
const makeCandleData = (candles: CandleData['candles'] = []): CandleData => ({
symbol: 'BTC',
interval: CandlePeriod.OneHour,
candles,
});

beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});

afterEach(() => {
jest.useRealTimers();
});

it('does not retry when there is no error', () => {
const fetchMoreHistory = jest.fn().mockResolvedValue(undefined);

renderHook(() =>
useRefetchCandleDataOnError({
candleData: null,
candleError: null,
fetchMoreHistory,
}),
);

jest.advanceTimersByTime(60_000);

expect(fetchMoreHistory).not.toHaveBeenCalled();
});

it('does not retry for non-rate-limit errors', () => {
const fetchMoreHistory = jest.fn().mockResolvedValue(undefined);

renderHook(() =>
useRefetchCandleDataOnError({
candleData: null,
candleError: new Error('Network timeout'),
fetchMoreHistory,
}),
);

jest.advanceTimersByTime(60_000);

expect(fetchMoreHistory).not.toHaveBeenCalled();
});

it('retries on "too many requests" error', async () => {
const fetchMoreHistory = jest.fn().mockResolvedValue(undefined);

renderHook(() =>
useRefetchCandleDataOnError({
candleData: null,
candleError: new Error('Too many requests'),
fetchMoreHistory,
}),
);

// First retry fires after 5s
await act(async () => {
jest.advanceTimersByTime(5_000);
});

expect(fetchMoreHistory).toHaveBeenCalledTimes(1);

// Second retry after another 5s
await act(async () => {
jest.advanceTimersByTime(5_000);
});

expect(fetchMoreHistory).toHaveBeenCalledTimes(2);
});

it('stops retrying once candles arrive', async () => {
const fetchMoreHistory = jest.fn().mockResolvedValue(undefined);

const { rerender } = renderHook(
(props) => useRefetchCandleDataOnError(props),
{
initialProps: {
candleData: null as CandleData | null,
candleError: new Error('Too many requests') as Error | null,
fetchMoreHistory,
},
},
);

// First retry
await act(async () => {
jest.advanceTimersByTime(5_000);
});
expect(fetchMoreHistory).toHaveBeenCalledTimes(1);

// Simulate candles arriving via ref update
const withCandles = makeCandleData([
{
time: 1700000000000,
open: '50000',
high: '51000',
low: '49000',
close: '50500',
volume: '100',
},
]);
rerender({
candleData: withCandles,
candleError: new Error('Too many requests'),
fetchMoreHistory,
});

// More time passes but no more retries since candles are present
await act(async () => {
jest.advanceTimersByTime(30_000);
});

expect(fetchMoreHistory).toHaveBeenCalledTimes(1);
});

it('stops retrying after max retries (12)', async () => {
const fetchMoreHistory = jest.fn().mockResolvedValue(undefined);

renderHook(() =>
useRefetchCandleDataOnError({
candleData: null,
candleError: new Error('too many requests'),
fetchMoreHistory,
}),
);

// Advance through all 12 retries (12 * 5s = 60s)
for (let i = 0; i < 12; i++) {
await act(async () => {
jest.advanceTimersByTime(5_000);
});
}

expect(fetchMoreHistory).toHaveBeenCalledTimes(12);

// Advance more - no additional retries
await act(async () => {
jest.advanceTimersByTime(30_000);
});

expect(fetchMoreHistory).toHaveBeenCalledTimes(12);
});

it('stops retrying on unmount', async () => {
const fetchMoreHistory = jest.fn().mockResolvedValue(undefined);

const { unmount } = renderHook(() =>
useRefetchCandleDataOnError({
candleData: null,
candleError: new Error('Too many requests'),
fetchMoreHistory,
}),
);

// First retry
await act(async () => {
jest.advanceTimersByTime(5_000);
});
expect(fetchMoreHistory).toHaveBeenCalledTimes(1);

unmount();

// More time passes but no more retries since unmounted
await act(async () => {
jest.advanceTimersByTime(30_000);
});

expect(fetchMoreHistory).toHaveBeenCalledTimes(1);
});

it('matches "too many requests" case-insensitively', async () => {
const fetchMoreHistory = jest.fn().mockResolvedValue(undefined);

renderHook(() =>
useRefetchCandleDataOnError({
candleData: null,
candleError: new Error('TOO MANY REQUESTS'),
fetchMoreHistory,
}),
);

await act(async () => {
jest.advanceTimersByTime(5_000);
});

expect(fetchMoreHistory).toHaveBeenCalledTimes(1);
});

it('does not retry when candles already exist', () => {
const fetchMoreHistory = jest.fn().mockResolvedValue(undefined);
const existingData = makeCandleData([
{
time: 1700000000000,
open: '50000',
high: '51000',
low: '49000',
close: '50500',
volume: '100',
},
]);

renderHook(() =>
useRefetchCandleDataOnError({
candleData: existingData,
candleError: new Error('Too many requests'),
fetchMoreHistory,
}),
);

jest.advanceTimersByTime(60_000);

// fetchMoreHistory gets called but the retry loop checks the ref
// and stops immediately because candles.length > 0
expect(fetchMoreHistory).toHaveBeenCalledTimes(0);
});
});
60 changes: 60 additions & 0 deletions app/components/UI/Perps/hooks/useRefetchCandleDataOnError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react';
import { sleep } from '@walletconnect/utils';
import type { CandleData } from '@metamask/perps-controller';

const TOO_MANY_REQUESTS_REGEX = /too many requests/i;
const MAX_RETRIES = 12;
const RETRY_DELAY_MS = 5000;

/**
* Retries `fetchMoreHistory` with exponential back-off when the candle
* subscription fails with a "too many requests" rate-limit error.
*
* Stops retrying once candles arrive or the retry budget is exhausted.
*/
export const useRefetchCandleDataOnError = ({
candleData,
candleError,
fetchMoreHistory,
}: {
candleData: CandleData | null;
candleError: Error | null;
fetchMoreHistory: () => Promise<void>;
}) => {
const candlesRefetchRef = useRef<{
candles: CandleData['candles'];
error: Error | null;
}>({ candles: [], error: null });
const fetchMoreHistoryRef = useRef<() => Promise<void>>(fetchMoreHistory);

useEffect(() => {
candlesRefetchRef.current.candles = candleData?.candles ?? [];
candlesRefetchRef.current.error = candleError;
fetchMoreHistoryRef.current = fetchMoreHistory;
}, [candleData, candleError, fetchMoreHistory]);

const candleErrorMessage = candleError?.message;
const shouldRetry =
!!candleErrorMessage && TOO_MANY_REQUESTS_REGEX.test(candleErrorMessage);

useEffect(() => {
if (!shouldRetry) return;
let isMounted = true;

const retry = async (retryCount: number = 0) => {
if (retryCount >= MAX_RETRIES) return;
if (!isMounted) return;
if (candlesRefetchRef.current.candles.length > 0) return;
await sleep(RETRY_DELAY_MS);
if (!isMounted) return;
if (candlesRefetchRef.current.candles.length > 0) return;
await fetchMoreHistoryRef.current();
await retry(retryCount + 1);
};

retry();
return () => {
isMounted = false;
};
}, [shouldRetry]);
};
Loading
Loading