Skip to content
3 changes: 3 additions & 0 deletions packages/network-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add `rpcMethodName` to `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointChainDegraded` event payloads ([#7954](https://github.com/MetaMask/core/pull/7954))
- This field contains the JSON-RPC method name (e.g. `eth_blockNumber`) that was being processed when the event fired, enabling identification of which methods produce the most slow requests or retry exhaustions.
- Add `type` and `retryReason` to `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointChainDegraded` event payloads ([#7988](https://github.com/MetaMask/core/pull/7988))
- `type` (`DegradedEventType`) is `'slow_success'` when the request succeeded but was slow, or `'retries_exhausted'` when retries ran out.
- `retryReason` (`RetryReason`, only present when `type` is `'retries_exhausted'`) classifies the error that was retried (e.g. `'non_successful_http_status'`, `'timed_out'`, `'connection_failed'`).

### Changed

Expand Down
5 changes: 5 additions & 0 deletions packages/network-controller/src/NetworkController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
ProxyWithAccessibleTarget,
} from './create-auto-managed-network-client';
import { createAutoManagedNetworkClient } from './create-auto-managed-network-client';
import type { DegradedEventType, RetryReason } from './create-network-client';
import { projectLogger, createModuleLogger } from './logger';
import type { RpcServiceOptions } from './rpc-service/rpc-service';
import { NetworkClientType } from './types';
Expand Down Expand Up @@ -526,7 +527,9 @@ export type NetworkControllerRpcEndpointChainDegradedEvent = {
chainId: Hex;
error: unknown;
networkClientId: NetworkClientId;
retryReason?: RetryReason;
rpcMethodName: string;
type: DegradedEventType;
},
];
};
Expand Down Expand Up @@ -565,7 +568,9 @@ export type NetworkControllerRpcEndpointDegradedEvent = {
error: unknown;
networkClientId: NetworkClientId;
primaryEndpointUrl: string;
retryReason?: RetryReason;
rpcMethodName: string;
type: DegradedEventType;
},
];
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { HttpError } from '@metamask/controller-utils';
import { FetchError } from 'node-fetch';

import { classifyRetryReason } from '../create-network-client';

describe('classifyRetryReason', () => {
it('returns "connection_failed" for FetchError connection failures', () => {
const error = new FetchError(
'request to https://example.com failed, reason: connect ECONNREFUSED',
'system',
);
expect(classifyRetryReason(error)).toBe('connection_failed');
});

it('returns "connection_failed" for TypeError network errors', () => {
const error = new TypeError('Failed to fetch');
expect(classifyRetryReason(error)).toBe('connection_failed');
});

it('returns "response_not_json" for SyntaxError (invalid JSON)', () => {
const error = new SyntaxError('Unexpected token < in JSON');
expect(classifyRetryReason(error)).toBe('response_not_json');
});

it('returns "response_not_json" for "invalid json" error messages', () => {
const error = new Error('invalid json response body');
expect(classifyRetryReason(error)).toBe('response_not_json');
});

it.each([502, 503, 504])(
'returns "non_successful_http_status" for %i errors',
(status) => {
expect(classifyRetryReason(new HttpError(status))).toBe(
'non_successful_http_status',
);
},
);

it('returns "timed_out" for ETIMEDOUT errors', () => {
const error = new Error('timed out');
Object.assign(error, { code: 'ETIMEDOUT' });
expect(classifyRetryReason(error)).toBe('timed_out');
});

it('returns "connection_reset" for ECONNRESET errors', () => {
const error = new Error('connection reset');
Object.assign(error, { code: 'ECONNRESET' });
expect(classifyRetryReason(error)).toBe('connection_reset');
});

it('returns "unknown" for unrecognized Error instances', () => {
expect(classifyRetryReason(new Error('something else'))).toBe('unknown');
});

it('returns "unknown" for non-Error values', () => {
expect(classifyRetryReason('a string')).toBe('unknown');
expect(classifyRetryReason(42)).toBe('unknown');
expect(classifyRetryReason(null)).toBe('unknown');
expect(classifyRetryReason(undefined)).toBe('unknown');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -539,8 +539,10 @@ describe('createNetworkClient - RPC endpoint events', () => {
rpcEndpointChainDegradedEventHandler,
).toHaveBeenCalledWith({
chainId,
type: 'retries_exhausted',
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
},
Expand Down Expand Up @@ -660,8 +662,10 @@ describe('createNetworkClient - RPC endpoint events', () => {
rpcEndpointChainDegradedEventHandler,
).toHaveBeenCalledWith({
chainId,
type: 'retries_exhausted',
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
},
Expand Down Expand Up @@ -769,30 +773,36 @@ describe('createNetworkClient - RPC endpoint events', () => {
rpcEndpointDegradedEventHandler,
).toHaveBeenNthCalledWith(1, {
chainId,
type: 'retries_exhausted',
endpointUrl: rpcUrl,
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
expect(
rpcEndpointDegradedEventHandler,
).toHaveBeenNthCalledWith(2, {
chainId,
type: 'retries_exhausted',
endpointUrl: rpcUrl,
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
expect(
rpcEndpointDegradedEventHandler,
).toHaveBeenNthCalledWith(3, {
chainId,
type: 'retries_exhausted',
endpointUrl: failoverEndpointUrl,
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
},
Expand Down Expand Up @@ -912,26 +922,31 @@ describe('createNetworkClient - RPC endpoint events', () => {
rpcEndpointDegradedEventHandler,
).toHaveBeenNthCalledWith(1, {
chainId,
type: 'retries_exhausted',
endpointUrl: rpcUrl,
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
expect(
rpcEndpointDegradedEventHandler,
).toHaveBeenNthCalledWith(2, {
chainId,
type: 'retries_exhausted',
endpointUrl: rpcUrl,
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
expect(
rpcEndpointDegradedEventHandler,
).toHaveBeenNthCalledWith(3, {
chainId,
type: 'slow_success',
endpointUrl: failoverEndpointUrl,
error: undefined,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
Expand All @@ -942,6 +957,7 @@ describe('createNetworkClient - RPC endpoint events', () => {
rpcEndpointDegradedEventHandler,
).toHaveBeenNthCalledWith(4, {
chainId,
type: 'slow_success',
endpointUrl: failoverEndpointUrl,
error: undefined,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
Expand Down Expand Up @@ -1143,8 +1159,10 @@ describe('createNetworkClient - RPC endpoint events', () => {
rpcEndpointChainDegradedEventHandler,
).toHaveBeenCalledWith({
chainId,
type: 'retries_exhausted',
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
},
Expand Down Expand Up @@ -1220,6 +1238,7 @@ describe('createNetworkClient - RPC endpoint events', () => {
rpcEndpointChainDegradedEventHandler,
).toHaveBeenCalledWith({
chainId,
type: 'slow_success',
error: undefined,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
rpcMethodName: 'eth_blockNumber',
Expand Down Expand Up @@ -1305,18 +1324,22 @@ describe('createNetworkClient - RPC endpoint events', () => {
);
expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({
chainId,
type: 'retries_exhausted',
endpointUrl: rpcUrl,
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({
chainId,
type: 'retries_exhausted',
endpointUrl: rpcUrl,
error: expectedDegradedError,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
primaryEndpointUrl: rpcUrl,
retryReason: 'non_successful_http_status',
rpcMethodName: 'eth_blockNumber',
});
},
Expand Down Expand Up @@ -1543,6 +1566,7 @@ describe('createNetworkClient - RPC endpoint events', () => {
);
expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({
chainId,
type: 'slow_success',
endpointUrl: rpcUrl,
error: undefined,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
Expand All @@ -1551,6 +1575,7 @@ describe('createNetworkClient - RPC endpoint events', () => {
});
expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({
chainId,
type: 'slow_success',
endpointUrl: rpcUrl,
error: undefined,
networkClientId: 'AAAA-AAAA-AAAA-AAAA',
Expand Down
60 changes: 60 additions & 0 deletions packages/network-controller/src/create-network-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ import type {
NetworkControllerMessenger,
} from './NetworkController';
import type { RpcServiceOptions } from './rpc-service/rpc-service';
import {
isConnectionError,
isConnectionResetError,
isJsonParseError,
isHttpServerError,
isTimeoutError,
} from './rpc-service/rpc-service';
import { RpcServiceChain } from './rpc-service/rpc-service-chain';
import type {
BlockTracker,
Expand All @@ -45,6 +52,50 @@ import { NetworkClientType } from './types';

const SECOND = 1000;

/**
* Why the degraded event was emitted.
*/
export type DegradedEventType = 'slow_success' | 'retries_exhausted';

/**
* The category of error that was retried until retries were exhausted.
*/
export type RetryReason =
| 'connection_failed'
| 'response_not_json'
| 'non_successful_http_status'
| 'timed_out'
| 'connection_reset'
| 'unknown';

/**
* Classifies the error that was being retried when retries were exhausted.
*
* @param error - The error from the last retry attempt.
* @returns A classification string.
*/
export function classifyRetryReason(error: unknown): RetryReason {
if (!(error instanceof Error)) {
return 'unknown';
}
if (isConnectionError(error)) {
return 'connection_failed';
}
if (isJsonParseError(error)) {
return 'response_not_json';
}
if (isHttpServerError(error)) {
return 'non_successful_http_status';
}
if (isTimeoutError(error)) {
return 'timed_out';
}
if (isConnectionResetError(error)) {
return 'connection_reset';
}
return 'unknown';
}

/**
* The pair of provider / block tracker that can be used to interface with the
* network and respond to new activity.
Expand Down Expand Up @@ -301,11 +352,15 @@ function createRpcServiceChain({

rpcServiceChain.onDegraded(({ rpcMethodName, ...rest }) => {
const error = getError(rest);
const type: DegradedEventType =
error === undefined ? 'slow_success' : 'retries_exhausted';
messenger.publish('NetworkController:rpcEndpointChainDegraded', {
chainId: configuration.chainId,
networkClientId: id,
error,
rpcMethodName,
type,
retryReason: error === undefined ? undefined : classifyRetryReason(error),
});
});

Expand All @@ -317,6 +372,8 @@ function createRpcServiceChain({
...rest
}) => {
const error = getError(rest);
const type: DegradedEventType =
error === undefined ? 'slow_success' : 'retries_exhausted';

messenger.publish('NetworkController:rpcEndpointDegraded', {
chainId: configuration.chainId,
Expand All @@ -325,6 +382,9 @@ function createRpcServiceChain({
endpointUrl,
error,
rpcMethodName,
type,
retryReason:
error === undefined ? undefined : classifyRetryReason(error),
});
},
);
Expand Down
2 changes: 2 additions & 0 deletions packages/network-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,6 @@ export { NetworkClientType } from './types';
export type { NetworkClient } from './create-network-client';
export type { AbstractRpcService } from './rpc-service/abstract-rpc-service';
export type { RpcServiceRequestable } from './rpc-service/rpc-service-requestable';
export type { DegradedEventType, RetryReason } from './create-network-client';
export { classifyRetryReason } from './create-network-client';
export { isConnectionError } from './rpc-service/rpc-service';
Loading
Loading