job-retry 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/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # job-retry
2
+
3
+ Retry any async function with exponential backoff, per-attempt timeout control, and a dead letter queue so permanently failed jobs are never silently lost.
4
+
5
+ ```ts
6
+ import { JobRetry } from 'job-retry';
7
+
8
+ const runner = new JobRetry({ attempts: 5, backoff: 'exponential', baseDelay: 1000, jitter: true });
9
+
10
+ const result = await runner.run('sendEmail', () => sendEmail(user));
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install job-retry
19
+ ```
20
+
21
+ For the Redis DLQ backend, also install ioredis:
22
+
23
+ ```bash
24
+ npm install ioredis
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Quick start
30
+
31
+ ```ts
32
+ import { JobRetry } from 'job-retry';
33
+
34
+ const runner = new JobRetry({
35
+ attempts: 5,
36
+ backoff: 'exponential',
37
+ baseDelay: 1000,
38
+ timeout: 5000,
39
+ jitter: true,
40
+ dlq: 'memory',
41
+ onRetry: (error, attempt) => console.log(`Attempt ${attempt} failed`, error),
42
+ onFailure: (job) => console.error('Job permanently failed', job),
43
+ onSuccess: (result, attempts) => console.log(`Succeeded after ${attempts} tries`),
44
+ });
45
+
46
+ // Run a job — retries automatically on failure
47
+ const result = await runner.run('sendEmail', () => sendEmail(user));
48
+
49
+ // Inspect the dead letter queue
50
+ const failed = await runner.dlq.getAll();
51
+
52
+ // Retry a failed job after you've fixed the underlying issue
53
+ await runner.dlq.retry(failed[0].id, runner);
54
+
55
+ // Remove a single entry or wipe everything
56
+ await runner.dlq.remove(failed[0].id);
57
+ await runner.dlq.clear();
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Options
63
+
64
+ | Option | Type | Default | Description |
65
+ |---|---|---|---|
66
+ | `attempts` | `number` | `3` | Maximum attempts before the job is moved to the DLQ |
67
+ | `backoff` | `'fixed' \| 'linear' \| 'exponential'` | `'exponential'` | Delay strategy between retries |
68
+ | `baseDelay` | `number` | `1000` | Base delay in milliseconds |
69
+ | `timeout` | `number` | none | Per-attempt timeout in ms — hanging attempts throw `TimeoutError` |
70
+ | `jitter` | `boolean` | `false` | Adds random delay (up to 1× baseDelay) to prevent thundering herd |
71
+ | `dlq` | `'memory' \| 'file' \| 'redis' \| DLQBackend` | `'memory'` | Dead letter queue backend |
72
+ | `dlqFilePath` | `string` | `'./job-retry-dlq.json'` | File path for the file backend |
73
+ | `dlqRedisClient` | `Redis` | — | ioredis client instance for the Redis backend |
74
+ | `onRetry` | `(error, attempt) => void` | — | Called after each failed attempt except the last |
75
+ | `onFailure` | `(job: DLQEntry) => void` | — | Called when the job is moved to the DLQ |
76
+ | `onSuccess` | `(result, attempts) => void` | — | Called on success when retries were needed |
77
+
78
+ ---
79
+
80
+ ## Backoff strategies
81
+
82
+ **Fixed** — waits `baseDelay` every attempt.
83
+
84
+ **Linear** — waits `baseDelay × attempt` (1s, 2s, 3s, …).
85
+
86
+ **Exponential** — waits `baseDelay × 2^(attempt−1)` (1s, 2s, 4s, 8s, …).
87
+
88
+ **Jitter** — adds `random(0, delay)` to the computed delay. Prevents multiple jobs from retrying at the exact same instant after a shared outage (thundering herd).
89
+
90
+ ---
91
+
92
+ ## Dead letter queue backends
93
+
94
+ ### Memory (default)
95
+
96
+ ```ts
97
+ new JobRetry({ dlq: 'memory' })
98
+ ```
99
+
100
+ Stored in an in-process array. Lost on restart. Good for development and testing.
101
+
102
+ ### File
103
+
104
+ ```ts
105
+ new JobRetry({
106
+ dlq: 'file',
107
+ dlqFilePath: './failed-jobs.json',
108
+ })
109
+ ```
110
+
111
+ Persisted to a JSON file on disk. Survives restarts. Good for single-server apps.
112
+
113
+ ### Redis
114
+
115
+ ```ts
116
+ import Redis from 'ioredis';
117
+
118
+ new JobRetry({
119
+ dlq: 'redis',
120
+ dlqRedisClient: new Redis(),
121
+ })
122
+ ```
123
+
124
+ Stored as Redis hashes with a list for ordering. Shared across multiple servers, survives restarts. Production-ready.
125
+
126
+ ### Custom backend
127
+
128
+ Implement the `DLQBackend` interface and pass the instance directly:
129
+
130
+ ```ts
131
+ import type { DLQBackend, DLQEntry } from 'job-retry';
132
+
133
+ class MyDLQ implements DLQBackend {
134
+ async push(entry: DLQEntry): Promise<void> { /* ... */ }
135
+ async getAll(): Promise<DLQEntry[]> { /* ... */ }
136
+ async get(id: string): Promise<DLQEntry | null> { /* ... */ }
137
+ async retry(id: string, runner: JobRetry): Promise<unknown> { /* ... */ }
138
+ async remove(id: string): Promise<void> { /* ... */ }
139
+ async clear(): Promise<void> { /* ... */ }
140
+ async size(): Promise<number> { /* ... */ }
141
+ }
142
+
143
+ new JobRetry({ dlq: new MyDLQ() })
144
+ ```
145
+
146
+ ---
147
+
148
+ ## DLQ API
149
+
150
+ | Method | Description |
151
+ |---|---|
152
+ | `dlq.getAll()` | Returns all entries in the queue |
153
+ | `dlq.get(id)` | Returns a single entry by ID, or null |
154
+ | `dlq.retry(id, runner)` | Re-runs the original function and removes the entry on success |
155
+ | `dlq.remove(id)` | Deletes an entry from the queue |
156
+ | `dlq.clear()` | Empties the entire queue |
157
+ | `dlq.size()` | Returns the number of entries |
158
+
159
+ ---
160
+
161
+ ## Error types
162
+
163
+ ```ts
164
+ import { MaxAttemptsExceededError, TimeoutError } from 'job-retry';
165
+
166
+ try {
167
+ await runner.run('job', fn);
168
+ } catch (err) {
169
+ if (err instanceof MaxAttemptsExceededError) {
170
+ console.log(`Failed after ${err.attempts} attempts`);
171
+ // err.cause holds the last underlying error
172
+ }
173
+ }
174
+ ```
175
+
176
+ `TimeoutError` is thrown internally when a per-attempt timeout fires. It becomes the `cause` on `MaxAttemptsExceededError`.
177
+
178
+ ---
179
+
180
+ ## TypeScript
181
+
182
+ Full types ship with the package — no `@types/job-retry` needed.
183
+
184
+ ```ts
185
+ import type { RetryOptions, DLQEntry, DLQBackend, BackoffStrategy } from 'job-retry';
186
+ ```
187
+
188
+ ---
189
+
190
+ ## License
191
+
192
+ MIT
@@ -0,0 +1,94 @@
1
+ import * as ioredis from 'ioredis';
2
+ import { Redis } from 'ioredis';
3
+
4
+ type BackoffStrategy = 'fixed' | 'linear' | 'exponential';
5
+ interface DLQEntry {
6
+ id: string;
7
+ name: string;
8
+ error: string;
9
+ timestamp: number;
10
+ attempts: number;
11
+ payload?: unknown;
12
+ }
13
+ interface DLQBackend {
14
+ push(entry: DLQEntry): Promise<void>;
15
+ getAll(): Promise<DLQEntry[]>;
16
+ get(id: string): Promise<DLQEntry | null>;
17
+ retry(id: string, runner: JobRetry): Promise<unknown>;
18
+ remove(id: string): Promise<void>;
19
+ clear(): Promise<void>;
20
+ size(): Promise<number>;
21
+ }
22
+ type DLQType = 'memory' | 'file' | 'redis';
23
+ interface RetryOptions {
24
+ attempts?: number;
25
+ backoff?: BackoffStrategy;
26
+ baseDelay?: number;
27
+ timeout?: number;
28
+ jitter?: boolean;
29
+ dlq?: DLQType | DLQBackend;
30
+ dlqFilePath?: string;
31
+ dlqRedisClient?: ioredis.Redis;
32
+ onRetry?: (error: unknown, attempt: number) => void;
33
+ onFailure?: (job: DLQEntry) => void;
34
+ onSuccess?: (result: unknown, attempts: number) => void;
35
+ }
36
+
37
+ declare class JobRetry {
38
+ private readonly opts;
39
+ readonly dlq: DLQBackend;
40
+ private replayFns;
41
+ constructor(opts?: RetryOptions);
42
+ run<T>(name: string, fn: () => Promise<T>): Promise<T>;
43
+ replayEntry(entry: DLQEntry): Promise<unknown>;
44
+ private resolveDLQ;
45
+ }
46
+
47
+ declare class MemoryDLQ implements DLQBackend {
48
+ private entries;
49
+ push(entry: DLQEntry): Promise<void>;
50
+ getAll(): Promise<DLQEntry[]>;
51
+ get(id: string): Promise<DLQEntry | null>;
52
+ retry(id: string, runner: JobRetry): Promise<unknown>;
53
+ remove(id: string): Promise<void>;
54
+ clear(): Promise<void>;
55
+ size(): Promise<number>;
56
+ }
57
+
58
+ declare class FileDLQ implements DLQBackend {
59
+ private filePath;
60
+ constructor(filePath?: string);
61
+ private read;
62
+ private write;
63
+ push(entry: DLQEntry): Promise<void>;
64
+ getAll(): Promise<DLQEntry[]>;
65
+ get(id: string): Promise<DLQEntry | null>;
66
+ retry(id: string, runner: JobRetry): Promise<unknown>;
67
+ remove(id: string): Promise<void>;
68
+ clear(): Promise<void>;
69
+ size(): Promise<number>;
70
+ }
71
+
72
+ declare class RedisDLQ implements DLQBackend {
73
+ private readonly redis;
74
+ constructor(redis: Redis);
75
+ push(entry: DLQEntry): Promise<void>;
76
+ getAll(): Promise<DLQEntry[]>;
77
+ get(id: string): Promise<DLQEntry | null>;
78
+ retry(id: string, runner: JobRetry): Promise<unknown>;
79
+ remove(id: string): Promise<void>;
80
+ clear(): Promise<void>;
81
+ size(): Promise<number>;
82
+ private getById;
83
+ }
84
+
85
+ declare class MaxAttemptsExceededError extends Error {
86
+ readonly attempts: number;
87
+ constructor(attempts: number, cause?: unknown);
88
+ }
89
+ declare class TimeoutError extends Error {
90
+ readonly timeoutMs: number;
91
+ constructor(timeoutMs: number);
92
+ }
93
+
94
+ export { type BackoffStrategy, type DLQBackend, type DLQEntry, type DLQType, FileDLQ, JobRetry, MaxAttemptsExceededError, MemoryDLQ, RedisDLQ, type RetryOptions, TimeoutError };
@@ -0,0 +1,94 @@
1
+ import * as ioredis from 'ioredis';
2
+ import { Redis } from 'ioredis';
3
+
4
+ type BackoffStrategy = 'fixed' | 'linear' | 'exponential';
5
+ interface DLQEntry {
6
+ id: string;
7
+ name: string;
8
+ error: string;
9
+ timestamp: number;
10
+ attempts: number;
11
+ payload?: unknown;
12
+ }
13
+ interface DLQBackend {
14
+ push(entry: DLQEntry): Promise<void>;
15
+ getAll(): Promise<DLQEntry[]>;
16
+ get(id: string): Promise<DLQEntry | null>;
17
+ retry(id: string, runner: JobRetry): Promise<unknown>;
18
+ remove(id: string): Promise<void>;
19
+ clear(): Promise<void>;
20
+ size(): Promise<number>;
21
+ }
22
+ type DLQType = 'memory' | 'file' | 'redis';
23
+ interface RetryOptions {
24
+ attempts?: number;
25
+ backoff?: BackoffStrategy;
26
+ baseDelay?: number;
27
+ timeout?: number;
28
+ jitter?: boolean;
29
+ dlq?: DLQType | DLQBackend;
30
+ dlqFilePath?: string;
31
+ dlqRedisClient?: ioredis.Redis;
32
+ onRetry?: (error: unknown, attempt: number) => void;
33
+ onFailure?: (job: DLQEntry) => void;
34
+ onSuccess?: (result: unknown, attempts: number) => void;
35
+ }
36
+
37
+ declare class JobRetry {
38
+ private readonly opts;
39
+ readonly dlq: DLQBackend;
40
+ private replayFns;
41
+ constructor(opts?: RetryOptions);
42
+ run<T>(name: string, fn: () => Promise<T>): Promise<T>;
43
+ replayEntry(entry: DLQEntry): Promise<unknown>;
44
+ private resolveDLQ;
45
+ }
46
+
47
+ declare class MemoryDLQ implements DLQBackend {
48
+ private entries;
49
+ push(entry: DLQEntry): Promise<void>;
50
+ getAll(): Promise<DLQEntry[]>;
51
+ get(id: string): Promise<DLQEntry | null>;
52
+ retry(id: string, runner: JobRetry): Promise<unknown>;
53
+ remove(id: string): Promise<void>;
54
+ clear(): Promise<void>;
55
+ size(): Promise<number>;
56
+ }
57
+
58
+ declare class FileDLQ implements DLQBackend {
59
+ private filePath;
60
+ constructor(filePath?: string);
61
+ private read;
62
+ private write;
63
+ push(entry: DLQEntry): Promise<void>;
64
+ getAll(): Promise<DLQEntry[]>;
65
+ get(id: string): Promise<DLQEntry | null>;
66
+ retry(id: string, runner: JobRetry): Promise<unknown>;
67
+ remove(id: string): Promise<void>;
68
+ clear(): Promise<void>;
69
+ size(): Promise<number>;
70
+ }
71
+
72
+ declare class RedisDLQ implements DLQBackend {
73
+ private readonly redis;
74
+ constructor(redis: Redis);
75
+ push(entry: DLQEntry): Promise<void>;
76
+ getAll(): Promise<DLQEntry[]>;
77
+ get(id: string): Promise<DLQEntry | null>;
78
+ retry(id: string, runner: JobRetry): Promise<unknown>;
79
+ remove(id: string): Promise<void>;
80
+ clear(): Promise<void>;
81
+ size(): Promise<number>;
82
+ private getById;
83
+ }
84
+
85
+ declare class MaxAttemptsExceededError extends Error {
86
+ readonly attempts: number;
87
+ constructor(attempts: number, cause?: unknown);
88
+ }
89
+ declare class TimeoutError extends Error {
90
+ readonly timeoutMs: number;
91
+ constructor(timeoutMs: number);
92
+ }
93
+
94
+ export { type BackoffStrategy, type DLQBackend, type DLQEntry, type DLQType, FileDLQ, JobRetry, MaxAttemptsExceededError, MemoryDLQ, RedisDLQ, type RetryOptions, TimeoutError };
package/dist/index.js ADDED
@@ -0,0 +1,322 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ FileDLQ: () => FileDLQ,
34
+ JobRetry: () => JobRetry,
35
+ MaxAttemptsExceededError: () => MaxAttemptsExceededError,
36
+ MemoryDLQ: () => MemoryDLQ,
37
+ RedisDLQ: () => RedisDLQ,
38
+ TimeoutError: () => TimeoutError
39
+ });
40
+ module.exports = __toCommonJS(index_exports);
41
+
42
+ // src/JobRetry.ts
43
+ var import_crypto = require("crypto");
44
+
45
+ // src/backoff.ts
46
+ function calculateDelay(attempt, strategy, baseDelay, jitter) {
47
+ let delay;
48
+ switch (strategy) {
49
+ case "fixed":
50
+ delay = baseDelay;
51
+ break;
52
+ case "linear":
53
+ delay = baseDelay * attempt;
54
+ break;
55
+ case "exponential":
56
+ delay = baseDelay * Math.pow(2, attempt - 1);
57
+ break;
58
+ }
59
+ if (jitter) {
60
+ delay = delay + Math.random() * delay;
61
+ }
62
+ return Math.round(delay);
63
+ }
64
+
65
+ // src/errors.ts
66
+ var MaxAttemptsExceededError = class extends Error {
67
+ constructor(attempts, cause) {
68
+ super(`Job failed after ${attempts} attempt${attempts === 1 ? "" : "s"}`);
69
+ this.name = "MaxAttemptsExceededError";
70
+ this.attempts = attempts;
71
+ if (cause instanceof Error) {
72
+ this.cause = cause;
73
+ }
74
+ }
75
+ };
76
+ var TimeoutError = class extends Error {
77
+ constructor(timeoutMs) {
78
+ super(`Job timed out after ${timeoutMs}ms`);
79
+ this.name = "TimeoutError";
80
+ this.timeoutMs = timeoutMs;
81
+ }
82
+ };
83
+
84
+ // src/timeout.ts
85
+ function withTimeout(promise, ms) {
86
+ let timer;
87
+ const timeoutPromise = new Promise((_, reject) => {
88
+ timer = setTimeout(() => reject(new TimeoutError(ms)), ms);
89
+ });
90
+ return Promise.race([promise, timeoutPromise]).finally(() => {
91
+ clearTimeout(timer);
92
+ });
93
+ }
94
+
95
+ // src/dlq/MemoryDLQ.ts
96
+ var MemoryDLQ = class {
97
+ constructor() {
98
+ this.entries = /* @__PURE__ */ new Map();
99
+ }
100
+ async push(entry) {
101
+ this.entries.set(entry.id, entry);
102
+ }
103
+ async getAll() {
104
+ return Array.from(this.entries.values());
105
+ }
106
+ async get(id) {
107
+ return this.entries.get(id) ?? null;
108
+ }
109
+ async retry(id, runner) {
110
+ const entry = this.entries.get(id);
111
+ if (!entry) throw new Error(`DLQ entry not found: ${id}`);
112
+ this.entries.delete(id);
113
+ return runner.run(entry.name, () => runner.replayEntry(entry));
114
+ }
115
+ async remove(id) {
116
+ this.entries.delete(id);
117
+ }
118
+ async clear() {
119
+ this.entries.clear();
120
+ }
121
+ async size() {
122
+ return this.entries.size;
123
+ }
124
+ };
125
+
126
+ // src/dlq/FileDLQ.ts
127
+ var import_fs = __toESM(require("fs"));
128
+ var import_path = __toESM(require("path"));
129
+ var FileDLQ = class {
130
+ constructor(filePath = "./job-retry-dlq.json") {
131
+ this.filePath = import_path.default.resolve(filePath);
132
+ }
133
+ read() {
134
+ if (!import_fs.default.existsSync(this.filePath)) return [];
135
+ try {
136
+ return JSON.parse(import_fs.default.readFileSync(this.filePath, "utf8"));
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+ write(entries) {
142
+ import_fs.default.writeFileSync(this.filePath, JSON.stringify(entries, null, 2), "utf8");
143
+ }
144
+ async push(entry) {
145
+ const entries = this.read();
146
+ entries.push(entry);
147
+ this.write(entries);
148
+ }
149
+ async getAll() {
150
+ return this.read();
151
+ }
152
+ async get(id) {
153
+ return this.read().find((e) => e.id === id) ?? null;
154
+ }
155
+ async retry(id, runner) {
156
+ const entries = this.read();
157
+ const idx = entries.findIndex((e) => e.id === id);
158
+ if (idx === -1) throw new Error(`DLQ entry not found: ${id}`);
159
+ const [entry] = entries.splice(idx, 1);
160
+ this.write(entries);
161
+ return runner.run(entry.name, () => runner.replayEntry(entry));
162
+ }
163
+ async remove(id) {
164
+ const entries = this.read().filter((e) => e.id !== id);
165
+ this.write(entries);
166
+ }
167
+ async clear() {
168
+ this.write([]);
169
+ }
170
+ async size() {
171
+ return this.read().length;
172
+ }
173
+ };
174
+
175
+ // src/dlq/RedisDLQ.ts
176
+ var LIST_KEY = "job-retry:dlq:ids";
177
+ var HASH_PREFIX = "job-retry:dlq:entry:";
178
+ var RedisDLQ = class {
179
+ constructor(redis) {
180
+ this.redis = redis;
181
+ }
182
+ async push(entry) {
183
+ await Promise.all([
184
+ this.redis.hset(HASH_PREFIX + entry.id, entry),
185
+ this.redis.rpush(LIST_KEY, entry.id)
186
+ ]);
187
+ }
188
+ async getAll() {
189
+ const ids = await this.redis.lrange(LIST_KEY, 0, -1);
190
+ if (ids.length === 0) return [];
191
+ const entries = await Promise.all(ids.map((id) => this.getById(id)));
192
+ return entries.filter((e) => e !== null);
193
+ }
194
+ async get(id) {
195
+ return this.getById(id);
196
+ }
197
+ async retry(id, runner) {
198
+ const entry = await this.getById(id);
199
+ if (!entry) throw new Error(`DLQ entry not found: ${id}`);
200
+ await this.remove(id);
201
+ return runner.run(entry.name, () => runner.replayEntry(entry));
202
+ }
203
+ async remove(id) {
204
+ await Promise.all([
205
+ this.redis.del(HASH_PREFIX + id),
206
+ this.redis.lrem(LIST_KEY, 0, id)
207
+ ]);
208
+ }
209
+ async clear() {
210
+ const ids = await this.redis.lrange(LIST_KEY, 0, -1);
211
+ if (ids.length > 0) {
212
+ await Promise.all(ids.map((id) => this.redis.del(HASH_PREFIX + id)));
213
+ }
214
+ await this.redis.del(LIST_KEY);
215
+ }
216
+ async size() {
217
+ return this.redis.llen(LIST_KEY);
218
+ }
219
+ async getById(id) {
220
+ const raw = await this.redis.hgetall(HASH_PREFIX + id);
221
+ if (!raw || Object.keys(raw).length === 0) return null;
222
+ return {
223
+ id: raw["id"],
224
+ name: raw["name"],
225
+ error: raw["error"],
226
+ timestamp: Number(raw["timestamp"]),
227
+ attempts: Number(raw["attempts"]),
228
+ payload: raw["payload"] ? JSON.parse(raw["payload"]) : void 0
229
+ };
230
+ }
231
+ };
232
+
233
+ // src/JobRetry.ts
234
+ var DEFAULTS = {
235
+ attempts: 3,
236
+ backoff: "exponential",
237
+ baseDelay: 1e3,
238
+ jitter: false
239
+ };
240
+ var JobRetry = class {
241
+ constructor(opts = {}) {
242
+ // Stores the fn for replay when a DLQ entry is retried
243
+ this.replayFns = /* @__PURE__ */ new Map();
244
+ this.opts = { ...DEFAULTS, ...opts };
245
+ this.dlq = this.resolveDLQ(opts);
246
+ }
247
+ async run(name, fn) {
248
+ const maxAttempts = this.opts.attempts ?? DEFAULTS.attempts;
249
+ let lastError;
250
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
251
+ try {
252
+ const promise = this.opts.timeout ? withTimeout(fn(), this.opts.timeout) : fn();
253
+ const result = await promise;
254
+ if (attempt > 1) {
255
+ this.opts.onSuccess?.(result, attempt);
256
+ }
257
+ return result;
258
+ } catch (err) {
259
+ lastError = err;
260
+ if (attempt < maxAttempts) {
261
+ this.opts.onRetry?.(err, attempt);
262
+ const delay = calculateDelay(
263
+ attempt,
264
+ this.opts.backoff ?? DEFAULTS.backoff,
265
+ this.opts.baseDelay ?? DEFAULTS.baseDelay,
266
+ this.opts.jitter ?? DEFAULTS.jitter
267
+ );
268
+ await sleep(delay);
269
+ }
270
+ }
271
+ }
272
+ const entry = {
273
+ id: (0, import_crypto.randomUUID)(),
274
+ name,
275
+ error: errorMessage(lastError),
276
+ timestamp: Date.now(),
277
+ attempts: maxAttempts
278
+ };
279
+ this.replayFns.set(entry.id, fn);
280
+ await this.dlq.push(entry);
281
+ this.opts.onFailure?.(entry);
282
+ throw new MaxAttemptsExceededError(maxAttempts, lastError instanceof Error ? lastError : void 0);
283
+ }
284
+ replayEntry(entry) {
285
+ const fn = this.replayFns.get(entry.id);
286
+ if (!fn) {
287
+ throw new Error(
288
+ `No replay function found for job "${entry.name}" (id: ${entry.id}). Replay is only available within the same process that originally ran the job.`
289
+ );
290
+ }
291
+ this.replayFns.delete(entry.id);
292
+ return fn();
293
+ }
294
+ resolveDLQ(opts) {
295
+ const backend = opts.dlq;
296
+ if (!backend || backend === "memory") return new MemoryDLQ();
297
+ if (backend === "file") return new FileDLQ(opts.dlqFilePath);
298
+ if (backend === "redis") {
299
+ if (!opts.dlqRedisClient) {
300
+ throw new Error('dlqRedisClient is required when dlq is "redis"');
301
+ }
302
+ return new RedisDLQ(opts.dlqRedisClient);
303
+ }
304
+ return backend;
305
+ }
306
+ };
307
+ function sleep(ms) {
308
+ return new Promise((resolve) => setTimeout(resolve, ms));
309
+ }
310
+ function errorMessage(err) {
311
+ if (err instanceof Error) return err.message;
312
+ return String(err);
313
+ }
314
+ // Annotate the CommonJS export names for ESM import in node:
315
+ 0 && (module.exports = {
316
+ FileDLQ,
317
+ JobRetry,
318
+ MaxAttemptsExceededError,
319
+ MemoryDLQ,
320
+ RedisDLQ,
321
+ TimeoutError
322
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,280 @@
1
+ // src/JobRetry.ts
2
+ import { randomUUID } from "crypto";
3
+
4
+ // src/backoff.ts
5
+ function calculateDelay(attempt, strategy, baseDelay, jitter) {
6
+ let delay;
7
+ switch (strategy) {
8
+ case "fixed":
9
+ delay = baseDelay;
10
+ break;
11
+ case "linear":
12
+ delay = baseDelay * attempt;
13
+ break;
14
+ case "exponential":
15
+ delay = baseDelay * Math.pow(2, attempt - 1);
16
+ break;
17
+ }
18
+ if (jitter) {
19
+ delay = delay + Math.random() * delay;
20
+ }
21
+ return Math.round(delay);
22
+ }
23
+
24
+ // src/errors.ts
25
+ var MaxAttemptsExceededError = class extends Error {
26
+ constructor(attempts, cause) {
27
+ super(`Job failed after ${attempts} attempt${attempts === 1 ? "" : "s"}`);
28
+ this.name = "MaxAttemptsExceededError";
29
+ this.attempts = attempts;
30
+ if (cause instanceof Error) {
31
+ this.cause = cause;
32
+ }
33
+ }
34
+ };
35
+ var TimeoutError = class extends Error {
36
+ constructor(timeoutMs) {
37
+ super(`Job timed out after ${timeoutMs}ms`);
38
+ this.name = "TimeoutError";
39
+ this.timeoutMs = timeoutMs;
40
+ }
41
+ };
42
+
43
+ // src/timeout.ts
44
+ function withTimeout(promise, ms) {
45
+ let timer;
46
+ const timeoutPromise = new Promise((_, reject) => {
47
+ timer = setTimeout(() => reject(new TimeoutError(ms)), ms);
48
+ });
49
+ return Promise.race([promise, timeoutPromise]).finally(() => {
50
+ clearTimeout(timer);
51
+ });
52
+ }
53
+
54
+ // src/dlq/MemoryDLQ.ts
55
+ var MemoryDLQ = class {
56
+ constructor() {
57
+ this.entries = /* @__PURE__ */ new Map();
58
+ }
59
+ async push(entry) {
60
+ this.entries.set(entry.id, entry);
61
+ }
62
+ async getAll() {
63
+ return Array.from(this.entries.values());
64
+ }
65
+ async get(id) {
66
+ return this.entries.get(id) ?? null;
67
+ }
68
+ async retry(id, runner) {
69
+ const entry = this.entries.get(id);
70
+ if (!entry) throw new Error(`DLQ entry not found: ${id}`);
71
+ this.entries.delete(id);
72
+ return runner.run(entry.name, () => runner.replayEntry(entry));
73
+ }
74
+ async remove(id) {
75
+ this.entries.delete(id);
76
+ }
77
+ async clear() {
78
+ this.entries.clear();
79
+ }
80
+ async size() {
81
+ return this.entries.size;
82
+ }
83
+ };
84
+
85
+ // src/dlq/FileDLQ.ts
86
+ import fs from "fs";
87
+ import path from "path";
88
+ var FileDLQ = class {
89
+ constructor(filePath = "./job-retry-dlq.json") {
90
+ this.filePath = path.resolve(filePath);
91
+ }
92
+ read() {
93
+ if (!fs.existsSync(this.filePath)) return [];
94
+ try {
95
+ return JSON.parse(fs.readFileSync(this.filePath, "utf8"));
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+ write(entries) {
101
+ fs.writeFileSync(this.filePath, JSON.stringify(entries, null, 2), "utf8");
102
+ }
103
+ async push(entry) {
104
+ const entries = this.read();
105
+ entries.push(entry);
106
+ this.write(entries);
107
+ }
108
+ async getAll() {
109
+ return this.read();
110
+ }
111
+ async get(id) {
112
+ return this.read().find((e) => e.id === id) ?? null;
113
+ }
114
+ async retry(id, runner) {
115
+ const entries = this.read();
116
+ const idx = entries.findIndex((e) => e.id === id);
117
+ if (idx === -1) throw new Error(`DLQ entry not found: ${id}`);
118
+ const [entry] = entries.splice(idx, 1);
119
+ this.write(entries);
120
+ return runner.run(entry.name, () => runner.replayEntry(entry));
121
+ }
122
+ async remove(id) {
123
+ const entries = this.read().filter((e) => e.id !== id);
124
+ this.write(entries);
125
+ }
126
+ async clear() {
127
+ this.write([]);
128
+ }
129
+ async size() {
130
+ return this.read().length;
131
+ }
132
+ };
133
+
134
+ // src/dlq/RedisDLQ.ts
135
+ var LIST_KEY = "job-retry:dlq:ids";
136
+ var HASH_PREFIX = "job-retry:dlq:entry:";
137
+ var RedisDLQ = class {
138
+ constructor(redis) {
139
+ this.redis = redis;
140
+ }
141
+ async push(entry) {
142
+ await Promise.all([
143
+ this.redis.hset(HASH_PREFIX + entry.id, entry),
144
+ this.redis.rpush(LIST_KEY, entry.id)
145
+ ]);
146
+ }
147
+ async getAll() {
148
+ const ids = await this.redis.lrange(LIST_KEY, 0, -1);
149
+ if (ids.length === 0) return [];
150
+ const entries = await Promise.all(ids.map((id) => this.getById(id)));
151
+ return entries.filter((e) => e !== null);
152
+ }
153
+ async get(id) {
154
+ return this.getById(id);
155
+ }
156
+ async retry(id, runner) {
157
+ const entry = await this.getById(id);
158
+ if (!entry) throw new Error(`DLQ entry not found: ${id}`);
159
+ await this.remove(id);
160
+ return runner.run(entry.name, () => runner.replayEntry(entry));
161
+ }
162
+ async remove(id) {
163
+ await Promise.all([
164
+ this.redis.del(HASH_PREFIX + id),
165
+ this.redis.lrem(LIST_KEY, 0, id)
166
+ ]);
167
+ }
168
+ async clear() {
169
+ const ids = await this.redis.lrange(LIST_KEY, 0, -1);
170
+ if (ids.length > 0) {
171
+ await Promise.all(ids.map((id) => this.redis.del(HASH_PREFIX + id)));
172
+ }
173
+ await this.redis.del(LIST_KEY);
174
+ }
175
+ async size() {
176
+ return this.redis.llen(LIST_KEY);
177
+ }
178
+ async getById(id) {
179
+ const raw = await this.redis.hgetall(HASH_PREFIX + id);
180
+ if (!raw || Object.keys(raw).length === 0) return null;
181
+ return {
182
+ id: raw["id"],
183
+ name: raw["name"],
184
+ error: raw["error"],
185
+ timestamp: Number(raw["timestamp"]),
186
+ attempts: Number(raw["attempts"]),
187
+ payload: raw["payload"] ? JSON.parse(raw["payload"]) : void 0
188
+ };
189
+ }
190
+ };
191
+
192
+ // src/JobRetry.ts
193
+ var DEFAULTS = {
194
+ attempts: 3,
195
+ backoff: "exponential",
196
+ baseDelay: 1e3,
197
+ jitter: false
198
+ };
199
+ var JobRetry = class {
200
+ constructor(opts = {}) {
201
+ // Stores the fn for replay when a DLQ entry is retried
202
+ this.replayFns = /* @__PURE__ */ new Map();
203
+ this.opts = { ...DEFAULTS, ...opts };
204
+ this.dlq = this.resolveDLQ(opts);
205
+ }
206
+ async run(name, fn) {
207
+ const maxAttempts = this.opts.attempts ?? DEFAULTS.attempts;
208
+ let lastError;
209
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
210
+ try {
211
+ const promise = this.opts.timeout ? withTimeout(fn(), this.opts.timeout) : fn();
212
+ const result = await promise;
213
+ if (attempt > 1) {
214
+ this.opts.onSuccess?.(result, attempt);
215
+ }
216
+ return result;
217
+ } catch (err) {
218
+ lastError = err;
219
+ if (attempt < maxAttempts) {
220
+ this.opts.onRetry?.(err, attempt);
221
+ const delay = calculateDelay(
222
+ attempt,
223
+ this.opts.backoff ?? DEFAULTS.backoff,
224
+ this.opts.baseDelay ?? DEFAULTS.baseDelay,
225
+ this.opts.jitter ?? DEFAULTS.jitter
226
+ );
227
+ await sleep(delay);
228
+ }
229
+ }
230
+ }
231
+ const entry = {
232
+ id: randomUUID(),
233
+ name,
234
+ error: errorMessage(lastError),
235
+ timestamp: Date.now(),
236
+ attempts: maxAttempts
237
+ };
238
+ this.replayFns.set(entry.id, fn);
239
+ await this.dlq.push(entry);
240
+ this.opts.onFailure?.(entry);
241
+ throw new MaxAttemptsExceededError(maxAttempts, lastError instanceof Error ? lastError : void 0);
242
+ }
243
+ replayEntry(entry) {
244
+ const fn = this.replayFns.get(entry.id);
245
+ if (!fn) {
246
+ throw new Error(
247
+ `No replay function found for job "${entry.name}" (id: ${entry.id}). Replay is only available within the same process that originally ran the job.`
248
+ );
249
+ }
250
+ this.replayFns.delete(entry.id);
251
+ return fn();
252
+ }
253
+ resolveDLQ(opts) {
254
+ const backend = opts.dlq;
255
+ if (!backend || backend === "memory") return new MemoryDLQ();
256
+ if (backend === "file") return new FileDLQ(opts.dlqFilePath);
257
+ if (backend === "redis") {
258
+ if (!opts.dlqRedisClient) {
259
+ throw new Error('dlqRedisClient is required when dlq is "redis"');
260
+ }
261
+ return new RedisDLQ(opts.dlqRedisClient);
262
+ }
263
+ return backend;
264
+ }
265
+ };
266
+ function sleep(ms) {
267
+ return new Promise((resolve) => setTimeout(resolve, ms));
268
+ }
269
+ function errorMessage(err) {
270
+ if (err instanceof Error) return err.message;
271
+ return String(err);
272
+ }
273
+ export {
274
+ FileDLQ,
275
+ JobRetry,
276
+ MaxAttemptsExceededError,
277
+ MemoryDLQ,
278
+ RedisDLQ,
279
+ TimeoutError
280
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "job-retry",
3
+ "version": "0.1.0",
4
+ "description": "Retry async functions with exponential backoff, per-attempt timeout, and a dead letter queue",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./dist/index.d.mts",
12
+ "default": "./dist/index.mjs"
13
+ },
14
+ "require": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ }
19
+ },
20
+ "files": ["dist", "README.md"],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format esm,cjs --dts",
23
+ "test": "jest",
24
+ "prepublishOnly": "npm run build && npm test"
25
+ },
26
+ "keywords": [
27
+ "retry",
28
+ "backoff",
29
+ "exponential-backoff",
30
+ "dead-letter-queue",
31
+ "dlq",
32
+ "job",
33
+ "queue",
34
+ "timeout",
35
+ "async"
36
+ ],
37
+ "author": "Rajat Thakur",
38
+ "license": "MIT",
39
+ "engines": {
40
+ "node": ">=16.0.0"
41
+ },
42
+ "peerDependencies": {
43
+ "ioredis": ">=5.0.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "ioredis": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "devDependencies": {
51
+ "@types/jest": "^29.0.0",
52
+ "@types/node": "^20.0.0",
53
+ "ioredis": "^5.0.0",
54
+ "ioredis-mock": "^8.0.0",
55
+ "jest": "^29.0.0",
56
+ "ts-jest": "^29.0.0",
57
+ "tsup": "^8.0.0",
58
+ "typescript": "^5.0.0"
59
+ }
60
+ }