diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx
index 15971c79..e64af373 100644
--- a/dashboard/src/pages/EventExplorerPage.tsx
+++ b/dashboard/src/pages/EventExplorerPage.tsx
@@ -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';
@@ -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.');
@@ -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');
diff --git a/dashboard/src/pages/EventsPage.tsx b/dashboard/src/pages/EventsPage.tsx
index b2572df8..b46598b9 100644
--- a/dashboard/src/pages/EventsPage.tsx
+++ b/dashboard/src/pages/EventsPage.tsx
@@ -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';
@@ -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.');
diff --git a/dashboard/src/pages/NotificationSearchPage.test.tsx b/dashboard/src/pages/NotificationSearchPage.test.tsx
index 1cfb2603..701bacbc 100644
--- a/dashboard/src/pages/NotificationSearchPage.test.tsx
+++ b/dashboard/src/pages/NotificationSearchPage.test.tsx
@@ -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();
- 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;
diff --git a/dashboard/src/pages/NotificationSearchPage.tsx b/dashboard/src/pages/NotificationSearchPage.tsx
index 20b952ad..cd4fd540 100644
--- a/dashboard/src/pages/NotificationSearchPage.tsx
+++ b/dashboard/src/pages/NotificationSearchPage.tsx
@@ -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,
@@ -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);
}
diff --git a/dashboard/src/services/eventsApi.timeout.test.ts b/dashboard/src/services/eventsApi.timeout.test.ts
new file mode 100644
index 00000000..f692b21a
--- /dev/null
+++ b/dashboard/src/services/eventsApi.timeout.test.ts
@@ -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;
+ fetchMock.mockImplementation(
+ (_input: RequestInfo | URL, init?: RequestInit) =>
+ new Promise((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;
+ fetchMock.mockImplementation(
+ (_input: RequestInfo | URL, init?: RequestInit) =>
+ new Promise((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;
+ });
+});
diff --git a/dashboard/src/services/eventsApi.ts b/dashboard/src/services/eventsApi.ts
index 82f892c7..f7d6776f 100644
--- a/dashboard/src/services/eventsApi.ts
+++ b/dashboard/src/services/eventsApi.ts
@@ -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 {
+ 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 {
- const response = await fetch(apiUrl);
+ const response = await fetchWithTimeout(apiUrl);
if (!response.ok) {
throw new Error(`Failed to fetch events: ${response.status}`);
}