Skip to content

Commit 5e52837

Browse files
fix: warmup timeout (#87)
* fix: warmup timeout * fix: warmup timeout
1 parent b1949a6 commit 5e52837

12 files changed

+165
-28
lines changed

README.md

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

35-
### Configuring Transport Timeout
35+
### Configuring Transport Timeouts
3636

37-
You can configure a default timeout (in milliseconds) for all requests made through the transport by passing the `defaultTimeout` option to `getDefaultTransport`:
37+
#### Default Request Timeout
38+
39+
By default, the transport has **no timeout** (`-1`) for requests. This is because most operations require user interaction (e.g., confirming transactions in the MetaMask extension), and we don't want to prematurely cancel requests while the user is reviewing them.
40+
41+
However, you can configure a default timeout (in milliseconds) for all requests by passing the `defaultTimeout` option:
42+
43+
```typescript
44+
const transport = getDefaultTransport({ defaultTimeout: 30000 }); // 30 seconds timeout for all requests
45+
const client = getMultichainClient({ transport });
46+
```
47+
48+
To explicitly disable timeouts (wait indefinitely), set the timeout to `-1`:
49+
50+
```typescript
51+
const transport = getDefaultTransport({ defaultTimeout: -1 }); // No timeout (default behavior)
52+
```
53+
54+
#### Warmup Timeout
55+
56+
The `warmupTimeout` is a special timeout used specifically for the **first request** sent immediately after the transport establishes its connection. This is useful because:
57+
58+
- Some transports need a brief moment to fully initialize before they can reliably process requests
59+
- The initial "warmup" request is typically a lightweight check (e.g., `wallet_getSession`) that doesn't require user interaction
60+
- This timeout is usually much shorter than the regular request timeout
3861

3962
```typescript
40-
const transport = getDefaultTransport({ defaultTimeout: 5000 }); // 5 seconds timeout for all requests
63+
const transport = getDefaultTransport({
64+
warmupTimeout: 200, // 200 ms for the initial warmup request
65+
defaultTimeout: -1 // No timeout for subsequent requests (user interactions)
66+
});
4167
const client = getMultichainClient({ transport });
4268
```
4369

70+
**Key differences between `warmupTimeout` and `defaultTimeout`:**
71+
72+
| Property | Purpose | Typical Value | When Applied |
73+
| ---------------- | ----------------------------- | ----------------- | ---------------------------------------- |
74+
| `warmupTimeout` | Initial connection validation | 200 ms | Only the first request after `connect()` |
75+
| `defaultTimeout` | Regular request operations | `-1` (no timeout) | All subsequent requests |
76+
```
77+
4478
## Extending RPC Types
4579
4680
The client's RPC requests are strongly typed, enforcing the RPC methods and params to be defined ahead of usage. The client supports extending
@@ -103,6 +137,7 @@ A transport must implement the following interface:
103137

104138
```typescript
105139
type Transport = {
140+
warmupTimeout?: number; // Optional timeout for the initial warmup request
106141
connect: () => Promise<void>;
107142
disconnect: () => Promise<void>;
108143
isConnected: () => boolean;
@@ -121,31 +156,50 @@ import { TransportError, TransportTimeoutError } from '@metamask/multichain-api-
121156
import type { Transport, TransportRequest, TransportResponse } from '@metamask/multichain-api-client';
122157

123158
type CustomTransportOptions = {
124-
defaultTimeout?: number; // ms
159+
defaultTimeout?: number; // Default timeout for all requests (use -1 for no timeout)
160+
warmupTimeout?: number; // Optional timeout for the initial warmup request
125161
};
126162

127163
export function getCustomTransport(options: CustomTransportOptions = {}): Transport {
128-
const { defaultTimeout = 5000 } = options;
164+
const { defaultTimeout = -1, warmupTimeout } = options; // Default: no timeout
129165

130166
return {
167+
warmupTimeout, // Expose warmupTimeout for the client to use
131168
connect: async () => { ... },
132169
disconnect: async () => { ... },
133170
isConnected: () => { ...},
134-
request: async <TRequest extends TransportRequest, TResponse extends TransportResponse>( request: TRequest, { timeout }: { timeout?: number } = {}): Promise<TResponse> => { ... },
171+
request: async <TRequest extends TransportRequest, TResponse extends TransportResponse>(
172+
request: TRequest,
173+
{ timeout = defaultTimeout }: { timeout?: number } = {}
174+
): Promise<TResponse> => {
175+
// If timeout is -1, don't apply any timeout
176+
if (timeout === -1) {
177+
return performRequest(request); // Your actual request logic
178+
}
179+
180+
// Otherwise, wrap the request with a timeout
181+
return withTimeout(
182+
performRequest(request),
183+
timeout,
184+
() => new TransportTimeoutError()
185+
);
186+
},
135187
onNotification: (callback: (data: unknown) => void) => { ... },
136188
};
137189
}
138190

139-
// Usage
140-
const transport = getCustomTransport({ defaultTimeout: 8000 });
191+
// Usage examples
192+
const transport = getCustomTransport({
193+
warmupTimeout: 500, // 500 ms for initial connection check
194+
defaultTimeout: -1 // No timeout for user interactions (default)
195+
});
141196
const client = getMultichainClient({ transport });
142197

143-
// Per-request override
198+
// Per-request timeout override
144199
await client.invokeMethod({
145200
scope: 'eip155:1',
146201
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
202+
{ timeout: 10000 } // 10 seconds timeout for this specific request
149203
});
150204
```
151205

src/helpers/utils.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,18 @@ describe('utils', () => {
106106
'custom',
107107
);
108108
});
109+
110+
it('should not apply timeout when timeoutMs is -1', async () => {
111+
const slowPromise = new Promise<string>((resolve) => {
112+
setTimeout(() => resolve('completed'), 100);
113+
});
114+
const result = await withTimeout(slowPromise, -1);
115+
expect(result).toBe('completed');
116+
});
117+
118+
it('should handle rejection when timeoutMs is -1', async () => {
119+
const failingPromise = Promise.reject(new Error('failed'));
120+
await expect(withTimeout(failingPromise, -1)).rejects.toThrow('failed');
121+
});
109122
});
110123
});

src/helpers/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,15 @@ export async function withRetry<T>(
6868
/**
6969
* Returns a promise that resolves or rejects like the given promise, but fails if the timeout is exceeded.
7070
* @param promise - The promise to monitor
71-
* @param timeoutMs - Maximum duration in ms
71+
* @param timeoutMs - Maximum duration in ms. Use -1 to disable timeout.
7272
* @param errorFactory - Optional callback to generate a custom error on timeout
7373
*/
7474
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, errorFactory?: () => Error): Promise<T> {
75+
// If timeout is -1, return the promise without timeout
76+
if (timeoutMs === -1) {
77+
return promise;
78+
}
79+
7580
return new Promise<T>((resolve, reject) => {
7681
const timer = setTimeout(() => {
7782
if (errorFactory) {

src/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ import type { Transport } from './types/transport';
2323
function getDefaultTransport({
2424
extensionId,
2525
defaultTimeout,
26-
}: { extensionId?: string; defaultTimeout?: number } = {}): Transport {
26+
warmupTimeout,
27+
}: { extensionId?: string; defaultTimeout?: number; warmupTimeout?: number } = {}): Transport {
2728
const isChrome = isChromeRuntime();
2829
return isChrome
29-
? getExternallyConnectableTransport({ extensionId, defaultTimeout })
30-
: getWindowPostMessageTransport({ defaultTimeout });
30+
? getExternallyConnectableTransport({ extensionId, defaultTimeout, warmupTimeout })
31+
: getWindowPostMessageTransport({ defaultTimeout, warmupTimeout });
3132
}
3233

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

src/multichainClient.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ describe('getMultichainClient', () => {
2727

2828
expect(result).toEqual(mockSession);
2929
// First call from initialization
30-
expect(mockTransport.request).toHaveBeenNthCalledWith(1, { method: 'wallet_getSession' });
30+
expect(mockTransport.request).toHaveBeenNthCalledWith(1, { method: 'wallet_getSession' }, { timeout: 1_000 });
3131
// Second call is the createSession request including options object
3232
expect(mockTransport.request).toHaveBeenNthCalledWith(
3333
2,
@@ -44,9 +44,14 @@ describe('getMultichainClient', () => {
4444
const result = await client.getSession();
4545

4646
expect(result).toEqual(mockSession);
47-
expect(mockTransport.request).toHaveBeenCalledWith({
48-
method: 'wallet_getSession',
49-
});
47+
// First call from initialization with warmupTimeout
48+
expect(mockTransport.request).toHaveBeenNthCalledWith(1, { method: 'wallet_getSession' }, { timeout: 1_000 });
49+
// Second call from explicit getSession()
50+
expect(mockTransport.request).toHaveBeenNthCalledWith(
51+
2,
52+
{ method: 'wallet_getSession', params: undefined },
53+
{ timeout: undefined },
54+
);
5055
});
5156

5257
describe('revokeSession', () => {
@@ -85,7 +90,7 @@ describe('getMultichainClient', () => {
8590
},
8691
});
8792
expect(signAndSendResult).toEqual({ signature: 'mock-signature' });
88-
expect(mockTransport.request).toHaveBeenNthCalledWith(1, { method: 'wallet_getSession' });
93+
expect(mockTransport.request).toHaveBeenNthCalledWith(1, { method: 'wallet_getSession' }, { timeout: 1_000 });
8994
expect(mockTransport.request).toHaveBeenNthCalledWith(
9095
2,
9196
{

src/multichainClient.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ export function getMultichainClient<T extends RpcApi = DefaultRpcApi>({
7070
await ensureConnected();
7171

7272
// Use withRetry to handle the case where the Multichain API requests don't resolve on page load (cf. https://github.com/MetaMask/metamask-mobile/issues/16550)
73-
await withRetry(() => transport.request({ method: 'wallet_getSession' }));
73+
await withRetry(() =>
74+
transport.request({ method: 'wallet_getSession' }, { timeout: transport.warmupTimeout ?? 1_000 }),
75+
);
7476
})();
7577

7678
return await initializationPromise;

src/transports/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export const INPAGE = 'metamask-inpage';
55
export const MULTICHAIN_SUBSTREAM_NAME = 'metamask-multichain-provider';
66
export const METAMASK_PROVIDER_STREAM_NAME = 'metamask-provider';
77
export const METAMASK_EXTENSION_CONNECT_CAN_RETRY = 'METAMASK_EXTENSION_CONNECT_CAN_RETRY';
8-
export const DEFAULT_REQUEST_TIMEOUT = 200; // 200ms
8+
export const DEFAULT_REQUEST_TIMEOUT = -1; // No timeout by default
9+
export const DEFAULT_WARMUP_TIMEOUT = 200; // 200 ms for initial warmup request

src/transports/externallyConnectableTransport.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,24 @@ describe('ExternallyConnectableTransport', () => {
179179
const response = await secondPromise;
180180
expect(response).toEqual({ id: MOCK_INITIAL_REQUEST_ID + 1, jsonrpc: '2.0', result: mockSession });
181181
});
182+
183+
it('should expose warmupTimeout when provided', () => {
184+
const transportWithWarmup = getExternallyConnectableTransport({
185+
extensionId: testExtensionId,
186+
warmupTimeout: 500,
187+
});
188+
expect(transportWithWarmup.warmupTimeout).toBe(500);
189+
});
190+
191+
it('should have default warmupTimeout of 200ms when not provided', () => {
192+
expect(transport.warmupTimeout).toBe(200);
193+
});
194+
195+
it('should support -1 as warmupTimeout to disable timeout', () => {
196+
const transportWithNoTimeout = getExternallyConnectableTransport({
197+
extensionId: testExtensionId,
198+
warmupTimeout: -1,
199+
});
200+
expect(transportWithNoTimeout.warmupTimeout).toBe(-1);
201+
});
182202
});

src/transports/externallyConnectableTransport.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { detectMetamaskExtensionId } from '../helpers/metamaskExtensionId';
22
import { getUniqueId, withTimeout } from '../helpers/utils';
33
import { TransportError, TransportTimeoutError } from '../types/errors';
44
import type { Transport, TransportResponse } from '../types/transport';
5-
import { DEFAULT_REQUEST_TIMEOUT, REQUEST_CAIP } from './constants';
5+
import { DEFAULT_REQUEST_TIMEOUT, DEFAULT_WARMUP_TIMEOUT, REQUEST_CAIP } from './constants';
66

77
/**
88
* Creates a transport that communicates with the MetaMask extension via Chrome's externally_connectable API
@@ -23,10 +23,10 @@ import { DEFAULT_REQUEST_TIMEOUT, REQUEST_CAIP } from './constants';
2323
* ```
2424
*/
2525
export function getExternallyConnectableTransport(
26-
params: { extensionId?: string; defaultTimeout?: number } = {},
26+
params: { extensionId?: string; defaultTimeout?: number; warmupTimeout?: number } = {},
2727
): Transport {
2828
let { extensionId } = params;
29-
const { defaultTimeout = DEFAULT_REQUEST_TIMEOUT } = params;
29+
const { defaultTimeout = DEFAULT_REQUEST_TIMEOUT, warmupTimeout = DEFAULT_WARMUP_TIMEOUT } = params;
3030
let chromePort: chrome.runtime.Port | undefined;
3131
let requestId = getUniqueId();
3232
const pendingRequests = new Map<number, (value: any) => void>();
@@ -74,6 +74,7 @@ export function getExternallyConnectableTransport(
7474
}
7575

7676
return {
77+
warmupTimeout,
7778
connect: async () => {
7879
try {
7980
if (!extensionId) {

src/transports/windowPostMessageTransport.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,4 +350,18 @@ describe('WindowPostMessageTransport', () => {
350350
const result = await secondPromise;
351351
expect(result).toEqual({ id: MOCK_INITIAL_REQUEST_ID + 1, result: mockSession });
352352
});
353+
354+
it('should expose warmupTimeout when provided', () => {
355+
const transportWithWarmup = getWindowPostMessageTransport({ warmupTimeout: 500 });
356+
expect(transportWithWarmup.warmupTimeout).toBe(500);
357+
});
358+
359+
it('should have default warmupTimeout of 200ms when not provided', () => {
360+
expect(transport.warmupTimeout).toBe(200);
361+
});
362+
363+
it('should support -1 as warmupTimeout to disable timeout', () => {
364+
const transportWithNoTimeout = getWindowPostMessageTransport({ warmupTimeout: -1 });
365+
expect(transportWithNoTimeout.warmupTimeout).toBe(-1);
366+
});
353367
});

0 commit comments

Comments
 (0)