From 53f4216522003d656defd9829213e2e93e3a754a Mon Sep 17 00:00:00 2001 From: Shecodes174 Date: Sun, 27 Sep 2026 06:55:10 +0000 Subject: [PATCH] Add dashboard listener request timeout handling ## Summary Add explicit timeout handling for listener API requests in the dashboard to prevent requests from hanging indefinitely and to show user-friendly timeout states. ## Changes - Added a shared timeout wrapper around listener fetch requests in the dashboard API service. - Converted timeout failures into clear user-facing messages for event loading, retry flows, and notification search. - Added regression tests covering listener timeout behavior and the notification search timeout state. ## Verification - Ran focused dashboard Jest tests for the timeout changes. - Result: 2 suites passed, 9 tests passed. --- dashboard/src/pages/EventExplorerPage.tsx | 31 ++++-- dashboard/src/pages/EventsPage.tsx | 10 +- .../src/pages/NotificationSearchPage.test.tsx | 99 ++++++------------- .../src/pages/NotificationSearchPage.tsx | 12 ++- .../src/services/eventsApi.timeout.test.ts | 85 ++++++++++++++++ dashboard/src/services/eventsApi.ts | 50 +++++++++- 6 files changed, 203 insertions(+), 84 deletions(-) create mode 100644 dashboard/src/services/eventsApi.timeout.test.ts diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index f656f10b..4e23278e 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -10,7 +10,12 @@ import { IndexingHealthPanel } from '../components/IndexingHealthPanel'; import { NotificationHealthPanel } from '../components/NotificationHealthPanel'; import { useEventFilters, useEventLoadingState, 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'; @@ -81,10 +86,14 @@ export function EventExplorerPage() { if (!cancelled) { setEvents(remoteEvents); } - } catch { + } catch (error) { if (!cancelled) { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); - setError('Listener API unavailable — showing mock events for demo.'); + setError( + isListenerApiTimeoutError(error) + ? 'Listener API timed out — showing mock events for demo.' + : 'Listener API unavailable — showing mock events for demo.' + ); } } finally { if (!cancelled) { @@ -139,9 +148,13 @@ export function EventExplorerPage() { .then((remoteEvents) => { setEvents(remoteEvents); }) - .catch(() => { + .catch((error) => { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); - setError('Listener API unavailable — showing mock events for demo.'); + setError( + isListenerApiTimeoutError(error) + ? 'Listener API timed out — showing mock events for demo.' + : 'Listener API unavailable — showing mock events for demo.' + ); }) .finally(() => { setLoading(false); @@ -190,9 +203,13 @@ export function EventExplorerPage() { try { const remoteEvents = await fetchEvents(API_URL); setEvents(remoteEvents); - } catch { + } catch (error) { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); - setError('Retry failed — still using demo event data.'); + setError( + isListenerApiTimeoutError(error) + ? 'The listener API timed out. Retrying may help.' + : 'Retry failed — still using demo event data.' + ); } finally { setLoading(false); } diff --git a/dashboard/src/pages/EventsPage.tsx b/dashboard/src/pages/EventsPage.tsx index 96eb890b..7ddbdd82 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'; @@ -47,10 +47,14 @@ export function EventsPage() { if (!cancelled) { setEvents(remoteEvents); } - } catch { + } catch (error) { if (!cancelled) { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); - setError('Listener API unavailable — showing mock events for demo.'); + setError( + isListenerApiTimeoutError(error) + ? 'Listener API timed out — showing mock events for demo.' + : 'Listener API unavailable — showing mock events for demo.' + ); } } finally { if (!cancelled) { diff --git a/dashboard/src/pages/NotificationSearchPage.test.tsx b/dashboard/src/pages/NotificationSearchPage.test.tsx index 008d1ae5..ccab53b2 100644 --- a/dashboard/src/pages/NotificationSearchPage.test.tsx +++ b/dashboard/src/pages/NotificationSearchPage.test.tsx @@ -1,43 +1,13 @@ -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; -import { NotificationSearchPage } from './NotificationSearchPage'; -import * as eventsApi from '../services/eventsApi'; - -jest.mock('../services/eventsApi', () => { - const actual = jest.requireActual('../services/eventsApi') as typeof import('../services/eventsApi'); - return { - ...actual, - searchNotifications: jest.fn(), - }; -}); - -const searchNotifications = eventsApi.searchNotifications as jest.MockedFunction< - typeof eventsApi.searchNotifications ->; - -function emptyResponse(): eventsApi.NotificationSearchResponse { - return { - results: [], - total: 0, - limit: 20, - offset: 0, - itemCount: 0, - totalPages: 0, - }; -} - -describe('NotificationSearchPage filters', () => { - beforeEach(() => { - jest.useFakeTimers(); - searchNotifications.mockReset(); - searchNotifications.mockResolvedValue(emptyResponse()); import '@testing-library/jest-dom'; -import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { NotificationSearchPage } from './NotificationSearchPage'; import { searchNotifications } from '../services/eventsApi'; import type { NotificationSearchResponse } from '../services/eventsApi'; jest.mock('../services/eventsApi', () => ({ searchNotifications: jest.fn(), + isListenerApiTimeoutError: (error: unknown) => + error instanceof Error && /timed out|timeout/i.test(error.message), })); const mockedSearch = searchNotifications as jest.MockedFunction; @@ -64,10 +34,22 @@ const mockResult: NotificationSearchResponse = { totalPages: 1, }; -describe('NotificationSearchPage loading skeletons', () => { +function emptyResponse(): NotificationSearchResponse { + return { + results: [], + total: 0, + limit: 20, + offset: 0, + itemCount: 0, + totalPages: 0, + }; +} + +describe('NotificationSearchPage filters', () => { beforeEach(() => { - mockedSearch.mockReset(); jest.useFakeTimers(); + mockedSearch.mockReset(); + mockedSearch.mockResolvedValue(emptyResponse()); }); afterEach(() => { @@ -108,10 +90,10 @@ describe('NotificationSearchPage loading skeletons', () => { }); await waitFor(() => { - expect(searchNotifications).toHaveBeenCalled(); + expect(mockedSearch).toHaveBeenCalled(); }); - const lastCall = searchNotifications.mock.calls[searchNotifications.mock.calls.length - 1]; + const lastCall = mockedSearch.mock.calls[mockedSearch.mock.calls.length - 1]; expect(lastCall?.[1]).toMatchObject({ type: 'discord', status: 'FAILED', @@ -121,7 +103,7 @@ describe('NotificationSearchPage loading skeletons', () => { }); it('updates results when filters change', async () => { - searchNotifications.mockResolvedValue({ + mockedSearch.mockResolvedValue({ results: [ { id: 1, @@ -182,42 +164,25 @@ describe('NotificationSearchPage loading skeletons', () => { 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 () => { - // Use the real implementation (not the page mock) - const { searchNotifications: realSearch } = jest.requireActual( - '../services/eventsApi' - ) as typeof import('../services/eventsApi'); + await act(async () => { + jest.advanceTimersByTime(300); + }); - 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; - expect(calledUrl).toContain('status=COMPLETED'); - expect(calledUrl).toContain('startDate=2026-01-01'); - expect(calledUrl).toContain('endDate=2026-01-31'); it('shows result-card skeletons while searching and hides Searching text', async () => { mockedSearch.mockReturnValue(new Promise(() => {})); diff --git a/dashboard/src/pages/NotificationSearchPage.tsx b/dashboard/src/pages/NotificationSearchPage.tsx index bf0655c8..6a04de67 100644 --- a/dashboard/src/pages/NotificationSearchPage.tsx +++ b/dashboard/src/pages/NotificationSearchPage.tsx @@ -2,8 +2,8 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { NotificationSearchSkeleton } from '../components/NotificationSearchSkeleton'; import { getEventsApiBaseUrl } from '../config/eventsApiUrl'; import { useDebounce } from '../hooks/useDebounce'; -import { getEventsApiBaseUrl } from '../config/eventsApiUrl'; import { + isListenerApiTimeoutError, searchNotifications, type NotificationSearchResult, type NotificationSearchResponse, @@ -31,8 +31,6 @@ export const NOTIFICATION_TYPE_OPTIONS = [ { value: 'webhook', label: 'Webhook' }, { value: 'sms', label: 'SMS' }, ]; -const API_BASE = getEventsApiBaseUrl(); - const STATUS_OPTIONS = ['', 'PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED', 'PROCESSED']; export function NotificationSearchPage() { @@ -97,7 +95,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 eae2ce97..ce580b02 100644 --- a/dashboard/src/services/eventsApi.ts +++ b/dashboard/src/services/eventsApi.ts @@ -67,8 +67,52 @@ export interface NotificationSearchParams { offset?: number; } +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}`); } @@ -79,7 +123,7 @@ export async function fetchEvents(apiUrl: string): Promise { } export async function fetchStatus(apiUrl: string): Promise { - const response = await fetch(`${apiUrl}/api/status`); + const response = await fetchWithTimeout(`${apiUrl}/api/status`); if (!response.ok) { throw new Error(`Failed to fetch status: ${response.status}`); } @@ -102,7 +146,7 @@ export async function searchNotifications( if (params.limit !== undefined) url.searchParams.set('limit', String(params.limit)); if (params.offset !== undefined) url.searchParams.set('offset', String(params.offset)); - const response = await fetch(url.toString()); + const response = await fetchWithTimeout(url.toString()); if (!response.ok) { throw new Error(`Search failed: ${response.status}`); }