Skip to content
Open
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
58 changes: 35 additions & 23 deletions app/frontend/src/hooks/useCampaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -10,38 +11,49 @@ import type {
import { useActivity } from './useActivity';

async function fetchCampaigns(): Promise<Campaign[]> {
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<Campaign> {
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<Campaign> {
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() {
Expand Down
3 changes: 2 additions & 1 deletion app/frontend/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -14,5 +15,5 @@ import { apiUrl } from './env';
*/
export const apiClient = createClient<paths>({
baseUrl: apiUrl,
fetch: fetchClient as typeof fetch,
fetch: withTimeoutFetch(fetchClient as typeof fetch) as typeof fetch,
});
137 changes: 137 additions & 0 deletions app/frontend/src/lib/fetch-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>((_, 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<Response>((_, 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<Response>((_, 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' });
});
});
31 changes: 31 additions & 0 deletions app/frontend/src/lib/fetch-timeout.ts
Original file line number Diff line number Diff line change
@@ -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));
};
}
4 changes: 4 additions & 0 deletions app/frontend/src/lib/retry.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
8 changes: 7 additions & 1 deletion app/frontend/src/lib/verification-api.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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.',
);
Expand Down
Loading