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
11 changes: 8 additions & 3 deletions dashboard/src/pages/EventExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
useFilteredEvents,
} from '../hooks/useEventSelectors';
import { useEventStore } from '../store/eventStore';
import { fetchEvents, fetchStatus, type ContractStatus } from '../services/eventsApi';
import {
fetchEvents,
fetchStatus,
isListenerApiTimeoutError,
type ContractStatus,
} from '../services/eventsApi';
import { resolveIndexingHealthUrl } from '../services/indexingHealthApi';
import { resolveNotificationHealthUrl } from '../services/notificationHealthApi';
import { generateMockEvents } from '../utils/eventData';
Expand Down Expand Up @@ -108,7 +113,7 @@ export function EventExplorerPage() {
setEvents(remoteEvents);
markSyncSuccess();
}
} catch {
} catch (error) {
if (!cancelled) {
setEvents(generateMockEvents(DEFAULT_EVENT_COUNT));
setError('Listener API unavailable — showing mock events for demo.');
Expand Down Expand Up @@ -170,7 +175,7 @@ export function EventExplorerPage() {
setEvents(remoteEvents);
markSyncSuccess();
})
.catch(() => {
.catch((error) => {
setEvents(generateMockEvents(DEFAULT_EVENT_COUNT));
setError('Listener API unavailable — showing mock events for demo.');
markSyncFailure('Wallet refresh failed');
Expand Down
4 changes: 2 additions & 2 deletions dashboard/src/pages/EventsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { WalletConnectButton } from '../components/WalletConnectButton';
import { getEventsApiBaseUrl } from '../config/eventsApiUrl';
import { useEventLoadingState } from '../hooks/useEventSelectors';
import { useEventStore } from '../store/eventStore';
import { fetchEvents } from '../services/eventsApi';
import { fetchEvents, isListenerApiTimeoutError } from '../services/eventsApi';
import { generateMockEvents } from '../utils/eventData';
import { restoreWalletSession } from '../services/wallet';

Expand Down Expand Up @@ -50,7 +50,7 @@ export function EventsPage() {
setEvents(remoteEvents);
markSyncSuccess();
}
} catch {
} catch (error) {
if (!cancelled) {
setEvents(generateMockEvents(DEFAULT_EVENT_COUNT));
setError('Listener API unavailable — showing mock events for demo.');
Expand Down
26 changes: 9 additions & 17 deletions dashboard/src/pages/NotificationSearchPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,33 +179,25 @@ describe('NotificationSearchPage filters', () => {
expect(screen.getByLabelText(/filter from date/i)).toHaveValue('');
expect(screen.queryByRole('button', { name: /clear all filters/i })).not.toBeInTheDocument();
});
});

describe('searchNotifications query params', () => {
const originalFetch = global.fetch;
it('shows a friendly timeout message when the listener search request expires', async () => {
mockedSearch.mockRejectedValue(new Error('Listener API request timed out after 10000ms.'));

beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => emptyResponse(),
});
});
render(<NotificationSearchPage />);

afterEach(() => {
global.fetch = originalFetch;
});
fireEvent.change(screen.getByLabelText(/free-text search/i), {
target: { value: 'payment' },
});

it('appends type, status, startDate, and endDate to the URL', async () => {
const { searchNotifications: realSearch } = jest.requireActual(
'../services/eventsApi',
) as typeof import('../services/eventsApi');

await realSearch('http://localhost:8787', {
type: 'webhook',
status: 'COMPLETED',
startDate: '2026-01-01',
endDate: '2026-01-31',
await waitFor(() => {
expect(screen.getByText(/The notification search timed out\. Please try again\./i)).toBeInTheDocument();
});
});

expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('type=webhook'));
const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string;
Expand Down
9 changes: 8 additions & 1 deletion dashboard/src/pages/NotificationSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useDebounce } from '../hooks/useDebounce';
import { EmptyState } from '../components/EmptyState';
import { CopyButton } from '../components/CopyButton';
import {
isListenerApiTimeoutError,
searchNotifications,
type NotificationSearchResult,
type NotificationSearchResponse,
Expand Down Expand Up @@ -125,7 +126,13 @@ export function NotificationSearchPage() {
setResponse(result);
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : 'Search failed');
setError(
isListenerApiTimeoutError(err)
? 'The notification search timed out. Please try again.'
: err instanceof Error
? err.message
: 'Search failed'
);
} finally {
setLoading(false);
}
Expand Down
85 changes: 85 additions & 0 deletions dashboard/src/services/eventsApi.timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { fetchEvents, searchNotifications } from './eventsApi';

describe('listener API request timeouts', () => {
beforeEach(() => {
jest.useFakeTimers();
Object.defineProperty(globalThis, 'fetch', {
writable: true,
configurable: true,
value: jest.fn(),
});
});

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

it('rejects fetchEvents when the listener API takes too long to respond', async () => {
const fetchMock = globalThis.fetch as jest.MockedFunction<typeof fetch>;
fetchMock.mockImplementation(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((resolve, reject) => {
const signal = init?.signal;
if (signal) {
signal.addEventListener(
'abort',
() => {
const abortError = new Error('The operation was aborted.');
Object.assign(abortError, { name: 'AbortError' });
reject(abortError);
},
{ once: true }
);
}

setTimeout(() => {
resolve({
ok: true,
json: async () => ({ events: [] }),
} as Response);
}, 30_000);
})
);

const request = expect(fetchEvents('http://localhost:8787/api/events')).rejects.toThrow(/timed out/i);

await jest.advanceTimersByTimeAsync(10_001);

await request;
});

it('rejects searchNotifications when the request exceeds the dashboard timeout', async () => {
const fetchMock = globalThis.fetch as jest.MockedFunction<typeof fetch>;
fetchMock.mockImplementation(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((resolve, reject) => {
const signal = init?.signal;
if (signal) {
signal.addEventListener(
'abort',
() => {
const abortError = new Error('The operation was aborted.');
Object.assign(abortError, { name: 'AbortError' });
reject(abortError);
},
{ once: true }
);
}

setTimeout(() => {
resolve({
ok: true,
json: async () => ({ results: [], total: 0, limit: 20, offset: 0, itemCount: 0, totalPages: 0 }),
} as Response);
}, 30_000);
})
);

const request = expect(searchNotifications('http://localhost:8787', { q: 'hello' })).rejects.toThrow(/timed out/i);

await jest.advanceTimersByTimeAsync(10_001);

await request;
});
});
46 changes: 45 additions & 1 deletion dashboard/src/services/eventsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,52 @@ export interface NotificationSearchParams {
sortBy?: 'newest' | 'oldest' | 'status';
}

export const LISTENER_API_TIMEOUT_MS = 10_000;

export function isListenerApiTimeoutError(error: unknown): boolean {
return error instanceof Error && /timed out|timeout/i.test(error.message);
}

async function fetchWithTimeout(
input: RequestInfo | URL,
init: RequestInit = {},
timeoutMs = LISTENER_API_TIMEOUT_MS
): Promise<Response> {
const controller = new AbortController();
const signal = init.signal;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

let abortListener: (() => void) | null = null;
if (signal) {
if (signal.aborted) {
controller.abort();
} else {
abortListener = () => controller.abort();
signal.addEventListener('abort', abortListener, { once: true });
}
}

try {
return await fetch(input, {
...init,
signal: controller.signal,
});
} catch (error) {
const err = error as { name?: string };
if (controller.signal.aborted && err?.name === 'AbortError') {
throw new Error(`Listener API request timed out after ${timeoutMs}ms.`);
}
throw error;
} finally {
clearTimeout(timeoutId);
if (abortListener && signal) {
signal.removeEventListener('abort', abortListener);
}
}
}

export async function fetchEvents(apiUrl: string): Promise<BlockchainEvent[]> {
const response = await fetch(apiUrl);
const response = await fetchWithTimeout(apiUrl);
if (!response.ok) {
throw new Error(`Failed to fetch events: ${response.status}`);
}
Expand Down
Loading