@wowistudio/grit 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeroen Huisman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # Grit
2
+
3
+ > ⚠️ **Work in Progress**: This library is currently under active development. The API may change and some features may be incomplete.
4
+
5
+ A fluent API for configurable retries with error filtering and backoff strategies (fixed, exponential, random jitter)
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ # Using npm
11
+ npm install @wowistudio/grit
12
+ # Using pnpm
13
+ pnpm add @wowistudio/grit
14
+ # Using yarn
15
+ yarn add @wowistudio/grit
16
+ ```
17
+
18
+ ## Usage Examples
19
+
20
+ ### Retry
21
+
22
+ Retry an operation a specified numbers of times. The total attempts will be 1 + n retries.
23
+
24
+ ```typescript
25
+ import { Grit } from '@wowistudio/grit';
26
+
27
+ class ValidationError extends Error {}
28
+ class NetworkError extends Error {}
29
+ class TimeoutError extends Error {}
30
+
31
+ // Basic
32
+ const result = await Grit.retry(3)
33
+ .attempt(fetchData);
34
+
35
+ console.log(result)
36
+
37
+ // Basic with access to attempt count
38
+ await Grit.retry(3)
39
+ .attempt((attemptCount) => {
40
+ console.log('Attempt:', attemptCount)
41
+ return apiCall()
42
+ })
43
+
44
+ // Retry only specific errors
45
+ await Grit.retry(5)
46
+ .onlyErrors([NetworkError, TimeoutError])
47
+ .attempt(() => makeNetworkRequest);
48
+
49
+ // Do not retry specific errors
50
+ await Grit.retry(3)
51
+ .skipErrors([ValidationError])
52
+ .attempt(apiCall);
53
+ ```
54
+
55
+ ### Delay
56
+
57
+ ```typescript
58
+ import { Grit } from '@wowistudio/grit';
59
+
60
+ // Delay with fixed intervals (in ms)
61
+ await Grit.retry(3)
62
+ .withDelay(1000)
63
+ .attempt(apiCall);
64
+
65
+ // Delay with exponential backoff
66
+ // This setup generates delay: [2000, 4000, 8000]
67
+ await Grit.retry(3)
68
+ .withDelay({
69
+ delay: 2000,
70
+ factor: 2
71
+ })
72
+ .attempt(apiCall);
73
+
74
+ // Delay with randomness
75
+ await Grit.retry(4)
76
+ .withDelay({
77
+ minDelay: 3000,
78
+ maxDelay: 4000,
79
+ })
80
+ .attempt(apiCall);
81
+
82
+ // Delay with exact delays for each retry
83
+ await Grit.retry(3)
84
+ .withDelay([500, 1000, 1500])
85
+ .attempt(apiCall);
86
+ ```
87
+
88
+ ### Timeout
89
+
90
+ > ⚠️ **Note**: JavaScript timeouts don't actually stop the execution of the underlying operation. The timeout will reject/throw an error after the specified duration, but the original operation may continue running in the background.
91
+
92
+ ```typescript
93
+ import { Grit } from '@wowistudio/grit';
94
+
95
+ // Operation timeout after specified amount of time
96
+ await Grit.retry(3)
97
+ .withTimeout(5000)
98
+ .attempt(apiCall);
99
+ ```
100
+
101
+ ### Fallback
102
+
103
+ ```typescript
104
+ import { Grit } from '@wowistudio/grit';
105
+
106
+ const fastCacheLookup = async () => {}
107
+ const slowerDbFetch = async () => {}
108
+
109
+ // Attempt with fallback
110
+ await Grit.retry(0)
111
+ .withTimeout(100)
112
+ .fallback(slowerDbFetch)
113
+ .attempt(fastCacheLookup)
114
+
115
+ // Attempt return type is typesafe and will be a union of return type .fallback & .attempt
116
+ const result: number | string = await Grit.retry(0)
117
+ .withTimeout(100)
118
+ .fallback(() => 1)
119
+ .attempt(() => 'a')
120
+ ```
121
+
122
+
123
+ ## Reusable Builder Instances
124
+
125
+ You can define a builder instance once and reuse it. Each call to `.attempt()` creates a new execution context, so retry state is independent between calls:
126
+
127
+ ```typescript
128
+ import { Grit } from '@wowistudio/grit';
129
+
130
+ // Define a shared builder configuration
131
+ const grit = Grit.retry(3)
132
+ .withDelay(2000)
133
+ .withTimeout(5000)
134
+ .onlyErrors([NetworkError])
135
+ .beforeRetry((retryCount) => {
136
+ console.log(`Retrying (attempt ${retryCount})...`);
137
+ });
138
+
139
+ // Use the same builder for multiple different operations
140
+ const userData = await grit.attempt(fetchUserData);
141
+ const orderData = await grit.attempt(fetchOrderData);
142
+ ```
143
+
144
+ ## License
145
+
146
+ This project is licensed under the MIT License.
147
+
@@ -0,0 +1,24 @@
1
+ import { GritError } from "./exceptions.js";
2
+ import type { DelayConfig, FunctionToExecute, TimeoutConfig } from "./types.js";
3
+ declare class GritBuilder<FallbackType = never> {
4
+ #private;
5
+ private retryCount?;
6
+ _onlyErrors: (typeof GritError)[];
7
+ _skipErrors: (typeof GritError)[];
8
+ fn: FunctionToExecute<any> | undefined;
9
+ $delay: DelayConfig | undefined;
10
+ $timeout: TimeoutConfig | undefined;
11
+ $fallback: FunctionToExecute<any> | undefined;
12
+ beforeRetryFn?: (retryCount: number) => void;
13
+ logging: boolean;
14
+ constructor(retryCount: number);
15
+ onlyErrors(errors: (typeof GritError)[]): this;
16
+ skipErrors(errors: (typeof GritError)[]): this;
17
+ withLogging(enabled?: boolean): this;
18
+ withDelay(config: DelayConfig): this;
19
+ withTimeout(timeout: TimeoutConfig): this;
20
+ beforeRetry(fn: (retryCount: number) => void): this;
21
+ withFallback<T>(fallback: FunctionToExecute<T>): GritBuilder<T>;
22
+ attempt<T = FallbackType>(fn: FunctionToExecute<T>): Promise<FallbackType extends never ? T : T | FallbackType>;
23
+ }
24
+ export { GritBuilder };
@@ -0,0 +1,62 @@
1
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
2
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
3
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
+ };
6
+ var _GritBuilder_instances, _GritBuilder_build;
7
+ import { Grit } from "./grit.js";
8
+ class GritBuilder {
9
+ constructor(retryCount) {
10
+ _GritBuilder_instances.add(this);
11
+ this._onlyErrors = [];
12
+ this._skipErrors = [];
13
+ this.fn = undefined;
14
+ this.logging = false;
15
+ this.retryCount = retryCount;
16
+ }
17
+ onlyErrors(errors) {
18
+ this._onlyErrors = errors;
19
+ return this;
20
+ }
21
+ skipErrors(errors) {
22
+ this._skipErrors = errors;
23
+ return this;
24
+ }
25
+ withLogging(enabled = true) {
26
+ this.logging = enabled;
27
+ return this;
28
+ }
29
+ withDelay(config) {
30
+ this.$delay = config;
31
+ return this;
32
+ }
33
+ withTimeout(timeout) {
34
+ this.$timeout = timeout;
35
+ return this;
36
+ }
37
+ beforeRetry(fn) {
38
+ this.beforeRetryFn = fn;
39
+ return this;
40
+ }
41
+ withFallback(fallback) {
42
+ const newBuilder = this;
43
+ newBuilder.$fallback = fallback;
44
+ return newBuilder;
45
+ }
46
+ attempt(fn) {
47
+ return __classPrivateFieldGet(this, _GritBuilder_instances, "m", _GritBuilder_build).call(this).attempt(fn);
48
+ }
49
+ }
50
+ _GritBuilder_instances = new WeakSet(), _GritBuilder_build = function _GritBuilder_build() {
51
+ return new Grit({
52
+ retryCount: this.retryCount,
53
+ onlyErrors: this._onlyErrors,
54
+ skipErrors: this._skipErrors,
55
+ delay: this.$delay,
56
+ timeout: this.$timeout,
57
+ beforeRetryFn: this.beforeRetryFn,
58
+ logging: this.logging,
59
+ fallback: this.$fallback,
60
+ });
61
+ };
62
+ export { GritBuilder };
@@ -0,0 +1,11 @@
1
+ declare class GritError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ declare class MaxRetriesError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ export declare class TimeoutError extends Error {
8
+ name: string;
9
+ constructor(message: string);
10
+ }
11
+ export { GritError, MaxRetriesError };
@@ -0,0 +1,20 @@
1
+ class GritError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "GritError";
5
+ }
6
+ }
7
+ class MaxRetriesError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "MaxRetriesError";
11
+ }
12
+ }
13
+ export class TimeoutError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = 'TimeoutError';
17
+ this.name = "TimeoutError";
18
+ }
19
+ }
20
+ export { GritError, MaxRetriesError };
package/dist/grit.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { GritBuilder } from "./builder.js";
2
+ import type { FunctionToExecute, GritProps } from "./types.js";
3
+ declare class Grit<T> {
4
+ #private;
5
+ private retryCount?;
6
+ private attempts;
7
+ private retries;
8
+ private onlyErrors;
9
+ private skipErrors;
10
+ private delay;
11
+ private fallback;
12
+ private timeout;
13
+ private beforeRetryFn?;
14
+ private logging;
15
+ constructor(props: GritProps);
16
+ attempt(fn: FunctionToExecute<T>): Promise<T>;
17
+ static retry(retryCount: number): GritBuilder<never>;
18
+ }
19
+ export { Grit };
package/dist/grit.js ADDED
@@ -0,0 +1,129 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
11
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
12
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
13
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
14
+ };
15
+ var _Grit_instances, _Grit_currentDelay_get, _Grit_handleBeforeRetry, _Grit_backoff, _Grit_withTimeout, _Grit_processError, _Grit_execute;
16
+ import { GritBuilder } from "./builder.js";
17
+ import { GritError, TimeoutError } from "./exceptions.js";
18
+ import { isObject, validateGritConfig } from "./utils.js";
19
+ class Grit {
20
+ constructor(props) {
21
+ _Grit_instances.add(this);
22
+ this.attempts = 1;
23
+ this.retries = 0;
24
+ this.logging = false;
25
+ const { retryCount, fn, onlyErrors, skipErrors, delay, timeout, beforeRetryFn, logging, fallback } = props;
26
+ this.retryCount = retryCount;
27
+ this.onlyErrors = onlyErrors || [];
28
+ this.skipErrors = skipErrors || [];
29
+ this.delay = delay;
30
+ this.fallback = fallback;
31
+ this.timeout = timeout;
32
+ this.beforeRetryFn = beforeRetryFn;
33
+ this.logging = logging || false;
34
+ validateGritConfig(delay, retryCount);
35
+ }
36
+ attempt(fn) {
37
+ return __awaiter(this, void 0, void 0, function* () {
38
+ return __classPrivateFieldGet(this, _Grit_instances, "m", _Grit_execute).call(this, fn);
39
+ });
40
+ }
41
+ static retry(retryCount) {
42
+ return new GritBuilder(retryCount);
43
+ }
44
+ }
45
+ _Grit_instances = new WeakSet(), _Grit_currentDelay_get = function _Grit_currentDelay_get() {
46
+ let delay = undefined;
47
+ if (typeof this.delay === "number") {
48
+ delay = this.delay;
49
+ }
50
+ else if (Array.isArray(this.delay)) {
51
+ delay = this.delay.shift();
52
+ }
53
+ else if (isObject(this.delay) && "delay" in this.delay) {
54
+ const { factor, delay: initialDelay } = this.delay;
55
+ delay = initialDelay * Math.pow(factor || 1, this.attempts - 2);
56
+ }
57
+ else if (isObject(this.delay) && "minDelay" in this.delay) {
58
+ const { minDelay, maxDelay, factor } = this.delay;
59
+ const randomDelay = Math.random() * (maxDelay - minDelay) + minDelay;
60
+ delay = factor ? randomDelay * Math.pow(factor, this.attempts - 2) : randomDelay;
61
+ }
62
+ if (!delay)
63
+ throw new GritError("No delay. Should never happen.");
64
+ if (this.logging)
65
+ console.debug("Delaying for", delay, "ms");
66
+ return delay;
67
+ }, _Grit_handleBeforeRetry = function _Grit_handleBeforeRetry() {
68
+ if (this.retries > 0 && this.beforeRetryFn)
69
+ this.beforeRetryFn(this.retries);
70
+ }, _Grit_backoff = function _Grit_backoff() {
71
+ return __awaiter(this, void 0, void 0, function* () {
72
+ return new Promise((resolve) => setTimeout(resolve, __classPrivateFieldGet(this, _Grit_instances, "a", _Grit_currentDelay_get)));
73
+ });
74
+ }, _Grit_withTimeout = function _Grit_withTimeout(promise) {
75
+ let timer = undefined;
76
+ const wrappedPromise = new Promise((resolve, reject) => {
77
+ // Use .then() instead of async IIFE to preserve stack traces
78
+ // eslint-disable-next-line promise/prefer-await-to-then, promise/prefer-catch
79
+ promise.then(resolve, reject);
80
+ if (this.timeout === Number.POSITIVE_INFINITY)
81
+ return;
82
+ // We create the error outside of `setTimeout` to preserve the stack trace.
83
+ const message = `Promise timed out after ${this.timeout} milliseconds`;
84
+ const timeoutError = new TimeoutError(message);
85
+ timer = setTimeout(() => {
86
+ reject(timeoutError);
87
+ }, this.timeout);
88
+ });
89
+ // eslint-disable-next-line promise/prefer-await-to-then
90
+ wrappedPromise.clear = () => {
91
+ clearTimeout(timer);
92
+ timer = undefined;
93
+ };
94
+ const cancelablePromise = wrappedPromise.finally(() => {
95
+ cancelablePromise.clear();
96
+ });
97
+ return cancelablePromise;
98
+ }, _Grit_processError = function _Grit_processError(error) {
99
+ if (error && typeof error === 'object' && 'constructor' in error) {
100
+ if (this.skipErrors.length > 0 && this.skipErrors.includes(error.constructor))
101
+ throw error;
102
+ if (this.onlyErrors.length > 0 && !this.onlyErrors.includes(error.constructor))
103
+ throw error;
104
+ }
105
+ this.attempts++;
106
+ this.retries++;
107
+ }, _Grit_execute = function _Grit_execute(fn) {
108
+ return __awaiter(this, void 0, void 0, function* () {
109
+ try {
110
+ __classPrivateFieldGet(this, _Grit_instances, "m", _Grit_handleBeforeRetry).call(this);
111
+ console.log('this.timeout', this.timeout);
112
+ if (this.timeout)
113
+ return yield __classPrivateFieldGet(this, _Grit_instances, "m", _Grit_withTimeout).call(this, Promise.resolve(fn(this.attempts)));
114
+ return yield fn(this.attempts);
115
+ }
116
+ catch (error) {
117
+ if (this.retries >= this.retryCount) {
118
+ if (this.fallback)
119
+ return this.fallback(this.attempts);
120
+ throw error;
121
+ }
122
+ __classPrivateFieldGet(this, _Grit_instances, "m", _Grit_processError).call(this, error);
123
+ if (this.delay)
124
+ return __classPrivateFieldGet(this, _Grit_instances, "m", _Grit_backoff).call(this).then(() => __classPrivateFieldGet(this, _Grit_instances, "m", _Grit_execute).call(this, fn));
125
+ return yield __classPrivateFieldGet(this, _Grit_instances, "m", _Grit_execute).call(this, fn);
126
+ }
127
+ });
128
+ };
129
+ export { Grit };
@@ -0,0 +1,3 @@
1
+ export { GritBuilder } from "./builder.js";
2
+ export { Grit } from "./grit.js";
3
+ export type { DelayConfig, FunctionToExecute } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { GritBuilder } from "./builder.js";
2
+ export { Grit } from "./grit.js";
@@ -0,0 +1,21 @@
1
+ export type GritProps = {
2
+ retryCount?: number;
3
+ fn?: FunctionToExecute<any>;
4
+ fallback?: FunctionToExecute<any>;
5
+ onlyErrors?: (typeof Error)[];
6
+ skipErrors?: (typeof Error)[];
7
+ delay?: DelayConfig;
8
+ timeout?: TimeoutConfig;
9
+ beforeRetryFn?: (retryCount: number) => void;
10
+ logging?: boolean;
11
+ };
12
+ export type TimeoutConfig = number;
13
+ export type DelayConfig = number | number[] | {
14
+ factor?: number;
15
+ delay: number;
16
+ } | {
17
+ factor?: number;
18
+ minDelay: number;
19
+ maxDelay: number;
20
+ };
21
+ export type FunctionToExecute<T> = (attempts?: number) => (Promise<T> | T);
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { DelayConfig } from "./types.js";
2
+ export declare const isObject: (o: any) => o is Record<string, any>;
3
+ export declare const isPromise: <T>(value: any) => value is Promise<T>;
4
+ export declare const validateGritConfig: (config: DelayConfig | undefined, retryCount: number | undefined) => void;
package/dist/utils.js ADDED
@@ -0,0 +1,50 @@
1
+ import { GritError } from "./exceptions.js";
2
+ export const isObject = (o) => {
3
+ return typeof o === 'object' && !Array.isArray(o) && o !== null;
4
+ };
5
+ export const isPromise = (value) => {
6
+ return value !== null && typeof value === "object" && typeof value.then === "function";
7
+ };
8
+ export const validateGritConfig = (config, retryCount) => {
9
+ if (!config)
10
+ return;
11
+ if (!retryCount)
12
+ throw new GritError("Missing retry config (Grit.retry(<count>))");
13
+ // validate array config
14
+ if (Array.isArray(config)) {
15
+ if (config.length !== retryCount)
16
+ throw new GritError("Delay array length must be equal to retry count");
17
+ for (const delay of config) {
18
+ if (delay <= 0)
19
+ throw new GritError("delay must be greater than 0");
20
+ }
21
+ return;
22
+ }
23
+ if (typeof config === "number") {
24
+ if (config <= 0)
25
+ throw new GritError("delay must be greater than 0");
26
+ return;
27
+ }
28
+ if (!isObject(config))
29
+ throw new GritError("Invalid backoff config");
30
+ // validate delay config
31
+ if ("delay" in config) {
32
+ if (typeof config.delay !== "number")
33
+ throw new GritError("delay must be a number");
34
+ if (config.delay <= 0)
35
+ throw new GritError("delay must be greater than 0");
36
+ return;
37
+ }
38
+ // validate random delay config
39
+ const { minDelay, maxDelay } = config;
40
+ if (!minDelay || typeof minDelay !== "number")
41
+ throw new GritError("minDelay must be a number");
42
+ if (!maxDelay || typeof maxDelay !== "number")
43
+ throw new GritError("maxDelay must be a number");
44
+ if (minDelay >= maxDelay)
45
+ throw new GritError("minDelay must be less than maxDelay");
46
+ if (minDelay <= 0)
47
+ throw new GritError("minDelay must be greater than 0");
48
+ if (maxDelay <= 0)
49
+ throw new GritError("maxDelay must be greater than 0");
50
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@wowistudio/grit",
3
+ "description": "A library for retry.",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "files": [
7
+ "/dist"
8
+ ],
9
+ "version": "0.0.1",
10
+ "license": "MIT",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
+ }
16
+ },
17
+ "type": "module",
18
+ "scripts": {
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
21
+ },
22
+ "dependencies": {},
23
+ "devDependencies": {
24
+ "vitest": "^2.1.8",
25
+ "@types/node": "^20.9.0"
26
+ }
27
+ }