@alchemy.run/node-utils 0.0.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,309 @@
1
+ export interface OperationOptions {
2
+ retries?: number;
3
+ factor?: number;
4
+ minTimeout?: number;
5
+ maxTimeout?: number;
6
+ randomize?: boolean;
7
+ forever?: boolean;
8
+ unref?: boolean;
9
+ maxRetryTime?: number;
10
+ }
11
+
12
+ interface RetryOperationOptions {
13
+ forever?: boolean;
14
+ unref?: boolean;
15
+ maxRetryTime?: number;
16
+ }
17
+
18
+ interface AttemptTimeoutOptions {
19
+ timeout?: number;
20
+ cb?: (attempt?: number) => void;
21
+ }
22
+
23
+ type AttemptFn = (attempt: number) => void;
24
+
25
+ const DEFAULTS: Required<
26
+ Omit<OperationOptions, "forever" | "unref" | "maxRetryTime">
27
+ > = {
28
+ retries: 10,
29
+ factor: 2,
30
+ minTimeout: 1000,
31
+ maxTimeout: Infinity,
32
+ randomize: false,
33
+ };
34
+
35
+ export function createTimeout(
36
+ attempt: number,
37
+ opts: Required<
38
+ Pick<OperationOptions, "minTimeout" | "maxTimeout" | "factor" | "randomize">
39
+ >,
40
+ ): number {
41
+ const random = opts.randomize ? Math.random() + 1 : 1;
42
+ const timeout = Math.round(
43
+ random * Math.max(opts.minTimeout, 1) * opts.factor ** attempt,
44
+ );
45
+
46
+ return Math.min(timeout, opts.maxTimeout);
47
+ }
48
+
49
+ export function timeouts(options?: OperationOptions | number[]): number[] {
50
+ if (Array.isArray(options)) {
51
+ return [...options];
52
+ }
53
+
54
+ const opts = { ...DEFAULTS, ...options };
55
+
56
+ if (opts.minTimeout > opts.maxTimeout) {
57
+ throw new Error("minTimeout is greater than maxTimeout");
58
+ }
59
+
60
+ const result: number[] = [];
61
+
62
+ for (let i = 0; i < opts.retries; i++) {
63
+ result.push(createTimeout(i, opts));
64
+ }
65
+
66
+ if (
67
+ options &&
68
+ !Array.isArray(options) &&
69
+ options.forever &&
70
+ result.length === 0
71
+ ) {
72
+ result.push(createTimeout(opts.retries, opts));
73
+ }
74
+
75
+ result.sort((a, b) => a - b);
76
+
77
+ return result;
78
+ }
79
+
80
+ export class RetryOperation {
81
+ private readonly originalTimeouts: number[];
82
+ private remainingTimeouts: number[];
83
+ private readonly options: RetryOperationOptions;
84
+ private readonly maxRetryTime: number;
85
+ private readonly cachedTimeouts: number[] | null;
86
+
87
+ private fn: AttemptFn | null = null;
88
+ private readonly errorList: Error[] = [];
89
+ private attemptCount = 1;
90
+ private operationTimeout: number | null = null;
91
+ private operationTimeoutCb: ((attempt?: number) => void) | null = null;
92
+ private operationTimer: ReturnType<typeof setTimeout> | null = null;
93
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
94
+ private operationStart = 0;
95
+
96
+ constructor(
97
+ initialTimeouts: number[],
98
+ options: RetryOperationOptions | boolean = {},
99
+ ) {
100
+ // Compatibility for the old (timeouts, retryForever) signature
101
+ const opts: RetryOperationOptions =
102
+ typeof options === "boolean" ? { forever: options } : options;
103
+
104
+ this.originalTimeouts = [...initialTimeouts];
105
+ this.remainingTimeouts = [...initialTimeouts];
106
+ this.options = opts;
107
+ this.maxRetryTime = opts.maxRetryTime ?? Infinity;
108
+ this.cachedTimeouts = opts.forever ? [...initialTimeouts] : null;
109
+ }
110
+
111
+ reset(): void {
112
+ this.attemptCount = 1;
113
+ this.remainingTimeouts = [...this.originalTimeouts];
114
+ }
115
+
116
+ stop(): void {
117
+ if (this.operationTimer) {
118
+ clearTimeout(this.operationTimer);
119
+ }
120
+ if (this.retryTimer) {
121
+ clearTimeout(this.retryTimer);
122
+ }
123
+
124
+ this.remainingTimeouts = [];
125
+ }
126
+
127
+ retry(err?: Error | null): boolean {
128
+ if (this.operationTimer) {
129
+ clearTimeout(this.operationTimer);
130
+ }
131
+
132
+ if (!err) {
133
+ return false;
134
+ }
135
+
136
+ const elapsed = Date.now() - this.operationStart;
137
+
138
+ if (elapsed >= this.maxRetryTime) {
139
+ this.errorList.push(err);
140
+ this.errorList.unshift(new Error("RetryOperation timeout occurred"));
141
+
142
+ return false;
143
+ }
144
+
145
+ this.errorList.push(err);
146
+
147
+ let timeout = this.remainingTimeouts.shift();
148
+
149
+ if (timeout === undefined) {
150
+ if (this.cachedTimeouts) {
151
+ // retry forever, only keep last error
152
+ this.errorList.splice(0, this.errorList.length - 1);
153
+ timeout = this.cachedTimeouts[this.cachedTimeouts.length - 1];
154
+ } else {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ this.retryTimer = setTimeout(() => {
160
+ this.attemptCount += 1;
161
+
162
+ if (this.operationTimeoutCb) {
163
+ this.operationTimer = setTimeout(() => {
164
+ this.operationTimeoutCb?.(this.attemptCount);
165
+ }, this.operationTimeout ?? 0);
166
+
167
+ if (
168
+ this.options.unref &&
169
+ typeof this.operationTimer.unref === "function"
170
+ ) {
171
+ this.operationTimer.unref();
172
+ }
173
+ }
174
+
175
+ this.fn?.(this.attemptCount);
176
+ }, timeout);
177
+
178
+ if (this.options.unref && typeof this.retryTimer.unref === "function") {
179
+ this.retryTimer.unref();
180
+ }
181
+
182
+ return true;
183
+ }
184
+
185
+ attempt(fn: AttemptFn, timeoutOps?: AttemptTimeoutOptions): void {
186
+ this.fn = fn;
187
+
188
+ if (timeoutOps) {
189
+ if (timeoutOps.timeout != null) {
190
+ this.operationTimeout = timeoutOps.timeout;
191
+ }
192
+ if (timeoutOps.cb) {
193
+ this.operationTimeoutCb = timeoutOps.cb;
194
+ }
195
+ }
196
+
197
+ if (this.operationTimeoutCb) {
198
+ this.operationTimer = setTimeout(() => {
199
+ this.operationTimeoutCb?.();
200
+ }, this.operationTimeout ?? 0);
201
+ }
202
+
203
+ this.operationStart = Date.now();
204
+ this.fn(this.attemptCount);
205
+ }
206
+
207
+ errors(): Error[] {
208
+ return this.errorList;
209
+ }
210
+
211
+ attempts(): number {
212
+ return this.attemptCount;
213
+ }
214
+
215
+ mainError(): Error | null {
216
+ if (this.errorList.length === 0) {
217
+ return null;
218
+ }
219
+
220
+ const counts = new Map<string, number>();
221
+ let mainError: Error | null = null;
222
+ let mainErrorCount = 0;
223
+
224
+ for (const error of this.errorList) {
225
+ const message = error.message;
226
+ const count = (counts.get(message) ?? 0) + 1;
227
+
228
+ counts.set(message, count);
229
+
230
+ if (count >= mainErrorCount) {
231
+ mainError = error;
232
+ mainErrorCount = count;
233
+ }
234
+ }
235
+
236
+ return mainError;
237
+ }
238
+ }
239
+
240
+ export function operation(
241
+ options?: OperationOptions | number[],
242
+ ): RetryOperation {
243
+ const computedTimeouts = timeouts(options);
244
+ const opts = !Array.isArray(options) ? options : undefined;
245
+
246
+ return new RetryOperation(computedTimeouts, {
247
+ forever: opts?.forever || opts?.retries === Infinity,
248
+ unref: opts?.unref,
249
+ maxRetryTime: opts?.maxRetryTime,
250
+ });
251
+ }
252
+
253
+ export function wrap<T extends Record<string, any>>(
254
+ obj: T,
255
+ optionsOrMethods?: OperationOptions | string[],
256
+ methodList?: string[],
257
+ ): void {
258
+ let options: OperationOptions | undefined;
259
+ let methods: string[] | undefined;
260
+
261
+ if (Array.isArray(optionsOrMethods)) {
262
+ methods = optionsOrMethods;
263
+ } else {
264
+ options = optionsOrMethods;
265
+ methods = methodList;
266
+ }
267
+
268
+ if (!methods) {
269
+ methods = [];
270
+ for (const key in obj) {
271
+ if (typeof obj[key] === "function") {
272
+ methods.push(key);
273
+ }
274
+ }
275
+ }
276
+
277
+ for (const method of methods) {
278
+ const original = obj[method] as (...args: any[]) => unknown;
279
+
280
+ (obj as Record<string, any>)[method] = function retryWrapper(
281
+ ...args: any[]
282
+ ) {
283
+ const op = operation(options);
284
+ const callback = args.pop() as (
285
+ err: Error | null,
286
+ ...rest: any[]
287
+ ) => void;
288
+
289
+ args.push(function (this: unknown, err: Error | null, ...rest: any[]) {
290
+ if (op.retry(err)) {
291
+ return;
292
+ }
293
+
294
+ const finalErr = err ? op.mainError() : err;
295
+ callback.call(this, finalErr, ...rest);
296
+ });
297
+
298
+ op.attempt(() => {
299
+ original.apply(obj, args);
300
+ });
301
+ };
302
+
303
+ (
304
+ (obj as Record<string, any>)[method] as { options?: OperationOptions }
305
+ ).options = options;
306
+ }
307
+ }
308
+
309
+ export default { operation, timeouts, createTimeout, wrap, RetryOperation };