Skip to content

Commit

Permalink
feat: add retryWithBackoff utility (#22)
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredLunde authored Apr 28, 2022
1 parent 698309e commit 82c596a
Show file tree
Hide file tree
Showing 13 changed files with 244 additions and 14 deletions.
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,52 @@ For all other props, the last prop object overrides all previous ones.

---

### retryWithBackoff()

Retry a promise until it resolves or the max number of retries is reached.

#### Arguments

| Name | Type | Required? | Description |
| --------- | --------------------------------------------------- | --------- | -------------------------------------------------------- |
| promiseFn | `() => Promise<T>` | Yes | A function that returns a promise to retry when it fails |
| config | [`RetryWithBackoffConfig`](#retrywithbackoffconfig) | No | Configuration options |

#### RetryWithBackoffConfig

```ts
interface RetryWithBackoffConfig {
/**
* Max number of retries
*
* @default 4
*/
maxRetries?: number;
/**
* Initial delay before first retry
*
* @default 100
*/
initialDelay?: number;
/**
* Multiplier for each subsequent retry
*
* @default 2
*/
delayMultiple?: number;
/**
* A function that should return `false` to stop retrying
*
* @param error - The error that caused the retry
*/
shouldRetry?: (error: unknown) => boolean;
}
```

[**⇗ Back to top**](#exploration)

---

## Path utilities

Utilities for unix-style paths
Expand Down
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,16 @@
"eslintConfig": {
"extends": [
"lunde"
]
],
"parserOptions": {
"project": [
"./tsconfig.eslint.json"
]
},
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error"
}
},
"eslintIgnore": [
"node_modules",
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ export type {
UseVirtualizeResult,
} from "./use-virtualize";
export { useVisibleNodes } from "./use-visible-nodes";
export { mergeProps } from "./utils";
export { mergeProps, retryWithBackoff } from "./utils";
7 changes: 6 additions & 1 deletion src/node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { FileTree, FileTreeNode } from "./file-tree";
import { subject } from "./tree/subject";
import type { NodePlugin } from "./use-node-plugins";
import { useNodePlugins } from "./use-node-plugins";
import { retryWithBackoff } from "./utils";

/**
* A React component that renders a node in a file tree with plugins. The
Expand Down Expand Up @@ -64,7 +65,11 @@ export function useNodeProps<Meta>(config: Omit<NodeProps<Meta>, "as">) {
if (expanded) {
tree.collapse(node);
} else {
tree.expand(node);
retryWithBackoff(() => tree.expand(node), {
shouldRetry() {
return node.expanded && !tree.isExpanded(node);
},
}).catch(() => {});
}
}
},
Expand Down
13 changes: 11 additions & 2 deletions src/tree/tree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import memoizeOne from "@essentials/memoize-one";
import { retryWithBackoff } from "../utils";
import type { Node } from "./branch";
import { Branch } from "./branch";
import { Leaf } from "./leaf";
Expand All @@ -25,7 +26,11 @@ export class Tree<NodeData = {}> {
this.root = root;
this.getNodes = getNodes;
this.comparator = comparator;
this.expand(this.root);
retryWithBackoff(() => this.expand(this.root), {
shouldRetry: () => {
return !this.isExpanded(this.root);
},
}).catch(() => {});
}

get visibleNodes(): number[] {
Expand Down Expand Up @@ -292,7 +297,11 @@ export class Tree<NodeData = {}> {
const node = nodes[i];

if (isBranch(node) && node.expanded) {
this.expand(node);
retryWithBackoff(() => this.expand(node), {
shouldRetry: () => {
return node.expanded && !this.isExpanded(node);
},
}).catch(() => {});
}
}
})();
Expand Down
21 changes: 16 additions & 5 deletions src/use-dnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Subject } from "./tree/subject";
import { pureSubject } from "./tree/subject";
import type { WindowRef } from "./types";
import { useObserver } from "./use-observer";
import { shallowEqual } from "./utils";
import { retryWithBackoff, shallowEqual } from "./utils";

/**
* A plugin hook for adding drag and drop to the file tree.
Expand Down Expand Up @@ -45,11 +45,22 @@ export function useDnd<Meta>(

storedTimeout.current.timeout = setTimeout(() => {
if (!event.dir.expanded) {
fileTree.expand(event.dir).then(() => {
if (event.dir === storedDir.current) {
dnd.setState({ ...event, type: "expanded" });
retryWithBackoff(
() =>
fileTree.expand(event.dir).then(() => {
if (event.dir === storedDir.current) {
dnd.setState({ ...event, type: "expanded" });
}
}),
{
shouldRetry() {
return (
event.dir === storedDir.current &&
!fileTree.isExpanded(event.dir)
);
},
}
});
).catch(() => {});
}
}, storedConfig.current.dragOverExpandTimeout ?? DEFAULT_DRAG_OVER_EXPAND_TIMEOUT);
} else if (
Expand Down
4 changes: 3 additions & 1 deletion src/use-file-tree-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export function useFileTreeSnapshot<Meta>(
}
}

observer({ expandedPaths, buriedPaths, version: SNAPSHOT_VERSION });
observer({ expandedPaths, buriedPaths, version: SNAPSHOT_VERSION })?.catch(
() => {}
);
});
}

Expand Down
7 changes: 6 additions & 1 deletion src/use-hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { WindowRef } from "./types";
import type { useRovingFocus } from "./use-roving-focus";
import type { useSelections } from "./use-selections";
import { useVisibleNodes } from "./use-visible-nodes";
import { retryWithBackoff } from "./utils";

/**
* A hook for adding standard hotkeys to the file tree.
Expand Down Expand Up @@ -182,7 +183,11 @@ export function useHotkeys(fileTree: FileTree, config: UseHotkeysConfig) {
if (node.expanded) {
fileTree.collapse(node);
} else {
fileTree.expand(node);
retryWithBackoff(() => fileTree.expand(node), {
shouldRetry() {
return node.expanded && !fileTree.isExpanded(node);
},
}).catch(() => {});
}
} else {
selections.clear();
Expand Down
1 change: 1 addition & 0 deletions src/use-virtualize.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { act, renderHook } from "@testing-library/react-hooks";
import { useVirtualize } from ".";
import { createFileTree } from "./file-tree";
Expand Down
71 changes: 70 additions & 1 deletion src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mergeProps } from ".";
import { shallowEqual, throttle } from "./utils";
import { retryWithBackoff, shallowEqual, throttle } from "./utils";

describe("shallowEqual()", () => {
it("should return true for equal objects", () => {
Expand Down Expand Up @@ -116,3 +116,72 @@ describe("throttle()", () => {
expect(fn).lastCalledWith(3);
});
});

describe("retryWithBackoff()", () => {
it("should retry with backoff", async () => {
const fn = jest.fn(async () => {
throw new Error("error");
});

const promise = retryWithBackoff(fn, {
initialDelay: 1,
delayMultiple: 2,
maxRetries: 3,
});

expect(fn).toHaveBeenCalledTimes(1);

await Promise.resolve();
jest.advanceTimersByTime(1);
await Promise.resolve();
expect(fn).toHaveBeenCalledTimes(2);

await Promise.resolve();
jest.advanceTimersByTime(2);
await Promise.resolve();
expect(fn).toHaveBeenCalledTimes(3);

await Promise.resolve();
jest.advanceTimersByTime(4);
await Promise.resolve();
expect(fn).toHaveBeenCalledTimes(4);

const catchFn = jest.fn();
await promise.catch(catchFn);
expect(catchFn).lastCalledWith(new Error("error"));
});

it("should return value", async () => {
const fn = jest.fn(async () => {
return "foo";
});
const promise = retryWithBackoff(fn);

expect(fn).toHaveBeenCalledTimes(1);
expect(await promise).toBe("foo");
});

it("prevent retrying if shouldRetry returns false", async () => {
const fn = jest.fn(async () => {
throw new Error("error");
});

const promise = retryWithBackoff(fn, {
initialDelay: 1,
delayMultiple: 2,
maxRetries: 3,
shouldRetry: () => false,
});

expect(fn).toHaveBeenCalledTimes(1);

await Promise.resolve();
jest.advanceTimersByTime(1);
await Promise.resolve();
expect(fn).toHaveBeenCalledTimes(1);

const catchFn = jest.fn();
await promise.catch(catchFn);
expect(catchFn).lastCalledWith(new Error("error"));
});
});
69 changes: 69 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,75 @@ export function shallowEqual<
return true;
}

/**
* Retry a promise until it resolves or the max number of retries is reached.
*
* @param promiseFn - A function that returns a promise to retry
* @param config - Options
* @param config.maxRetries - Max number of retries
* @param config.initialDelay - Initial delay before first retry
* @param config.delayMultiple - Multiplier for each subsequent retry
* @param config.shouldRetry - A function that should return `false` to stop retrying
*/
export async function retryWithBackoff<T>(
promiseFn: () => Promise<T>,
config: RetryWithBackoffConfig = {}
): Promise<T> {
const {
maxRetries = 4,
initialDelay = 100,
delayMultiple = 2,
shouldRetry,
} = config;

try {
const result = await promiseFn();
return result;
} catch (err) {
if (
maxRetries === 0 ||
(typeof shouldRetry === "function" && !shouldRetry(err))
) {
throw err;
}

await new Promise((resolve) => setTimeout(resolve, initialDelay));
return retryWithBackoff(promiseFn, {
maxRetries: maxRetries - 1,
initialDelay: initialDelay * delayMultiple,
delayMultiple,
shouldRetry,
});
}
}

export interface RetryWithBackoffConfig {
/**
* Max number of retries
*
* @default 4
*/
maxRetries?: number;
/**
* Initial delay before first retry
*
* @default 100
*/
initialDelay?: number;
/**
* Multiplier for each subsequent retry
*
* @default 2
*/
delayMultiple?: number;
/**
* A function that should return `false` to stop retrying
*
* @param error - The error that caused the retry
*/
shouldRetry?: (error: unknown) => boolean;
}

interface Props {
[key: string]: any;
}
Expand Down
4 changes: 4 additions & 0 deletions tsconfig.eslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": []
}
2 changes: 1 addition & 1 deletion types/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

0 comments on commit 82c596a

Please sign in to comment.