Skip to content

Commit 342482a

Browse files
Merge pull request #1025 from ProvableHQ/feat/network-retries
2 parents 0e2c01a + 8205bd0 commit 342482a

File tree

3 files changed

+201
-43
lines changed

3 files changed

+201
-43
lines changed

sdk/src/network-client.ts

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { get, post, parseJSON, logAndThrow } from "./utils";
1+
import { get, post, parseJSON, logAndThrow, retryWithBackoff } from "./utils";
22
import { Account } from "./account";
33
import { BlockJSON } from "./models/blockJSON";
44
import { TransactionJSON } from "./models/transaction/transactionJSON";
@@ -98,16 +98,16 @@ class AleoNetworkClient {
9898

9999
/**
100100
* Set a header in the `AleoNetworkClient`s header map
101-
*
101+
*
102102
* @param {string} headerName The name of the header to set
103103
* @param {string} value The header value
104-
*
104+
*
105105
* @example
106106
* import { AleoNetworkClient } from "@provablehq/sdk/mainnet.js";
107-
*
107+
*
108108
* // Create a networkClient
109109
* const networkClient = new AleoNetworkClient();
110-
*
110+
*
111111
* // Set the value of the `Accept-Language` header to `en-US`
112112
* networkClient.setHeader('Accept-Language', 'en-US');
113113
*/
@@ -117,20 +117,20 @@ class AleoNetworkClient {
117117

118118
/**
119119
* Remove a header from the `AleoNetworkClient`s header map
120-
*
120+
*
121121
* @param {string} headerName The name of the header to be removed
122-
*
122+
*
123123
* @example
124124
* import { AleoNetworkClient } from "@provablehq/sdk/mainnet.js";
125-
*
125+
*
126126
* // Create a networkClient
127127
* const networkClient = new AleoNetworkClient();
128-
*
128+
*
129129
* // Remove the default `X-Aleo-SDK-Version` header
130130
* networkClient.removeHeader('X-Aleo-SDK-Version');
131131
*/
132132
removeHeader(headerName: string) {
133-
delete this.headers[headerName]
133+
delete this.headers[headerName];
134134
}
135135

136136
/**
@@ -140,7 +140,8 @@ class AleoNetworkClient {
140140
*/
141141
async fetchData<Type>(url = "/"): Promise<Type> {
142142
try {
143-
return parseJSON(await this.fetchRaw(url));
143+
const raw = await this.fetchRaw(url);
144+
return parseJSON(raw);
144145
} catch (error) {
145146
throw new Error(`Error fetching data: ${error}`);
146147
}
@@ -156,15 +157,28 @@ class AleoNetworkClient {
156157
*/
157158
async fetchRaw(url = "/"): Promise<string> {
158159
try {
159-
const response = await get(this.host + url, {
160-
headers: this.headers,
160+
return await retryWithBackoff(async () => {
161+
const response = await get(this.host + url, {
162+
headers: this.headers,
163+
});
164+
return await response.text();
161165
});
162-
return await response.text();
163166
} catch (error) {
164167
throw new Error(`Error fetching data: ${error}`);
165168
}
166169
}
167170

171+
/**
172+
* Wrapper around the POST helper to allow mocking in tests. Not meant for use in production.
173+
*
174+
* @param url The URL to POST to.
175+
* @param options The RequestInit options for the POST request.
176+
* @returns The Response object from the POST request.
177+
*/
178+
private async _sendPost(url: string, options: RequestInit) {
179+
return post(url, options);
180+
}
181+
168182
/**
169183
* Attempt to find records in the Aleo blockchain.
170184
*
@@ -375,8 +389,11 @@ class AleoNetworkClient {
375389
);
376390
// Attempt to see if the serial number is spent
377391
try {
378-
await this.getTransitionId(
379-
serialNumber,
392+
await retryWithBackoff(
393+
() =>
394+
this.getTransitionId(
395+
serialNumber,
396+
),
380397
);
381398
continue;
382399
} catch (error) {
@@ -1386,12 +1403,14 @@ class AleoNetworkClient {
13861403
? transaction.toString()
13871404
: transaction;
13881405
try {
1389-
const response = await post(this.host + "/transaction/broadcast", {
1390-
body: transaction_string,
1391-
headers: Object.assign({}, this.headers, {
1392-
"Content-Type": "application/json",
1406+
const response = await retryWithBackoff(() =>
1407+
this._sendPost(this.host + "/transaction/broadcast", {
1408+
body: transaction_string,
1409+
headers: Object.assign({}, this.headers, {
1410+
"Content-Type": "application/json",
1411+
}),
13931412
}),
1394-
});
1413+
);
13951414

13961415
try {
13971416
const text = await response.text();
@@ -1416,28 +1435,29 @@ class AleoNetworkClient {
14161435
*/
14171436
async submitSolution(solution: string): Promise<string> {
14181437
try {
1419-
const response = await post(this.host + "/solution/broadcast", {
1420-
body: solution,
1421-
headers: Object.assign({}, this.headers, {
1422-
"Content-Type": "application/json",
1438+
const response = await retryWithBackoff(() =>
1439+
post(this.host + "/solution/broadcast", {
1440+
body: solution,
1441+
headers: Object.assign({}, this.headers, {
1442+
"Content-Type": "application/json",
1443+
}),
14231444
}),
1424-
});
1445+
);
14251446

14261447
try {
14271448
const text = await response.text();
14281449
return parseJSON(text);
14291450
} catch (error: any) {
14301451
throw new Error(
1431-
`Error posting transaction. Aleo network response: ${error.message}`,
1452+
`Error posting solution. Aleo network response: ${error.message}`,
14321453
);
14331454
}
14341455
} catch (error: any) {
14351456
throw new Error(
1436-
`Error posting transaction: No response received: ${error.message}`,
1457+
`Error posting solution: No response received: ${error.message}`,
14371458
);
14381459
}
14391460
}
1440-
14411461
/**
14421462
* Await a submitted transaction to be confirmed or rejected on the Aleo network.
14431463
*

sdk/src/utils.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ export function logAndThrow(message: string): never {
33
throw new Error(message);
44
}
55

6-
76
export function parseJSON(json: string): any {
87
function revive(key: string, value: any, context: any) {
98
if (Number.isInteger(value)) {
@@ -16,7 +15,6 @@ export function parseJSON(json: string): any {
1615
return JSON.parse(json, revive as any);
1716
}
1817

19-
2018
export async function get(url: URL | string, options?: RequestInit) {
2119
const response = await fetch(url, options);
2220

@@ -27,7 +25,6 @@ export async function get(url: URL | string, options?: RequestInit) {
2725
return response;
2826
}
2927

30-
3128
export async function post(url: URL | string, options: RequestInit) {
3229
options.method = "POST";
3330

@@ -39,3 +36,56 @@ export async function post(url: URL | string, options: RequestInit) {
3936

4037
return response;
4138
}
39+
40+
type RetryOptions = {
41+
maxAttempts?: number;
42+
baseDelay?: number;
43+
jitter?: number;
44+
retryOnStatus?: number[]; // e.g. [500, 502, 503]
45+
shouldRetry?: (err: any) => boolean;
46+
};
47+
48+
export async function retryWithBackoff<T>(
49+
fn: () => Promise<T>,
50+
{
51+
maxAttempts = 5,
52+
baseDelay = 100,
53+
jitter,
54+
retryOnStatus = [],
55+
shouldRetry,
56+
}: RetryOptions = {},
57+
): Promise<T> {
58+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
59+
try {
60+
return await fn();
61+
} catch (err: any) {
62+
const isLast = attempt === maxAttempts;
63+
const error = err as Error & { code?: string; status?: number };
64+
65+
let retryable = false;
66+
67+
if (typeof error.status === "number") {
68+
if (error.status >= 500) {
69+
retryable = true;
70+
} else if (error.status >= 400 && shouldRetry) {
71+
retryable = shouldRetry(error);
72+
}
73+
} else if (shouldRetry) {
74+
retryable = shouldRetry(error);
75+
}
76+
77+
if (!retryable || isLast) throw error;
78+
79+
const jitterAmount = jitter ?? baseDelay;
80+
const actualJitter = Math.floor(Math.random() * jitterAmount);
81+
const delay = baseDelay * 2 ** (attempt - 1) + actualJitter;
82+
console.warn(
83+
`Retry ${attempt}/${maxAttempts} failed. Retrying in ${delay}ms...`,
84+
);
85+
86+
await new Promise((res) => setTimeout(res, delay));
87+
}
88+
}
89+
90+
throw new Error("retryWithBackoff: unreachable");
91+
}

sdk/tests/network-client.test.ts

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
TransitionObject,
1919
} from "@provablehq/sdk/%%NETWORK%%.js";
2020
import { beaconPrivateKeyString } from "./data/account-data";
21+
import { retryWithBackoff } from "../src/utils";
2122

2223
async function catchError(f: () => Promise<any>): Promise<Error | null> {
2324
try {
@@ -204,24 +205,111 @@ describe("NodeConnection", () => {
204205
});
205206
});
206207

208+
describe("retryWithBackoff", () => {
209+
it("should retry failed network requests and eventually give up", async () => {
210+
const client = new AleoNetworkClient("http://localhost:1234");
211+
212+
let attemptCount = 0;
213+
214+
client.fetchRaw = async () => {
215+
return await retryWithBackoff(async () => {
216+
attemptCount++;
217+
console.warn(`fake fetchRaw attempt ${attemptCount}`);
218+
throw Object.assign(new Error("503 Service Unavailable"), {
219+
status: 503,
220+
});
221+
});
222+
};
223+
224+
try {
225+
await client.fetchData("/block/latest");
226+
throw new Error("Expected fetchData to fail");
227+
} catch (err: any) {
228+
expect(err.message).to.include("503");
229+
expect(attemptCount).to.be.greaterThan(1);
230+
}
231+
});
232+
233+
it("should retry failed transaction submissions and eventually give up", async () => {
234+
const client = new AleoNetworkClient("http://localhost:1234");
235+
236+
let attemptCount = 0;
237+
238+
client["_sendPost"] = async () => {
239+
attemptCount++;
240+
console.warn(`fake sendPost attempt ${attemptCount}`);
241+
const error = new Error("503 Service Unavailable") as any;
242+
error.status = 503;
243+
throw error;
244+
};
245+
246+
try {
247+
await retryWithBackoff(
248+
() =>
249+
client["_sendPost"]("http://fakeurl", {
250+
method: "POST",
251+
}),
252+
{
253+
retryOnStatus: [503],
254+
},
255+
);
256+
throw new Error("Expected retryWithBackoff to fail");
257+
} catch (err: any) {
258+
expect(err.message).to.include("503");
259+
expect(attemptCount).to.be.greaterThan(1);
260+
}
261+
});
262+
263+
it("should retry solution submission and eventually throw", async () => {
264+
const client = new AleoNetworkClient("http://localhost:1234");
265+
266+
let attemptCount = 0;
267+
268+
client["_sendPost"] = async function () {
269+
attemptCount++;
270+
throw Object.assign(new Error("Network error"), {
271+
status: 503,
272+
});
273+
};
274+
275+
try {
276+
await retryWithBackoff(
277+
() =>
278+
client["_sendPost"]("http://fakeurl", {
279+
method: "POST",
280+
}),
281+
{
282+
retryOnStatus: [503],
283+
},
284+
);
285+
throw new Error("Expected sendPost to fail");
286+
} catch (err: any) {
287+
expect(err.message).to.include("Network error");
288+
expect(attemptCount).to.be.greaterThan(1);
289+
}
290+
});
291+
});
292+
207293
describe("setHeader", () => {
208294
it("should correctly update the headers map", async () => {
209-
connection.setHeader('X-Test-Header', 'testvalue');
210-
expect(connection.headers['X-Test-Header']).equal('testvalue');
211-
})
295+
connection.setHeader("X-Test-Header", "testvalue");
296+
expect(connection.headers["X-Test-Header"]).equal("testvalue");
297+
});
212298

213299
it("should update existing header in map", async () => {
214-
connection.setHeader('X-Test-Header', 'secondtestvalue');
215-
expect(connection.headers['X-Test-Header']).equal('secondtestvalue');
216-
})
217-
})
300+
connection.setHeader("X-Test-Header", "secondtestvalue");
301+
expect(connection.headers["X-Test-Header"]).equal(
302+
"secondtestvalue",
303+
);
304+
});
305+
});
218306

219307
describe("removeHeader", () => {
220308
it("should remove header from the map", async () => {
221-
connection.removeHeader('X-Test-Header');
222-
expect(connection.headers['X-Test-Header']).undefined;
223-
})
224-
})
309+
connection.removeHeader("X-Test-Header");
310+
expect(connection.headers["X-Test-Header"]).undefined;
311+
});
312+
});
225313

226314
describe("waitForTransactionConfirmation", () => {
227315
const mainnetAcceptedTx =

0 commit comments

Comments
 (0)