diff --git a/src/Executable.js b/src/Executable.js index 19205c1b9..d5328aaa4 100644 --- a/src/Executable.js +++ b/src/Executable.js @@ -24,6 +24,7 @@ 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 @@ -568,7 +569,12 @@ export default class Executable { this._requestTimeout != null && startTime + this._requestTimeout <= Date.now() ) { - throw new Error("timeout exceeded"); + throw new MaxAttemptsOrTimeoutError( + `timeout exceeded`, + this._nodeAccountIds.isEmpty + ? "No node account ID set" + : this._nodeAccountIds.current.toString(), + ); } let nodeAccountId; @@ -741,10 +747,12 @@ export default class Executable { // 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 Error( + + throw new MaxAttemptsOrTimeoutError( `max attempts of ${maxAttempts.toString()} was reached for request with last error being: ${ persistentError != null ? persistentError.toString() : "" }`, + this._nodeAccountIds.current.toString(), ); } diff --git a/src/MaxAttemptsOrTimeoutError.js b/src/MaxAttemptsOrTimeoutError.js new file mode 100644 index 000000000..9b0f411a4 --- /dev/null +++ b/src/MaxAttemptsOrTimeoutError.js @@ -0,0 +1,61 @@ +/*- + * ‌ + * Hedera JavaScript SDK + * ​ + * Copyright (C) 2020 - 2024 Hedera Hashgraph, LLC + * ​ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ‍ + */ + +/** + * @typedef {object} MaxAttemptsOrTimeoutErrorJSON + * @property {string} message + * @property {string} nodeAccountId + * + */ + +export default class MaxAttemptsOrTimeoutError extends Error { + /** + * @param {string} message + * @param {string} nodeAccountId + */ + constructor(message, nodeAccountId) { + // Call the Error constructor with the message + super(message); + + // Assign the nodeAccountId as a custom property + this.nodeAccountId = nodeAccountId; + } + + toJSON() { + return { + message: this.message, + nodeAccountId: this.nodeAccountId, + }; + } + + /** + * @returns {string} + */ + toString() { + return JSON.stringify(this.toJSON()); + } + + /** + * @returns {MaxAttemptsOrTimeoutErrorJSON} + */ + valueOf() { + return this.toJSON(); + } +} diff --git a/src/channel/NodeChannel.js b/src/channel/NodeChannel.js index c2bef476b..64d557d7e 100644 --- a/src/channel/NodeChannel.js +++ b/src/channel/NodeChannel.js @@ -22,6 +22,7 @@ import { Client, credentials } from "@grpc/grpc-js"; import Channel from "./Channel.js"; import GrpcServicesError from "../grpc/GrpcServiceError.js"; import GrpcStatus from "../grpc/GrpcStatus.js"; +import { ALL_NETWORK_IPS } from "../constants/ClientConstants.js"; /** * @property {?HashgraphProto.proto.CryptoService} _crypto @@ -102,7 +103,14 @@ export default class NodeChannel extends Channel { this._client.waitForReady(deadline, (err) => { if (err) { - callback(new GrpcServicesError(GrpcStatus.Timeout)); + callback( + new GrpcServicesError( + GrpcStatus.Timeout, + ALL_NETWORK_IPS[ + this._client.getChannel().getChannelzRef().name + ], + ), + ); } else { this._client.makeUnaryRequest( `/proto.${serviceName}/${method.name}`, diff --git a/src/channel/WebChannel.js b/src/channel/WebChannel.js index f21a4c289..5e8857c3e 100644 --- a/src/channel/WebChannel.js +++ b/src/channel/WebChannel.js @@ -18,6 +18,7 @@ * ‍ */ +import { ALL_WEB_NETWORK_NODES } from "../constants/ClientConstants.js"; import GrpcServiceError from "../grpc/GrpcServiceError.js"; import GrpcStatus from "../grpc/GrpcStatus.js"; import HttpError from "../http/HttpError.js"; @@ -83,6 +84,7 @@ export default class WebChannel extends Channel { if (grpcStatus != null && grpcMessage != null) { const error = new GrpcServiceError( GrpcStatus._fromValue(parseInt(grpcStatus)), + ALL_WEB_NETWORK_NODES[this._address].toString(), ); error.message = grpcMessage; callback(error, null); @@ -96,6 +98,7 @@ export default class WebChannel extends Channel { const err = new GrpcServiceError( // retry on grpc web errors GrpcStatus._fromValue(18), + ALL_WEB_NETWORK_NODES[this._address].toString(), ); callback(err, null); } diff --git a/src/constants/ClientConstants.js b/src/constants/ClientConstants.js index 36e09a498..0577f0102 100644 --- a/src/constants/ClientConstants.js +++ b/src/constants/ClientConstants.js @@ -64,3 +64,121 @@ export const NATIVE_TESTNET = { export const NATIVE_PREVIEWNET = { "https://grpc-web.previewnet.myhbarwallet.com:443": new AccountId(3), }; + +/** + * @type {Record} + */ +export const ALL_WEB_NETWORK_NODES = { + ...MAINNET, + ...WEB_TESTNET, + ...WEB_PREVIEWNET, +}; + +/** + * @type {Record} + */ +export const ALL_NETWORK_IPS = { + // MAINNET + "34.239.82.6:50211": "0.0.3", + "35.237.200.180:50211": "0.0.3", + "3.130.52.236:50211": "0.0.4", + "35.186.191.247:50211": "0.0.4", + "3.18.18.254:50211": "0.0.5", + "35.192.2.25:50211": "0.0.5", + "74.50.117.35:50211": "0.0.5", + "23.111.186.250:50211": "0.0.5", + "107.155.64.98:50211": "0.0.5", + "13.52.108.243:50211": "0.0.6", + "35.199.161.108:50211": "0.0.6", + "3.114.54.4:50211": "0.0.7", + "35.203.82.240:50211": "0.0.7", + "35.236.5.219:50211": "0.0.8", + "35.183.66.150:50211": "0.0.8", + "35.181.158.250:50211": "0.0.9", + "35.197.192.225:50211": "0.0.9", + "177.154.62.234:50211": "0.0.10", + "3.248.27.48:50211": "0.0.10", + "35.242.233.154:50211": "0.0.10", + "13.53.119.185:50211": "0.0.11", + "35.240.118.96:50211": "0.0.11", + "35.204.86.32:50211": "0.0.12", + "35.177.162.180:50211": "0.0.12", + "34.215.192.104:50211": "0.0.13", + "35.234.132.107:50211": "0.0.13", + "52.8.21.141:50211": "0.0.14", + "35.236.2.27:50211": "0.0.14", + "35.228.11.53:50211": "0.0.15", + "3.121.238.26:50211": "0.0.15", + "34.91.181.183:50211": "0.0.16", + "18.157.223.230:50211": "0.0.16", + "34.86.212.247:50211": "0.0.17", + "18.232.251.19:50211": "0.0.17", + "141.94.175.187:50211": "0.0.18", + "34.89.87.138:50211": "0.0.19", + "18.168.4.59:50211": "0.0.19", + "34.82.78.255:50211": "0.0.20", + "52.39.162.216:50211": "0.0.20", + "34.76.140.109:50211": "0.0.21", + "13.36.123.209:50211": "0.0.21", + "52.78.202.34:50211": "0.0.22", + "34.64.141.166:50211": "0.0.22", + "3.18.91.176:50211": "0.0.23", + "35.232.244.145:50211": "0.0.23", + "69.167.169.208:50211": "0.0.23", + "34.89.103.38:50211": "0.0.24", + "18.135.7.211:50211": "0.0.24", + "34.93.112.7:50211": "0.0.25", + "13.232.240.207:50211": "0.0.25", + "13.228.103.14:50211": "0.0.26", + "34.87.150.174:50211": "0.0.26", + "13.56.4.96:50211": "0.0.27", + "34.125.200.96:50211": "0.0.27", + "35.198.220.75:50211": "0.0.28", + "18.139.47.5:50211": "0.0.28", + "54.74.60.120:50211": "0.0.29", + "34.142.71.129:50211": "0.0.29", + "80.85.70.197:50211": "0.0.29", + "35.234.249.150:50211": "0.0.30", + "34.201.177.212:50211": "0.0.30", + "217.76.57.165:50211": "0.0.31", + "3.77.94.254:50211": "0.0.31", + "34.107.78.179:50211": "0.0.31", + "34.86.186.151:50211": "0.0.32", + "3.20.81.230:50211": "0.0.32", + "18.136.65.22:50211": "0.0.33", + "34.142.172.228:50211": "0.0.33", + "34.16.139.248:50211": "0.0.34", + "35.155.212.90:50211": "0.0.34", + // TESTNET + "34.94.106.61:50211": "0.0.3", + "50.18.132.211:50211": "0.0.3", + "3.212.6.13:50211": "0.0.4", + "35.237.119.55:50211": "0.0.4", + "35.245.27.193:50211": "0.0.5", + "52.20.18.86:50211": "0.0.5", + "34.83.112.116:50211": "0.0.6", + "54.70.192.33:50211": "0.0.6", + "34.94.160.4:50211": "0.0.7", + "54.176.199.109:50211": "0.0.7", + "35.155.49.147:50211": "0.0.8", + "34.106.102.218:50211": "0.0.8", + "34.133.197.230:50211": "0.0.9", + "52.14.252.207:50211": "0.0.9", + // LOCAL NODE + "127.0.0.1:50211": "0.0.3", + // PREVIEW NET + "3.211.248.172:50211": "0.0.3", + "35.231.208.148:50211": "0.0.3", + "35.199.15.177:50211": "0.0.4", + "3.133.213.146:50211": "0.0.4", + "35.225.201.195:50211": "0.0.5", + "52.15.105.130:50211": "0.0.5", + "54.241.38.1:50211": "0.0.6", + "35.247.109.135:50211": "0.0.6", + "54.177.51.127:50211": "0.0.7", + "35.235.65.51:50211": "0.0.7", + "34.106.247.65:50211": "0.0.8", + "35.83.89.171:50211": "0.0.8", + "50.18.17.93:50211": "0.0.9", + "34.125.23.49:50211": "0.0.9", +}; diff --git a/src/exports.js b/src/exports.js index 9f11f15b9..81f6579b5 100644 --- a/src/exports.js +++ b/src/exports.js @@ -186,6 +186,7 @@ export { default as FreezeType } from "./FreezeType.js"; export { default as TokenKeyValidation } from "./token/TokenKeyValidation.js"; export { default as StatusError } from "./StatusError.js"; +export { default as MaxAttemptsOrTimeoutError } from "./MaxAttemptsOrTimeoutError.js"; export { default as PrecheckStatusError } from "./PrecheckStatusError.js"; export { default as ReceiptStatusError } from "./ReceiptStatusError.js"; export { default as LedgerId } from "./LedgerId.js"; diff --git a/src/grpc/GrpcServiceError.js b/src/grpc/GrpcServiceError.js index 5a80b0197..ec71c48b4 100644 --- a/src/grpc/GrpcServiceError.js +++ b/src/grpc/GrpcServiceError.js @@ -31,8 +31,9 @@ import GrpcStatus from "./GrpcStatus.js"; export default class GrpcServiceError extends Error { /** * @param {GrpcStatus} status + * @param {string} [nodeAccountId] */ - constructor(status) { + constructor(status, nodeAccountId) { super( `gRPC service failed with: Status: ${status.toString()}, Code: ${status.valueOf()}`, ); @@ -42,6 +43,11 @@ export default class GrpcServiceError extends Error { */ this.status = status; + /** + * Optional: node account ID associated with the error + */ + this.nodeAccountId = nodeAccountId; + this.name = "GrpcServiceError"; if (typeof Error.captureStackTrace !== "undefined") { diff --git a/test/unit/Executable.js b/test/unit/Executable.js index b2e2cf1b0..5aac9a001 100644 --- a/test/unit/Executable.js +++ b/test/unit/Executable.js @@ -8,4 +8,4 @@ describe("Executable", function () { ), ).to.be.true; }); -}); +}); \ No newline at end of file diff --git a/test/unit/MaxAttemptsOrTimeoutError.js b/test/unit/MaxAttemptsOrTimeoutError.js new file mode 100644 index 000000000..ce13737a4 --- /dev/null +++ b/test/unit/MaxAttemptsOrTimeoutError.js @@ -0,0 +1,91 @@ +import { + AccountId, + TransferTransaction, + Hbar, + MaxAttemptsOrTimeoutError, +} from "../../src/index.js"; +import Mocker from "./Mocker.js"; + +describe("MaxAttemptsOrTimeoutError", function () { + let message; + let nodeAccountId; + let error; + + beforeEach(function () { + message = "Test error message"; + nodeAccountId = "0.0.3"; + + error = new MaxAttemptsOrTimeoutError(message, nodeAccountId); + }); + + it("should create an instance with correct properties", () => { + expect(error).to.be.instanceOf(MaxAttemptsOrTimeoutError); + expect(error.message).to.be.equal(message); + expect(error.nodeAccountId).to.be.equal(nodeAccountId); + }); + + it("toJSON should return correct JSON representation", () => { + const expectedJson = { + message, + nodeAccountId, + }; + + expect(error.toJSON()).to.be.deep.equal(expectedJson); + }); + + it("toString should return a JSON string", () => { + const expectedString = JSON.stringify({ + message, + nodeAccountId, + }); + + expect(error.toString()).to.be.equal(expectedString); + }); + + it("valueOf should return the same result as toJSON", () => { + expect(error.valueOf()).to.be.deep.equal(error.toJSON()); + }); + + describe("Transaction execution errors", function () { + let client, transaction; + + beforeEach(async function () { + const setup = await Mocker.withResponses([]); + client = setup.client; + transaction = new TransferTransaction() + .addHbarTransfer("0.0.2", new Hbar(1)) + .setNodeAccountIds([new AccountId(5)]); + }); + + it("should throw a timeout error when the timeout exceeds", async function () { + // Set the client's request timeout to 0 for testing + client.setRequestTimeout(0); + transaction = transaction.freezeWith(client); + + try { + await transaction.execute(client); + throw new Error("Expected request to time out but it didn't."); + } catch (error) { + expect(error.message).to.include("timeout exceeded"); + expect(error.nodeAccountId).to.equal("0.0.5"); + } + }); + + it("should throw a max attempts error when max attempts is reached", async function () { + // Set the transaction's max attempts to 0 for testing + transaction = transaction.setMaxAttempts(0).freezeWith(client); + + try { + await transaction.execute(client); + throw new Error( + "Expected request to fail due to max attempts being reached.", + ); + } catch (error) { + expect(error.message).to.include( + "max attempts of 0 was reached for request with last error being:", + ); + expect(error.nodeAccountId).to.equal("0.0.5"); + } + }); + }); +});