@parity/product-sdk-tx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/retry.ts ADDED
@@ -0,0 +1,218 @@
1
+ import { createLogger } from "@parity/product-sdk-logger";
2
+
3
+ import { TxBatchError, TxDispatchError, TxSigningRejectedError, TxTimeoutError } from "./errors.js";
4
+ import type { RetryOptions } from "./types.js";
5
+
6
+ const log = createLogger("tx:retry");
7
+
8
+ function sleep(ms: number): Promise<void> {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+
12
+ /**
13
+ * Whether an error is deterministic and should not be retried.
14
+ *
15
+ * - Batch errors are deterministic input validation failures (e.g., empty calls array).
16
+ * - Dispatch errors are on-chain failures (e.g., insufficient balance) that will
17
+ * produce the same result on retry.
18
+ * - Signing rejections are explicit user intent.
19
+ * - Timeouts mean we already waited the full duration; retrying would double the wait.
20
+ */
21
+ function isNonRetryable(error: unknown): boolean {
22
+ return (
23
+ error instanceof TxBatchError ||
24
+ error instanceof TxDispatchError ||
25
+ error instanceof TxSigningRejectedError ||
26
+ error instanceof TxTimeoutError
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Calculate delay with exponential backoff and jitter.
32
+ *
33
+ * Jitter prevents thundering-herd when multiple clients retry simultaneously.
34
+ * The delay is `min(baseDelay * 2^attempt, maxDelay) * random(0.5, 1.0)`.
35
+ */
36
+ export function calculateDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
37
+ const exponential = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
38
+ const jitter = 0.5 + Math.random() * 0.5;
39
+ return Math.round(exponential * jitter);
40
+ }
41
+
42
+ /**
43
+ * Wrap an async function with retry logic and exponential backoff.
44
+ *
45
+ * Only retries transient errors (network disconnects, temporary RPC failures).
46
+ * Deterministic errors ({@link TxDispatchError}, {@link TxBatchError}), user
47
+ * rejections ({@link TxSigningRejectedError}), and timeouts ({@link TxTimeoutError})
48
+ * are rethrown immediately without retry.
49
+ *
50
+ * @param fn - The async function to retry.
51
+ * @param options - Retry configuration.
52
+ * @returns The result of the first successful call.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * const result = await withRetry(
57
+ * () => submitAndWatch(tx, signer),
58
+ * { maxAttempts: 3, baseDelayMs: 1_000 },
59
+ * );
60
+ * ```
61
+ */
62
+ export async function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T> {
63
+ const maxAttempts = options?.maxAttempts ?? 3;
64
+ const baseDelayMs = options?.baseDelayMs ?? 1_000;
65
+ const maxDelayMs = options?.maxDelayMs ?? 15_000;
66
+
67
+ let lastError: unknown;
68
+
69
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
70
+ try {
71
+ return await fn();
72
+ } catch (error) {
73
+ lastError = error;
74
+
75
+ if (isNonRetryable(error)) {
76
+ throw error;
77
+ }
78
+
79
+ if (attempt + 1 >= maxAttempts) {
80
+ break;
81
+ }
82
+
83
+ const delay = calculateDelay(attempt, baseDelayMs, maxDelayMs);
84
+ log.warn(`Attempt ${attempt + 1}/${maxAttempts} failed, retrying in ${delay}ms`, {
85
+ error: error instanceof Error ? error.message : String(error),
86
+ });
87
+ await sleep(delay);
88
+ }
89
+ }
90
+
91
+ throw lastError;
92
+ }
93
+
94
+ if (import.meta.vitest) {
95
+ const { describe, test, expect, vi, beforeEach } = import.meta.vitest;
96
+ const { configure } = await import("@parity/product-sdk-logger");
97
+
98
+ beforeEach(() => {
99
+ configure({ handler: () => {} });
100
+ vi.useRealTimers();
101
+ });
102
+
103
+ describe("withRetry", () => {
104
+ test("returns on first success", async () => {
105
+ const result = await withRetry(() => Promise.resolve("ok"));
106
+ expect(result).toBe("ok");
107
+ });
108
+
109
+ test("retries transient error then succeeds", async () => {
110
+ let calls = 0;
111
+ const result = await withRetry(
112
+ () => {
113
+ calls++;
114
+ if (calls < 2) return Promise.reject(new Error("Network error"));
115
+ return Promise.resolve("recovered");
116
+ },
117
+ { baseDelayMs: 1 },
118
+ );
119
+ expect(result).toBe("recovered");
120
+ expect(calls).toBe(2);
121
+ });
122
+
123
+ test("gives up after maxAttempts", async () => {
124
+ let calls = 0;
125
+ await expect(
126
+ withRetry(
127
+ () => {
128
+ calls++;
129
+ return Promise.reject(new Error("Persistent failure"));
130
+ },
131
+ { maxAttempts: 3, baseDelayMs: 1 },
132
+ ),
133
+ ).rejects.toThrow("Persistent failure");
134
+ expect(calls).toBe(3);
135
+ });
136
+
137
+ test("does NOT retry TxDispatchError", async () => {
138
+ let calls = 0;
139
+ await expect(
140
+ withRetry(
141
+ () => {
142
+ calls++;
143
+ return Promise.reject(
144
+ new TxDispatchError({}, "Balances.InsufficientBalance"),
145
+ );
146
+ },
147
+ { maxAttempts: 3, baseDelayMs: 1 },
148
+ ),
149
+ ).rejects.toThrow(TxDispatchError);
150
+ expect(calls).toBe(1);
151
+ });
152
+
153
+ test("does NOT retry TxSigningRejectedError", async () => {
154
+ let calls = 0;
155
+ await expect(
156
+ withRetry(
157
+ () => {
158
+ calls++;
159
+ return Promise.reject(new TxSigningRejectedError());
160
+ },
161
+ { maxAttempts: 3, baseDelayMs: 1 },
162
+ ),
163
+ ).rejects.toThrow(TxSigningRejectedError);
164
+ expect(calls).toBe(1);
165
+ });
166
+
167
+ test("does NOT retry TxBatchError", async () => {
168
+ let calls = 0;
169
+ await expect(
170
+ withRetry(
171
+ () => {
172
+ calls++;
173
+ return Promise.reject(new TxBatchError("Cannot batch zero calls"));
174
+ },
175
+ { maxAttempts: 3, baseDelayMs: 1 },
176
+ ),
177
+ ).rejects.toThrow(TxBatchError);
178
+ expect(calls).toBe(1);
179
+ });
180
+
181
+ test("does NOT retry TxTimeoutError", async () => {
182
+ let calls = 0;
183
+ await expect(
184
+ withRetry(
185
+ () => {
186
+ calls++;
187
+ return Promise.reject(new TxTimeoutError(300_000));
188
+ },
189
+ { maxAttempts: 3, baseDelayMs: 1 },
190
+ ),
191
+ ).rejects.toThrow(TxTimeoutError);
192
+ expect(calls).toBe(1);
193
+ });
194
+
195
+ test("respects maxDelayMs cap", () => {
196
+ // attempt=10 with baseDelay=1000 would be 1024000ms uncapped
197
+ const delay = calculateDelay(10, 1_000, 15_000);
198
+ expect(delay).toBeLessThanOrEqual(15_000);
199
+ expect(delay).toBeGreaterThan(0);
200
+ });
201
+
202
+ test("applies jitter (delay varies between calls)", () => {
203
+ const delays = Array.from({ length: 20 }, () => calculateDelay(2, 1_000, 15_000));
204
+ const unique = new Set(delays);
205
+ // With jitter, we should get multiple distinct values out of 20 samples
206
+ expect(unique.size).toBeGreaterThan(1);
207
+ });
208
+
209
+ test("exponential backoff increases delay", () => {
210
+ const base = 1_000;
211
+ // The minimum possible delay at each attempt (jitter factor = 0.5)
212
+ const minDelay2 = base * 4 * 0.5; // 2000
213
+ // attempt 2 minimum should be greater than attempt 0 maximum
214
+ const maxDelay0 = base * 1.0; // 1000
215
+ expect(minDelay2).toBeGreaterThan(maxDelay0);
216
+ });
217
+ });
218
+ }