Skip to content

Commit 27d1a4c

Browse files
committed
feat(node): add NodeGateway retrying nonce requests if resp outdated
1 parent 8181844 commit 27d1a4c

File tree

8 files changed

+200
-17
lines changed

8 files changed

+200
-17
lines changed

Diff for: src/index-browser.ts

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export { default as AeSdk } from './AeSdk';
6262
export { default as AeSdkAepp } from './AeSdkAepp';
6363
export { default as AeSdkWallet } from './AeSdkWallet';
6464
export { default as Node } from './node/Direct';
65+
export { default as NodeGateway } from './node/Gateway';
6566
export { default as verifyTransaction } from './tx/validator';
6667
export { default as AccountBase } from './account/Base';
6768
export { default as MemoryAccount } from './account/Memory';

Diff for: src/node/Direct.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,30 @@ export default class NodeDefault extends NodeBase {
2727
constructor(
2828
url: string,
2929
{
30-
ignoreVersion = false, retryCount = 3, retryOverallDelay = 800, ...options
30+
ignoreVersion = false, _disableGatewayWarning = false,
31+
retryCount = 3, retryOverallDelay = 800,
32+
...options
3133
}: NodeOptionalParams & {
3234
ignoreVersion?: boolean;
35+
_disableGatewayWarning?: boolean;
3336
retryCount?: number;
3437
retryOverallDelay?: number;
3538
} = {},
3639
) {
40+
const { hostname } = new URL(url);
41+
if (
42+
!_disableGatewayWarning
43+
&& ['mainnet.aeternity.io', 'testnet.aeternity.io'].includes(hostname)
44+
) {
45+
console.warn(`Node: use NodeGateway to connect to ${hostname} for better reliability.`);
46+
}
3747
// eslint-disable-next-line constructor-super
3848
super(url, {
3949
allowInsecureConnection: true,
4050
additionalPolicies: [
4151
genRequestQueuesPolicy(),
4252
genCombineGetRequestsPolicy(),
53+
// TODO: move to NodeGateway in the next breaking release
4354
genRetryOnFailurePolicy(retryCount, retryOverallDelay),
4455
genErrorFormatterPolicy((body: ErrorModel) => ` ${body.reason}`),
4556
],

Diff for: src/node/Gateway.ts

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import NodeDirect from './Direct';
2+
import { getIntervals } from '../utils/autorest';
3+
import { pause } from '../utils/other';
4+
import { buildTx, unpackTx } from '../tx/builder';
5+
import { Tag } from '../tx/builder/constants';
6+
import getTransactionSignerAddress from '../tx/transaction-signer';
7+
import { Encoded } from '../utils/encoder';
8+
import { IllegalArgumentError } from '../utils/errors';
9+
10+
/**
11+
* Implements request retry strategies to improve reliability of connection to multiple nodes behind
12+
* load balancer.
13+
*/
14+
export default class NodeGateway extends NodeDirect {
15+
#nonces: Record<string, number> = {};
16+
17+
readonly #retryIntervals: number[];
18+
19+
/**
20+
* @param url - Url for node API
21+
* @param options - Options
22+
*/
23+
constructor(
24+
url: string,
25+
{
26+
retryCount = 8, retryOverallDelay = 3000, ...options
27+
}: ConstructorParameters<typeof NodeDirect>[1] = {},
28+
) {
29+
super(url, {
30+
...options, retryCount, retryOverallDelay, _disableGatewayWarning: true,
31+
});
32+
this.#retryIntervals = getIntervals(retryCount, retryOverallDelay);
33+
}
34+
35+
#saveNonce(tx: Encoded.Transaction): void {
36+
const { encodedTx } = unpackTx(tx, Tag.SignedTx);
37+
if (encodedTx.tag === Tag.GaMetaTx) return;
38+
if (!('nonce' in encodedTx)) {
39+
throw new IllegalArgumentError('Transaction doesn\'t have nonce field');
40+
}
41+
const address = getTransactionSignerAddress(tx);
42+
this.#nonces[address] = encodedTx.nonce;
43+
if (encodedTx.tag === Tag.PayingForTx) {
44+
this.#saveNonce(buildTx(encodedTx.tx));
45+
}
46+
}
47+
48+
// @ts-expect-error use code generation to create node class or integrate bigint to autorest
49+
override async postTransaction(
50+
...args: Parameters<NodeDirect['postTransaction']>
51+
): ReturnType<NodeDirect['postTransaction']> {
52+
const res = super.postTransaction(...args);
53+
try {
54+
this.#saveNonce(args[0].tx as Encoded.Transaction);
55+
} catch (error) {
56+
console.warn('NodeGateway: failed to save nonce,', error);
57+
}
58+
return res;
59+
}
60+
61+
async #retryNonceRequest<T>(
62+
address: string,
63+
doRequest: () => Promise<T>,
64+
getNonce: (t: T) => number,
65+
): Promise<T> {
66+
for (let attempt = 0; attempt < this.#retryIntervals.length; attempt += 1) {
67+
const result = await doRequest();
68+
const nonce = getNonce(result);
69+
if (nonce >= (this.#nonces[address] ?? -1)) {
70+
return result;
71+
}
72+
await pause(this.#retryIntervals[attempt]);
73+
}
74+
return doRequest();
75+
}
76+
77+
// @ts-expect-error use code generation to create node class or integrate bigint to autorest
78+
override async getAccountByPubkey(
79+
...args: Parameters<NodeDirect['getAccountByPubkey']>
80+
): ReturnType<NodeDirect['getAccountByPubkey']> {
81+
return this.#retryNonceRequest(
82+
args[0],
83+
async () => super.getAccountByPubkey(...args),
84+
({ nonce, kind }) => (kind === 'generalized' ? Number.MAX_SAFE_INTEGER : nonce),
85+
);
86+
}
87+
88+
// @ts-expect-error use code generation to create node class or integrate bigint to autorest
89+
override async getAccountNextNonce(
90+
...args: Parameters<NodeDirect['getAccountNextNonce']>
91+
): ReturnType<NodeDirect['getAccountNextNonce']> {
92+
return this.#retryNonceRequest(
93+
args[0],
94+
async () => super.getAccountNextNonce(...args),
95+
({ nextNonce }) => (nextNonce === 0 ? Number.MAX_SAFE_INTEGER : nextNonce - 1),
96+
);
97+
}
98+
}

Diff for: src/tx/validator.ts

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export default async function verifyTransaction(
8181
ignoreVersion: true,
8282
pipeline: nodeNotCached.pipeline.clone(),
8383
additionalPolicies: [genAggressiveCacheGetResponsesPolicy()],
84+
_disableGatewayWarning: true,
8485
});
8586
return verifyTransactionInternal(unpackTx(transaction), node, []);
8687
}

Diff for: src/utils/autorest.ts

+9-12
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ export const genVersionCheckPolicy = (
117117
},
118118
});
119119

120+
export const getIntervals = (retryCount: number, retryOverallDelay: number): number[] => {
121+
const intervals = new Array(retryCount).fill(0)
122+
.map((_, idx) => ((idx + 1) / retryCount) ** 2);
123+
const intervalSum = intervals.reduce((a, b) => a + b, 0);
124+
return intervals.map((el) => Math.floor((el / intervalSum) * retryOverallDelay));
125+
};
126+
120127
export const genRetryOnFailurePolicy = (
121128
retryCount: number,
122129
retryOverallDelay: number,
@@ -125,20 +132,10 @@ export const genRetryOnFailurePolicy = (
125132
name: 'retry-on-failure',
126133
async sendRequest(request, next) {
127134
const statusesToNotRetry = [200, 400, 403, 410, 500];
128-
129-
const intervals = new Array(retryCount).fill(0)
130-
.map((_, idx) => ((idx + 1) / retryCount) ** 2);
131-
const intervalSum = intervals.reduce((a, b) => a + b, 0);
132-
const intervalsInMs = intervals.map((e) => Math.floor((e / intervalSum) * retryOverallDelay));
133-
135+
const intervals = getIntervals(retryCount, retryOverallDelay);
134136
let error = new RestError('Not expected to be thrown');
135137
for (let attempt = 0; attempt <= retryCount; attempt += 1) {
136-
if (attempt !== 0) {
137-
await pause(intervalsInMs[attempt - 1]);
138-
const urlParsed = new URL(request.url);
139-
urlParsed.searchParams.set('__sdk-retry', attempt.toString());
140-
request.url = urlParsed.toString();
141-
}
138+
if (attempt !== 0) await pause(intervals[attempt - 1]);
142139
try {
143140
return await next(request);
144141
} catch (e) {

Diff for: test/integration/NodeGateway.ts

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, before } from 'mocha';
2+
import { expect } from 'chai';
3+
import { getSdk, url } from '.';
4+
import {
5+
NodeGateway, AeSdk, Tag, buildTx, Encoded,
6+
} from '../../src';
7+
import { bindRequestCounter } from '../utils';
8+
9+
describe('NodeGateway', () => {
10+
let aeSdk: AeSdk;
11+
const node = new NodeGateway(url, { retryCount: 2, retryOverallDelay: 500 });
12+
node.pipeline.addPolicy({
13+
name: 'swallow-post-tx-request',
14+
async sendRequest(request, next) {
15+
const suffix = 'transactions?int-as-string=true';
16+
if (!request.url.endsWith(suffix)) return next(request);
17+
request.url = request.url.replace(suffix, 'status');
18+
request.method = 'GET';
19+
delete request.body;
20+
const response = await next(request);
21+
response.bodyAsText = '{"tx_hash": "fake"}';
22+
return response;
23+
},
24+
});
25+
let spendTxHighNonce: Encoded.Transaction;
26+
27+
before(async () => {
28+
aeSdk = await getSdk();
29+
const spendTx = buildTx({
30+
tag: Tag.SpendTx, recipientId: aeSdk.address, senderId: aeSdk.address, nonce: 1e10,
31+
});
32+
spendTxHighNonce = await aeSdk.signTransaction(spendTx);
33+
});
34+
35+
it('doesn\'t retries getAccountByPubkey before seeing a transaction', async () => {
36+
const getCount = bindRequestCounter(node);
37+
await node.getAccountByPubkey(aeSdk.address);
38+
expect(getCount()).to.be.equal(1);
39+
});
40+
41+
it('doesn\'t retries getAccountNextNonce before seeing a transaction', async () => {
42+
const getCount = bindRequestCounter(node);
43+
await node.getAccountNextNonce(aeSdk.address);
44+
expect(getCount()).to.be.equal(1);
45+
});
46+
47+
it('retries getAccountByPubkey', async () => {
48+
await node.postTransaction({ tx: spendTxHighNonce });
49+
const getCount = bindRequestCounter(node);
50+
await node.getAccountByPubkey(aeSdk.address);
51+
expect(getCount()).to.be.equal(3);
52+
});
53+
54+
it('retries getAccountNextNonce once for multiple calls', async () => {
55+
await node.postTransaction({ tx: spendTxHighNonce });
56+
const getCount = bindRequestCounter(node);
57+
const nonces = await Promise.all(
58+
new Array(3).fill(undefined).map(async () => node.getAccountNextNonce(aeSdk.address)),
59+
);
60+
expect(getCount()).to.be.equal(3);
61+
expect(nonces).to.be.eql(nonces.map(() => ({ nextNonce: 1 })));
62+
});
63+
64+
it('doesn\'t retries nonce for generalized account', async () => {
65+
const sourceCode = `contract BlindAuth =
66+
stateful entrypoint authorize() : bool = false`;
67+
await aeSdk.createGeneralizedAccount('authorize', [], { sourceCode });
68+
await node.postTransaction({ tx: spendTxHighNonce });
69+
70+
const getCount = bindRequestCounter(node);
71+
await node.getAccountByPubkey(aeSdk.address);
72+
await node.getAccountNextNonce(aeSdk.address);
73+
expect(getCount()).to.be.equal(2);
74+
});
75+
});

Diff for: test/integration/index.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { after } from 'mocha';
22
import {
3-
AeSdk, CompilerHttpNode, MemoryAccount, Node, Encoded, ConsensusProtocolVersion,
3+
AeSdk, CompilerHttpNode, MemoryAccount, Node, NodeGateway, Encoded, ConsensusProtocolVersion,
44
} from '../../src';
55
import '..';
66

@@ -70,8 +70,8 @@ export function addTransactionHandler(cb: TransactionHandler): void {
7070
transactionHandlers.push(cb);
7171
}
7272

73-
class NodeHandleTx extends Node {
74-
// @ts-expect-error use code generation to create node class?
73+
class NodeHandleTx extends (network == null ? Node : NodeGateway) {
74+
// @ts-expect-error use code generation to create node class or integrate bigint to autorest
7575
override async postTransaction(
7676
...args: Parameters<Node['postTransaction']>
7777
): ReturnType<Node['postTransaction']> {

Diff for: test/integration/~execution-cost.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '../../src';
1010
import { pause } from '../../src/utils/other';
1111

12-
const node = new Node(url);
12+
const node = new Node(url, { _disableGatewayWarning: true });
1313
interface TxDetails { tx: Encoded.Transaction; cost: bigint; blockHash: Encoded.MicroBlockHash }
1414
const sentTxPromises: Array<Promise<TxDetails | undefined>> = [];
1515

0 commit comments

Comments
 (0)