diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 89b6eed1284..d484c207a21 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -20,14 +20,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for automatic failover when Infura is unavailable ([#5630](https://github.com/MetaMask/core/pull/5630)) - An Infura RPC endpoint can now be configured with a list of failover URLs via `failoverUrls`. - If, after many attempts, an Infura network is perceived to be down, the list of failover URLs will be tried in turn. -- Add messenger action `NetworkController:rpcEndpointUnavailable` for responding to when a RPC endpoint becomes unavailable (see above) ([#5492](https://github.com/MetaMask/core/pull/5492) +- Add messenger action `NetworkController:rpcEndpointUnavailable` for responding to when a RPC endpoint becomes unavailable (see above) ([#5492](https://github.com/MetaMask/core/pull/5492), [#5501](https://github.com/MetaMask/core/pull/5501)) - Also add associated type `NetworkControllerRpcEndpointUnavailableEvent`. -- Add messenger action `NetworkController:rpcEndpointDegraded` for responding to when a RPC endpoint becomes degraded ([#5492](https://github.com/MetaMask/core/pull/5492) +- Add messenger action `NetworkController:rpcEndpointDegraded` for responding to when a RPC endpoint becomes degraded ([#5492](https://github.com/MetaMask/core/pull/5492)) - Also add associated type `NetworkControllerRpcEndpointDegradedEvent`. -- Add messenger action `NetworkController:rpcEndpointRequestRetried` for responding to when a RPC endpoint is retried following a retriable error ([#5492](https://github.com/MetaMask/core/pull/5492) +- Add messenger action `NetworkController:rpcEndpointRequestRetried` for responding to when a RPC endpoint is retried following a retriable error ([#5492](https://github.com/MetaMask/core/pull/5492)) - Also add associated type `NetworkControllerRpcEndpointRequestRetriedEvent`. - This is mainly useful for tests when mocking timers. -- Export `RpcServiceRequestable` type, which was previously named `AbstractRpcService` [#5492](https://github.com/MetaMask/core/pull/5492) +- Export `RpcServiceRequestable` type, which was previously named `AbstractRpcService` ([#5492](https://github.com/MetaMask/core/pull/5492)) +- Export `isConnectionError` utility function ([#5501](https://github.com/MetaMask/core/pull/5501)) ### Changed @@ -37,9 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - At minimum you will need to pass `fetch` and `btoa`. - The `NetworkControllerOptions` also reflects this change. - **BREAKING:** Add required property `failoverUrls` to `RpcEndpoint` ([#5630](https://github.com/MetaMask/core/pull/5630)) - - The `NetworkControllerState` and the `state` option to `NetworkController` also reflects this change + - The `NetworkControllerState` and the `state` option to `NetworkController` also reflect this change - **BREAKING:** Add required property `failoverRpcUrls` to `NetworkClientConfiguration` ([#5630](https://github.com/MetaMask/core/pull/5630)) - - The `configuration` property in the `AutoManagedNetworkClient` and `NetworkClient` types also reflects this change + - The `configuration` property in the `AutoManagedNetworkClient` and `NetworkClient` types also reflect this change - **BREAKING:** The `AbstractRpcService` type now has a non-optional `endpointUrl` property ([#5492](https://github.com/MetaMask/core/pull/5492)) - The old version of `AbstractRpcService` is now called `RpcServiceRequestable` - Synchronize retry logic and error handling behavior between Infura and custom RPC endpoints ([#5290](https://github.com/MetaMask/core/pull/5290)) diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 9ae7fcf87ed..728fe268b1c 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -444,6 +444,7 @@ export type NetworkControllerRpcEndpointUnavailableEvent = { chainId: Hex; endpointUrl: string; failoverEndpointUrl?: string; + error: unknown; }, ]; }; diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index d0f6c182133..41d9be6700f 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -84,11 +84,19 @@ export function createNetworkClient({ endpointUrl, })), ); - rpcService.onBreak(({ endpointUrl, failoverEndpointUrl }) => { + rpcService.onBreak(({ endpointUrl, failoverEndpointUrl, ...rest }) => { + let error: unknown; + if ('error' in rest) { + error = rest.error; + } else if ('value' in rest) { + error = rest.value; + } + messenger.publish('NetworkController:rpcEndpointUnavailable', { chainId: configuration.chainId, endpointUrl, failoverEndpointUrl, + error, }); }); rpcService.onDegraded(({ endpointUrl }) => { diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index 04dee63930e..42f264deb1e 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -57,3 +57,4 @@ 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 { isConnectionError } from './rpc-service/rpc-service'; diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index ed6b791434f..e2766fd2a08 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -136,7 +136,7 @@ export const CONNECTION_ERRORS = [ * @returns True if the error indicates that the network cannot be connected to, * and false otherwise. */ -export default function isConnectionError(error: unknown) { +export function isConnectionError(error: unknown) { if (!(typeof error === 'object' && error !== null && 'message' in error)) { return false; } diff --git a/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts b/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts index 45190c862fd..80f411757af 100644 --- a/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts +++ b/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts @@ -284,8 +284,8 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( } describe.each([ - [405, 'The method does not exist / is not available'], - [429, 'Request is being rate limited'], + [405, 'The method does not exist / is not available.'], + [429, 'Request is being rate limited.'], ])( 'if the RPC endpoint returns a %d response', (httpStatus, errorMessage) => { @@ -424,6 +424,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -493,6 +496,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -611,6 +617,7 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( describe('if the RPC endpoint returns a response that is not 405, 429, 503, or 504', () => { const httpStatus = 500; + const errorMessage = `Non-200 status code: '${httpStatus}'`; it('throws a generic, undescriptive error', async () => { await withMockedCommunications({ providerType }, async (comms) => { @@ -746,6 +753,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }, ); }, @@ -813,6 +823,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -925,6 +938,8 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( describe.each([503, 504])( 'if the RPC endpoint returns a %d response', (httpStatus) => { + const errorMessage = 'Gateway timeout'; + it('retries the request up to 5 times until there is a 200 response', async () => { await withMockedCommunications({ providerType }, async (comms) => { const request = { method }; @@ -990,7 +1005,7 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( ); }, ); - await expect(promiseForResult).rejects.toThrow('Gateway timeout'); + await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); @@ -1131,6 +1146,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -1231,6 +1249,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -1560,6 +1581,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to ${rpcUrl} failed, reason: ${errorCode}`, + }), }); }, ); @@ -1658,6 +1682,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to https://failover.endpoint/ failed, reason: ${errorCode}`, + }), }); }, ); @@ -1777,6 +1804,8 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( ); describe('if the RPC endpoint responds with invalid JSON', () => { + const errorMessage = 'not valid JSON'; + it('retries the request up to 5 times until it responds with valid JSON', async () => { await withMockedCommunications({ providerType }, async (comms) => { const request = { method }; @@ -1841,7 +1870,7 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( }, ); - await expect(promiseForResult).rejects.toThrow('not valid JSON'); + await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); @@ -1973,6 +2002,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }, ); }, @@ -2068,6 +2100,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -2371,6 +2406,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to ${rpcUrl} failed, reason: Failed to fetch`, + }), }, ); }, @@ -2464,6 +2502,9 @@ export function testsForRpcMethodsThatCheckForBlockHashInResponse( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to https://failover.endpoint/ failed, reason: Failed to fetch`, + }), }); }, ); diff --git a/packages/network-controller/tests/provider-api-tests/block-param.ts b/packages/network-controller/tests/provider-api-tests/block-param.ts index 2c02aff92d1..b5f2e49b501 100644 --- a/packages/network-controller/tests/provider-api-tests/block-param.ts +++ b/packages/network-controller/tests/provider-api-tests/block-param.ts @@ -350,8 +350,8 @@ export function testsForRpcMethodSupportingBlockParam( }); describe.each([ - [405, 'The method does not exist / is not available'], - [429, 'Request is being rate limited'], + [405, 'The method does not exist / is not available.'], + [429, 'Request is being rate limited.'], ])( 'if the RPC endpoint returns a %d response', (httpStatus, errorMessage) => { @@ -531,6 +531,9 @@ export function testsForRpcMethodSupportingBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -613,6 +616,9 @@ export function testsForRpcMethodSupportingBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -747,6 +753,7 @@ export function testsForRpcMethodSupportingBlockParam( describe('if the RPC endpoint returns a response that is not 405, 429, 503, or 504', () => { const httpStatus = 500; + const errorMessage = `Non-200 status code: '${httpStatus}'`; it('throws a generic, undescriptive error', async () => { await withMockedCommunications({ providerType }, async (comms) => { @@ -783,9 +790,7 @@ export function testsForRpcMethodSupportingBlockParam( async ({ makeRpcCall }) => makeRpcCall(request), ); - await expect(promiseForResult).rejects.toThrow( - `Non-200 status code: '${httpStatus}'`, - ); + await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); @@ -929,6 +934,9 @@ export function testsForRpcMethodSupportingBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -1011,6 +1019,9 @@ export function testsForRpcMethodSupportingBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -1145,6 +1156,8 @@ export function testsForRpcMethodSupportingBlockParam( describe.each([503, 504])( 'if the RPC endpoint returns a %d response', (httpStatus) => { + const errorMessage = 'Gateway timeout'; + it('retries the request up to 5 times until there is a 200 response', async () => { await withMockedCommunications({ providerType }, async (comms) => { const request = { @@ -1240,7 +1253,7 @@ export function testsForRpcMethodSupportingBlockParam( ); }, ); - await expect(promiseForResult).rejects.toThrow('Gateway timeout'); + await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); @@ -1409,6 +1422,9 @@ export function testsForRpcMethodSupportingBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -1524,6 +1540,9 @@ export function testsForRpcMethodSupportingBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -1926,6 +1945,9 @@ export function testsForRpcMethodSupportingBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to ${rpcUrl} failed, reason: ${errorCode}`, + }), }); }, ); @@ -2039,6 +2061,9 @@ export function testsForRpcMethodSupportingBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to https://failover.endpoint/ failed, reason: ${errorCode}`, + }), }); }, ); @@ -2171,6 +2196,8 @@ export function testsForRpcMethodSupportingBlockParam( ); describe('if the RPC endpoint responds with invalid JSON', () => { + const errorMessage = 'not valid JSON'; + it('retries the request up to 5 times until it responds with valid JSON', async () => { await withMockedCommunications({ providerType }, async (comms) => { const request = { @@ -2266,7 +2293,7 @@ export function testsForRpcMethodSupportingBlockParam( }, ); - await expect(promiseForResult).rejects.toThrow('not valid JSON'); + await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); @@ -2430,6 +2457,9 @@ export function testsForRpcMethodSupportingBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -2543,6 +2573,9 @@ export function testsForRpcMethodSupportingBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -2927,6 +2960,9 @@ export function testsForRpcMethodSupportingBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to ${rpcUrl} failed, reason: Failed to fetch`, + }), }); }, ); @@ -3037,6 +3073,9 @@ export function testsForRpcMethodSupportingBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to https://failover.endpoint/ failed, reason: Failed to fetch`, + }), }); }, ); diff --git a/packages/network-controller/tests/provider-api-tests/no-block-param.ts b/packages/network-controller/tests/provider-api-tests/no-block-param.ts index a179148af56..f66ca1e3d94 100644 --- a/packages/network-controller/tests/provider-api-tests/no-block-param.ts +++ b/packages/network-controller/tests/provider-api-tests/no-block-param.ts @@ -240,8 +240,8 @@ export function testsForRpcMethodAssumingNoBlockParam( }); describe.each([ - [405, 'The method does not exist / is not available'], - [429, 'Request is being rate limited'], + [405, 'The method does not exist / is not available.'], + [429, 'Request is being rate limited.'], ])( 'if the RPC endpoint returns a %d response', (httpStatus, errorMessage) => { @@ -380,6 +380,9 @@ export function testsForRpcMethodAssumingNoBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -449,6 +452,9 @@ export function testsForRpcMethodAssumingNoBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -567,6 +573,7 @@ export function testsForRpcMethodAssumingNoBlockParam( describe('if the RPC endpoint returns a response that is not 405, 429, 503, or 504', () => { const httpStatus = 500; + const errorMessage = `Non-200 status code: '${httpStatus}'`; it('throws a generic, undescriptive error', async () => { await withMockedCommunications({ providerType }, async (comms) => { @@ -587,9 +594,7 @@ export function testsForRpcMethodAssumingNoBlockParam( async ({ makeRpcCall }) => makeRpcCall(request), ); - await expect(promiseForResult).rejects.toThrow( - `Non-200 status code: '${httpStatus}'`, - ); + await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); @@ -702,6 +707,9 @@ export function testsForRpcMethodAssumingNoBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }, ); }, @@ -769,6 +777,9 @@ export function testsForRpcMethodAssumingNoBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: errorMessage, + }), }); }, ); @@ -881,6 +892,8 @@ export function testsForRpcMethodAssumingNoBlockParam( describe.each([503, 504])( 'if the RPC endpoint returns a %d response', (httpStatus) => { + const errorMessage = 'Gateway timeout'; + it('retries the request up to 5 times until there is a 200 response', async () => { await withMockedCommunications({ providerType }, async (comms) => { const request = { method }; @@ -946,7 +959,7 @@ export function testsForRpcMethodAssumingNoBlockParam( ); }, ); - await expect(promiseForResult).rejects.toThrow('Gateway timeout'); + await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); @@ -1087,6 +1100,9 @@ export function testsForRpcMethodAssumingNoBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -1187,6 +1203,9 @@ export function testsForRpcMethodAssumingNoBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -1516,6 +1535,9 @@ export function testsForRpcMethodAssumingNoBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to ${rpcUrl} failed, reason: ${errorCode}`, + }), }); }, ); @@ -1614,6 +1636,9 @@ export function testsForRpcMethodAssumingNoBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to https://failover.endpoint/ failed, reason: ${errorCode}`, + }), }); }, ); @@ -1733,6 +1758,8 @@ export function testsForRpcMethodAssumingNoBlockParam( ); describe('if the RPC endpoint responds with invalid JSON', () => { + const errorMessage = 'not valid JSON'; + it('retries the request up to 5 times until it responds with valid JSON', async () => { await withMockedCommunications({ providerType }, async (comms) => { const request = { method }; @@ -1797,7 +1824,7 @@ export function testsForRpcMethodAssumingNoBlockParam( }, ); - await expect(promiseForResult).rejects.toThrow('not valid JSON'); + await expect(promiseForResult).rejects.toThrow(errorMessage); }); }); @@ -1929,6 +1956,9 @@ export function testsForRpcMethodAssumingNoBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }, ); }, @@ -2024,6 +2054,9 @@ export function testsForRpcMethodAssumingNoBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }), }); }, ); @@ -2327,6 +2360,9 @@ export function testsForRpcMethodAssumingNoBlockParam( chainId, endpointUrl: rpcUrl, failoverEndpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to ${rpcUrl} failed, reason: Failed to fetch`, + }), }, ); }, @@ -2420,6 +2456,9 @@ export function testsForRpcMethodAssumingNoBlockParam( ).toHaveBeenNthCalledWith(2, { chainId, endpointUrl: 'https://failover.endpoint/', + error: expect.objectContaining({ + message: `request to https://failover.endpoint/ failed, reason: Failed to fetch`, + }), }); }, );