diff --git a/.gitignore b/.gitignore index ca3497e..66651be 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ coverage/ *.js.map *.tsbuildinfo docs/reference/ +fix.md # IDE dist diff --git a/src/escrow/monitor.ts b/src/escrow/monitor.ts index e8fb161..c712e61 100644 --- a/src/escrow/monitor.ts +++ b/src/escrow/monitor.ts @@ -1,9 +1,38 @@ import { TrustFlowEvent, EventHandler } from '../types/events'; import { logger } from '../utils/logger'; +/** + * The phase in which a polling error occurred. + */ +export type EscrowMonitorErrorPhase = 'fetch' | 'handler'; + +/** + * Context passed to the {@link EscrowMonitor.onError} callback describing where + * the error originated and, for handler failures, the event and handler involved. + */ +export interface EscrowMonitorErrorContext { + /** The phase of polling in which the error occurred. */ + phase: EscrowMonitorErrorPhase; + /** The event that triggered the failing handler, when `phase === 'handler'`. */ + event?: TrustFlowEvent; + /** The handler that threw, when `phase === 'handler'`. */ + handler?: EventHandler; +} + +/** + * Callback invoked by {@link EscrowMonitor} when a poll (fetchFn) or an event + * handler fails. Consumers that register this callback can react to otherwise + * silently-discarded errors. + */ +export type EscrowMonitorOnError = ( + error: unknown, + context: EscrowMonitorErrorContext +) => void; + export class EscrowMonitor { private handlers = new Map>(); private pollingInterval?: ReturnType; + private errorCallback?: EscrowMonitorOnError; on(type: string, handler: EventHandler): this { if (!this.handlers.has(type)) { @@ -18,14 +47,39 @@ export class EscrowMonitor { return this; } + /** + * Register an optional error callback that is invoked whenever a poll + * (`fetchFn`) or an event handler fails. Without it, failures are only + * surfaced through the SDK's logger and existing polling behavior is + * unchanged. + * + * @param callback - Called with the thrown error and context describing + * whether it originated from fetching events or handling an event. + * @returns `this` for chaining. + */ + onError(callback: EscrowMonitorOnError): this { + this.errorCallback = callback; + return this; + } + startPolling(intervalMs = 5000, fetchFn: () => Promise): void { this.pollingInterval = setInterval(async () => { - const events = await fetchFn().catch(() => []); + let events: TrustFlowEvent[]; + try { + events = await fetchFn(); + } catch (error) { + logger.error('Failed to fetch events during polling', error); + this.errorCallback?.(error, { phase: 'fetch' }); + return; + } for (const event of events) { const handlers = this.handlers.get(event.type) ?? new Set(); const wildcards = this.handlers.get('*') ?? new Set(); [...handlers, ...wildcards].forEach((h) => { - Promise.resolve(h(event)).catch((err) => logger.error(String(err))); + Promise.resolve(h(event)).catch((error: unknown) => { + logger.error('Event handler failed', { error, event }); + this.errorCallback?.(error, { phase: 'handler', event, handler: h }); + }); }); } }, intervalMs); diff --git a/tests/monitor.test.ts b/tests/monitor.test.ts index 809bb70..60b8b45 100644 --- a/tests/monitor.test.ts +++ b/tests/monitor.test.ts @@ -1,6 +1,14 @@ import { EscrowMonitor } from '../src/escrow/monitor'; describe('EscrowMonitor', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('registers and fires event handlers', async () => { const monitor = new EscrowMonitor(); let received: any = null; @@ -19,4 +27,50 @@ describe('EscrowMonitor', () => { monitor.off('escrow.created', h); expect((monitor as any).handlers.get('escrow.created')?.size).toBe(0); }); + + describe('onError', () => { + const event = { + type: 'escrow.created' as const, + escrowId: '1', + payload: {}, + blockNumber: 1, + txHash: 'abc', + timestamp: Date.now(), + }; + + it('invokes onError when fetchFn rejects', async () => { + const monitor = new EscrowMonitor(); + const onError = jest.fn(); + monitor.onError(onError); + const fetchFn = jest.fn().mockRejectedValue(new Error('network down')); + monitor.startPolling(1000, fetchFn); + + await jest.advanceTimersByTimeAsync(1000); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toEqual(new Error('network down')); + expect(onError.mock.calls[0][1].phase).toBe('fetch'); + expect(fetchFn).toHaveBeenCalled(); + + monitor.stopPolling(); + }); + + it('invokes onError when an event handler rejects', async () => { + const monitor = new EscrowMonitor(); + const onError = jest.fn(); + monitor.onError(onError); + const boom = jest.fn().mockRejectedValue(new Error('handler boom')); + monitor.on('escrow.created', boom); + const fetchFn = jest.fn().mockResolvedValue([event]); + monitor.startPolling(1000, fetchFn); + + await jest.advanceTimersByTimeAsync(1000); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toEqual(new Error('handler boom')); + expect(onError.mock.calls[0][1].phase).toBe('handler'); + + monitor.stopPolling(); + }); + }); });