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 +192 -0
- package/dist/index.d.mts +94 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +322 -0
- package/dist/index.mjs +280 -0
- package/package.json +60 -0
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
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|