diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index eb65f736892..1d09f8b6ca6 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -31,6 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Automatically update network status metadata when chain-level RPC events are published ([#7186](https://github.com/MetaMask/core/pull/7186)) + - `NetworkController` now automatically subscribes to `NetworkController:rpcEndpointChainUnavailable`, `NetworkController:rpcEndpointChainDegraded`, and `NetworkController:rpcEndpointChainAvailable` events and updates the corresponding network's status metadata in state when these events are published. + - This enables real-time network status updates without requiring explicit `lookupNetwork` calls, providing more accurate and timely network availability information. ## [26.0.0] diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index f7251450f43..9b2ab8d9e95 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -1439,6 +1439,31 @@ export class NetworkController extends BaseController< `${this.name}:updateNetwork`, this.updateNetwork.bind(this), ); + + this.messenger.subscribe( + `${this.name}:rpcEndpointChainUnavailable`, + ({ networkClientId }) => { + this.#updateMetadataForNetwork(networkClientId, { + networkStatus: NetworkStatus.Unavailable, + }); + }, + ); + this.messenger.subscribe( + `${this.name}:rpcEndpointChainDegraded`, + ({ networkClientId }) => { + this.#updateMetadataForNetwork(networkClientId, { + networkStatus: NetworkStatus.Degraded, + }); + }, + ); + this.messenger.subscribe( + `${this.name}:rpcEndpointChainAvailable`, + ({ networkClientId }) => { + this.#updateMetadataForNetwork(networkClientId, { + networkStatus: NetworkStatus.Available, + }); + }, + ); } /** @@ -1846,11 +1871,11 @@ export class NetworkController extends BaseController< async #lookupGivenNetwork(networkClientId: NetworkClientId) { const { networkStatus, isEIP1559Compatible } = await this.#determineNetworkMetadata(networkClientId); - this.#updateMetadataForNetwork( - networkClientId, + + this.#updateMetadataForNetwork(networkClientId, { networkStatus, isEIP1559Compatible, - ); + }); } /** @@ -1925,11 +1950,10 @@ export class NetworkController extends BaseController< } } - this.#updateMetadataForNetwork( - this.state.selectedNetworkClientId, + this.#updateMetadataForNetwork(this.state.selectedNetworkClientId, { networkStatus, isEIP1559Compatible, - ); + }); if (isInfura) { if (networkStatus === NetworkStatus.Available) { @@ -1949,14 +1973,17 @@ export class NetworkController extends BaseController< * Updates the metadata for the given network in state. * * @param networkClientId - The associated network client ID. - * @param networkStatus - The network status to store in state. - * @param isEIP1559Compatible - The EIP-1559 compatibility status to + * @param metadata - The metadata to store in state. + * @param metadata.networkStatus - The network status to store in state. + * @param metadata.isEIP1559Compatible - The EIP-1559 compatibility status to * store in state. */ #updateMetadataForNetwork( networkClientId: NetworkClientId, - networkStatus: NetworkStatus, - isEIP1559Compatible: boolean | undefined, + metadata: { + networkStatus: NetworkStatus; + isEIP1559Compatible?: boolean | undefined; + }, ) { this.update((state) => { if (state.networksMetadata[networkClientId] === undefined) { @@ -1965,12 +1992,15 @@ export class NetworkController extends BaseController< EIPS: {}, }; } - const meta = state.networksMetadata[networkClientId]; - meta.status = networkStatus; - if (isEIP1559Compatible === undefined) { - delete meta.EIPS[1559]; - } else { - meta.EIPS[1559] = isEIP1559Compatible; + const newMetadata = state.networksMetadata[networkClientId]; + newMetadata.status = metadata.networkStatus; + + if ('isEIP1559Compatible' in metadata) { + if (metadata.isEIP1559Compatible === undefined) { + delete newMetadata.EIPS[1559]; + } else { + newMetadata.EIPS[1559] = metadata.isEIP1559Compatible; + } } }); } diff --git a/packages/network-controller/src/constants.ts b/packages/network-controller/src/constants.ts index bdccc1f57aa..1708cc2ebf6 100644 --- a/packages/network-controller/src/constants.ts +++ b/packages/network-controller/src/constants.ts @@ -1,26 +1,34 @@ /** - * Represents the availability state of the currently selected network. + * Represents the availability status of an RPC endpoint. (Regrettably, the + * name of this type is a misnomer.) + * + * The availability status is set both automatically (as requests are made) and + * manually (when `lookupNetwork` is called). */ export enum NetworkStatus { /** - * The network may or may not be able to receive requests, but either no - * attempt has been made to determine this, or an attempt was made but was - * unsuccessful. + * Either the availability status of the RPC endpoint has not been determined, + * or request that `lookupNetwork` performed returned an unknown error. */ Unknown = 'unknown', /** - * The network is able to receive and respond to requests. + * The RPC endpoint is consistently returning successful (2xx) responses. */ Available = 'available', /** - * The network was unable to receive and respond to requests for unknown - * reasons. + * Either the last request to the RPC endpoint was either too slow, or the + * endpoint is consistently returning errors and the number of retries has + * been reached. + */ + Degraded = 'degraded', + /** + * The RPC endpoint is consistently returning enough 5xx errors that requests + * have been paused. */ Unavailable = 'unavailable', /** - * The network is not only unavailable, but is also inaccessible for the user - * specifically based on their location. This state only applies to Infura - * networks. + * The RPC endpoint is inaccessible for the user based on their location. This + * status only applies to Infura networks. */ Blocked = 'blocked', } diff --git a/packages/network-controller/tests/NetworkController.provider.test.ts b/packages/network-controller/tests/NetworkController.provider.test.ts new file mode 100644 index 00000000000..d2ad58a9c2e --- /dev/null +++ b/packages/network-controller/tests/NetworkController.provider.test.ts @@ -0,0 +1,894 @@ +import { + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, +} from '@metamask/controller-utils'; +import nock from 'nock'; +import type { SinonFakeTimers } from 'sinon'; +import { useFakeTimers } from 'sinon'; + +import { + buildCustomNetworkConfiguration, + buildCustomRpcEndpoint, + withController, +} from './helpers'; +import { NetworkStatus } from '../src/constants'; + +describe('NetworkController provider tests', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('sets the status of a network client to "available" the first time its (sole) RPC endpoint returns a 2xx response', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller }) => { + const { provider } = controller.getNetworkClientById(networkClientId); + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Available, + ); + }, + ); + }); + + it('sets the status of a network client to "degraded" when its (sole) RPC endpoint responds with 2xx but slowly', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(() => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }, + ]; + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(() => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }, + ]; + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller }) => { + const { provider } = controller.getNetworkClientById(networkClientId); + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('sets the status of a network client to "degraded" when failed requests to its (sole) RPC endpoint reach the max number of retries', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(5) + .reply(503); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const { provider } = controller.getNetworkClientById(networkClientId); + + await expect( + provider.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }), + ).rejects.toThrow('RPC endpoint not found or unavailable'); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('transitions the status of a network client from "degraded" to "available" the first time a failover is activated and returns a 2xx response', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: primaryEndpointUrl, + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary. + await provider.request(request); + + expect(stateChangeListener).toHaveBeenCalledTimes(2); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'degraded', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'available', + }, + ], + ); + }, + ); + }); + + it('does not transition the status of a network client from "degraded" the first time a failover is activated if it returns a non-2xx response', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(5) + .reply(503); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: primaryEndpointUrl, + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary, + // run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('does not transition the status of a network client from "degraded" the first time a failover is activated if requests are slow to complete', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(() => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }, + ]; + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(() => { + clock.tick(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }, + ]; + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: primaryEndpointUrl, + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary. + await provider.request(request); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('sets the status of a network client to "unavailable" when all of its RPC endpoints consistently return 5xx errors, reaching the max consecutive number of failures', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: primaryEndpointUrl, + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary, + // run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the secondary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the secondary, break the circuit. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Unavailable, + ); + }, + ); + }); + + it('transitions the status of a network client from "unavailable" to "available" when its (sole) RPC endpoint consistently returns 5xx errors for a while and then recovers', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, break the circuit. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Wait until the circuit break duration passes and hit the endpoint + // again. + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + await provider.request(request); + + expect(stateChangeListener).toHaveBeenCalledTimes(3); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'degraded', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'unavailable', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 3, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'available', + }, + ], + ); + }, + ); + }); + + it('transitions the status of a network client from "available" to "unavailable" when its (sole) RPC endpoint responds with 2xx and then returns too many 5xx responses, reaching the max number of consecutive failures', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + isRpcFailoverEnabled: true, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + clock.next(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the endpoint and see that it is successful. + await provider.request(request); + // Wait for the block tracker to reset the cache. (For some reason, + // multiple timers exist.) + clock.runAll(); + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, break the circuit. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + expect(stateChangeListener).toHaveBeenCalledTimes(3); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'available', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'degraded', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 3, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'unavailable', + }, + ], + ); + }, + ); + }); +}); diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 6383310fdca..5ce8f855c2f 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -33,6 +33,7 @@ import { buildUpdateNetworkCustomRpcEndpointFields, INFURA_NETWORKS, TESTNET, + withController, } from './helpers'; import type { RootMessenger } from './helpers'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; @@ -50,8 +51,6 @@ import type { NetworkClientId, NetworkConfiguration, NetworkControllerEvents, - NetworkControllerMessenger, - NetworkControllerOptions, NetworkControllerStateChangeEvent, NetworkState, } from '../src/NetworkController'; @@ -16589,53 +16588,6 @@ function lookupNetworkTests({ }); } -type WithControllerCallback = ({ - controller, -}: { - controller: NetworkController; - messenger: RootMessenger; - networkControllerMessenger: NetworkControllerMessenger; -}) => Promise | ReturnValue; - -type WithControllerOptions = Partial; - -type WithControllerArgs = - | [WithControllerCallback] - | [WithControllerOptions, WithControllerCallback]; - -/** - * Builds a controller based on the given options, and calls the given function - * with that controller. - * - * @param args - Either a function, or an options bag + a function. The options - * bag is equivalent to the options that NetworkController takes (although - * `messenger` and `infuraProjectId` are filled in if not given); the function - * will be called with the built controller. - * @returns Whatever the callback returns. - */ -async function withController( - ...args: WithControllerArgs -): Promise { - const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const messenger = buildRootMessenger(); - const networkControllerMessenger = buildNetworkControllerMessenger(messenger); - const controller = new NetworkController({ - messenger: networkControllerMessenger, - infuraProjectId: 'infura-project-id', - getRpcServiceOptions: () => ({ - fetch, - btoa, - }), - ...rest, - }); - try { - return await fn({ controller, messenger, networkControllerMessenger }); - } finally { - const { blockTracker } = controller.getProviderAndBlockTracker(); - await blockTracker?.destroy(); - } -} - /** * Builds an object that `createNetworkClient` returns. * diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts index 5cb3735fbb0..cb54c44bf37 100644 --- a/packages/network-controller/tests/helpers.ts +++ b/packages/network-controller/tests/helpers.ts @@ -22,13 +22,13 @@ import { FakeProvider } from '../../../tests/fake-provider'; import type { FakeProviderStub } from '../../../tests/fake-provider'; import { buildTestObject } from '../../../tests/helpers'; import { + NetworkController, type BuiltInNetworkClientId, type CustomNetworkClientId, type NetworkClient, type NetworkClientConfiguration, type NetworkClientId, type NetworkConfiguration, - type NetworkController, } from '../src'; import type { AutoManagedNetworkClient } from '../src/create-auto-managed-network-client'; import type { @@ -37,6 +37,7 @@ import type { CustomRpcEndpoint, InfuraRpcEndpoint, NetworkControllerMessenger, + NetworkControllerOptions, UpdateNetworkCustomRpcEndpointFields, } from '../src/NetworkController'; import { RpcEndpointType } from '../src/NetworkController'; @@ -598,3 +599,50 @@ function generateCustomRpcEndpointUrl(): string { testEndpointCounter += 1; return url; } + +type WithControllerCallback = ({ + controller, +}: { + controller: NetworkController; + messenger: RootMessenger; + networkControllerMessenger: NetworkControllerMessenger; +}) => Promise | ReturnValue; + +type WithControllerOptions = Partial; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +/** + * Builds a controller based on the given options, and calls the given function + * with that controller. + * + * @param args - Either a function, or an options bag + a function. The options + * bag is equivalent to the options that NetworkController takes (although + * `messenger` and `infuraProjectId` are filled in if not given); the function + * will be called with the built controller. + * @returns Whatever the callback returns. + */ +export async function withController( + ...args: WithControllerArgs +): Promise { + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const messenger = buildRootMessenger(); + const networkControllerMessenger = buildNetworkControllerMessenger(messenger); + const controller = new NetworkController({ + messenger: networkControllerMessenger, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: () => ({ + fetch, + btoa, + }), + ...rest, + }); + try { + return await fn({ controller, messenger, networkControllerMessenger }); + } finally { + const { blockTracker } = controller.getProviderAndBlockTracker(); + await blockTracker?.destroy(); + } +}