From 27d1a4c26ca88bd70d514e35a54beca6e2d02e60 Mon Sep 17 00:00:00 2001 From: Denis Davidyuk Date: Tue, 13 Feb 2024 19:18:40 +0700 Subject: [PATCH] feat(node): add NodeGateway retrying nonce requests if resp outdated --- src/index-browser.ts | 1 + src/node/Direct.ts | 13 +++- src/node/Gateway.ts | 98 +++++++++++++++++++++++++++++ src/tx/validator.ts | 1 + src/utils/autorest.ts | 21 +++---- test/integration/NodeGateway.ts | 75 ++++++++++++++++++++++ test/integration/index.ts | 6 +- test/integration/~execution-cost.ts | 2 +- 8 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 src/node/Gateway.ts create mode 100644 test/integration/NodeGateway.ts diff --git a/src/index-browser.ts b/src/index-browser.ts index b73993d965..48fee31ec1 100644 --- a/src/index-browser.ts +++ b/src/index-browser.ts @@ -62,6 +62,7 @@ export { default as AeSdk } from './AeSdk'; export { default as AeSdkAepp } from './AeSdkAepp'; export { default as AeSdkWallet } from './AeSdkWallet'; export { default as Node } from './node/Direct'; +export { default as NodeGateway } from './node/Gateway'; export { default as verifyTransaction } from './tx/validator'; export { default as AccountBase } from './account/Base'; export { default as MemoryAccount } from './account/Memory'; diff --git a/src/node/Direct.ts b/src/node/Direct.ts index ae5db01fe2..4c13a326f7 100644 --- a/src/node/Direct.ts +++ b/src/node/Direct.ts @@ -27,19 +27,30 @@ export default class NodeDefault extends NodeBase { constructor( url: string, { - ignoreVersion = false, retryCount = 3, retryOverallDelay = 800, ...options + ignoreVersion = false, _disableGatewayWarning = false, + retryCount = 3, retryOverallDelay = 800, + ...options }: NodeOptionalParams & { ignoreVersion?: boolean; + _disableGatewayWarning?: boolean; retryCount?: number; retryOverallDelay?: number; } = {}, ) { + const { hostname } = new URL(url); + if ( + !_disableGatewayWarning + && ['mainnet.aeternity.io', 'testnet.aeternity.io'].includes(hostname) + ) { + console.warn(`Node: use NodeGateway to connect to ${hostname} for better reliability.`); + } // eslint-disable-next-line constructor-super super(url, { allowInsecureConnection: true, additionalPolicies: [ genRequestQueuesPolicy(), genCombineGetRequestsPolicy(), + // TODO: move to NodeGateway in the next breaking release genRetryOnFailurePolicy(retryCount, retryOverallDelay), genErrorFormatterPolicy((body: ErrorModel) => ` ${body.reason}`), ], diff --git a/src/node/Gateway.ts b/src/node/Gateway.ts new file mode 100644 index 0000000000..beb9d50a5b --- /dev/null +++ b/src/node/Gateway.ts @@ -0,0 +1,98 @@ +import NodeDirect from './Direct'; +import { getIntervals } from '../utils/autorest'; +import { pause } from '../utils/other'; +import { buildTx, unpackTx } from '../tx/builder'; +import { Tag } from '../tx/builder/constants'; +import getTransactionSignerAddress from '../tx/transaction-signer'; +import { Encoded } from '../utils/encoder'; +import { IllegalArgumentError } from '../utils/errors'; + +/** + * Implements request retry strategies to improve reliability of connection to multiple nodes behind + * load balancer. + */ +export default class NodeGateway extends NodeDirect { + #nonces: Record = {}; + + readonly #retryIntervals: number[]; + + /** + * @param url - Url for node API + * @param options - Options + */ + constructor( + url: string, + { + retryCount = 8, retryOverallDelay = 3000, ...options + }: ConstructorParameters[1] = {}, + ) { + super(url, { + ...options, retryCount, retryOverallDelay, _disableGatewayWarning: true, + }); + this.#retryIntervals = getIntervals(retryCount, retryOverallDelay); + } + + #saveNonce(tx: Encoded.Transaction): void { + const { encodedTx } = unpackTx(tx, Tag.SignedTx); + if (encodedTx.tag === Tag.GaMetaTx) return; + if (!('nonce' in encodedTx)) { + throw new IllegalArgumentError('Transaction doesn\'t have nonce field'); + } + const address = getTransactionSignerAddress(tx); + this.#nonces[address] = encodedTx.nonce; + if (encodedTx.tag === Tag.PayingForTx) { + this.#saveNonce(buildTx(encodedTx.tx)); + } + } + + // @ts-expect-error use code generation to create node class or integrate bigint to autorest + override async postTransaction( + ...args: Parameters + ): ReturnType { + const res = super.postTransaction(...args); + try { + this.#saveNonce(args[0].tx as Encoded.Transaction); + } catch (error) { + console.warn('NodeGateway: failed to save nonce,', error); + } + return res; + } + + async #retryNonceRequest( + address: string, + doRequest: () => Promise, + getNonce: (t: T) => number, + ): Promise { + for (let attempt = 0; attempt < this.#retryIntervals.length; attempt += 1) { + const result = await doRequest(); + const nonce = getNonce(result); + if (nonce >= (this.#nonces[address] ?? -1)) { + return result; + } + await pause(this.#retryIntervals[attempt]); + } + return doRequest(); + } + + // @ts-expect-error use code generation to create node class or integrate bigint to autorest + override async getAccountByPubkey( + ...args: Parameters + ): ReturnType { + return this.#retryNonceRequest( + args[0], + async () => super.getAccountByPubkey(...args), + ({ nonce, kind }) => (kind === 'generalized' ? Number.MAX_SAFE_INTEGER : nonce), + ); + } + + // @ts-expect-error use code generation to create node class or integrate bigint to autorest + override async getAccountNextNonce( + ...args: Parameters + ): ReturnType { + return this.#retryNonceRequest( + args[0], + async () => super.getAccountNextNonce(...args), + ({ nextNonce }) => (nextNonce === 0 ? Number.MAX_SAFE_INTEGER : nextNonce - 1), + ); + } +} diff --git a/src/tx/validator.ts b/src/tx/validator.ts index d4a3ee9dbf..289cf32986 100644 --- a/src/tx/validator.ts +++ b/src/tx/validator.ts @@ -81,6 +81,7 @@ export default async function verifyTransaction( ignoreVersion: true, pipeline: nodeNotCached.pipeline.clone(), additionalPolicies: [genAggressiveCacheGetResponsesPolicy()], + _disableGatewayWarning: true, }); return verifyTransactionInternal(unpackTx(transaction), node, []); } diff --git a/src/utils/autorest.ts b/src/utils/autorest.ts index 19a8853560..0aac2badc1 100644 --- a/src/utils/autorest.ts +++ b/src/utils/autorest.ts @@ -117,6 +117,13 @@ export const genVersionCheckPolicy = ( }, }); +export const getIntervals = (retryCount: number, retryOverallDelay: number): number[] => { + const intervals = new Array(retryCount).fill(0) + .map((_, idx) => ((idx + 1) / retryCount) ** 2); + const intervalSum = intervals.reduce((a, b) => a + b, 0); + return intervals.map((el) => Math.floor((el / intervalSum) * retryOverallDelay)); +}; + export const genRetryOnFailurePolicy = ( retryCount: number, retryOverallDelay: number, @@ -125,20 +132,10 @@ export const genRetryOnFailurePolicy = ( name: 'retry-on-failure', async sendRequest(request, next) { const statusesToNotRetry = [200, 400, 403, 410, 500]; - - const intervals = new Array(retryCount).fill(0) - .map((_, idx) => ((idx + 1) / retryCount) ** 2); - const intervalSum = intervals.reduce((a, b) => a + b, 0); - const intervalsInMs = intervals.map((e) => Math.floor((e / intervalSum) * retryOverallDelay)); - + const intervals = getIntervals(retryCount, retryOverallDelay); let error = new RestError('Not expected to be thrown'); for (let attempt = 0; attempt <= retryCount; attempt += 1) { - if (attempt !== 0) { - await pause(intervalsInMs[attempt - 1]); - const urlParsed = new URL(request.url); - urlParsed.searchParams.set('__sdk-retry', attempt.toString()); - request.url = urlParsed.toString(); - } + if (attempt !== 0) await pause(intervals[attempt - 1]); try { return await next(request); } catch (e) { diff --git a/test/integration/NodeGateway.ts b/test/integration/NodeGateway.ts new file mode 100644 index 0000000000..bd0b3c5a23 --- /dev/null +++ b/test/integration/NodeGateway.ts @@ -0,0 +1,75 @@ +import { describe, it, before } from 'mocha'; +import { expect } from 'chai'; +import { getSdk, url } from '.'; +import { + NodeGateway, AeSdk, Tag, buildTx, Encoded, +} from '../../src'; +import { bindRequestCounter } from '../utils'; + +describe('NodeGateway', () => { + let aeSdk: AeSdk; + const node = new NodeGateway(url, { retryCount: 2, retryOverallDelay: 500 }); + node.pipeline.addPolicy({ + name: 'swallow-post-tx-request', + async sendRequest(request, next) { + const suffix = 'transactions?int-as-string=true'; + if (!request.url.endsWith(suffix)) return next(request); + request.url = request.url.replace(suffix, 'status'); + request.method = 'GET'; + delete request.body; + const response = await next(request); + response.bodyAsText = '{"tx_hash": "fake"}'; + return response; + }, + }); + let spendTxHighNonce: Encoded.Transaction; + + before(async () => { + aeSdk = await getSdk(); + const spendTx = buildTx({ + tag: Tag.SpendTx, recipientId: aeSdk.address, senderId: aeSdk.address, nonce: 1e10, + }); + spendTxHighNonce = await aeSdk.signTransaction(spendTx); + }); + + it('doesn\'t retries getAccountByPubkey before seeing a transaction', async () => { + const getCount = bindRequestCounter(node); + await node.getAccountByPubkey(aeSdk.address); + expect(getCount()).to.be.equal(1); + }); + + it('doesn\'t retries getAccountNextNonce before seeing a transaction', async () => { + const getCount = bindRequestCounter(node); + await node.getAccountNextNonce(aeSdk.address); + expect(getCount()).to.be.equal(1); + }); + + it('retries getAccountByPubkey', async () => { + await node.postTransaction({ tx: spendTxHighNonce }); + const getCount = bindRequestCounter(node); + await node.getAccountByPubkey(aeSdk.address); + expect(getCount()).to.be.equal(3); + }); + + it('retries getAccountNextNonce once for multiple calls', async () => { + await node.postTransaction({ tx: spendTxHighNonce }); + const getCount = bindRequestCounter(node); + const nonces = await Promise.all( + new Array(3).fill(undefined).map(async () => node.getAccountNextNonce(aeSdk.address)), + ); + expect(getCount()).to.be.equal(3); + expect(nonces).to.be.eql(nonces.map(() => ({ nextNonce: 1 }))); + }); + + it('doesn\'t retries nonce for generalized account', async () => { + const sourceCode = `contract BlindAuth = + stateful entrypoint authorize() : bool = false`; + await aeSdk.createGeneralizedAccount('authorize', [], { sourceCode }); + await node.postTransaction({ tx: spendTxHighNonce }); + + const getCount = bindRequestCounter(node); + await node.getAccountByPubkey(aeSdk.address); + await node.getAccountNextNonce(aeSdk.address); + expect(getCount()).to.be.equal(2); + }); +}); diff --git a/test/integration/index.ts b/test/integration/index.ts index b6b9ff9d08..cd9cc4bc44 100644 --- a/test/integration/index.ts +++ b/test/integration/index.ts @@ -1,6 +1,6 @@ import { after } from 'mocha'; import { - AeSdk, CompilerHttpNode, MemoryAccount, Node, Encoded, ConsensusProtocolVersion, + AeSdk, CompilerHttpNode, MemoryAccount, Node, NodeGateway, Encoded, ConsensusProtocolVersion, } from '../../src'; import '..'; @@ -70,8 +70,8 @@ export function addTransactionHandler(cb: TransactionHandler): void { transactionHandlers.push(cb); } -class NodeHandleTx extends Node { - // @ts-expect-error use code generation to create node class? +class NodeHandleTx extends (network == null ? Node : NodeGateway) { + // @ts-expect-error use code generation to create node class or integrate bigint to autorest override async postTransaction( ...args: Parameters ): ReturnType { diff --git a/test/integration/~execution-cost.ts b/test/integration/~execution-cost.ts index 66f2391e66..8a3291c148 100644 --- a/test/integration/~execution-cost.ts +++ b/test/integration/~execution-cost.ts @@ -9,7 +9,7 @@ import { } from '../../src'; import { pause } from '../../src/utils/other'; -const node = new Node(url); +const node = new Node(url, { _disableGatewayWarning: true }); interface TxDetails { tx: Encoded.Transaction; cost: bigint; blockHash: Encoded.MicroBlockHash } const sentTxPromises: Array> = [];