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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ coverage/
*.js.map
*.tsbuildinfo
docs/reference/
fix.md

# IDE
dist
58 changes: 56 additions & 2 deletions src/escrow/monitor.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<EventHandler>>();
private pollingInterval?: ReturnType<typeof setInterval>;
private errorCallback?: EscrowMonitorOnError;

on(type: string, handler: EventHandler): this {
if (!this.handlers.has(type)) {
Expand All @@ -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<TrustFlowEvent[]>): 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);
Expand Down
54 changes: 54 additions & 0 deletions tests/monitor.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
});
});
});