From 9ae4a222b1989cdba8f359f3e16d80353daeafbc Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 18 Feb 2026 14:04:37 -0800 Subject: [PATCH 1/9] feat: add degradedType and retriedError to degraded events --- packages/network-controller/CHANGELOG.md | 7 +- .../src/NetworkController.ts | 10 ++- .../rpc-endpoint-events.test.ts | 25 +++++++ .../src/create-network-client.ts | 26 ++++--- packages/network-controller/src/index.ts | 1 + .../src/rpc-service/rpc-service-chain.test.ts | 46 ++++++++++++ .../rpc-service/rpc-service-requestable.ts | 8 +- .../src/rpc-service/rpc-service.test.ts | 18 ++++- .../src/rpc-service/rpc-service.ts | 74 +++++++++++++++++-- 9 files changed, 195 insertions(+), 20 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 064d9e3bc44..6f40ac0286d 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -11,11 +11,14 @@ 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 `degradedType` and `retriedError` to `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointChainDegraded` event payloads ([#7988](https://github.com/MetaMask/core/pull/7988)) + - `degradedType` is `'slow_success'` when the request succeeded but was slow, or `'retries_exhausted'` when retries ran out. + - `retriedError` (only present when `degradedType` is `'retries_exhausted'`) classifies the error that was retried (e.g. `'non_success_http_status'`, `'timed_out'`, `'request_not_initiated'`). ### Changed -- **BREAKING:** The `RpcServiceRequestable` type's `onDegraded` listener now receives `rpcMethodName: string` in its data parameter ([#7954](https://github.com/MetaMask/core/pull/7954)) - - Implementors of this interface will need to accept the new field in their `onDegraded` callback signature. +- **BREAKING:** The `RpcServiceRequestable` type's `onDegraded` listener now receives `rpcMethodName: string`, `degradedType: DegradedType`, and optionally `retriedError: RetriedError` in its data parameter ([#7954](https://github.com/MetaMask/core/pull/7954), [#7988](https://github.com/MetaMask/core/pull/7988)) + - Implementors of this interface will need to accept the new fields in their `onDegraded` callback signature. - Bump `@metamask/eth-json-rpc-middleware` from `^23.0.0` to `^23.1.0` ([#7810](https://github.com/MetaMask/core/pull/7810)) - Bump `@metamask/json-rpc-engine` from `^10.2.1` to `^10.2.2` ([#7856](https://github.com/MetaMask/core/pull/7856)) diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 79d878dbbc6..31df4bce172 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -48,7 +48,11 @@ import type { } from './create-auto-managed-network-client'; import { createAutoManagedNetworkClient } from './create-auto-managed-network-client'; import { projectLogger, createModuleLogger } from './logger'; -import type { RpcServiceOptions } from './rpc-service/rpc-service'; +import type { + DegradedType, + RetriedError, + RpcServiceOptions, +} from './rpc-service/rpc-service'; import { NetworkClientType } from './types'; import type { BlockTracker, @@ -524,8 +528,10 @@ export type NetworkControllerRpcEndpointChainDegradedEvent = { payload: [ { chainId: Hex; + degradedType: DegradedType; error: unknown; networkClientId: NetworkClientId; + retriedError?: RetriedError; rpcMethodName: string; }, ]; @@ -561,10 +567,12 @@ export type NetworkControllerRpcEndpointDegradedEvent = { payload: [ { chainId: Hex; + degradedType: DegradedType; endpointUrl: string; error: unknown; networkClientId: NetworkClientId; primaryEndpointUrl: string; + retriedError?: RetriedError; rpcMethodName: string; }, ]; 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..25e61227393 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, + degradedType: 'retries_exhausted', error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); }, @@ -660,8 +662,10 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointChainDegradedEventHandler, ).toHaveBeenCalledWith({ chainId, + degradedType: 'retries_exhausted', error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); }, @@ -769,30 +773,36 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(1, { chainId, + degradedType: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); expect( rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(2, { chainId, + degradedType: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); expect( rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(3, { chainId, + degradedType: 'retries_exhausted', endpointUrl: failoverEndpointUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); }, @@ -912,26 +922,31 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(1, { chainId, + degradedType: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); expect( rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(2, { chainId, + degradedType: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); expect( rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(3, { chainId, + degradedType: 'slow_success', endpointUrl: failoverEndpointUrl, error: undefined, networkClientId: 'AAAA-AAAA-AAAA-AAAA', @@ -942,6 +957,7 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(4, { chainId, + degradedType: 'slow_success', endpointUrl: failoverEndpointUrl, error: undefined, networkClientId: 'AAAA-AAAA-AAAA-AAAA', @@ -1143,8 +1159,10 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointChainDegradedEventHandler, ).toHaveBeenCalledWith({ chainId, + degradedType: 'retries_exhausted', error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); }, @@ -1220,6 +1238,7 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointChainDegradedEventHandler, ).toHaveBeenCalledWith({ chainId, + degradedType: '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, + degradedType: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ chainId, + degradedType: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, + retriedError: 'non_success_http_status', rpcMethodName: 'eth_blockNumber', }); }, @@ -1543,6 +1566,7 @@ describe('createNetworkClient - RPC endpoint events', () => { ); expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ chainId, + degradedType: 'slow_success', endpointUrl: rpcUrl, error: undefined, networkClientId: 'AAAA-AAAA-AAAA-AAAA', @@ -1551,6 +1575,7 @@ describe('createNetworkClient - RPC endpoint events', () => { }); expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ chainId, + degradedType: '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..a9457fdf1e7 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -299,21 +299,27 @@ function createRpcServiceChain({ }, ); - rpcServiceChain.onDegraded(({ rpcMethodName, ...rest }) => { - const error = getError(rest); - messenger.publish('NetworkController:rpcEndpointChainDegraded', { - chainId: configuration.chainId, - networkClientId: id, - error, - rpcMethodName, - }); - }); + rpcServiceChain.onDegraded( + ({ rpcMethodName, degradedType, retriedError, ...rest }) => { + const error = getError(rest); + messenger.publish('NetworkController:rpcEndpointChainDegraded', { + chainId: configuration.chainId, + networkClientId: id, + error, + rpcMethodName, + degradedType, + retriedError, + }); + }, + ); rpcServiceChain.onServiceDegraded( ({ endpointUrl, primaryEndpointUrl: primaryEndpointUrlFromEvent, rpcMethodName, + degradedType, + retriedError, ...rest }) => { const error = getError(rest); @@ -325,6 +331,8 @@ function createRpcServiceChain({ endpointUrl, error, rpcMethodName, + degradedType, + retriedError, }); }, ); diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index 98153162fe7..af8f03e7cc7 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -61,4 +61,5 @@ 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 { DegradedType, RetriedError } from './rpc-service/rpc-service'; export { isConnectionError } from './rpc-service/rpc-service'; diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts index bc62a3ee93f..dcda681d15b 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -1092,6 +1092,8 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1140,6 +1142,7 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -1214,6 +1217,8 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1282,6 +1287,8 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_blockNumber', }); @@ -1359,6 +1366,8 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1432,10 +1441,13 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(2); expect(onDegradedListener).toHaveBeenNthCalledWith(1, { + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onDegradedListener).toHaveBeenNthCalledWith(2, { + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -1533,10 +1545,13 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(2); expect(onDegradedListener).toHaveBeenNthCalledWith(1, { + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onDegradedListener).toHaveBeenNthCalledWith(2, { + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -1592,12 +1607,16 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1648,11 +1667,13 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -1729,17 +1750,22 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1819,18 +1845,23 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${secondaryEndpointUrl}/`, + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -1905,18 +1936,23 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -2016,30 +2052,39 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${secondaryEndpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(4, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${secondaryEndpointUrl}/`, + degradedType: 'retries_exhausted', + retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(5, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -2212,6 +2257,7 @@ describe('RpcServiceChain', () => { // Verify degradation occurred after the first (slow) request expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ + degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); diff --git a/packages/network-controller/src/rpc-service/rpc-service-requestable.ts b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts index 2433f60634e..448a6ea0633 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-requestable.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts @@ -6,6 +6,7 @@ import type { JsonRpcResponse, } from '@metamask/utils'; +import type { DegradedType, RetriedError } from './rpc-service'; import type { CockatielEventToEventListenerWithData, ExcludeCockatielEventData, @@ -65,7 +66,12 @@ export type RpcServiceRequestable = { onDegraded( listener: CockatielEventToEventListenerWithData< ServicePolicy['onDegraded'], - { endpointUrl: string; rpcMethodName: string } + { + endpointUrl: string; + rpcMethodName: string; + degradedType: DegradedType; + retriedError?: RetriedError; + } >, ): ReturnType; diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index 5b65bd853a2..fa2554a237c 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -400,13 +400,17 @@ describe('RpcService', () => { testsForRetriableFetchErrors({ producedError: error, expectedError: error, + expectedRetriedError: 'request_not_initiated', }); }, ); - describe.each(['ETIMEDOUT', 'ECONNRESET'])( + describe.each([ + ['ETIMEDOUT', 'timed_out'], + ['ECONNRESET', 'connection_reset'], + ] as const)( 'if making the request throws a "%s" error', - (errorCode) => { + (errorCode, expectedRetriedError) => { const error = new Error('timed out'); // @ts-expect-error `code` does not exist on the Error type, but is // still used by Node. @@ -415,6 +419,7 @@ describe('RpcService', () => { testsForRetriableFetchErrors({ producedError: error, expectedError: error, + expectedRetriedError, }); }, ); @@ -1030,6 +1035,7 @@ describe('RpcService', () => { expect(onDegradedListener).toHaveBeenCalledWith({ endpointUrl: `${endpointUrl}/`, rpcMethodName: 'eth_chainId', + degradedType: 'slow_success', }); }); @@ -1094,10 +1100,12 @@ describe('RpcService', () => { expect(onDegradedListener).toHaveBeenCalledWith({ endpointUrl: `${endpointUrl}/`, rpcMethodName: 'eth_blockNumber', + degradedType: 'slow_success', }); expect(onDegradedListener).toHaveBeenCalledWith({ endpointUrl: `${endpointUrl}/`, rpcMethodName: 'eth_gasPrice', + degradedType: 'slow_success', }); }); @@ -1422,13 +1430,17 @@ function testsForNonRetriableErrors({ * @param args.producedError - The error produced when `fetch` is called. * @param args.expectedError - The error that a call to the service's `request` * method is expected to produce. + * @param args.expectedRetriedError - The expected `retriedError` classification + * string for the degraded event. */ function testsForRetriableFetchErrors({ producedError, expectedError, + expectedRetriedError, }: { producedError: Error; expectedError: string | jest.Constructable | RegExp | Error; + expectedRetriedError: string; }): void { // This function is designed to be used inside of a describe, so this won't be // a problem in practice. @@ -1494,6 +1506,8 @@ function testsForRetriableFetchErrors({ endpointUrl: `${endpointUrl}/`, error: expectedError, rpcMethodName: 'eth_chainId', + degradedType: 'retries_exhausted', + retriedError: expectedRetriedError, }); }); diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index b23fc0093af..a0c3b118813 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -65,6 +65,21 @@ export type RpcServiceOptions = { isOffline: () => boolean; }; +/** + * Why the RPC service became degraded. + */ +export type DegradedType = 'slow_success' | 'retries_exhausted'; + +/** + * The category of error that was retried until retries were exhausted. + */ +export type RetriedError = + | 'request_not_initiated' + | 'response_not_json' + | 'non_success_http_status' + | 'timed_out' + | 'connection_reset'; + const log = createModuleLogger(projectLogger, 'RpcService'); /** @@ -205,6 +220,43 @@ function isJsonParseError(error: unknown): boolean { ); } +/** + * Classifies the error that was being retried when retries were exhausted. + * + * @param error - The error from the last retry attempt. + * @returns A classification string, or `undefined` if the error doesn't match + * any known category. + */ +function classifyRetriedError(error: unknown): RetriedError | undefined { + if (isConnectionError(error)) { + return 'request_not_initiated'; + } + if (isJsonParseError(error)) { + return 'response_not_json'; + } + if ( + typeof error === 'object' && + error !== null && + 'httpStatus' in error && + [502, 503, 504].includes((error as { httpStatus: number }).httpStatus) + ) { + return 'non_success_http_status'; + } + if ( + typeof error === 'object' && + error !== null && + hasProperty(error, 'code') + ) { + if (error.code === 'ETIMEDOUT') { + return 'timed_out'; + } + if (error.code === 'ECONNRESET') { + return 'connection_reset'; + } + } + return undefined; +} + /** * Guarantees a URL, even given a string. This is useful for checking components * of that URL. @@ -408,11 +460,23 @@ 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, + degradedType: 'slow_success', + }); + } else { + listener({ + ...data, + endpointUrl: this.endpointUrl.toString(), + rpcMethodName: this.#currentRpcMethodName, + degradedType: 'retries_exhausted', + retriedError: classifyRetriedError( + 'error' in data ? data.error : undefined, + ), + }); + } }); } From 9848b08612f47d4f465d0f3ad9e902303576e222 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 18 Feb 2026 14:34:46 -0800 Subject: [PATCH 2/9] refactor: extract shared helpers for retry filter and error classification --- .../src/rpc-service/rpc-service.ts | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index a0c3b118813..3e5da7aa6cd 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -220,6 +220,40 @@ function isJsonParseError(error: unknown): boolean { ); } +/** + * Determines whether the given error represents a server HTTP 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. + */ +function isServerHttpError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'httpStatus' in error && + [502, 503, 504].includes((error as { httpStatus: number }).httpStatus) + ); +} + +/** + * Determines whether the given error has a `code` property matching + * `ETIMEDOUT` or `ECONNRESET`. + * + * @param error - The error object to test. + * @returns True if the error code is `ETIMEDOUT` or `ECONNRESET`. + */ +function isTimeoutOrResetError( + error: unknown, +): error is { code: 'ETIMEDOUT' | 'ECONNRESET' } { + return ( + typeof error === 'object' && + error !== null && + hasProperty(error, 'code') && + (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET') + ); +} + /** * Classifies the error that was being retried when retries were exhausted. * @@ -234,25 +268,11 @@ function classifyRetriedError(error: unknown): RetriedError | undefined { if (isJsonParseError(error)) { return 'response_not_json'; } - if ( - typeof error === 'object' && - error !== null && - 'httpStatus' in error && - [502, 503, 504].includes((error as { httpStatus: number }).httpStatus) - ) { + if (isServerHttpError(error)) { return 'non_success_http_status'; } - if ( - typeof error === 'object' && - error !== null && - hasProperty(error, 'code') - ) { - if (error.code === 'ETIMEDOUT') { - return 'timed_out'; - } - if (error.code === 'ECONNRESET') { - return 'connection_reset'; - } + if (isTimeoutOrResetError(error)) { + return error.code === 'ETIMEDOUT' ? 'timed_out' : 'connection_reset'; } return undefined; } @@ -376,12 +396,9 @@ 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')) + isServerHttpError(error) || + // Ignore timeout and connection reset errors + isTimeoutOrResetError(error) ); }), }); From f29efd1b5d09ba9a9e49dadff59c66f4d2bfbce4 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 18 Feb 2026 15:27:40 -0800 Subject: [PATCH 3/9] refactor: move classification to create-network-client, rename types and fields - Move DegradedType/RetriedError out of RpcService into create-network-client as DegradedEventType/RetryReason (keeps RpcService simple) - Rename degradedType -> type, retriedError -> retryReason - Rename values: request_not_initiated -> connection_failed, non_success_http_status -> non_successful_response - Split isTimeoutOrResetError into isTimeoutError + isConnectionResetError - Export isJsonParseError, isServerHttpError, isTimeoutError, isConnectionResetError --- packages/network-controller/CHANGELOG.md | 10 +-- .../src/NetworkController.ts | 15 ++-- .../rpc-endpoint-events.test.ts | 50 +++++------ .../src/create-network-client.ts | 86 +++++++++++++++---- packages/network-controller/src/index.ts | 2 +- .../src/rpc-service/rpc-service-chain.test.ts | 64 ++++---------- .../rpc-service/rpc-service-requestable.ts | 3 - .../src/rpc-service/rpc-service.test.ts | 18 +--- .../src/rpc-service/rpc-service.ts | 79 +++++------------ 9 files changed, 146 insertions(+), 181 deletions(-) diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 6f40ac0286d..3a50396d98b 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -11,14 +11,14 @@ 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 `degradedType` and `retriedError` to `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointChainDegraded` event payloads ([#7988](https://github.com/MetaMask/core/pull/7988)) - - `degradedType` is `'slow_success'` when the request succeeded but was slow, or `'retries_exhausted'` when retries ran out. - - `retriedError` (only present when `degradedType` is `'retries_exhausted'`) classifies the error that was retried (e.g. `'non_success_http_status'`, `'timed_out'`, `'request_not_initiated'`). +- 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_response'`, `'timed_out'`, `'connection_failed'`). ### Changed -- **BREAKING:** The `RpcServiceRequestable` type's `onDegraded` listener now receives `rpcMethodName: string`, `degradedType: DegradedType`, and optionally `retriedError: RetriedError` in its data parameter ([#7954](https://github.com/MetaMask/core/pull/7954), [#7988](https://github.com/MetaMask/core/pull/7988)) - - Implementors of this interface will need to accept the new fields in their `onDegraded` callback signature. +- **BREAKING:** The `RpcServiceRequestable` type's `onDegraded` listener now receives `rpcMethodName: string` in its data parameter ([#7954](https://github.com/MetaMask/core/pull/7954)) + - Implementors of this interface will need to accept the new field in their `onDegraded` callback signature. - Bump `@metamask/eth-json-rpc-middleware` from `^23.0.0` to `^23.1.0` ([#7810](https://github.com/MetaMask/core/pull/7810)) - Bump `@metamask/json-rpc-engine` from `^10.2.1` to `^10.2.2` ([#7856](https://github.com/MetaMask/core/pull/7856)) diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 31df4bce172..4b0b08cf127 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -47,12 +47,9 @@ 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 { - DegradedType, - RetriedError, - RpcServiceOptions, -} from './rpc-service/rpc-service'; +import type { RpcServiceOptions } from './rpc-service/rpc-service'; import { NetworkClientType } from './types'; import type { BlockTracker, @@ -528,11 +525,11 @@ export type NetworkControllerRpcEndpointChainDegradedEvent = { payload: [ { chainId: Hex; - degradedType: DegradedType; error: unknown; networkClientId: NetworkClientId; - retriedError?: RetriedError; + retryReason?: RetryReason; rpcMethodName: string; + type: DegradedEventType; }, ]; }; @@ -567,13 +564,13 @@ export type NetworkControllerRpcEndpointDegradedEvent = { payload: [ { chainId: Hex; - degradedType: DegradedType; endpointUrl: string; error: unknown; networkClientId: NetworkClientId; primaryEndpointUrl: string; - retriedError?: RetriedError; + retryReason?: RetryReason; rpcMethodName: string; + type: DegradedEventType; }, ]; }; 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 25e61227393..d9575151cf1 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,10 +539,10 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointChainDegradedEventHandler, ).toHaveBeenCalledWith({ chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); }, @@ -662,10 +662,10 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointChainDegradedEventHandler, ).toHaveBeenCalledWith({ chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); }, @@ -773,36 +773,36 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(1, { chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); expect( rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(2, { chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); expect( rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(3, { chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', endpointUrl: failoverEndpointUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); }, @@ -922,31 +922,31 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(1, { chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); expect( rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(2, { chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); expect( rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(3, { chainId, - degradedType: 'slow_success', + type: 'slow_success', endpointUrl: failoverEndpointUrl, error: undefined, networkClientId: 'AAAA-AAAA-AAAA-AAAA', @@ -957,7 +957,7 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointDegradedEventHandler, ).toHaveBeenNthCalledWith(4, { chainId, - degradedType: 'slow_success', + type: 'slow_success', endpointUrl: failoverEndpointUrl, error: undefined, networkClientId: 'AAAA-AAAA-AAAA-AAAA', @@ -1159,10 +1159,10 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointChainDegradedEventHandler, ).toHaveBeenCalledWith({ chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); }, @@ -1238,7 +1238,7 @@ describe('createNetworkClient - RPC endpoint events', () => { rpcEndpointChainDegradedEventHandler, ).toHaveBeenCalledWith({ chainId, - degradedType: 'slow_success', + type: 'slow_success', error: undefined, networkClientId: 'AAAA-AAAA-AAAA-AAAA', rpcMethodName: 'eth_blockNumber', @@ -1324,22 +1324,22 @@ describe('createNetworkClient - RPC endpoint events', () => { ); expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ chainId, - degradedType: 'retries_exhausted', + type: 'retries_exhausted', endpointUrl: rpcUrl, error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retriedError: 'non_success_http_status', + retryReason: 'non_successful_response', rpcMethodName: 'eth_blockNumber', }); }, @@ -1566,7 +1566,7 @@ describe('createNetworkClient - RPC endpoint events', () => { ); expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ chainId, - degradedType: 'slow_success', + type: 'slow_success', endpointUrl: rpcUrl, error: undefined, networkClientId: 'AAAA-AAAA-AAAA-AAAA', @@ -1575,7 +1575,7 @@ describe('createNetworkClient - RPC endpoint events', () => { }); expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ chainId, - degradedType: 'slow_success', + 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 a9457fdf1e7..6a328f9e6d5 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_response' + | 'timed_out' + | 'connection_reset'; + +/** + * Classifies the error that was being retried when retries were exhausted. + * + * @param error - The error from the last retry attempt. + * @returns A classification string, or `undefined` if the error doesn't match + * any known category. + */ +function classifyRetryReason(error: unknown): RetryReason | undefined { + if (!(error instanceof Error)) { + return undefined; + } + if (isConnectionError(error)) { + return 'connection_failed'; + } + if (isJsonParseError(error)) { + return 'response_not_json'; + } + if (isHttpServerError(error)) { + return 'non_successful_response'; + } + if (isTimeoutError(error)) { + return 'timed_out'; + } + if (isConnectionResetError(error)) { + return 'connection_reset'; + } + return undefined; +} + /** * The pair of provider / block tracker that can be used to interface with the * network and respond to new activity. @@ -299,30 +350,30 @@ function createRpcServiceChain({ }, ); - rpcServiceChain.onDegraded( - ({ rpcMethodName, degradedType, retriedError, ...rest }) => { - const error = getError(rest); - messenger.publish('NetworkController:rpcEndpointChainDegraded', { - chainId: configuration.chainId, - networkClientId: id, - error, - rpcMethodName, - degradedType, - retriedError, - }); - }, - ); + 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), + }); + }); rpcServiceChain.onServiceDegraded( ({ endpointUrl, primaryEndpointUrl: primaryEndpointUrlFromEvent, rpcMethodName, - degradedType, - retriedError, ...rest }) => { const error = getError(rest); + const type: DegradedEventType = + error === undefined ? 'slow_success' : 'retries_exhausted'; messenger.publish('NetworkController:rpcEndpointDegraded', { chainId: configuration.chainId, @@ -331,8 +382,9 @@ function createRpcServiceChain({ endpointUrl, error, rpcMethodName, - degradedType, - retriedError, + type, + retryReason: + error === undefined ? undefined : classifyRetryReason(error), }); }, ); diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index af8f03e7cc7..c870f63c5c7 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -61,5 +61,5 @@ 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 { DegradedType, RetriedError } from './rpc-service/rpc-service'; +export type { DegradedEventType, RetryReason } from './create-network-client'; export { isConnectionError } from './rpc-service/rpc-service'; diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts index dcda681d15b..6fd1bebe0e6 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -1092,8 +1092,6 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1142,7 +1140,6 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ - degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -1217,8 +1214,6 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1287,8 +1282,6 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_blockNumber', }); @@ -1366,8 +1359,6 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1441,13 +1432,10 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(2); expect(onDegradedListener).toHaveBeenNthCalledWith(1, { - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onDegradedListener).toHaveBeenNthCalledWith(2, { - degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -1545,13 +1533,10 @@ describe('RpcServiceChain', () => { expect(onDegradedListener).toHaveBeenCalledTimes(2); expect(onDegradedListener).toHaveBeenNthCalledWith(1, { - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onDegradedListener).toHaveBeenNthCalledWith(2, { - degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); }); @@ -1607,16 +1592,14 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1667,13 +1650,13 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'slow_success', + rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'slow_success', + rpcMethodName: 'eth_chainId', }); }); @@ -1750,22 +1733,20 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'slow_success', + rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1845,23 +1826,21 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${secondaryEndpointUrl}/`, - degradedType: 'slow_success', + rpcMethodName: 'eth_chainId', }); }); @@ -1936,23 +1915,21 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - degradedType: 'slow_success', + rpcMethodName: 'eth_chainId', }); }); @@ -2052,39 +2029,35 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${secondaryEndpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(4, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${secondaryEndpointUrl}/`, - degradedType: 'retries_exhausted', - retriedError: 'non_success_http_status', + error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(5, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - degradedType: 'slow_success', + rpcMethodName: 'eth_chainId', }); }); @@ -2257,7 +2230,6 @@ describe('RpcServiceChain', () => { // Verify degradation occurred after the first (slow) request expect(onDegradedListener).toHaveBeenCalledTimes(1); expect(onDegradedListener).toHaveBeenCalledWith({ - degradedType: 'slow_success', rpcMethodName: 'eth_chainId', }); diff --git a/packages/network-controller/src/rpc-service/rpc-service-requestable.ts b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts index 448a6ea0633..99bdc3df29b 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-requestable.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts @@ -6,7 +6,6 @@ import type { JsonRpcResponse, } from '@metamask/utils'; -import type { DegradedType, RetriedError } from './rpc-service'; import type { CockatielEventToEventListenerWithData, ExcludeCockatielEventData, @@ -69,8 +68,6 @@ export type RpcServiceRequestable = { { endpointUrl: string; rpcMethodName: string; - degradedType: DegradedType; - retriedError?: RetriedError; } >, ): ReturnType; diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index fa2554a237c..be1d4971eef 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -400,17 +400,13 @@ describe('RpcService', () => { testsForRetriableFetchErrors({ producedError: error, expectedError: error, - expectedRetriedError: 'request_not_initiated', }); }, ); - describe.each([ - ['ETIMEDOUT', 'timed_out'], - ['ECONNRESET', 'connection_reset'], - ] as const)( + describe.each(['ETIMEDOUT', 'ECONNRESET'] as const)( 'if making the request throws a "%s" error', - (errorCode, expectedRetriedError) => { + (errorCode) => { const error = new Error('timed out'); // @ts-expect-error `code` does not exist on the Error type, but is // still used by Node. @@ -419,7 +415,6 @@ describe('RpcService', () => { testsForRetriableFetchErrors({ producedError: error, expectedError: error, - expectedRetriedError, }); }, ); @@ -1035,7 +1030,6 @@ describe('RpcService', () => { expect(onDegradedListener).toHaveBeenCalledWith({ endpointUrl: `${endpointUrl}/`, rpcMethodName: 'eth_chainId', - degradedType: 'slow_success', }); }); @@ -1100,12 +1094,10 @@ describe('RpcService', () => { expect(onDegradedListener).toHaveBeenCalledWith({ endpointUrl: `${endpointUrl}/`, rpcMethodName: 'eth_blockNumber', - degradedType: 'slow_success', }); expect(onDegradedListener).toHaveBeenCalledWith({ endpointUrl: `${endpointUrl}/`, rpcMethodName: 'eth_gasPrice', - degradedType: 'slow_success', }); }); @@ -1430,17 +1422,13 @@ function testsForNonRetriableErrors({ * @param args.producedError - The error produced when `fetch` is called. * @param args.expectedError - The error that a call to the service's `request` * method is expected to produce. - * @param args.expectedRetriedError - The expected `retriedError` classification - * string for the degraded event. */ function testsForRetriableFetchErrors({ producedError, expectedError, - expectedRetriedError, }: { producedError: Error; expectedError: string | jest.Constructable | RegExp | Error; - expectedRetriedError: string; }): void { // This function is designed to be used inside of a describe, so this won't be // a problem in practice. @@ -1506,8 +1494,6 @@ function testsForRetriableFetchErrors({ endpointUrl: `${endpointUrl}/`, error: expectedError, rpcMethodName: 'eth_chainId', - degradedType: 'retries_exhausted', - retriedError: expectedRetriedError, }); }); diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index 3e5da7aa6cd..f98e480c0c1 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -65,21 +65,6 @@ export type RpcServiceOptions = { isOffline: () => boolean; }; -/** - * Why the RPC service became degraded. - */ -export type DegradedType = 'slow_success' | 'retries_exhausted'; - -/** - * The category of error that was retried until retries were exhausted. - */ -export type RetriedError = - | 'request_not_initiated' - | 'response_not_json' - | 'non_success_http_status' - | 'timed_out' - | 'connection_reset'; - const log = createModuleLogger(projectLogger, 'RpcService'); /** @@ -213,7 +198,7 @@ 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)) @@ -221,60 +206,39 @@ function isJsonParseError(error: unknown): boolean { } /** - * Determines whether the given error represents a server HTTP 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. */ -function isServerHttpError(error: unknown): boolean { +export function isHttpServerError(error: Error): boolean { return ( - typeof error === 'object' && - error !== null && 'httpStatus' in error && - [502, 503, 504].includes((error as { httpStatus: number }).httpStatus) + (error.httpStatus === 502 || + error.httpStatus === 503 || + error.httpStatus === 504) ); } /** - * Determines whether the given error has a `code` property matching - * `ETIMEDOUT` or `ECONNRESET`. + * 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` or `ECONNRESET`. + * @returns True if the error code is `ETIMEDOUT`. */ -function isTimeoutOrResetError( - error: unknown, -): error is { code: 'ETIMEDOUT' | 'ECONNRESET' } { - return ( - typeof error === 'object' && - error !== null && - hasProperty(error, 'code') && - (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET') - ); +export function isTimeoutError(error: Error): boolean { + return hasProperty(error, 'code') && error.code === 'ETIMEDOUT'; } /** - * Classifies the error that was being retried when retries were exhausted. + * Determines whether the given error has a `code` property of `ECONNRESET`. * - * @param error - The error from the last retry attempt. - * @returns A classification string, or `undefined` if the error doesn't match - * any known category. + * @param error - The error object to test. + * @returns True if the error code is `ECONNRESET`. */ -function classifyRetriedError(error: unknown): RetriedError | undefined { - if (isConnectionError(error)) { - return 'request_not_initiated'; - } - if (isJsonParseError(error)) { - return 'response_not_json'; - } - if (isServerHttpError(error)) { - return 'non_success_http_status'; - } - if (isTimeoutOrResetError(error)) { - return error.code === 'ETIMEDOUT' ? 'timed_out' : 'connection_reset'; - } - return undefined; +export function isConnectionResetError(error: Error): boolean { + return hasProperty(error, 'code') && error.code === 'ECONNRESET'; } /** @@ -396,9 +360,11 @@ export class RpcService implements AbstractRpcService { // Ignore server sent HTML error pages or truncated JSON responses isJsonParseError(error) || // Ignore server overload errors - isServerHttpError(error) || - // Ignore timeout and connection reset errors - isTimeoutOrResetError(error) + isHttpServerError(error) || + // Ignore timeout errors + isTimeoutError(error) || + // Ignore connection reset errors + isConnectionResetError(error) ); }), }); @@ -481,17 +447,12 @@ export class RpcService implements AbstractRpcService { listener({ endpointUrl: this.endpointUrl.toString(), rpcMethodName: this.#currentRpcMethodName, - degradedType: 'slow_success', }); } else { listener({ ...data, endpointUrl: this.endpointUrl.toString(), rpcMethodName: this.#currentRpcMethodName, - degradedType: 'retries_exhausted', - retriedError: classifyRetriedError( - 'error' in data ? data.error : undefined, - ), }); } }); From 8f4342f202213cd05e62cef66876b95312e6db3b Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 18 Feb 2026 15:36:48 -0800 Subject: [PATCH 4/9] fix: remove useless line return --- .../src/rpc-service/rpc-service-chain.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts index 6fd1bebe0e6..9269c9cdd02 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -1592,14 +1592,12 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1650,13 +1648,11 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - rpcMethodName: 'eth_chainId', }); }); @@ -1733,20 +1729,17 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); @@ -1915,21 +1908,18 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${endpointUrl}/`, endpointUrl: `${endpointUrl}/`, - rpcMethodName: 'eth_chainId', }); }); From f1058692579a54954ce97415f685936be7d6f383 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 18 Feb 2026 15:40:00 -0800 Subject: [PATCH 5/9] fix: remove useless as const cast --- packages/network-controller/src/rpc-service/rpc-service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts index be1d4971eef..5b65bd853a2 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -404,7 +404,7 @@ describe('RpcService', () => { }, ); - describe.each(['ETIMEDOUT', 'ECONNRESET'] as const)( + describe.each(['ETIMEDOUT', 'ECONNRESET'])( 'if making the request throws a "%s" error', (errorCode) => { const error = new Error('timed out'); From 9a8ab317c0064dba98343f7f3691b226f96dc1db Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 18 Feb 2026 15:40:37 -0800 Subject: [PATCH 6/9] fix: remove useless rpc-service-requestable change --- .../src/rpc-service/rpc-service-requestable.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/network-controller/src/rpc-service/rpc-service-requestable.ts b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts index 99bdc3df29b..2433f60634e 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-requestable.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts @@ -65,10 +65,7 @@ export type RpcServiceRequestable = { onDegraded( listener: CockatielEventToEventListenerWithData< ServicePolicy['onDegraded'], - { - endpointUrl: string; - rpcMethodName: string; - } + { endpointUrl: string; rpcMethodName: string } >, ): ReturnType; From 18211487517c3655551e7b2c7b32c64b37dae106 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 18 Feb 2026 15:41:50 -0800 Subject: [PATCH 7/9] fix: remove useless line return --- .../src/rpc-service/rpc-service-chain.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts index 9269c9cdd02..bc62a3ee93f 100644 --- a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -1819,21 +1819,18 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${secondaryEndpointUrl}/`, - rpcMethodName: 'eth_chainId', }); }); @@ -2019,35 +2016,30 @@ describe('RpcServiceChain', () => { expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${secondaryEndpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(4, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${secondaryEndpointUrl}/`, - error: expectedDegradedError, rpcMethodName: 'eth_chainId', }); expect(onServiceDegradedListener).toHaveBeenNthCalledWith(5, { primaryEndpointUrl: `${primaryEndpointUrl}/`, endpointUrl: `${primaryEndpointUrl}/`, - rpcMethodName: 'eth_chainId', }); }); From c87f4097f7311235fad0e57ecc432ea43c31f8a2 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 19 Feb 2026 09:05:59 -0800 Subject: [PATCH 8/9] refactor: rename to non_successful_http_status, return unknown, export and test classifyRetryReason --- packages/network-controller/CHANGELOG.md | 2 +- .../classify-retry-reason.test.ts | 78 +++++++++++++++++++ .../rpc-endpoint-events.test.ts | 20 ++--- .../src/create-network-client.ts | 16 ++-- packages/network-controller/src/index.ts | 1 + 5 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 packages/network-controller/src/create-network-client-tests/classify-retry-reason.test.ts diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 3a50396d98b..4bb9c8d54d4 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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_response'`, `'timed_out'`, `'connection_failed'`). + - `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/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..38f3634f947 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/classify-retry-reason.test.ts @@ -0,0 +1,78 @@ +import { HttpError } from '@metamask/controller-utils'; + +import { classifyRetryReason } from '../create-network-client'; + +/** + * Creates a FetchError-like object matching the pattern that isConnectionError + * expects from node-fetch. + * + * @param message - The error message. + * @returns A FetchError-like object. + */ +function createFetchError(message: string): Error { + const error = new Error(message); + Object.defineProperty(error, 'constructor', { + value: { name: 'FetchError' }, + }); + Object.defineProperty(error.constructor, 'name', { + value: 'FetchError', + writable: false, + }); + return error; +} + +describe('classifyRetryReason', () => { + it('returns "connection_failed" for FetchError connection failures', () => { + const error = createFetchError( + 'request to https://example.com failed, reason: connect ECONNREFUSED', + ); + 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 d9575151cf1..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 @@ -542,7 +542,7 @@ describe('createNetworkClient - RPC endpoint events', () => { type: 'retries_exhausted', error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); }, @@ -665,7 +665,7 @@ describe('createNetworkClient - RPC endpoint events', () => { type: 'retries_exhausted', error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); }, @@ -778,7 +778,7 @@ describe('createNetworkClient - RPC endpoint events', () => { error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); expect( @@ -790,7 +790,7 @@ describe('createNetworkClient - RPC endpoint events', () => { error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); expect( @@ -802,7 +802,7 @@ describe('createNetworkClient - RPC endpoint events', () => { error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); }, @@ -927,7 +927,7 @@ describe('createNetworkClient - RPC endpoint events', () => { error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); expect( @@ -939,7 +939,7 @@ describe('createNetworkClient - RPC endpoint events', () => { error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); expect( @@ -1162,7 +1162,7 @@ describe('createNetworkClient - RPC endpoint events', () => { type: 'retries_exhausted', error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); }, @@ -1329,7 +1329,7 @@ describe('createNetworkClient - RPC endpoint events', () => { error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ @@ -1339,7 +1339,7 @@ describe('createNetworkClient - RPC endpoint events', () => { error: expectedDegradedError, networkClientId: 'AAAA-AAAA-AAAA-AAAA', primaryEndpointUrl: rpcUrl, - retryReason: 'non_successful_response', + retryReason: 'non_successful_http_status', rpcMethodName: 'eth_blockNumber', }); }, diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index 6a328f9e6d5..bac8cda2af9 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -63,20 +63,20 @@ export type DegradedEventType = 'slow_success' | 'retries_exhausted'; export type RetryReason = | 'connection_failed' | 'response_not_json' - | 'non_successful_response' + | 'non_successful_http_status' | 'timed_out' - | 'connection_reset'; + | '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, or `undefined` if the error doesn't match - * any known category. + * @returns A classification string. */ -function classifyRetryReason(error: unknown): RetryReason | undefined { +export function classifyRetryReason(error: unknown): RetryReason { if (!(error instanceof Error)) { - return undefined; + return 'unknown'; } if (isConnectionError(error)) { return 'connection_failed'; @@ -85,7 +85,7 @@ function classifyRetryReason(error: unknown): RetryReason | undefined { return 'response_not_json'; } if (isHttpServerError(error)) { - return 'non_successful_response'; + return 'non_successful_http_status'; } if (isTimeoutError(error)) { return 'timed_out'; @@ -93,7 +93,7 @@ function classifyRetryReason(error: unknown): RetryReason | undefined { if (isConnectionResetError(error)) { return 'connection_reset'; } - return undefined; + return 'unknown'; } /** diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index c870f63c5c7..3e339ca8f33 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -62,4 +62,5 @@ 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'; From f75d349586f43016ad2771ba5304f5908cc261b0 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 19 Feb 2026 09:28:58 -0800 Subject: [PATCH 9/9] fix: FetchError from node-fetch --- .../classify-retry-reason.test.ts | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) 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 index 38f3634f947..1a4e25f32d9 100644 --- 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 @@ -1,30 +1,13 @@ import { HttpError } from '@metamask/controller-utils'; +import { FetchError } from 'node-fetch'; import { classifyRetryReason } from '../create-network-client'; -/** - * Creates a FetchError-like object matching the pattern that isConnectionError - * expects from node-fetch. - * - * @param message - The error message. - * @returns A FetchError-like object. - */ -function createFetchError(message: string): Error { - const error = new Error(message); - Object.defineProperty(error, 'constructor', { - value: { name: 'FetchError' }, - }); - Object.defineProperty(error.constructor, 'name', { - value: 'FetchError', - writable: false, - }); - return error; -} - describe('classifyRetryReason', () => { it('returns "connection_failed" for FetchError connection failures', () => { - const error = createFetchError( + const error = new FetchError( 'request to https://example.com failed, reason: connect ECONNREFUSED', + 'system', ); expect(classifyRetryReason(error)).toBe('connection_failed'); });