Skip to content

Commit 6a43768

Browse files
fix: add timeout getMultichainClient to prevent remove connections fr… (#73)
* fix: add timeout getMultichainClient to prevent remove connections from dropping * Move timeout management to Transport layer * Fix retry * fix lint after rebase * fix: cleanup pendingRequests on timeout to prevent memory leak + tests --------- Co-authored-by: Edouard Bougon <[email protected]>
1 parent 9f6fc7a commit 6a43768

13 files changed

+377
-105
lines changed

README.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ const result = await client.invokeMethod({
3232
await client.revokeSession();
3333
```
3434

35+
### Configuring Transport Timeout
36+
37+
You can configure a default timeout (in milliseconds) for all requests made through the transport by passing the `defaultTimeout` option to `getDefaultTransport`:
38+
39+
```typescript
40+
const transport = getDefaultTransport({ defaultTimeout: 5000 }); // 5 seconds timeout for all requests
41+
const client = getMultichainClient({ transport });
42+
```
43+
3544
## Extending RPC Types
3645

3746
The client's RPC requests are strongly typed, enforcing the RPC methods and params to be defined ahead of usage. The client supports extending
@@ -78,6 +87,16 @@ const result = await client.invokeMethod({
7887

7988
Transports handle the communication layer between your application and the wallet. You can create custom transports for different environments or communication methods.
8089

90+
### Timeout Responsibility
91+
92+
It is recommended that each custom transport implements its own request timeout mechanism rather than relying on higher layers. This ensures:
93+
94+
- Transport-specific optimizations (e.g., aborting underlying network channels, clearing listeners)
95+
- Consistent error semantics (e.g., always throwing a dedicated `TransportTimeoutError` or custom error type)
96+
- Better resource cleanup in environments like browsers or workers
97+
98+
Your `request` implementation should accept an optional `{ timeout?: number }` argument.
99+
81100
### Transport Interface
82101

83102
A transport must implement the following interface:
@@ -87,29 +106,47 @@ type Transport = {
87106
connect: () => Promise<void>;
88107
disconnect: () => Promise<void>;
89108
isConnected: () => boolean;
90-
request: <TRequest, TResponse>(request: TRequest) => Promise<TResponse>;
109+
request: <TRequest, TResponse>(
110+
request: TRequest,
111+
options?: { timeout?: number }
112+
) => Promise<TResponse>;
91113
onNotification: (callback: (data: unknown) => void) => () => void;
92114
};
93115
```
94116

95117
### Example: Custom Transport
96118

97119
```typescript
120+
import { TransportError, TransportTimeoutError } from '@metamask/multichain-api-client';
98121
import type { Transport, TransportRequest, TransportResponse } from '@metamask/multichain-api-client';
99122

100-
export function getCustomTransport(): Transport {
123+
type CustomTransportOptions = {
124+
defaultTimeout?: number; // ms
125+
};
126+
127+
export function getCustomTransport(options: CustomTransportOptions = {}): Transport {
128+
const { defaultTimeout = 5000 } = options;
129+
101130
return {
102131
connect: async () => { ... },
103132
disconnect: async () => { ... },
104133
isConnected: () => { ...},
105-
request: async <TRequest extends TransportRequest, TResponse extends TransportResponse>( request: TRequest ): Promise<TResponse> => { ... },
134+
request: async <TRequest extends TransportRequest, TResponse extends TransportResponse>( request: TRequest, { timeout }: { timeout?: number } = {}): Promise<TResponse> => { ... },
106135
onNotification: (callback: (data: unknown) => void) => { ... },
107136
};
108137
}
109138

110139
// Usage
111-
const transport = getCustomTransport();
140+
const transport = getCustomTransport({ defaultTimeout: 8000 });
112141
const client = getMultichainClient({ transport });
142+
143+
// Per-request override
144+
await client.invokeMethod({
145+
scope: 'eip155:1',
146+
request: { method: 'eth_chainId', params: [] },
147+
// The transport's request implementation can expose a timeout override
148+
{ timeout: 10000 // 10 seconds timeout for this request only
149+
});
113150
```
114151
115152
## Error Handling

src/helpers/utils.test.ts

Lines changed: 93 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,110 @@
11
import { describe, expect, it } from 'vitest';
2-
import { withRetry } from './utils';
2+
import { withRetry, withTimeout } from './utils';
33

4-
/**
5-
* Mock function that returns a promise that resolves only when called again after a delay
6-
* This function mocks MetaMask Multichain API wallet_getSession method, where early calls may never resolve
7-
*
8-
* @returns A promise that resolves after a delay
9-
*/
10-
function mockMultichainApiRequest() {
11-
const startTime = Date.now();
4+
describe('utils', () => {
5+
class CustomTimeoutError extends Error {}
6+
class CustomError extends Error {}
127

13-
// Delay for the first call to resolve
14-
const successThresholdDelay = 300;
8+
describe('withRetry', () => {
9+
it('retries on thrown error until success', async () => {
10+
let attempts = 0;
11+
const fn = async () => {
12+
attempts++;
13+
if (attempts < 3) {
14+
throw new Error('fail');
15+
}
16+
return 'ok';
17+
};
18+
const result = await withRetry(fn, { maxRetries: 5 });
19+
expect(result).toBe('ok');
20+
expect(attempts).toBe(3);
21+
});
1522

16-
return async () => {
17-
const callTime = Date.now();
18-
if (callTime - startTime < successThresholdDelay) {
19-
// Promise that never resolves
20-
await new Promise(() => {});
21-
}
22-
return 'success';
23-
};
24-
}
23+
it('throws last error after exceeding maxRetries', async () => {
24+
let attempts = 0;
25+
const fn = async () => {
26+
attempts++;
27+
throw new Error('boom');
28+
};
29+
await expect(withRetry(fn, { maxRetries: 2 })).rejects.toThrow('boom');
30+
// maxRetries=2 => attempts 0,1,2 (3 total)
31+
expect(attempts).toBe(3);
32+
});
2533

26-
function mockThrowingFn() {
27-
const startTime = Date.now();
34+
it('retries only specific error class with delay', async () => {
35+
let attempts = 0;
36+
const fn = async () => {
37+
attempts++;
38+
if (attempts < 3) {
39+
throw new CustomError('Custom Error');
40+
}
41+
return 'done';
42+
};
43+
const start = Date.now();
44+
const result = await withRetry(fn, { maxRetries: 5, retryDelay: 20, timeoutErrorClass: CustomTimeoutError });
45+
const elapsed = Date.now() - start;
46+
expect(result).toBe('done');
47+
expect(attempts).toBe(3);
48+
// Two retries with ~20ms delay each (allow some tolerance)
49+
expect(elapsed).toBeGreaterThanOrEqual(30);
50+
});
2851

29-
// Delay for the first call to resolve
30-
const successThresholdDelay = 300;
52+
it('retries only TimeoutError class without delay', async () => {
53+
let attempts = 0;
54+
const fn = async () => {
55+
attempts++;
56+
if (attempts < 3) {
57+
throw new CustomTimeoutError('Custom Error');
58+
}
59+
return 'done';
60+
};
61+
const start = Date.now();
62+
const result = await withRetry(fn, { maxRetries: 5, retryDelay: 20, timeoutErrorClass: CustomTimeoutError });
63+
const elapsed = Date.now() - start;
64+
expect(result).toBe('done');
65+
expect(attempts).toBe(3);
66+
expect(elapsed).toBeLessThanOrEqual(20);
67+
});
3168

32-
return async () => {
33-
const callTime = Date.now();
34-
if (callTime - startTime < successThresholdDelay) {
35-
throw new Error('error');
36-
}
37-
return 'success';
38-
};
39-
}
69+
it('continues retrying even if non-timeout errors occur (no delay applied for them)', async () => {
70+
const sequenceErrors = [new Error('other'), new CustomTimeoutError('timeout'), new CustomTimeoutError('timeout')];
71+
let attempts = 0;
72+
const fn = async () => {
73+
if (attempts < sequenceErrors.length) {
74+
const err = sequenceErrors[attempts];
75+
attempts++;
76+
throw err;
77+
}
78+
attempts++;
79+
return 'final';
80+
};
81+
const result = await withRetry(fn, { maxRetries: 5, retryDelay: 10, timeoutErrorClass: CustomTimeoutError });
82+
expect(result).toBe('final');
83+
expect(attempts).toBe(4); // 3 fail + 1 success
84+
});
85+
});
4086

41-
describe('utils', () => {
42-
describe('withRetry', () => {
43-
it('should retry a function until it succeeds', async () => {
44-
const result = await withRetry(mockMultichainApiRequest(), { maxRetries: 4, requestTimeout: 100 });
45-
expect(result).toBe('success');
87+
describe('withTimeout', () => {
88+
it('should resolve before timeout', async () => {
89+
const result = await withTimeout(Promise.resolve('ok'), 1000);
90+
expect(result).toBe('ok');
4691
});
4792

48-
it('should retry a function that never resolves until it succeeds', async () => {
49-
await expect(
50-
async () => await withRetry(mockMultichainApiRequest(), { maxRetries: 2, requestTimeout: 100 }),
51-
).rejects.toThrow('Timeout reached');
93+
it('should reject after timeout', async () => {
94+
await expect(withTimeout(new Promise(() => {}), 50)).rejects.toThrow('Timeout after 50ms');
5295
});
5396

54-
it('should retry a throwing function until it succeeds', async () => {
55-
const result = await withRetry(mockThrowingFn(), { maxRetries: 4, requestTimeout: 100 });
56-
expect(result).toBe('success');
97+
it('should propagate rejection from promise', async () => {
98+
await expect(withTimeout(Promise.reject(new Error('fail')), 1000)).rejects.toThrow('fail');
5799
});
58100

59-
it('should retry a throwing function until it succeeds', async () => {
60-
await expect(
61-
async () => await withRetry(mockThrowingFn(), { maxRetries: 2, requestTimeout: 100 }),
62-
).rejects.toThrow('error');
101+
it('should use custom error from errorFactory', async () => {
102+
await expect(withTimeout(new Promise(() => {}), 10, () => new CustomTimeoutError('custom'))).rejects.toThrow(
103+
CustomTimeoutError,
104+
);
105+
await expect(withTimeout(new Promise(() => {}), 10, () => new CustomTimeoutError('custom'))).rejects.toThrow(
106+
'custom',
107+
);
63108
});
64109
});
65110
});

src/helpers/utils.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// chrome is a global injected by browser extensions
2+
declare const chrome: any;
3+
14
/**
25
* Detects if we're in a Chrome-like environment with extension support
36
*/
@@ -17,35 +20,57 @@ export async function withRetry<T>(
1720
fn: () => Promise<T>,
1821
options: {
1922
maxRetries?: number;
20-
requestTimeout?: number;
2123
retryDelay?: number;
24+
timeoutErrorClass?: new (...args: any[]) => Error;
2225
} = {},
2326
): Promise<T> {
24-
const { maxRetries = 10, requestTimeout = 200, retryDelay = requestTimeout } = options;
25-
const errorMessage = 'Timeout reached';
27+
const { maxRetries = 10, retryDelay = 200, timeoutErrorClass } = options;
2628

2729
for (let attempt = 0; attempt <= maxRetries; attempt++) {
2830
try {
29-
// Use Promise.race to implement timeout
30-
const timeoutPromise = new Promise<never>((_, reject) => {
31-
setTimeout(() => reject(new Error(errorMessage)), requestTimeout);
32-
});
33-
34-
const result = await Promise.race([fn(), timeoutPromise]);
35-
return result;
31+
return await fn();
3632
} catch (error) {
3733
// If this was the last attempt, throw the error
3834
if (attempt >= maxRetries) {
3935
throw error;
4036
}
4137

4238
// Wait before retrying (unless it was a timeout, then retry immediately)
43-
if (error instanceof Error && error.message !== errorMessage) {
44-
await new Promise((resolve) => setTimeout(resolve, retryDelay));
39+
if (timeoutErrorClass && typeof timeoutErrorClass === 'function' && error instanceof timeoutErrorClass) {
40+
continue;
4541
}
42+
43+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
4644
}
4745
}
4846

4947
// This should never be reached due to the throw in the loop
5048
throw new Error('Max retries exceeded');
5149
}
50+
51+
/**
52+
* Returns a promise that resolves or rejects like the given promise, but fails if the timeout is exceeded.
53+
* @param promise - The promise to monitor
54+
* @param timeoutMs - Maximum duration in ms
55+
* @param errorFactory - Optional callback to generate a custom error on timeout
56+
*/
57+
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, errorFactory?: () => Error): Promise<T> {
58+
return new Promise<T>((resolve, reject) => {
59+
const timer = setTimeout(() => {
60+
if (errorFactory) {
61+
reject(errorFactory());
62+
} else {
63+
reject(new Error(`Timeout after ${timeoutMs}ms`));
64+
}
65+
}, timeoutMs);
66+
promise
67+
.then((value) => {
68+
clearTimeout(timer);
69+
resolve(value);
70+
})
71+
.catch((err) => {
72+
clearTimeout(timer);
73+
reject(err);
74+
});
75+
});
76+
}

src/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ import type { Transport } from './types/transport';
2020
* const transport = getDefaultTransport({ extensionId: '...' });
2121
* ```
2222
*/
23-
function getDefaultTransport(params: { extensionId?: string } = {}): Transport {
23+
function getDefaultTransport({
24+
extensionId,
25+
defaultTimeout,
26+
}: { extensionId?: string; defaultTimeout?: number } = {}): Transport {
2427
const isChrome = isChromeRuntime();
25-
return isChrome ? getExternallyConnectableTransport(params) : getWindowPostMessageTransport();
28+
return isChrome
29+
? getExternallyConnectableTransport({ extensionId, defaultTimeout })
30+
: getWindowPostMessageTransport({ defaultTimeout });
2631
}
2732

2833
export { getMultichainClient, getDefaultTransport, getExternallyConnectableTransport, getWindowPostMessageTransport };

0 commit comments

Comments
 (0)