Skip to content
Open
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 packages/snap-networks-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `SynchronizationError`, `formatAccountSyncFailures`, and the `AccountSyncFailure` type, for reporting account synchronization failures with per-account failure details embedded in the error message (details must live in the message because `snap_trackError` only serializes `name`, `message`, `stack`, and `cause`). ([#XXX](https://github.com/MetaMask/internal-snaps/pull/XXX))
- Add `wrapSnapHandlers` to wrap any Snap entrypoint handlers with `withCatchAndThrowSnapError`, with optional per-handler `logError` overrides ([#341](https://github.com/MetaMask/internal-snaps/pull/341))
- Add `noopAssetHandlers`, the no-op asset entrypoints network Snaps must export to keep the `endowment:assets` permission ([#341](https://github.com/MetaMask/internal-snaps/pull/341))
- Add a shared `AnalyticsService` and typed event properties for network Snap telemetry. ([#327](https://github.com/MetaMask/internal-snaps/pull/327))
Expand Down
5 changes: 5 additions & 0 deletions packages/snap-networks-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,12 @@ export {
createSnapErrorHandling,
createTrackError,
createWithCatchAndThrowSnapError,
formatAccountSyncFailures,
getSyncFailuresFromSettledResult,
isSnapRpcError,
normalizeError,
stringifyReason,
SynchronizationError,
} from './utils/errors';
export { InFlightCoalescer } from './utils/dedupe/InFlightCoalescer';
export { InMemoryCache } from './utils/cache/InMemoryCache';
Expand Down Expand Up @@ -137,6 +141,7 @@ export type {
WithCatchAndThrowSnapError,
} from './utils/handlers/wrapSnapHandlers';
export type {
AccountSyncFailure,
CreateSnapErrorHandlingOptions,
CreateTrackErrorOptions,
CreateWithCatchAndThrowSnapErrorOptions,
Expand Down
7 changes: 7 additions & 0 deletions packages/snap-networks-utils/src/utils/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
export { createSnapErrorHandling, createTrackError } from './trackError';
export {
SynchronizationError,
formatAccountSyncFailures,
getSyncFailuresFromSettledResult,
} from './syncError';
export type { AccountSyncFailure } from './syncError';
export { createWithCatchAndThrowSnapError, normalizeError } from './errors';
export { stringifyReason } from './stringifyReason';
export { isSnapRpcError } from './snapRpcError';
export type {
CreateSnapErrorHandlingOptions,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { stringifyReason } from './stringifyReason';

describe('stringifyReason', () => {
it('stringifies a plain error as name and message', () => {
expect(stringifyReason(new Error('boom'))).toBe('Error: boom');
});

it('appends the cause of wrapped errors', () => {
const error = new Error('Failed to synchronize account', {
cause: new Error('502 Bad Gateway'),
});

expect(stringifyReason(error)).toBe(
'Error: Failed to synchronize account (Error: 502 Bad Gateway)',
);
});

it('appends causes that are not errors', () => {
const error = new Error('boom', { cause: '502 Bad Gateway' });

expect(stringifyReason(error)).toBe('Error: boom (502 Bad Gateway)');
});

it('does not append an explicit null cause', () => {
const error = new Error('boom', { cause: null });

expect(stringifyReason(error)).toBe('Error: boom');
});

it('stringifies non-error reasons as-is', () => {
expect(stringifyReason('plain failure')).toBe('plain failure');
expect(stringifyReason(42)).toBe('42');
});

it('returns a placeholder when stringification throws', () => {
const hostile = {
toString(): string {
throw new Error('hostile toString');
},
};

expect(stringifyReason(hostile)).toBe('Unknown error');
});

it('returns a placeholder when the error itself cannot be stringified', () => {
const hostileError = new Error('boom');
hostileError.toString = (): string => {
throw new Error('hostile toString');
};

expect(stringifyReason(hostileError)).toBe('Unknown error');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Stringifies a rejection reason without ever throwing: `String(reason)`
* throws when the reason has a hostile `toString`/`Symbol.toPrimitive`. If the
* reason is an error with a `cause`, the cause is appended, so the wrapped
* failure (e.g. the underlying network error) is part of the reported reason.
*
* Intended for building human-readable failure reasons (e.g. for
* `AccountSyncFailure.reason`), where wrapper errors would otherwise hide the
* original failure behind a generic message.
*
* @param reason - The rejection reason to stringify.
* @returns The reason as a string, or a placeholder when stringification fails.
*/
export function stringifyReason(reason: unknown): string {
try {
if (!(reason instanceof Error)) {
return String(reason);
}

const message = String(reason);
const { cause } = reason;

if (cause === null || cause === undefined) {
return message;
}

// Deliberately permissive: any cause detail is worth reporting, and a
// pathological value still ends up as the placeholder via the outer catch.
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return `${message} (${String(cause)})`;
} catch {
return 'Unknown error';
}
}
140 changes: 140 additions & 0 deletions packages/snap-networks-utils/src/utils/errors/syncError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
SynchronizationError,
formatAccountSyncFailures,
getSyncFailuresFromSettledResult,
} from './syncError';

describe('formatAccountSyncFailures', () => {
it('formats a single failure', () => {
const failures = [{ accountId: 'account-1', reason: 'Error: boom' }];

expect(formatAccountSyncFailures(failures)).toBe('account-1: Error: boom');
});

it('formats multiple failures separated by semicolons', () => {
const failures = [
{ accountId: 'account-1', reason: 'Error: boom' },
{ accountId: 'account-2', reason: 'TimeoutError: too slow' },
];

expect(formatAccountSyncFailures(failures)).toBe(
'account-1: Error: boom; account-2: TimeoutError: too slow',
);
});

it('truncates long reasons', () => {
const failures = [{ accountId: 'account-1', reason: 'x'.repeat(1000) }];

const formatted = formatAccountSyncFailures(failures);

expect(formatted).toHaveLength('account-1: '.length + 300 + 1);
expect(formatted).toContain('…');
});

it('truncates the list when there are many failures', () => {
const failures = Array.from({ length: 15 }, (_, index) => ({
accountId: `account-${index}`,
reason: 'Error: boom',
}));

const formatted = formatAccountSyncFailures(failures);

expect(formatted).toContain('account-9: Error: boom');
expect(formatted).not.toContain('account-10: Error: boom');
expect(formatted.endsWith('+5 more')).toBe(true);
});

it('returns an empty string for no failures', () => {
expect(formatAccountSyncFailures([])).toBe('');
});
});

describe('getSyncFailuresFromSettledResult', () => {
it('maps rejections to failures with stringified reasons', () => {
const results = [
{ status: 'fulfilled', value: 'ok' },
{
status: 'rejected',
reason: new Error('Failed to synchronize account', {
cause: new Error('502 Bad Gateway'),
}),
},
{ status: 'rejected', reason: 42 },
] as PromiseSettledResult<unknown>[];

expect(
getSyncFailuresFromSettledResult(results, [
'account-1',
'account-2',
'account-3',
]),
).toStrictEqual([
{
accountId: 'account-2',
reason: 'Error: Failed to synchronize account (Error: 502 Bad Gateway)',
},
{ accountId: 'account-3', reason: '42' },
]);
});

it('skips rejections without a matching account ID', () => {
const results = [
{ status: 'rejected', reason: new Error('boom') },
{ status: 'rejected', reason: new Error('also boom') },
] as PromiseSettledResult<unknown>[];

expect(
getSyncFailuresFromSettledResult(results, ['account-1']),
).toStrictEqual([{ accountId: 'account-1', reason: 'Error: boom' }]);
});

it('returns an empty list when nothing is rejected', () => {
const results = [
{ status: 'fulfilled', value: 'ok' },
] as PromiseSettledResult<unknown>[];

expect(
getSyncFailuresFromSettledResult(results, ['account-1']),
).toStrictEqual([]);
});
});

describe('SynchronizationError', () => {
it('sets the name and the message', () => {
const error = new SynchronizationError('Account synchronization failures');

expect(error.name).toBe('SynchronizationError');
expect(error.message).toBe('Account synchronization failures');
expect(error.failures).toStrictEqual([]);
});

it('appends failure details to the message so they survive error tracking', () => {
const failures = [
{ accountId: 'account-1', reason: 'Error: boom' },
{ accountId: 'account-2', reason: 'TimeoutError: too slow' },
];

const error = new SynchronizationError(
'Account synchronization failures',
failures,
);

expect(error.name).toBe('SynchronizationError');
expect(error.message).toBe(
'Account synchronization failures (2 failed): account-1: Error: boom; ' +
'account-2: TimeoutError: too slow',
);
expect(error.failures).toBe(failures);
});

it('supports a cause', () => {
const cause = new Error('root cause');
const error = new SynchronizationError(
'Account synchronization failures',
[{ accountId: 'account-1', reason: 'Error: boom' }],
{ cause },
);

expect(error.cause).toBe(cause);
});
});
125 changes: 125 additions & 0 deletions packages/snap-networks-utils/src/utils/errors/syncError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { stringifyReason } from './stringifyReason';

/**
* A single account synchronization failure, with enough context to understand
* which account failed and why.
*/
export type AccountSyncFailure = {
/** The ID of the account that failed to synchronize. */
accountId: string;
/** The failure reason, as a human-readable string. */
reason: string;
};

/**
* Maps settled synchronization results to account synchronization failures:
* each rejection is attributed to the account ID at the same index
* (`Promise.allSettled` preserves order).
*
* Rejection reasons are stringified with {@link stringifyReason}, so wrapped
* errors report their cause as well.
*
* @param results - The settled synchronization results.
* @param accountIds - The account IDs, in the same order as `results`.
* @returns The collected failures, in results order.
*/
export function getSyncFailuresFromSettledResult(
results: PromiseSettledResult<unknown>[],
accountIds: string[],
): AccountSyncFailure[] {
const failures: AccountSyncFailure[] = [];

results.forEach((result, index) => {
const accountId = accountIds[index];
if (accountId && result.status === 'rejected') {
failures.push({
accountId,
reason: stringifyReason(result.reason),
});
}
});

return failures;
}

/**
* Maximum number of characters kept from each failure reason. Reasons can be
* arbitrarily long (e.g. full network error messages), so they are truncated
* to keep the error message readable in Sentry.
*/
const MAX_REASON_LENGTH = 300;

/**
* Maximum number of failures listed in the error message. When more accounts
* fail, the remaining ones are summarized with a `+N more` suffix.
*/
const MAX_LISTED_FAILURES = 10;

/**
* Formats account synchronization failures as a compact, single-line summary.
*
* The summary is intended to be embedded in the error message of a
* `SynchronizationError`: error tracking via `snap_trackError` serializes only
* the error's `name`, `message`, `stack`, and `cause`, so any details stored in
* other properties (e.g. a `data` field) are lost before reaching Sentry.
*
* @param failures - The account synchronization failures to format.
* @returns A single-line summary of the failures, truncated to keep the
* message readable.
*/
export function formatAccountSyncFailures(
failures: AccountSyncFailure[],
): string {
const listed = failures.slice(0, MAX_LISTED_FAILURES);
const details = listed.map((failure) => {
const reason =
failure.reason.length > MAX_REASON_LENGTH
? `${failure.reason.slice(0, MAX_REASON_LENGTH)}…`
: failure.reason;

return `${failure.accountId}: ${reason}`;
});

const remaining = failures.length - listed.length;
const suffix = remaining > 0 ? `; +${remaining} more` : '';

return `${details.join('; ')}${suffix}`;
}

/**
* Error thrown when one or more accounts fail to synchronize.
*
* The failure details are embedded in the message (not only in a property) so
* that they survive error tracking: `snap_trackError` serializes only the
* error's `name`, `message`, `stack`, and `cause`.
*/
export class SynchronizationError extends Error {
/**
* The structured list of account synchronization failures. Useful for logs;
* note that it is not preserved by error tracking.
*/
readonly failures: AccountSyncFailure[];

/**
* Construct a new synchronization error.
*
* @param message - The error message, e.g. `'Account synchronization failures'`.
* @param failures - The per-account failures to append to the message.
* @param options - Additional error options.
* @param options.cause - The underlying error that caused the synchronization failure, if any.
*/
constructor(
message: string,
failures: AccountSyncFailure[] = [],
options?: { cause?: unknown },
) {
const details =
failures.length > 0
? ` (${failures.length} failed): ${formatAccountSyncFailures(failures)}`
: '';

super(`${message}${details}`, options);
this.name = 'SynchronizationError';
this.failures = failures;
}
}
Loading
Loading