Skip to content

Commit

Permalink
feat(node): add NodeGateway retrying nonce requests if resp outdated
Browse files Browse the repository at this point in the history
  • Loading branch information
davidyuk committed Mar 2, 2024
1 parent 8181844 commit 27d1a4c
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 17 deletions.
1 change: 1 addition & 0 deletions src/index-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 12 additions & 1 deletion src/node/Direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`),
],
Expand Down
98 changes: 98 additions & 0 deletions src/node/Gateway.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {};

readonly #retryIntervals: number[];

/**
* @param url - Url for node API
* @param options - Options
*/
constructor(
url: string,
{
retryCount = 8, retryOverallDelay = 3000, ...options
}: ConstructorParameters<typeof NodeDirect>[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');

Check warning on line 39 in src/node/Gateway.ts

View check run for this annotation

Codecov / codecov/patch

src/node/Gateway.ts#L39

Added line #L39 was not covered by tests
}
const address = getTransactionSignerAddress(tx);
this.#nonces[address] = encodedTx.nonce;
if (encodedTx.tag === Tag.PayingForTx) {
this.#saveNonce(buildTx(encodedTx.tx));

Check warning on line 44 in src/node/Gateway.ts

View check run for this annotation

Codecov / codecov/patch

src/node/Gateway.ts#L44

Added line #L44 was not covered by tests
}
}

// @ts-expect-error use code generation to create node class or integrate bigint to autorest
override async postTransaction(
...args: Parameters<NodeDirect['postTransaction']>
): ReturnType<NodeDirect['postTransaction']> {
const res = super.postTransaction(...args);
try {
this.#saveNonce(args[0].tx as Encoded.Transaction);
} catch (error) {
console.warn('NodeGateway: failed to save nonce,', error);

Check warning on line 56 in src/node/Gateway.ts

View check run for this annotation

Codecov / codecov/patch

src/node/Gateway.ts#L56

Added line #L56 was not covered by tests
}
return res;
}

async #retryNonceRequest<T>(
address: string,
doRequest: () => Promise<T>,
getNonce: (t: T) => number,
): Promise<T> {
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<NodeDirect['getAccountByPubkey']>
): ReturnType<NodeDirect['getAccountByPubkey']> {
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<NodeDirect['getAccountNextNonce']>
): ReturnType<NodeDirect['getAccountNextNonce']> {
return this.#retryNonceRequest(
args[0],
async () => super.getAccountNextNonce(...args),
({ nextNonce }) => (nextNonce === 0 ? Number.MAX_SAFE_INTEGER : nextNonce - 1),
);
}
}
1 change: 1 addition & 0 deletions src/tx/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export default async function verifyTransaction(
ignoreVersion: true,
pipeline: nodeNotCached.pipeline.clone(),
additionalPolicies: [genAggressiveCacheGetResponsesPolicy()],
_disableGatewayWarning: true,
});
return verifyTransactionInternal(unpackTx(transaction), node, []);
}
Expand Down
21 changes: 9 additions & 12 deletions src/utils/autorest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
75 changes: 75 additions & 0 deletions test/integration/NodeGateway.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 3 additions & 3 deletions test/integration/index.ts
Original file line number Diff line number Diff line change
@@ -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 '..';

Expand Down Expand Up @@ -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<Node['postTransaction']>
): ReturnType<Node['postTransaction']> {
Expand Down
2 changes: 1 addition & 1 deletion test/integration/~execution-cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Promise<TxDetails | undefined>> = [];

Expand Down

0 comments on commit 27d1a4c

Please sign in to comment.