Skip to content

Commit 5c742cc

Browse files
authored
fix: handle errors on the edge (#430)
* Handle errors on the edge (Meaning snap `index.ts`) * Convert errors to `SnapError` on every handler
1 parent fefb2dd commit 5c742cc

4 files changed

Lines changed: 309 additions & 148 deletions

File tree

packages/snap/snap.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snap-solana-wallet.git"
88
},
99
"source": {
10-
"shasum": "5dayxdLk26hWbBj7hAl3wzDaPIPWYYsnsvQcIKuo8KA=",
10+
"shasum": "M/TAq4KOxYiej93YfzvH5+bpPF1bJk+sGuy/LbxGV5A=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { expect } from '@jest/globals';
2+
import { SnapError } from '@metamask/snaps-sdk';
3+
4+
import { withCatchAndThrowSnapError } from './errors';
5+
import logger from './logger';
6+
7+
// Mock the logger to avoid actual console output during tests
8+
jest.mock('./logger', () => ({
9+
error: jest.fn(),
10+
}));
11+
12+
describe('errors', () => {
13+
const mockLogger = logger as jest.Mocked<typeof logger>;
14+
15+
beforeEach(() => {
16+
jest.clearAllMocks();
17+
});
18+
19+
describe('handle', () => {
20+
it('returns the result when the function succeeds', async () => {
21+
const mockFn = jest.fn().mockResolvedValue('success');
22+
23+
const result = await withCatchAndThrowSnapError(mockFn);
24+
25+
expect(result).toBe('success');
26+
expect(mockFn).toHaveBeenCalledTimes(1);
27+
expect(mockLogger.error).not.toHaveBeenCalled();
28+
});
29+
30+
it('handles and re-throws errors as SnapError', async () => {
31+
const originalError = new Error('Test error');
32+
const mockFn = jest.fn().mockRejectedValue(originalError);
33+
34+
await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
35+
SnapError,
36+
);
37+
38+
expect(mockFn).toHaveBeenCalledTimes(1);
39+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
40+
});
41+
42+
it('logs errors with the correct scope and error details', async () => {
43+
const originalError = new Error('Test error');
44+
const mockFn = jest.fn().mockRejectedValue(originalError);
45+
46+
try {
47+
await withCatchAndThrowSnapError(mockFn);
48+
} catch (error) {
49+
// Expected to throw
50+
}
51+
52+
expect(mockLogger.error).toHaveBeenCalledWith(
53+
{ error: expect.any(SnapError) },
54+
expect.stringContaining(`[SnapError]`),
55+
);
56+
57+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
58+
const logCall = mockLogger.error.mock.calls[0];
59+
const loggedError = logCall?.[0]?.error;
60+
expect(loggedError).toBeInstanceOf(SnapError);
61+
});
62+
63+
it('handles non-Error objects and converts them to SnapError', async () => {
64+
const nonErrorValue = 'string error';
65+
const mockFn = jest.fn().mockRejectedValue(nonErrorValue);
66+
67+
await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
68+
SnapError,
69+
);
70+
71+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
72+
const logCall = mockLogger.error.mock.calls[0];
73+
const loggedError = logCall?.[0]?.error;
74+
expect(loggedError).toBeInstanceOf(SnapError);
75+
});
76+
77+
it('handles null and undefined errors', async () => {
78+
const mockFn = jest.fn().mockRejectedValue(null);
79+
80+
await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
81+
SnapError,
82+
);
83+
84+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
85+
});
86+
87+
it('preserves the original error message in the SnapError', async () => {
88+
const originalError = new Error('Custom error message');
89+
const mockFn = jest.fn().mockRejectedValue(originalError);
90+
91+
let caughtError: unknown;
92+
try {
93+
await withCatchAndThrowSnapError(mockFn);
94+
} catch (error) {
95+
caughtError = error;
96+
}
97+
98+
expect(caughtError).toBeInstanceOf(SnapError);
99+
const snapError = caughtError as SnapError;
100+
expect(snapError.message).toBe('Custom error message');
101+
});
102+
103+
it('handles async functions that return different types', async () => {
104+
const testCases = [
105+
{ value: 42, type: 'number' },
106+
{ value: { key: 'value' }, type: 'object' },
107+
{ value: [1, 2, 3], type: 'array' },
108+
{ value: true, type: 'boolean' },
109+
{ value: null, type: 'null' },
110+
];
111+
112+
for (const testCase of testCases) {
113+
const mockFn = jest.fn().mockResolvedValue(testCase.value);
114+
115+
const result = await withCatchAndThrowSnapError(mockFn);
116+
117+
expect(result).toBe(testCase.value);
118+
expect(mockLogger.error).not.toHaveBeenCalled();
119+
}
120+
});
121+
122+
it('handles functions that throw different error types', async () => {
123+
const errorTypes = [
124+
new TypeError('Type error'),
125+
new ReferenceError('Reference error'),
126+
new RangeError('Range error'),
127+
new SyntaxError('Syntax error'),
128+
];
129+
130+
for (const errorType of errorTypes) {
131+
const mockFn = jest.fn().mockRejectedValue(errorType);
132+
133+
await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
134+
SnapError,
135+
);
136+
}
137+
138+
expect(mockLogger.error).toHaveBeenCalledTimes(errorTypes.length);
139+
const logCalls = mockLogger.error.mock.calls;
140+
expect(logCalls).toHaveLength(errorTypes.length);
141+
142+
for (let i = 0; i < errorTypes.length; i++) {
143+
const logCall = logCalls[i];
144+
const loggedError = logCall?.[0]?.error;
145+
expect(loggedError).toBeInstanceOf(SnapError);
146+
expect(loggedError?.message).toBe(errorTypes[i]?.message);
147+
}
148+
});
149+
150+
it('includes error stack trace in the logged error', async () => {
151+
const originalError = new Error('Test error');
152+
originalError.stack = 'Error: Test error\n at test.js:1:1';
153+
const mockFn = jest.fn().mockRejectedValue(originalError);
154+
155+
try {
156+
await withCatchAndThrowSnapError(mockFn);
157+
} catch (error) {
158+
// Expected to throw
159+
}
160+
161+
expect(mockLogger.error).toHaveBeenCalledWith(
162+
{ error: expect.any(SnapError) },
163+
expect.stringContaining('[SnapError]'),
164+
);
165+
});
166+
167+
it('handles functions that throw promises', async () => {
168+
const rejectedPromise = Promise.reject(new Error('Promise error'));
169+
const mockFn = jest.fn().mockImplementation(async () => rejectedPromise);
170+
171+
await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow(
172+
SnapError,
173+
);
174+
175+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
176+
});
177+
});
178+
});
Lines changed: 24 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,24 @@
11
import {
22
MethodNotFoundError,
3-
UserRejectedRequestError,
4-
MethodNotSupportedError,
53
ParseError,
64
ResourceNotFoundError,
75
ResourceUnavailableError,
8-
TransactionRejected,
96
ChainDisconnectedError,
7+
TransactionRejected,
108
DisconnectedError,
9+
InternalError,
1110
UnauthorizedError,
1211
UnsupportedMethodError,
13-
InternalError,
1412
InvalidInputError,
1513
InvalidParamsError,
1614
InvalidRequestError,
1715
LimitExceededError,
1816
SnapError,
17+
MethodNotSupportedError,
18+
UserRejectedRequestError,
1919
} from '@metamask/snaps-sdk';
2020

21-
export class CustomError extends Error {
22-
name!: string;
23-
24-
constructor(message?: string) {
25-
super(message);
26-
27-
// set error name as constructor name, make it not enumerable to keep native Error behavior
28-
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new.target#new.target_in_constructors
29-
// see https://github.com/adriengibrat/ts-custom-error/issues/30
30-
Object.defineProperty(this, 'name', {
31-
value: new.target.name,
32-
enumerable: false,
33-
configurable: true,
34-
});
35-
36-
// fix the extended error prototype chain
37-
// because typescript __extends implementation can't
38-
// see https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
39-
Object.setPrototypeOf(this, new.target.prototype);
40-
// remove constructor from stack trace
41-
Error.captureStackTrace(this, this.constructor);
42-
}
43-
}
44-
45-
/**
46-
* Compacts an error to a specific error instance.
47-
*
48-
* @param error - The error instance to be compacted.
49-
* @param ErrCtor - The error constructor for the desired error instance.
50-
* @returns The compacted error instance.
51-
*/
52-
export function compactError<ErrorInstance extends Error>(
53-
error: ErrorInstance,
54-
ErrCtor: new (message?: string) => ErrorInstance,
55-
): ErrorInstance {
56-
if (error instanceof ErrCtor) {
57-
return error;
58-
}
59-
return new ErrCtor(error.message);
60-
}
21+
import logger from './logger';
6122

6223
/**
6324
* Determines if the given error is a Snap RPC error.
@@ -88,3 +49,22 @@ export function isSnapRpcError(error: Error): boolean {
8849
];
8950
return errors.some((errType) => error instanceof errType);
9051
}
52+
53+
export const withCatchAndThrowSnapError = async <ResponseT>(
54+
fn: () => Promise<ResponseT>,
55+
): Promise<ResponseT> => {
56+
try {
57+
return await fn();
58+
} catch (errorInstance: any) {
59+
const error = isSnapRpcError(errorInstance)
60+
? errorInstance
61+
: new SnapError(errorInstance);
62+
63+
logger.error(
64+
{ error },
65+
`[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`,
66+
);
67+
68+
throw error;
69+
}
70+
};

0 commit comments

Comments
 (0)