@metamask/network-controller
Version:
Provides an interface to the currently selected network via a MetaMask-compatible provider object
425 lines • 19.2 kB
JavaScript
"use strict";
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
if (kind === "m") throw new TypeError("Private method is not writable");
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var _RpcService_instances, _RpcService_fetch, _RpcService_fetchOptions, _RpcService_logger, _RpcService_policy, _RpcService_getDefaultFetchOptions, _RpcService_getCompleteFetchOptions, _RpcService_executeAndProcessRequest;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RpcService = exports.isConnectionError = exports.CUSTOM_RPC_ERRORS = exports.CONNECTION_ERRORS = exports.DEFAULT_MAX_CONSECUTIVE_FAILURES = exports.DEFAULT_MAX_RETRIES = void 0;
const controller_utils_1 = require("@metamask/controller-utils");
const rpc_errors_1 = require("@metamask/rpc-errors");
const utils_1 = require("@metamask/utils");
const cockatiel_1 = require("cockatiel");
const deepmerge_1 = __importDefault(require("deepmerge"));
const logger_1 = require("../logger.cjs");
const log = (0, logger_1.createModuleLogger)(logger_1.projectLogger, 'RpcService');
/**
* The maximum number of times that a failing service should be re-run before
* giving up.
*/
exports.DEFAULT_MAX_RETRIES = 4;
/**
* The maximum number of times that the service is allowed to fail before
* pausing further retries. This is set to a value such that if given a
* service that continually fails, the policy needs to be executed 3 times
* before further retries are paused.
*/
exports.DEFAULT_MAX_CONSECUTIVE_FAILURES = (1 + exports.DEFAULT_MAX_RETRIES) * 3;
/**
* The list of error messages that represent a failure to connect to the network.
*
* This list was derived from Sindre Sorhus's `is-network-error` package:
* <https://github.com/sindresorhus/is-network-error/blob/7bbfa8be9482ce1427a21fbff60e3ee1650dd091/index.js>
*/
exports.CONNECTION_ERRORS = [
// Chrome
{
constructorName: 'TypeError',
pattern: /network error/u,
},
// Chrome
{
constructorName: 'TypeError',
pattern: /Failed to fetch/u,
},
// Firefox
{
constructorName: 'TypeError',
pattern: /NetworkError when attempting to fetch resource\./u,
},
// Safari 16
{
constructorName: 'TypeError',
pattern: /The Internet connection appears to be offline\./u,
},
// Safari 17+
{
constructorName: 'TypeError',
pattern: /Load failed/u,
},
// `cross-fetch`
{
constructorName: 'TypeError',
pattern: /Network request failed/u,
},
// `node-fetch`
{
constructorName: 'FetchError',
pattern: /request to (.+) failed/u,
},
// Undici (Node.js)
{
constructorName: 'TypeError',
pattern: /fetch failed/u,
},
// Undici (Node.js)
{
constructorName: 'TypeError',
pattern: /terminated/u,
},
];
/**
* Custom JSON-RPC error codes for specific cases.
*
* These should be moved to `@metamask/rpc-errors` eventually.
*/
exports.CUSTOM_RPC_ERRORS = {
unauthorized: -32006,
httpClientError: -32080,
};
/**
* Determines whether the given error represents a failure to reach the network
* after request parameters have been validated.
*
* This is somewhat difficult to verify because JavaScript engines (and in
* some cases libraries) produce slightly different error messages for this
* particular scenario, and we need to account for this.
*
* @param error - The error.
* @returns True if the error indicates that the network cannot be connected to,
* and false otherwise.
*/
function isConnectionError(error) {
if (!(typeof error === 'object' && error !== null && 'message' in error)) {
return false;
}
const { message } = error;
return (typeof message === 'string' &&
!isNockError(message) &&
exports.CONNECTION_ERRORS.some(({ constructorName, pattern }) => {
return (error.constructor.name === constructorName && pattern.test(message));
}));
}
exports.isConnectionError = isConnectionError;
/**
* Determines whether the given error message refers to a Nock error.
*
* It's important that if we failed to mock a request in a test, the resulting
* error does not cause the request to be retried so that we can see it right
* away.
*
* @param message - The error message to test.
* @returns True if the message indicates a missing Nock mock, false otherwise.
*/
function isNockError(message) {
return message.includes('Nock:');
}
/**
* Determine whether the given error message indicates a failure to parse JSON.
*
* This is different in tests vs. implementation code because it may manifest as
* a FetchError or a SyntaxError.
*
* @param error - The error object to test.
* @returns True if the error indicates a JSON parse error, false otherwise.
*/
function isJsonParseError(error) {
return (error instanceof SyntaxError ||
/invalid json/iu.test((0, utils_1.getErrorMessage)(error)));
}
/**
* Guarantees a URL, even given a string. This is useful for checking components
* of that URL.
*
* @param endpointUrlOrUrlString - Either a URL object or a string that
* represents the URL of an endpoint.
* @returns A URL object.
*/
function getNormalizedEndpointUrl(endpointUrlOrUrlString) {
return endpointUrlOrUrlString instanceof URL
? endpointUrlOrUrlString
: new URL(endpointUrlOrUrlString);
}
/**
* Strips username and password from a URL.
*
* @param url - The URL to strip credentials from.
* @returns A new URL object with credentials removed.
*/
function stripCredentialsFromUrl(url) {
const strippedUrl = new URL(url.toString());
strippedUrl.username = '';
strippedUrl.password = '';
return strippedUrl;
}
/**
* This class is responsible for making a request to an endpoint that implements
* the JSON-RPC protocol. It is designed to gracefully handle network and server
* failures, retrying requests using exponential backoff. It also offers a hook
* which can used to respond to slow requests.
*/
class RpcService {
/**
* Constructs a new RpcService object.
*
* @param options - The options. See {@link RpcServiceOptions}.
*/
constructor(options) {
_RpcService_instances.add(this);
/**
* The function used to make an HTTP request.
*/
_RpcService_fetch.set(this, void 0);
/**
* A common set of options that the request options will extend.
*/
_RpcService_fetchOptions.set(this, void 0);
/**
* A `loglevel` logger.
*/
_RpcService_logger.set(this, void 0);
/**
* The policy that wraps the request.
*/
_RpcService_policy.set(this, void 0);
const { btoa: givenBtoa, endpointUrl, fetch: givenFetch, logger, fetchOptions = {}, policyOptions = {}, } = options;
__classPrivateFieldSet(this, _RpcService_fetch, givenFetch, "f");
const normalizedUrl = getNormalizedEndpointUrl(endpointUrl);
__classPrivateFieldSet(this, _RpcService_fetchOptions, __classPrivateFieldGet(this, _RpcService_instances, "m", _RpcService_getDefaultFetchOptions).call(this, normalizedUrl, fetchOptions, givenBtoa), "f");
this.endpointUrl = stripCredentialsFromUrl(normalizedUrl);
__classPrivateFieldSet(this, _RpcService_logger, logger, "f");
__classPrivateFieldSet(this, _RpcService_policy, (0, controller_utils_1.createServicePolicy)({
maxRetries: exports.DEFAULT_MAX_RETRIES,
maxConsecutiveFailures: exports.DEFAULT_MAX_CONSECUTIVE_FAILURES,
...policyOptions,
retryFilterPolicy: (0, controller_utils_1.handleWhen)((error) => {
return (
// Ignore errors where the request failed to establish
isConnectionError(error) ||
// 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)) ||
((0, utils_1.hasProperty)(error, 'code') &&
(error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET')));
}),
}), "f");
}
/**
* Resets the underlying composite Cockatiel policy.
*
* This is useful in a collection of RpcServices where some act as failovers
* for others where you effectively want to invalidate the failovers when the
* primary recovers.
*/
resetPolicy() {
__classPrivateFieldGet(this, _RpcService_policy, "f").reset();
}
/**
* @returns The state of the underlying circuit.
*/
getCircuitState() {
return __classPrivateFieldGet(this, _RpcService_policy, "f").getCircuitState();
}
/**
* Listens for when the RPC service retries the request.
*
* @param listener - The callback to be called when the retry occurs.
* @returns What {@link ServicePolicy.onRetry} returns.
* @see {@link createServicePolicy}
*/
onRetry(listener) {
return __classPrivateFieldGet(this, _RpcService_policy, "f").onRetry((data) => {
listener({ ...data, endpointUrl: this.endpointUrl.toString() });
});
}
/**
* Listens for when the RPC service retries the request too many times in a
* row, causing the underlying circuit to break.
*
* @param listener - The callback to be called when the circuit is broken.
* @returns What {@link ServicePolicy.onBreak} returns.
* @see {@link createServicePolicy}
*/
onBreak(listener) {
return __classPrivateFieldGet(this, _RpcService_policy, "f").onBreak((data) => {
// `{ isolated: true }` is a special object that shows up when `isolate`
// is called on the circuit breaker. Usually `isolate` is used to hold the
// circuit open, but we (ab)use this method in `createServicePolicy` to
// reset the circuit breaker policy. When we do this, we don't want to
// call `onBreak` handlers, because then it causes
// `NetworkController:rpcEndpointUnavailable` and
// `NetworkController:rpcEndpointChainUnavailable` to be published. So we
// have to ignore that object here. The consequence is that `isolate`
// doesn't function the way it is intended, at least in the context of an
// RpcService. However, we are making a bet that we won't need to use it
// other than how we are already using it.
if (!('isolated' in data)) {
listener({ ...data, endpointUrl: this.endpointUrl.toString() });
}
});
}
/**
* Listens for when the policy underlying this RPC service detects a slow
* request.
*
* @param listener - The callback to be called when the request is slow.
* @returns What {@link ServicePolicy.onDegraded} returns.
* @see {@link createServicePolicy}
*/
onDegraded(listener) {
return __classPrivateFieldGet(this, _RpcService_policy, "f").onDegraded((data) => {
listener({ ...(data ?? {}), endpointUrl: this.endpointUrl.toString() });
});
}
/**
* Listens for when the policy underlying this RPC service is available.
*
* @param listener - The callback to be called when the request is available.
* @returns What {@link ServicePolicy.onAvailable} returns.
* @see {@link createServicePolicy}
*/
onAvailable(listener) {
return __classPrivateFieldGet(this, _RpcService_policy, "f").onAvailable(() => {
listener({ endpointUrl: this.endpointUrl.toString() });
});
}
async request(
// The request object may be frozen and must not be mutated.
jsonRpcRequest, fetchOptions = {}) {
const completeFetchOptions = __classPrivateFieldGet(this, _RpcService_instances, "m", _RpcService_getCompleteFetchOptions).call(this, jsonRpcRequest, fetchOptions);
return await __classPrivateFieldGet(this, _RpcService_instances, "m", _RpcService_executeAndProcessRequest).call(this, completeFetchOptions);
}
}
exports.RpcService = RpcService;
_RpcService_fetch = new WeakMap(), _RpcService_fetchOptions = new WeakMap(), _RpcService_logger = new WeakMap(), _RpcService_policy = new WeakMap(), _RpcService_instances = new WeakSet(), _RpcService_getDefaultFetchOptions = function _RpcService_getDefaultFetchOptions(endpointUrl, fetchOptions, givenBtoa) {
if (endpointUrl.username && endpointUrl.password) {
const authString = `${endpointUrl.username}:${endpointUrl.password}`;
const encodedCredentials = givenBtoa(authString);
return (0, deepmerge_1.default)(fetchOptions, {
headers: { Authorization: `Basic ${encodedCredentials}` },
});
}
return fetchOptions;
}, _RpcService_getCompleteFetchOptions = function _RpcService_getCompleteFetchOptions(jsonRpcRequest, fetchOptions) {
const defaultOptions = {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
};
const mergedOptions = (0, deepmerge_1.default)(defaultOptions, (0, deepmerge_1.default)(__classPrivateFieldGet(this, _RpcService_fetchOptions, "f"), fetchOptions));
const { id, jsonrpc, method, params } = jsonRpcRequest;
const body = JSON.stringify({
id,
jsonrpc,
method,
params,
});
return { ...mergedOptions, body };
}, _RpcService_executeAndProcessRequest =
/**
* Makes the request using the Cockatiel policy that this service creates.
*
* @param fetchOptions - The options for `fetch`; will be combined with the
* fetch options passed to the constructor
* @returns The decoded JSON-RPC response from the endpoint.
* @throws An "authorized" JSON-RPC error (code -32006) if the response HTTP status is 401.
* @throws A "rate limiting" JSON-RPC error (code -32005) if the response HTTP status is 429.
* @throws A "resource unavailable" JSON-RPC error (code -32002) if the response HTTP status is 402, 404, or any 5xx.
* @throws A generic HTTP client JSON-RPC error (code -32050) for any other 4xx HTTP status codes.
* @throws A "parse" JSON-RPC error (code -32700) if the response is not valid JSON.
*/
async function _RpcService_executeAndProcessRequest(fetchOptions) {
let response;
try {
log(`[${this.endpointUrl}] Circuit state`, __classPrivateFieldGet(this, _RpcService_policy, "f").getCircuitState());
const jsonDecodedResponse = await __classPrivateFieldGet(this, _RpcService_policy, "f").execute(async (context) => {
log('REQUEST INITIATED:', this.endpointUrl.toString(), '::', fetchOptions,
// @ts-expect-error This property _is_ here, the type of
// ServicePolicy is just wrong.
`(attempt ${context.attempt + 1})`);
response = await __classPrivateFieldGet(this, _RpcService_fetch, "f").call(this, this.endpointUrl, fetchOptions);
if (!response.ok) {
throw new controller_utils_1.HttpError(response.status);
}
log('REQUEST SUCCESSFUL:', this.endpointUrl.toString(), response.status);
return await response.json();
});
this.lastError = undefined;
return jsonDecodedResponse;
}
catch (error) {
log('REQUEST ERROR:', this.endpointUrl.toString(), error);
this.lastError =
error instanceof Error ? error : new Error((0, utils_1.getErrorMessage)(error));
if (error instanceof controller_utils_1.HttpError) {
const status = error.httpStatus;
if (status === 401) {
throw new rpc_errors_1.JsonRpcError(exports.CUSTOM_RPC_ERRORS.unauthorized, 'Unauthorized.', {
httpStatus: status,
});
}
if (status === 429) {
throw rpc_errors_1.rpcErrors.limitExceeded({
message: 'Request is being rate limited.',
data: {
httpStatus: status,
},
});
}
if (status >= 500 || status === 402 || status === 404) {
throw rpc_errors_1.rpcErrors.resourceUnavailable({
message: 'RPC endpoint not found or unavailable.',
data: {
httpStatus: status,
},
});
}
// Handle all other 4xx errors as generic HTTP client errors
throw new rpc_errors_1.JsonRpcError(exports.CUSTOM_RPC_ERRORS.httpClientError, 'RPC endpoint returned HTTP client error.', {
httpStatus: status,
});
}
else if (isJsonParseError(error)) {
throw rpc_errors_1.rpcErrors.parse({
message: 'RPC endpoint did not return JSON.',
});
}
else if (error instanceof controller_utils_1.BrokenCircuitError) {
__classPrivateFieldGet(this, _RpcService_logger, "f")?.warn(error);
const remainingCircuitOpenDuration = __classPrivateFieldGet(this, _RpcService_policy, "f").getRemainingCircuitOpenDuration();
const formattedRemainingCircuitOpenDuration = Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format((remainingCircuitOpenDuration ?? __classPrivateFieldGet(this, _RpcService_policy, "f").circuitBreakDuration) /
utils_1.Duration.Minute);
throw rpc_errors_1.rpcErrors.resourceUnavailable({
message: `RPC endpoint returned too many errors, retrying in ${formattedRemainingCircuitOpenDuration} minutes. Consider using a different RPC endpoint.`,
});
}
throw error;
}
};
//# sourceMappingURL=rpc-service.cjs.map