Skip to content

Commit 8ef69b8

Browse files
authored
Add core Retry support with backoff (#134)
1 parent 9f03431 commit 8ef69b8

File tree

12 files changed

+200
-29
lines changed

12 files changed

+200
-29
lines changed

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"author": "incepter",
55
"sideEffects": false,
66
"name": "async-states",
7-
"version": "1.0.0-pre-11",
7+
"version": "1.0.0-pre-12",
88
"main": "dist/umd/index",
99
"types": "dist/es/index",
1010
"module": "dist/es/src/index",

packages/core/src/AsyncState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,7 @@ export class AsyncState<T, E, R> implements StateInterface<T, E, R> {
612612
}
613613

614614
const runIndicators = {
615+
attempt: 1,
615616
cleared: false, // abort was called and abort callbacks were removed
616617
aborted: false, // aborted before fulfillment
617618
fulfilled: false, // resolved to something, either success or error
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {AsyncState, ProducerConfig, Status} from "../..";
2+
import {timeout} from "./test-utils";
3+
import {expect} from "@jest/globals";
4+
import {flushPromises} from "../utils/test-utils";
5+
6+
// @ts-ignore
7+
jest.useFakeTimers("modern");
8+
describe('AsyncState - retry', () => {
9+
it('should retry synchronous work', async () => {
10+
// given
11+
let key = "retry-1";
12+
let spy = jest.fn();
13+
let retry = jest.fn().mockImplementation(() => true);
14+
let config: ProducerConfig<number> = {
15+
retryConfig: {
16+
retry,
17+
enabled: true,
18+
maxAttempts: 2,
19+
}
20+
};
21+
function producer(): number {
22+
throw 15;
23+
}
24+
25+
// when
26+
let instance = new AsyncState(key, producer, config);
27+
instance.on("change", {status: Status.error, handler: spy});
28+
29+
// then
30+
instance.run();
31+
await jest.advanceTimersByTime(1);
32+
await flushPromises();
33+
34+
expect(instance.state.status).toBe(Status.error);
35+
expect(instance.state.data).toBe(15);
36+
37+
expect(retry).toHaveBeenCalledTimes(3); // third time it be > maxRetries
38+
expect(retry.mock.calls[0][0]).toBe(1);
39+
expect(retry.mock.calls[1][0]).toBe(2);
40+
expect(retry.mock.calls[2][0]).toBe(3);
41+
42+
expect(spy).toHaveBeenCalledTimes(1);
43+
expect(spy.mock.calls[0][0].data).toBe(15);
44+
});
45+
it('should retry asynchronous work', async () => {
46+
// given
47+
let key = "retry-2";
48+
let didErrorCount = 0;
49+
let maxErrorCount = 3;
50+
let producer = function producer() {
51+
return timeout<number>(50, 0)().then((value) => {
52+
if (didErrorCount === maxErrorCount) {
53+
return value;
54+
}
55+
didErrorCount += 1;
56+
throw 5;
57+
});
58+
}
59+
let retry = jest.fn().mockImplementation(() => true);
60+
let config: ProducerConfig<number> = {
61+
retryConfig: {
62+
retry,
63+
enabled: true,
64+
maxAttempts: 4,
65+
backoff: () => 10,
66+
}
67+
};
68+
69+
// when
70+
let instance = new AsyncState(key, producer, config);
71+
72+
// then
73+
instance.run();
74+
await jest.advanceTimersByTime(50);
75+
await flushPromises();
76+
77+
expect(instance.state.status).toBe(Status.pending);
78+
expect(retry).toHaveBeenCalledTimes(1);
79+
expect(retry.mock.calls[0][0]).toBe(1);
80+
expect(retry.mock.calls[0][1]).toBe(5); // error thrown
81+
82+
await jest.advanceTimersByTime(60); // backoff = 10 + 50 of timeout producer
83+
await flushPromises();
84+
85+
expect(instance.state.status).toBe(Status.pending);
86+
expect(retry).toHaveBeenCalledTimes(2);
87+
expect(retry.mock.calls[1][0]).toBe(2);
88+
expect(retry.mock.calls[1][1]).toBe(5); // error thrown
89+
90+
await jest.advanceTimersByTime(60); // backoff = 10 + 50 of timeout producer
91+
await flushPromises();
92+
93+
expect(instance.state.status).toBe(Status.pending);
94+
expect(retry).toHaveBeenCalledTimes(3);
95+
expect(retry.mock.calls[2][0]).toBe(3);
96+
expect(retry.mock.calls[2][1]).toBe(5); // error thrown
97+
98+
await jest.advanceTimersByTime(60); // backoff = 10 + 50 of timeout producer
99+
await flushPromises();
100+
101+
retry.mockClear();
102+
expect(instance.state.data).toBe(0);
103+
expect(instance.state.status).toBe(Status.success);
104+
expect(retry).not.toHaveBeenCalled();
105+
});
106+
});

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {StateBuilder} from "./helpers/StateBuilder";
1919
export {hookReturn, createHook, autoRun} from "./state-hook/StateHook";
2020

2121
export type {
22+
RetryConfig,
2223
PoolInterface,
2324
ProducerRunConfig,
2425
ProducerRunInput,

packages/core/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ export interface ProducerProps<T, E = any, R = any> extends ProducerEffects {
257257
}
258258

259259
export type RunIndicators = {
260+
attempt: number,
260261
cleared: boolean,
261262
aborted: boolean,
262263
fulfilled: boolean,
@@ -292,7 +293,16 @@ export type ProducerConfig<T, E = any, R = any> = {
292293

293294
// dev only
294295
hideFromDevtools?: boolean,
296+
retryConfig?: RetryConfig<T, E, R>,
295297
}
298+
299+
export type RetryConfig<T, E, R> = {
300+
enabled: boolean,
301+
maxAttempts?: number,
302+
backoff?: number | ((attemptIndex:number, error: E) => number),
303+
retry?: boolean | ((attemptIndex:number, error: E) => boolean)
304+
}
305+
296306
export type StateFunctionUpdater<T, E = any, R = any> = (updater: State<T, E, R>) => T;
297307
export type StateUpdater<T, E = any, R = any> = (updater: StateFunctionUpdater<T, E, R> | T, status?: Status, callbacks?: ProducerCallbacks<T, E, R>) => void;
298308

packages/core/src/wrapper.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ProducerCallbacks,
44
ProducerProps,
55
ProducerWrapperInput,
6+
RetryConfig,
67
RunIndicators
78
} from "./types";
89
import {
@@ -95,18 +96,70 @@ export function producerWrapper<T, E = any, R = any>(
9596

9697
function onFail(error: E) {
9798
if (!indicators.aborted) {
99+
let retryConfig = input.instance?.config.retryConfig;
100+
if (retryConfig && retryConfig.enabled) {
101+
if (shouldRetry(indicators.attempt, retryConfig, error)) {
102+
let backoff = getRetryBackoff(indicators.attempt, retryConfig, error);
103+
let id, abort;
104+
indicators.attempt += 1;
105+
106+
if (isFunction(setTimeout)) {
107+
id = setTimeout(() => {
108+
abort = producerWrapper(input, props, indicators, callbacks);
109+
}, backoff);
110+
} else {
111+
abort = producerWrapper(input, props, indicators, callbacks);
112+
}
113+
114+
props.onAbort(() => {
115+
clearTimeout(id);
116+
if (isFunction(abort)) {
117+
abort!();
118+
}
119+
});
120+
return;
121+
}
122+
}
123+
98124
indicators.fulfilled = true;
99125
let errorState = StateBuilder.error<T, E>(error, savedProps);
100126
replaceState(errorState, true, callbacks);
101127
}
102128
}
103129
}
104130

131+
function shouldRetry<T, E, R>(
132+
attempt: number,
133+
retryConfig: RetryConfig<T, E, R>,
134+
error: E
135+
): boolean {
136+
let {retry, maxAttempts} = retryConfig;
137+
let canRetry = !!maxAttempts && attempt <= maxAttempts;
138+
let shouldRetry: boolean = retry === undefined ? true : !!retry;
139+
if (isFunction(retry)) {
140+
shouldRetry = (retry as (attemptIndex:number, error: E) => boolean)(attempt, error);
141+
}
142+
143+
return canRetry && shouldRetry;
144+
}
145+
146+
function getRetryBackoff<T, E, R>(
147+
attempt: number,
148+
retryConfig: RetryConfig<T, E, R>,
149+
error: E
150+
): number {
151+
let {backoff} = retryConfig;
152+
if (isFunction(backoff)) {
153+
return (backoff as (attemptIndex:number, error: E) => number)(attempt, error);
154+
}
155+
return (backoff as number) || 0;
156+
}
157+
105158
function stepGenerator<T>(
106159
generatorInstance: Generator<any, T, any>,
107160
props,
108161
indicators
109-
): {done: true, value: T} | Promise<T> {
162+
): { done: true, value: T } | Promise<T> {
110163
let generator = generatorInstance.next();
111164

112165
while (!generator.done && !isPromise(generator.value)) {

packages/devtools-extension/package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"sideEffects": false,
33
"types": "dist/index",
4-
"version": "1.0.0-pre-11",
4+
"version": "1.0.0-pre-12",
55
"main": "dist/index.umd.js",
66
"name": "async-states-devtools",
77
"module": "dist/index.development.mjs",
@@ -22,12 +22,12 @@
2222
"react-resizable": "^3.0.4"
2323
},
2424
"peerDependencies": {
25-
"async-states": "^1.0.0-pre-11",
26-
"react-async-states": "^1.0.0-pre-11"
25+
"async-states": "^1.0.0-pre-12",
26+
"react-async-states": "^1.0.0-pre-12"
2727
},
2828
"devDependencies": {
29-
"async-states": "workspace:^1.0.0-pre-11",
30-
"react-async-states": "workspace:^1.0.0-pre-11",
29+
"async-states": "workspace:^1.0.0-pre-12",
30+
"react-async-states": "workspace:^1.0.0-pre-12",
3131
"@rollup/plugin-replace": "^5.0.1",
3232
"@types/node": "^18.11.9",
3333
"@types/react": "^18.0.24",

packages/react-async-states-utils/package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"private": false,
33
"license": "MIT",
4-
"version": "1.0.0-pre-11",
4+
"version": "1.0.0-pre-12",
55
"author": "incepter",
66
"sideEffects": false,
77
"main": "dist/umd/index",
@@ -20,13 +20,13 @@
2020
"dev": "pnpm clean:dist && rollup -c rollup/rollup.config.dev.js -w"
2121
},
2222
"peerDependencies": {
23-
"async-states": "^1.0.0-pre-11",
24-
"react-async-states": "^1.0.0-pre-11",
23+
"async-states": "^1.0.0-pre-12",
24+
"react-async-states": "^1.0.0-pre-12",
2525
"react": "^16.8.0 || ^17.0.2 || ^18.0.0"
2626
},
2727
"devDependencies": {
28-
"async-states": "workspace:^1.0.0-pre-11",
29-
"react-async-states": "workspace:^1.0.0-pre-11",
28+
"async-states": "workspace:^1.0.0-pre-12",
29+
"react-async-states": "workspace:^1.0.0-pre-12",
3030
"@babel/plugin-proposal-class-properties": "^7.18.6",
3131
"@babel/preset-env": "^7.20.2",
3232
"@babel/preset-react": "^7.18.6",

packages/react-async-states-utils/src/runc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function runc<T, E = any, R = any>(config: RuncConfig<T, E, R>): AbortFn
5858
}
5959

6060
let realProducer = producerWrapper.bind(null, input);
61-
let runIndicators = {cleared: false, aborted: false, fulfilled: false};
61+
let runIndicators = {cleared: false, aborted: false, fulfilled: false, attempt: 1};
6262

6363
let producerProps: ProducerProps<T, E, R> = constructPropsObject(
6464
initialState,

packages/react-async-states/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"license": "MIT",
44
"author": "incepter",
55
"sideEffects": false,
6-
"version": "1.0.0-pre-11",
6+
"version": "1.0.0-pre-12",
77
"main": "dist/umd/index",
88
"types": "dist/es/index",
99
"module": "dist/es/index",
@@ -20,11 +20,11 @@
2020
"dev": "pnpm clean:dist && rollup -c rollup/rollup.config.dev.js -w"
2121
},
2222
"peerDependencies": {
23-
"async-states": "^1.0.0-pre-11",
23+
"async-states": "^1.0.0-pre-12",
2424
"react": "^16.8.0 || ^17.0.2 || ^18.0.0"
2525
},
2626
"devDependencies": {
27-
"async-states": "workspace:^1.0.0-pre-11",
27+
"async-states": "workspace:^1.0.0-pre-12",
2828
"@babel/plugin-proposal-class-properties": "^7.18.6",
2929
"@babel/preset-env": "^7.20.2",
3030
"@babel/preset-react": "^7.18.6",

0 commit comments

Comments
 (0)