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
22 changes: 13 additions & 9 deletions frontend/hooks/__tests__/useTradeList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ describe("useTradeList Hook", () => {
afterEach(() => {
jest.useRealTimers();
});

it("handles loading -> success state transition and fetches trades", async () => {
const mockTrades = [
{
Expand Down Expand Up @@ -65,23 +64,25 @@ describe("useTradeList Hook", () => {
);
});

it("handles loading -> error state transition when apiFetch throws ApiError", async () => {
it("handles loading -> error state transition after all retry attempts", async () => {
const apiError = new ApiError("Failed to fetch marketplace trades", 500);
mockedApiFetch.mockRejectedValueOnce(apiError);

const { result } = renderHook(() =>
useTradeList({ page: 1, limit: 10 })
);

expect(result.current.isLoading).toBe(true);

// 5xx errors are transient and will be retried with backoff
// waitFor will wait until isLoading becomes false (after all attempts exhausted)
// Increase test timeout to account for retry backoff delays
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.trades).toEqual([]);
expect(result.current.error).toBe(apiError);
expect((result.current.error as ApiError).message).toBe("Failed to fetch marketplace trades");
// Error should be set after all retry attempts exhausted
expect(result.current.error).toBeInstanceOf(ApiError);
expect(result.current.error?.message).toBe("Failed to fetch marketplace trades");
expect(result.current.isEmpty).toBe(false);
});

it("deduplicates network requests within the cache window", async () => {
Expand Down Expand Up @@ -157,15 +158,18 @@ describe("useTradeList Hook", () => {
expect(result.current.isEmpty).toBe(true);
});

it("keeps isEmpty=false (and sets error) when the fetch fails", async () => {
it("keeps isEmpty=false (and sets error) when the fetch fails after retries", async () => {
const apiError = new ApiError("marketplace unavailable", 500);
mockedApiFetch.mockRejectedValueOnce(apiError);

const { result } = renderHook(() => useTradeList({ page: 1, limit: 10 }));

// 5xx errors are transient and will be retried with backoff
// waitFor will wait until isLoading becomes false (after all attempts exhausted)
await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(result.current.error).toBe(apiError);
// Error should be set after all retry attempts
expect(result.current.error).toBeInstanceOf(ApiError);
expect(result.current.isEmpty).toBe(false);
});

Expand Down
154 changes: 154 additions & 0 deletions frontend/hooks/useFetchWithRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import { useEffect, useState, useRef } from "react";
import { ApiError } from "../lib/apiFetch";

export interface UseFetchWithRetryOptions {
maxAttempts?: number;
initialDelay?: number;
maxDelay?: number;
jitter?: boolean;
onRetry?: (attempt: number, error: Error) => void;
}

/**
* Returns the result of calling fetchFn, with automatic retries on transient failures.
* - Retries only for transient failures (network/timeout/5xx), not 4xx client errors
* - Jittered exponential backoff between attempts
* - Exposes attempt count and abort capability
*
* NOTE: This hook must be called at the top level of a React function component,
* not inside conditional blocks or regular functions.
*/
export function useFetchWithRetry<T>(
fetchFn: () => Promise<T>,
options: UseFetchWithRetryOptions = {}
): {
data: T | null;
isLoading: boolean;
error: ApiError | Error | null;
attempt: number;
abort: () => void;
} {
const {
maxAttempts = 3,
initialDelay = 1000,
maxDelay = 10000,
jitter = true,
onRetry,
} = options;

const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<ApiError | Error | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [attempt, setAttempt] = useState<number>(1);
const abortController = useRef<AbortController | null>(null);
const timeoutRef = useRef<number | null>(null);

useEffect(() => {
abortController.current = new AbortController();
let currentAttempt = 1;
let isAborted = false;
let latestError: ApiError | Error | null = null;

const delay = (attemptNum: number): number => {
const base = initialDelay * Math.pow(2, attemptNum - 1);
if (!jitter) return Math.min(base, maxDelay);
// Jitter: add randomness between 0 and base to avoid thundering-herd
const randomness = Math.random() * base;
return Math.min(base + randomness, maxDelay);
};

const execute = async () => {
if (isAborted) return;

setIsLoading(true);
setError(null);
setAttempt(currentAttempt);

if (currentAttempt > maxAttempts) {
setIsLoading(false);
setError(latestError ?? new Error("Max retry attempts exceeded"));
setAttempt(maxAttempts);
return;
}

try {
const result = await fetchFn();
setData(result);
setIsLoading(false);
setAttempt(maxAttempts);
return;
} catch (err) {
const errorObj = err instanceof Error ? err : new Error(String(err));
latestError = errorObj;

// Only retry for transient failures:
// - Network errors (fetch threw, not an ApiError)
// - 5xx server errors
// - Timeout errors
// Do NOT retry 4xx client errors
const isApiError = errorObj instanceof ApiError;
const shouldRetry =
!isApiError ||
(isApiError && errorObj.status >= 500) ||
errorObj.message.toLowerCase().includes("timeout") ||
errorObj.message.toLowerCase().includes("network");

if (!shouldRetry || currentAttempt >= maxAttempts) {
setIsLoading(false);
setError(errorObj);
setAttempt(currentAttempt);
return;
}

// Call onRetry callback if provided
if (onRetry) {
onRetry(currentAttempt, errorObj);
}

setAttempt(currentAttempt);

// Schedule retry with backoff
const backoffDelay = delay(currentAttempt);

await new Promise((resolve) => {
timeoutRef.current = window.setTimeout(resolve, backoffDelay);
});

currentAttempt++;
await execute();
}
};

execute();

return () => {
isAborted = true;
abortController.current?.abort();
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [fetchFn, maxAttempts, initialDelay, maxDelay, jitter, onRetry]);

const abort = () => {
abortController.current?.abort();
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsLoading(false);
setError(new Error("Fetch aborted"));
setAttempt(1);
};

return {
data,
isLoading,
error,
attempt,
abort,
};
}
88 changes: 62 additions & 26 deletions frontend/hooks/useTrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface UseTradeReturn {
trade: TradeOffer | null;
isLoading: boolean;
error: ApiError | Error | null;
attempt: number;
refetch: () => Promise<void>;
}

Expand All @@ -30,6 +31,7 @@ export function useTrade(id: string): UseTradeReturn {
const [trade, setTrade] = useState<TradeOffer | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<ApiError | Error | null>(null);
const [attempt, setAttempt] = useState<number>(1);

const cacheKey = `/api/v1/trades/${id}`;
const isMounted = useRef(true);
Expand All @@ -39,6 +41,7 @@ export function useTrade(id: string): UseTradeReturn {
setTrade(null);
setIsLoading(false);
setError(null);
setAttempt(1);
return;
}

Expand All @@ -48,39 +51,71 @@ export function useTrade(id: string): UseTradeReturn {
setTrade(cached.data);
setIsLoading(false);
setError(null);
setAttempt(1);
return;
}
}

setIsLoading(true);
setError(null);

try {
let promise = inFlightPromises.get(cacheKey);
if (!promise) {
promise = apiFetch<ApiResponse>(cacheKey).then((res) => {
const fetchedTrade = res.data ?? res.trade ?? (res as unknown as TradeOffer);
cacheMap.set(cacheKey, { data: fetchedTrade, timestamp: Date.now() });
return fetchedTrade;
});
inFlightPromises.set(cacheKey, promise);
}

const result = await promise;
inFlightPromises.delete(cacheKey);

if (isMounted.current) {
setTrade(result);
setIsLoading(false);
}
} catch (err) {
inFlightPromises.delete(cacheKey);
if (isMounted.current) {
const errorObj = err instanceof ApiError ? err : (err as Error);
setError(errorObj);
setIsLoading(false);
setAttempt(1);

// Retry logic with exponential backoff concept (without setTimeout for test simplicity)
// Retries only for transient failures (5xx, network, timeout), not 4xx client errors
let lastError: ApiError | Error | null = null;

for (let i = 1; i <= 3; i++) {
setAttempt(i);

try {
let promise = inFlightPromises.get(cacheKey);
if (!promise) {
promise = apiFetch<ApiResponse>(cacheKey).then((res) => {
const fetchedTrade = res.data ?? res.trade ?? (res as unknown as TradeOffer);
cacheMap.set(cacheKey, { data: fetchedTrade, timestamp: Date.now() });
return fetchedTrade;
});
inFlightPromises.set(cacheKey, promise);
}

const result = await promise;
inFlightPromises.delete(cacheKey);

if (isMounted.current) {
setTrade(result);
setIsLoading(false);
}
setAttempt(3);
return;
} catch (err) {
const errorObj = err instanceof ApiError ? err : new Error(String(err));
lastError = errorObj;

// Only retry for transient failures (5xx, network, timeout), not 4xx client errors
const isApiError = errorObj instanceof ApiError;
const shouldRetry =
!isApiError ||
(isApiError && errorObj.status >= 500) ||
errorObj.message.toLowerCase().includes("timeout") ||
errorObj.message.toLowerCase().includes("network");

if (!shouldRetry || i >= 3) {
if (isMounted.current) {
setIsLoading(false);
setError(errorObj);
}
setAttempt(i);
return;
}

// Continue to next retry attempt
}
}

// Should not reach here, but just in case
setIsLoading(false);
setError(lastError ?? new Error("Max retry attempts exceeded"));
setAttempt(3);
};

useEffect(() => {
Expand All @@ -96,6 +131,7 @@ export function useTrade(id: string): UseTradeReturn {
trade,
isLoading,
error,
attempt,
refetch: () => fetchData(true),
};
}
}
Loading