UNPKG

@hashgraph/sdk

Version:
891 lines (799 loc) 29.2 kB
// SPDX-License-Identifier: Apache-2.0 import GrpcServiceError from "./grpc/GrpcServiceError.js"; import GrpcStatus from "./grpc/GrpcStatus.js"; import List from "./transaction/List.js"; import * as hex from "./encoding/hex.js"; import HttpError from "./http/HttpError.js"; import Status from "./Status.js"; import MaxAttemptsOrTimeoutError from "./MaxAttemptsOrTimeoutError.js"; /** * @typedef {import("./account/AccountId.js").default} AccountId * @typedef {import("./channel/Channel.js").default} Channel * @typedef {import("./channel/MirrorChannel.js").default} MirrorChannel * @typedef {import("./transaction/TransactionId.js").default} TransactionId * @typedef {import("./client/Client.js").ClientOperator} ClientOperator * @typedef {import("./Signer.js").Signer} Signer * @typedef {import("./PublicKey.js").default} PublicKey * @typedef {import("./logger/Logger.js").default} Logger */ /** * @enum {string} */ export const ExecutionState = { Finished: "Finished", Retry: "Retry", Error: "Error", }; export const RST_STREAM = /\brst[^0-9a-zA-Z]stream\b/i; export const DEFAULT_MAX_ATTEMPTS = 10; /** * @abstract * @internal * @template RequestT * @template ResponseT * @template OutputT */ export default class Executable { constructor() { /** * The number of times we can retry the grpc call * * @internal * @type {number} */ this._maxAttempts = DEFAULT_MAX_ATTEMPTS; /** * List of node account IDs for each transaction that has been * built. * * @internal * @type {List<AccountId>} */ this._nodeAccountIds = new List(); /** * List of the transaction node account IDs to check if * the node account ID of the request is in the list * * @protected * @type {Array<string>} */ this.transactionNodeIds = []; /** * @internal */ this._signOnDemand = false; /** * This is the request's min backoff * * @internal * @type {number | null} */ this._minBackoff = null; /** * This is the request's max backoff * * @internal * @type {number} */ this._maxBackoff = 8000; /** * The operator that was used to execute this request. * The reason we save the operator in the request is because of the signing on * demand feature. This feature requires us to sign new request on each attempt * meaning if a client with an operator was used we'd need to sign with the operator * on each attempt. * * @internal * @type {ClientOperator | null} */ this._operator = null; /** * The complete timeout for running the `execute()` method * * @internal * @type {number | null} */ this._requestTimeout = null; /** * The grpc request timeout aka deadline. * * The reason we have this is because there were times that consensus nodes held the grpc * connection, but didn't return anything; not error nor regular response. This resulted * in some weird behavior in the SDKs. To fix this we've added a grpc deadline to prevent * nodes from stalling the executing of a request. * * @internal * @type {number | null} */ this._grpcDeadline = null; /** * Logger * * @protected * @type {Logger | null} */ this._logger = null; } /** * Get the list of node account IDs on the request. If no nodes are set, then null is returned. * The reasoning for this is simply "legacy behavior". * * @returns {?AccountId[]} */ get nodeAccountIds() { if (this._nodeAccountIds.isEmpty) { return null; } else { this._nodeAccountIds.setLocked(); return this._nodeAccountIds.list; } } /** * Set the node account IDs on the request * * @param {AccountId[]} nodeIds * @returns {this} */ setNodeAccountIds(nodeIds) { // Set the node account IDs, and lock the list. This will require `execute` // to use these nodes instead of random nodes from the network. this._nodeAccountIds.setList(nodeIds).setLocked(); return this; } /** * @deprecated * @returns {number} */ get maxRetries() { console.warn("Deprecated: use maxAttempts instead"); return this.maxAttempts; } /** * @param {number} maxRetries * @returns {this} */ setMaxRetries(maxRetries) { console.warn("Deprecated: use setMaxAttempts() instead"); return this.setMaxAttempts(maxRetries); } /** * Get the max attempts on the request * * @returns {number} */ get maxAttempts() { return this._maxAttempts; } /** * Set the max attempts on the request * * @param {number} maxAttempts * @returns {this} */ setMaxAttempts(maxAttempts) { this._maxAttempts = maxAttempts; return this; } /** * Get the grpc deadline * * @returns {?number} */ get grpcDeadline() { return this._grpcDeadline; } /** * Set the grpc deadline * * @param {number} grpcDeadline * @returns {this} */ setGrpcDeadline(grpcDeadline) { this._grpcDeadline = grpcDeadline; return this; } /** * Set the min backoff for the request * * @param {number} minBackoff * @returns {this} */ setMinBackoff(minBackoff) { // Honestly we shouldn't be checking for null since that should be TypeScript's job. // Also verify that min backoff is not greater than max backoff. if (minBackoff == null) { throw new Error("minBackoff cannot be null."); } else if (this._maxBackoff != null && minBackoff > this._maxBackoff) { throw new Error("minBackoff cannot be larger than maxBackoff."); } this._minBackoff = minBackoff; return this; } /** * Get the min backoff * * @returns {number | null} */ get minBackoff() { return this._minBackoff; } /** * Set the max backoff for the request * * @param {?number} maxBackoff * @returns {this} */ setMaxBackoff(maxBackoff) { // Honestly we shouldn't be checking for null since that should be TypeScript's job. // Also verify that max backoff is not less than min backoff. if (maxBackoff == null) { throw new Error("maxBackoff cannot be null."); } else if (this._minBackoff != null && maxBackoff < this._minBackoff) { throw new Error("maxBackoff cannot be smaller than minBackoff."); } this._maxBackoff = maxBackoff; return this; } /** * Get the max backoff * * @returns {number} */ get maxBackoff() { return this._maxBackoff; } /** * This method is responsible for doing any work before the executing process begins. * For paid queries this will result in executing a cost query, for transactions this * will make sure we save the operator and sign any requests that need to be signed * in case signing on demand is disabled. * * @abstract * @protected * @param {import("./client/Client.js").default<Channel, *>} client * @returns {Promise<void>} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _beforeExecute(client) { throw new Error("not implemented"); } /** * Create a protobuf request which will be passed into the `_execute()` method * * @abstract * @protected * @returns {Promise<RequestT>} */ _makeRequestAsync() { throw new Error("not implemented"); } /** * This name is a bit wrong now, but the purpose of this method is to map the * request and response into an error. This method will only be called when * `_shouldRetry` returned `ExecutionState.Error` * * @abstract * @internal * @param {RequestT} request * @param {ResponseT} response * @param {AccountId} nodeId * @returns {Error} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _mapStatusError(request, response, nodeId) { throw new Error("not implemented"); } /** * Map the request, response, and the node account ID used for this attempt into a response. * This method will only be called when `_shouldRetry` returned `ExecutionState.Finished` * * @abstract * @protected * @param {ResponseT} response * @param {AccountId} nodeAccountId * @param {RequestT} request * @returns {Promise<OutputT>} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _mapResponse(response, nodeAccountId, request) { throw new Error("not implemented"); } /** * Perform a single grpc call with the given request. Each request has it's own * required service so we just pass in channel, and it'$ the request's responsiblity * to use the right service and call the right grpc method. * * @abstract * @internal * @param {Channel} channel * @param {RequestT} request * @returns {Promise<ResponseT>} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _execute(channel, request) { throw new Error("not implemented"); } /** * Return the current transaction ID for the request. All requests which are * use the same transaction ID for each node, but the catch is that `Transaction` * implicitly supports chunked transactions. Meaning there could be multiple * transaction IDs stored in the request, and a different transaction ID will be used * on subsequent calls to `execute()` * * FIXME: This method can most likely be removed, although some further inspection * is required. * * @abstract * @protected * @returns {TransactionId} */ _getTransactionId() { throw new Error("not implemented"); } /** * Return the log ID for this particular request * * Log IDs are simply a string constructed to make it easy to track each request's * execution even when mulitple requests are executing in parallel. Typically, this * method returns the format of `[<request type>.<timestamp of the transaction ID>]` * * Maybe we should deduplicate this using ${this.consturtor.name} * * @abstract * @internal * @returns {string} */ _getLogId() { throw new Error("not implemented"); } /** * Serialize the request into bytes * * @abstract * @param {RequestT} request * @returns {Uint8Array} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _requestToBytes(request) { throw new Error("not implemented"); } /** * Serialize the response into bytes * * @abstract * @param {ResponseT} response * @returns {Uint8Array} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _responseToBytes(response) { throw new Error("not implemented"); } /** * Determine if we should continue the execution process, error, or finish. * * FIXME: This method should really be called something else. Initially it returned * a boolean so `shouldRetry` made sense, but now it returns an enum, so the name * no longer makes sense. * * @abstract * @protected * @param {RequestT} request * @param {ResponseT} response * @returns {[Status, ExecutionState]} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _shouldRetry(request, response) { throw new Error("not implemented"); } /** * Determine if we should error based on the gRPC status * * Unlike `shouldRetry` this method does in fact still return a boolean * * @protected * @param {Error} error * @returns {boolean} */ _shouldRetryExceptionally(error) { if (error instanceof GrpcServiceError) { return ( error.status._code === GrpcStatus.Timeout._code || error.status._code === GrpcStatus.Unavailable._code || error.status._code === GrpcStatus.ResourceExhausted._code || error.status._code === GrpcStatus.GrpcWeb._code || (error.status._code === GrpcStatus.Internal._code && RST_STREAM.test(error.message)) ); } else { // if we get to the 'else' statement, the 'error' is instanceof 'HttpError' // and in this case, we have to retry always return true; } } /** * A helper method for setting the operator on the request * * @internal * @param {AccountId} accountId * @param {PublicKey} publicKey * @param {(message: Uint8Array) => Promise<Uint8Array>} transactionSigner * @returns {this} */ _setOperatorWith(accountId, publicKey, transactionSigner) { this._operator = { transactionSigner, accountId, publicKey, }; return this; } /** * Execute this request using the signer * * This method is part of the signature providers feature * https://hips.hedera.com/hip/hip-338 * * @param {Signer} signer * @returns {Promise<OutputT>} */ async executeWithSigner(signer) { return signer.call(this); } /** * @returns {boolean} * @abstract * @protected */ isBatchedAndNotBatchTransaction() { return false; } /** * Execute the request using a client and an optional request timeout * * @template {Channel} ChannelT * @template {MirrorChannel} MirrorChannelT * @param {import("./client/Client.js").default<ChannelT, MirrorChannelT>} client * @param {number=} requestTimeout * @returns {Promise<OutputT>} */ async execute(client, requestTimeout) { // we check if its local node then backoff mechanism should be disabled // and we increase the retry attempts const isLocalNode = client.network["127.0.0.1:50211"] != null; if (this.isBatchedAndNotBatchTransaction()) { throw new Error( "Cannot execute batchified transaction outside of BatchTransaction", ); } // If the logger on the request is not set, use the logger in client // (if set, otherwise do not use logger) this._logger = this._logger == null ? client._logger != null ? client._logger : null : this._logger; // If the request timeout is set on the request we'll prioritize that instead // of the parameter provided, and if the parameter isn't provided we'll // use the default request timeout on client if (this._requestTimeout == null) { this._requestTimeout = requestTimeout != null ? requestTimeout : client.requestTimeout; } // Some request need to perform additional requests before the executing // such as paid queries need to fetch the cost of the query before // finally executing the actual query. await this._beforeExecute(client); // If the max backoff on the request is not set, use the default value in client if (this._maxBackoff == null) { this._maxBackoff = client.maxBackoff; } // If the min backoff on the request is not set, use the default value in client if (this._minBackoff == null) { this._minBackoff = client.minBackoff; } // Save the start time to be used later with request timeout const startTime = Date.now(); // Saves each error we get so when we err due to max attempts exceeded we'll have // the last error that was returned by the consensus node let persistentError = null; // If the max attempts on the request is not set, use the default value in client // If the default value in client is not set, use a default of 10. // // FIXME: current implementation is wrong, update to follow comment above. // ... existing code ... const LOCAL_NODE_ATTEMPTS = 1000; const maxAttempts = isLocalNode ? LOCAL_NODE_ATTEMPTS : client._maxAttempts ?? this._maxAttempts; // Checks if has a valid nodes to which the TX can be sent if (this.transactionNodeIds.length) { const nodeAccountIds = this._nodeAccountIds.list.map((nodeId) => nodeId.toString(), ); const hasValidNodes = this.transactionNodeIds.some((nodeId) => nodeAccountIds.includes(nodeId), ); if (!hasValidNodes) { const displayNodeAccountIds = nodeAccountIds.length > 2 ? `${nodeAccountIds.slice(0, 2).join(", ")} ...` : nodeAccountIds.join(", "); const isSingleNode = nodeAccountIds.length === 1; throw new Error( `Attempting to execute a transaction against node${ isSingleNode ? "" : "s" } ${displayNodeAccountIds}, ` + `which ${ isSingleNode ? "is" : "are" } not included in the Client's node list. Please review your Client configuration.`, ); } } // The retry loop for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { // Determine if we've exceeded request timeout if ( this._requestTimeout != null && startTime + this._requestTimeout <= Date.now() ) { throw new MaxAttemptsOrTimeoutError( `timeout exceeded`, this._nodeAccountIds.isEmpty ? "No node account ID set" : this._nodeAccountIds.current.toString(), ); } let nodeAccountId; let node; if (this._nodeAccountIds.isEmpty) { node = client._network.getNode(); nodeAccountId = node.accountId; this._nodeAccountIds.setList([nodeAccountId]); } else { nodeAccountId = this._nodeAccountIds.current; node = client._network.getNode(nodeAccountId); } if (node == null) { throw new Error( `NodeAccountId not recognized: ${nodeAccountId.toString()}`, ); } if (this.transactionNodeIds.length) { const isNodeAccountIdValid = this.transactionNodeIds.includes( nodeAccountId.toString(), ); if (!isNodeAccountIdValid) { console.error( `Attempting to execute a transaction against node ${nodeAccountId.toString()}, which is not included in the Client's node list. Please review your Client configuration.`, ); this._nodeAccountIds.advance(); continue; } } // Get the log ID for the request. const logId = this._getLogId(); if (this._logger) { this._logger.debug( `[${logId}] Node AccountID: ${node.accountId.toString()}, IP: ${node.address.toString()}`, ); } const channel = node.getChannel(); const request = await this._makeRequestAsync(); let response; if (!node.isHealthy()) { const isLastNode = this._nodeAccountIds.index === this._nodeAccountIds.list.length - 1; // Check if the request is a transaction receipt or record // request to retry 10 times, because getReceiptQuery/getRecordQuery // are single node requests if ( isTransactionReceiptOrRecordRequest(request) || isLocalNode ) { await delayForAttempt( isLocalNode, attempt, this._minBackoff, this._maxBackoff, ); continue; } if (isLastNode || this._nodeAccountIds.length <= 1) { throw new Error( `Network connectivity issue: All nodes are unhealthy. Original node list: ${this._nodeAccountIds.list.join( ", ", )}`, ); } if (this._logger) { this._logger.debug( `[${logId}] Node is not healthy, trying the next node.`, ); } this._nodeAccountIds.advance(); continue; } this._nodeAccountIds.advance(); try { // Race the execution promise against the grpc timeout to prevent grpc connections // from blocking this request const promises = []; // If a grpc deadline is est, we should race it, otherwise the only thing in the // list of promises will be the execution promise. if (this._grpcDeadline != null) { promises.push( // eslint-disable-next-line ie11/no-loop-func new Promise((_, reject) => setTimeout( // eslint-disable-next-line ie11/no-loop-func () => reject(new Error("grpc deadline exceeded")), /** @type {number=} */ (this._grpcDeadline), ), ), ); } if (this._logger) { this._logger.trace( `[${this._getLogId()}] sending protobuf ${hex.encode( this._requestToBytes(request), )}`, ); } promises.push(this._execute(channel, request)); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment response = /** @type {ResponseT} */ ( await Promise.race(promises) ); } catch (err) { // If we received a grpc status error we need to determine if // we should retry on this error, or err from the request entirely. const error = GrpcServiceError._fromResponse( /** @type {Error} */ (err), ); // Save the error in case we retry persistentError = error; if (this._logger) { this._logger.debug( `[${logId}] received error ${JSON.stringify(error)}`, ); } if ( (error instanceof GrpcServiceError || error instanceof HttpError) && this._shouldRetryExceptionally(error) && attempt <= maxAttempts ) { // Increase the backoff for the particular node and remove it from // the healthy node list if (this._logger) { this._logger.debug( `[${this._getLogId()}] node with accountId: ${node.accountId.toString()} and proxy IP: ${node.address.toString()} is unhealthy`, ); } client._network.increaseBackoff(node); continue; } throw err; } if (this._logger) { this._logger.trace( `[${this._getLogId()}] sending protobuf ${hex.encode( this._responseToBytes(response), )}`, ); } // If we didn't receive an error we should decrease the current nodes backoff // in case it is a recovering node client._network.decreaseBackoff(node); // Determine what execution state we're in by the response // For transactions this would be as simple as checking the response status is `OK` // while for _most_ queries it would check if the response status is `SUCCESS` // The only odd balls are `TransactionReceiptQuery` and `TransactionRecordQuery` const [status, shouldRetry] = this._shouldRetry(request, response); if ( status.toString() !== Status.Ok.toString() && status.toString() !== Status.Success.toString() ) { persistentError = status; } // Determine by the executing state what we should do switch (shouldRetry) { case ExecutionState.Retry: await delayForAttempt( isLocalNode, attempt, this._minBackoff, this._maxBackoff, ); continue; case ExecutionState.Finished: return this._mapResponse(response, nodeAccountId, request); case ExecutionState.Error: throw this._mapStatusError( request, response, nodeAccountId, ); default: throw new Error( "(BUG) non-exhaustive switch statement for `ExecutionState`", ); } } // We'll only get here if we've run out of attempts, so we return an error wrapping the // persistent error we saved before. throw new MaxAttemptsOrTimeoutError( `max attempts of ${maxAttempts.toString()} was reached for request with last error being: ${ persistentError != null ? persistentError.toString() : "" }`, this._nodeAccountIds.current.toString(), ); } /** * The current purpose of this method is to easily support signature providers since * signature providers need to serialize _any_ request into bytes. `Query` and `Transaction` * already implement `toBytes()` so it only made sense to make it available here too. * * @abstract * @returns {Uint8Array} */ toBytes() { throw new Error("not implemented"); } /** * Set logger * * @param {Logger} logger * @returns {this} */ setLogger(logger) { this._logger = logger; return this; } /** * Get logger if set * * @returns {?Logger} */ get logger() { return this._logger; } } /** * Checks if the request is a transaction receipt or record request * * @template T * @param {T} request - The request to check * @returns {boolean} - True if the request is a transaction receipt or record */ function isTransactionReceiptOrRecordRequest(request) { if (typeof request !== "object" || request === null) { return false; } return ( "transactionGetReceipt" in request || "transactionGetRecord" in request ); } /** * A simple function that returns a promise timeout for a specific period of time * * @param {boolean} isLocalNode * @param {number} attempt * @param {number} minBackoff * @param {number} maxBackoff * @returns {Promise<void>} */ function delayForAttempt(isLocalNode, attempt, minBackoff, maxBackoff) { if (isLocalNode) { return new Promise((resolve) => setTimeout(resolve, minBackoff)); } // 0.1s, 0.2s, 0.4s, 0.8s, ... const ms = Math.min( Math.floor(minBackoff * Math.pow(2, attempt)), maxBackoff, ); return new Promise((resolve) => setTimeout(resolve, ms)); }