diff --git a/app/frontend/src/hooks/useCampaigns.ts b/app/frontend/src/hooks/useCampaigns.ts index 09dce4a..0f2841a 100644 --- a/app/frontend/src/hooks/useCampaigns.ts +++ b/app/frontend/src/hooks/useCampaigns.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; +import { isTimeoutError } from '@/lib/fetch-timeout'; import type { Campaign, CampaignCreatePayload, @@ -10,38 +11,49 @@ import type { import { useActivity } from './useActivity'; async function fetchCampaigns(): Promise { - const { data, error } = await apiClient.GET('/api/v1/campaigns', { - params: { query: { includeArchived: false } }, - }); - if (error) { - throw new Error((error as { message?: string }).message ?? 'Failed to fetch campaigns'); + try { + const { data, error } = await apiClient.GET('/api/v1/campaigns', { + params: { query: { includeArchived: false } }, + }); + if (error) throw new Error((error as { message?: string }).message ?? 'Failed to fetch campaigns'); + const result = data as unknown as { data?: Campaign[] } | Campaign[] | null; + if (Array.isArray(result)) return result; + return result?.data ?? []; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; } - const result = data as unknown as { data?: Campaign[] } | Campaign[] | null; - if (Array.isArray(result)) return result; - return result?.data ?? []; } async function postCampaign(payload: CampaignCreatePayload): Promise { - const { data, error, response } = await apiClient.POST('/api/v1/campaigns', { - body: payload as never, - }); - if (error || !response.ok) { - throw new Error((error as { message?: string } | undefined)?.message ?? `Failed to create campaign`); + try { + const { data, error, response } = await apiClient.POST('/api/v1/campaigns', { + body: payload as never, + }); + if (error || !response.ok) { + throw new Error((error as { message?: string } | undefined)?.message ?? 'Failed to create campaign'); + } + const result = data as unknown as { data?: Campaign } | Campaign | null; + return (result && 'data' in result ? result.data : result) as Campaign; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; } - const result = data as unknown as { data?: Campaign } | Campaign | null; - return (result && 'data' in result ? result.data : result) as Campaign; } async function patchCampaign(id: string, payload: CampaignUpdatePayload): Promise { - const { data, error } = await apiClient.PATCH('/api/v1/campaigns/{id}', { - params: { path: { id } }, - body: payload as never, - }); - if (error) { - throw new Error((error as { message?: string }).message ?? `Failed to update campaign`); + try { + const { data, error } = await apiClient.PATCH('/api/v1/campaigns/{id}', { + params: { path: { id } }, + body: payload as never, + }); + if (error) throw new Error((error as { message?: string }).message ?? 'Failed to update campaign'); + const result = data as unknown as { data?: Campaign } | Campaign | null; + return (result && 'data' in result ? result.data : result) as Campaign; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; } - const result = data as unknown as { data?: Campaign } | Campaign | null; - return (result && 'data' in result ? result.data : result) as Campaign; } export function useCampaigns() { diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 8a09a0f..b7125e8 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -2,6 +2,7 @@ import createClient from 'openapi-fetch'; import type { paths } from './generated/api'; import { fetchClient } from './mock-api/client'; import { apiUrl } from './env'; +import { withTimeoutFetch } from './fetch-timeout'; /** * Typed API client for the ChainForge backend. @@ -14,5 +15,5 @@ import { apiUrl } from './env'; */ export const apiClient = createClient({ baseUrl: apiUrl, - fetch: fetchClient as typeof fetch, + fetch: withTimeoutFetch(fetchClient as typeof fetch) as typeof fetch, }); diff --git a/app/frontend/src/lib/fetch-timeout.test.ts b/app/frontend/src/lib/fetch-timeout.test.ts new file mode 100644 index 0000000..472b2e7 --- /dev/null +++ b/app/frontend/src/lib/fetch-timeout.test.ts @@ -0,0 +1,137 @@ +import { withTimeoutFetch, isTimeoutError, DEFAULT_TIMEOUT_MS } from './fetch-timeout'; + +describe('isTimeoutError', () => { + it('returns true for DOMException AbortError', () => { + expect(isTimeoutError(new DOMException('aborted', 'AbortError'))).toBe(true); + }); + + it('returns true for DOMException TimeoutError', () => { + expect(isTimeoutError(new DOMException('timed out', 'TimeoutError'))).toBe(true); + }); + + it('returns false for a plain Error', () => { + expect(isTimeoutError(new Error('something went wrong'))).toBe(false); + }); + + it('returns false for a TypeError', () => { + expect(isTimeoutError(new TypeError('Failed to fetch'))).toBe(false); + }); + + it('returns false for null', () => { + expect(isTimeoutError(null)).toBe(false); + }); + + it('returns false for a string', () => { + expect(isTimeoutError('timeout')).toBe(false); + }); +}); + +describe('withTimeoutFetch', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resolves normally when the request completes before the timeout', async () => { + const mockFetch = jest.fn().mockResolvedValue(new Response('ok', { status: 200 })); + const timedFetch = withTimeoutFetch(mockFetch, 5_000); + + const promise = timedFetch('https://example.com/api'); + const response = await promise; + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('aborts the request when the timeout fires', async () => { + let capturedSignal: AbortSignal | undefined; + const mockFetch = jest.fn().mockImplementation((_input, init?: RequestInit) => { + capturedSignal = init?.signal as AbortSignal | undefined; + return new Promise((_, reject) => { + if (capturedSignal) { + capturedSignal.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + } + }); + }); + + const timedFetch = withTimeoutFetch(mockFetch, 5_000); + const promise = timedFetch('https://example.com/api'); + + jest.advanceTimersByTime(5_000); + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }); + expect(capturedSignal?.aborted).toBe(true); + }); + + it('clears the timer when the request resolves before timeout', async () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + const mockFetch = jest.fn().mockResolvedValue(new Response('ok')); + const timedFetch = withTimeoutFetch(mockFetch, 5_000); + + await timedFetch('https://example.com/api'); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + + it('aborts immediately when the caller passes a pre-aborted signal', async () => { + const callerController = new AbortController(); + callerController.abort(); + + let capturedSignal: AbortSignal | undefined; + const mockFetch = jest.fn().mockImplementation((_input, init?: RequestInit) => { + capturedSignal = init?.signal as AbortSignal | undefined; + return Promise.resolve(new Response('ok')); + }); + + const timedFetch = withTimeoutFetch(mockFetch, 5_000); + await timedFetch('https://example.com/api', { signal: callerController.signal }); + + expect(capturedSignal?.aborted).toBe(true); + }); + + it('aborts when the caller signal fires before the timeout', async () => { + const callerController = new AbortController(); + let capturedSignal: AbortSignal | undefined; + + const mockFetch = jest.fn().mockImplementation((_input, init?: RequestInit) => { + capturedSignal = init?.signal as AbortSignal | undefined; + return new Promise((_, reject) => { + capturedSignal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }); + }); + + const timedFetch = withTimeoutFetch(mockFetch, 5_000); + const promise = timedFetch('https://example.com/api', { signal: callerController.signal }); + + callerController.abort(); + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('uses DEFAULT_TIMEOUT_MS when no timeout is specified', async () => { + let capturedSignal: AbortSignal | undefined; + const mockFetch = jest.fn().mockImplementation((_input, init?: RequestInit) => { + capturedSignal = init?.signal as AbortSignal | undefined; + return new Promise((_, reject) => { + capturedSignal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }); + }); + + const timedFetch = withTimeoutFetch(mockFetch); + const promise = timedFetch('https://example.com/api'); + + jest.advanceTimersByTime(DEFAULT_TIMEOUT_MS); + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); diff --git a/app/frontend/src/lib/fetch-timeout.ts b/app/frontend/src/lib/fetch-timeout.ts new file mode 100644 index 0000000..f1283bb --- /dev/null +++ b/app/frontend/src/lib/fetch-timeout.ts @@ -0,0 +1,31 @@ +export const DEFAULT_TIMEOUT_MS = 30_000; + +export function isTimeoutError(error: unknown): boolean { + return ( + error instanceof DOMException && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ); +} + +function mergeSignals(a: AbortSignal, b: AbortSignal): AbortSignal { + const controller = new AbortController(); + if (a.aborted) { controller.abort(a.reason); return controller.signal; } + if (b.aborted) { controller.abort(b.reason); return controller.signal; } + a.addEventListener('abort', () => controller.abort(a.reason), { once: true }); + b.addEventListener('abort', () => controller.abort(b.reason), { once: true }); + return controller.signal; +} + +export function withTimeoutFetch( + baseFetch: typeof globalThis.fetch, + timeoutMs = DEFAULT_TIMEOUT_MS, +): typeof globalThis.fetch { + return (input, init) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const signal = init?.signal + ? mergeSignals(init.signal as AbortSignal, controller.signal) + : controller.signal; + return baseFetch(input, { ...init, signal }).finally(() => clearTimeout(timer)); + }; +} diff --git a/app/frontend/src/lib/retry.ts b/app/frontend/src/lib/retry.ts index bd75b10..07616e2 100644 --- a/app/frontend/src/lib/retry.ts +++ b/app/frontend/src/lib/retry.ts @@ -1,6 +1,10 @@ const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]; function isRetryable(error: unknown): boolean { + if ( + error instanceof DOMException && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ) return false; if (error instanceof Response || (error && typeof (error as { status?: number }).status === 'number')) { const status = (error as { status: number }).status; return status >= 500; diff --git a/app/frontend/src/lib/verification-api.ts b/app/frontend/src/lib/verification-api.ts index 840a18d..06a5439 100644 --- a/app/frontend/src/lib/verification-api.ts +++ b/app/frontend/src/lib/verification-api.ts @@ -1,6 +1,7 @@ import type { VerificationResult } from '@/types/verification'; import { withRetry } from '@/lib/retry'; import { apiClient } from '@/lib/api-client'; +import { isTimeoutError } from '@/lib/fetch-timeout'; export class VerificationApiError extends Error { constructor(message: string) { @@ -25,7 +26,12 @@ export async function startEvidenceVerification( ); data = result.data as VerificationResult | undefined; response = result.response; - } catch { + } catch (error) { + if (isTimeoutError(error)) { + throw new VerificationApiError( + 'Request timed out. Please check your connection and try again.', + ); + } throw new VerificationApiError( 'Unable to reach the verification service. Please check your connection and try again.', ); diff --git a/app/frontend/src/lib/verification-inbox-api.ts b/app/frontend/src/lib/verification-inbox-api.ts index 9c25913..cba9652 100644 --- a/app/frontend/src/lib/verification-inbox-api.ts +++ b/app/frontend/src/lib/verification-inbox-api.ts @@ -1,5 +1,6 @@ import { withRetry } from '@/lib/retry'; import { apiClient } from '@/lib/api-client'; +import { isTimeoutError } from '@/lib/fetch-timeout'; import type { VerificationInboxResponse, VerificationInboxItem, @@ -11,56 +12,72 @@ import type { export async function fetchInbox( filters: Partial, ): Promise { - const { data, error } = await withRetry(() => - apiClient.GET('/api/v1/verification-inbox', { - params: { - query: { - status: filters.status || undefined, - page: filters.page && filters.page > 1 ? filters.page : undefined, - dateFrom: filters.dateFrom || undefined, - dateTo: filters.dateTo || undefined, + try { + const { data, error } = await withRetry(() => + apiClient.GET('/api/v1/verification-inbox', { + params: { + query: { + status: filters.status || undefined, + page: filters.page && filters.page > 1 ? filters.page : undefined, + dateFrom: filters.dateFrom || undefined, + dateTo: filters.dateTo || undefined, + }, }, - }, - }), - ); - if (error) throw new Error((error as { message?: string }).message ?? 'Failed to fetch inbox'); - return data as VerificationInboxResponse; + }), + ); + if (error) throw new Error((error as { message?: string }).message ?? 'Failed to fetch inbox'); + return data as VerificationInboxResponse; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; + } } export async function fetchStats(): Promise { - const { data, error } = await withRetry(() => - apiClient.GET('/api/v1/verification-inbox/stats'), - ); - if (error) throw new Error((error as { message?: string }).message ?? 'Failed to fetch stats'); - return data as VerificationStats; + try { + const { data, error } = await withRetry(() => + apiClient.GET('/api/v1/verification-inbox/stats'), + ); + if (error) throw new Error((error as { message?: string }).message ?? 'Failed to fetch stats'); + return data as VerificationStats; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; + } } export async function fetchDetails(id: string): Promise { - const { data, error } = await withRetry(() => - apiClient.GET('/api/v1/verification-inbox/{id}', { - params: { path: { id } }, - }), - ); - if (error) throw new Error((error as { message?: string }).message ?? 'Failed to fetch verification'); - return data as VerificationInboxItem; + try { + const { data, error } = await withRetry(() => + apiClient.GET('/api/v1/verification-inbox/{id}', { + params: { path: { id } }, + }), + ); + if (error) throw new Error((error as { message?: string }).message ?? 'Failed to fetch verification'); + return data as VerificationInboxItem; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; + } } export async function approveVerification( id: string, payload: { nextStepMessage?: string; internalNote?: string }, ): Promise { - const { data, error } = await withRetry(() => - apiClient.POST('/api/v1/verification-inbox/{id}/approve', { - params: { path: { id } }, - body: payload, - }), - ); - if (error) { - throw new Error( - (error as { message?: string }).message ?? `Approve failed`, + try { + const { data, error } = await withRetry(() => + apiClient.POST('/api/v1/verification-inbox/{id}/approve', { + params: { path: { id } }, + body: payload, + }), ); + if (error) throw new Error((error as { message?: string }).message ?? 'Approve failed'); + return data as VerificationInboxItem; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; } - return data as VerificationInboxItem; } export async function rejectVerification( @@ -71,18 +88,19 @@ export async function rejectVerification( internalNote?: string; }, ): Promise { - const { data, error } = await withRetry(() => - apiClient.POST('/api/v1/verification-inbox/{id}/reject', { - params: { path: { id } }, - body: payload, - }), - ); - if (error) { - throw new Error( - (error as { message?: string }).message ?? `Reject failed`, + try { + const { data, error } = await withRetry(() => + apiClient.POST('/api/v1/verification-inbox/{id}/reject', { + params: { path: { id } }, + body: payload, + }), ); + if (error) throw new Error((error as { message?: string }).message ?? 'Reject failed'); + return data as VerificationInboxItem; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; } - return data as VerificationInboxItem; } export async function requestResubmission( @@ -93,44 +111,51 @@ export async function requestResubmission( internalNote?: string; }, ): Promise { - const { data, error } = await withRetry(() => - apiClient.POST('/api/v1/verification-inbox/{id}/request-resubmission', { - params: { path: { id } }, - body: payload, - }), - ); - if (error) { - throw new Error( - (error as { message?: string }).message ?? `Resubmission request failed`, + try { + const { data, error } = await withRetry(() => + apiClient.POST('/api/v1/verification-inbox/{id}/request-resubmission', { + params: { path: { id } }, + body: payload, + }), ); + if (error) throw new Error((error as { message?: string }).message ?? 'Resubmission request failed'); + return data as VerificationInboxItem; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; } - return data as VerificationInboxItem; } export async function fetchNotes(id: string): Promise { - const { data, error } = await withRetry(() => - apiClient.GET('/api/v1/verification-inbox/{id}/notes', { - params: { path: { id } }, - }), - ); - if (error) throw new Error((error as { message?: string }).message ?? 'Failed to fetch notes'); - return (data ?? []) as InternalNote[]; + try { + const { data, error } = await withRetry(() => + apiClient.GET('/api/v1/verification-inbox/{id}/notes', { + params: { path: { id } }, + }), + ); + if (error) throw new Error((error as { message?: string }).message ?? 'Failed to fetch notes'); + return (data ?? []) as InternalNote[]; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; + } } export async function addNote( id: string, payload: { content: string; category?: string }, ): Promise { - const { data, error } = await withRetry(() => - apiClient.POST('/api/v1/verification-inbox/{id}/notes', { - params: { path: { id } }, - body: payload, - }), - ); - if (error) { - throw new Error( - (error as { message?: string }).message ?? `Add note failed`, + try { + const { data, error } = await withRetry(() => + apiClient.POST('/api/v1/verification-inbox/{id}/notes', { + params: { path: { id } }, + body: payload, + }), ); + if (error) throw new Error((error as { message?: string }).message ?? 'Add note failed'); + return data as InternalNote; + } catch (error) { + if (isTimeoutError(error)) throw new Error('Request timed out. Please check your connection and try again.'); + throw error; } - return data as InternalNote; }