diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 064d9e3bc44..4bb9c8d54d4 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -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 diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 79d878dbbc6..4b0b08cf127 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -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'; @@ -526,7 +527,9 @@ export type NetworkControllerRpcEndpointChainDegradedEvent = { chainId: Hex; error: unknown; networkClientId: NetworkClientId; + retryReason?: RetryReason; rpcMethodName: string; + type: DegradedEventType; }, ]; }; @@ -565,7 +568,9 @@ export type NetworkControllerRpcEndpointDegradedEvent = { error: unknown; networkClientId: NetworkClientId; primaryEndpointUrl: string; + retryReason?: RetryReason; rpcMethodName: string; + type: DegradedEventType; }, ]; }; diff --git a/packages/network-controller/src/create-network-client-tests/classify-retry-reason.test.ts b/packages/network-controller/src/create-network-client-tests/classify-retry-reason.test.ts new file mode 100644 index 00000000000..1a4e25f32d9 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/classify-retry-reason.test.ts @@ -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'); + }); +}); diff --git a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts index af281686c42..28069e17aa3 100644 --- a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts +++ b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts @@ -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', }); }, @@ -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', }); }, @@ -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', }); }, @@ -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', @@ -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', @@ -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', }); }, @@ -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', @@ -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', }); }, @@ -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', @@ -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', diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index d9e7982134f..bac8cda2af9 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -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, @@ -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. @@ -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), }); }); @@ -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, @@ -325,6 +382,9 @@ function createRpcServiceChain({ endpointUrl, error, rpcMethodName, + type, + retryReason: + error === undefined ? undefined : classifyRetryReason(error), }); }, ); diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index 98153162fe7..3e339ca8f33 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -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'; diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index b23fc0093af..f98e480c0c1 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -198,13 +198,49 @@ function isNockError(message: string): boolean { * @param error - The error object to test. * @returns True if the error indicates a JSON parse error, false otherwise. */ -function isJsonParseError(error: unknown): boolean { +export function isJsonParseError(error: unknown): boolean { return ( error instanceof SyntaxError || /invalid json/iu.test(getErrorMessage(error)) ); } +/** + * Determines whether the given error represents a HTTP server error + * (502, 503, or 504) that should be retried. + * + * @param error - The error object to test. + * @returns True if the error has an httpStatus of 502, 503, or 504. + */ +export function isHttpServerError(error: Error): boolean { + return ( + 'httpStatus' in error && + (error.httpStatus === 502 || + error.httpStatus === 503 || + error.httpStatus === 504) + ); +} + +/** + * Determines whether the given error has a `code` property of `ETIMEDOUT`. + * + * @param error - The error object to test. + * @returns True if the error code is `ETIMEDOUT`. + */ +export function isTimeoutError(error: Error): boolean { + return hasProperty(error, 'code') && error.code === 'ETIMEDOUT'; +} + +/** + * Determines whether the given error has a `code` property of `ECONNRESET`. + * + * @param error - The error object to test. + * @returns True if the error code is `ECONNRESET`. + */ +export function isConnectionResetError(error: Error): boolean { + return hasProperty(error, 'code') && error.code === 'ECONNRESET'; +} + /** * Guarantees a URL, even given a string. This is useful for checking components * of that URL. @@ -324,12 +360,11 @@ export class RpcService implements AbstractRpcService { // Ignore server sent HTML error pages or truncated JSON responses isJsonParseError(error) || // Ignore server overload errors - ('httpStatus' in error && - (error.httpStatus === 502 || - error.httpStatus === 503 || - error.httpStatus === 504)) || - (hasProperty(error, 'code') && - (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET')) + isHttpServerError(error) || + // Ignore timeout errors + isTimeoutError(error) || + // Ignore connection reset errors + isConnectionResetError(error) ); }), }); @@ -408,11 +443,18 @@ export class RpcService implements AbstractRpcService { listener: Parameters[0], ): IDisposable { return this.#policy.onDegraded((data) => { - listener({ - ...(data ?? {}), - endpointUrl: this.endpointUrl.toString(), - rpcMethodName: this.#currentRpcMethodName, - }); + if (data === undefined) { + listener({ + endpointUrl: this.endpointUrl.toString(), + rpcMethodName: this.#currentRpcMethodName, + }); + } else { + listener({ + ...data, + endpointUrl: this.endpointUrl.toString(), + rpcMethodName: this.#currentRpcMethodName, + }); + } }); }