@anishhs/retryq 1.0.0 → 1.2.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 +856 -167
- package/dist/cjs/index.d.ts +9 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +13 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/manager.d.ts +139 -0
- package/dist/cjs/manager.d.ts.map +1 -0
- package/dist/cjs/manager.js +431 -0
- package/dist/cjs/manager.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/types.d.ts +237 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +3 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/utils.d.ts +43 -0
- package/dist/cjs/utils.d.ts.map +1 -0
- package/dist/cjs/utils.js +71 -0
- package/dist/cjs/utils.js.map +1 -0
- package/dist/cjs/validation.d.ts +37 -0
- package/dist/cjs/validation.d.ts.map +1 -0
- package/dist/cjs/validation.js +69 -0
- package/dist/cjs/validation.js.map +1 -0
- package/dist/esm/index.d.ts +9 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +8 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/manager.d.ts +139 -0
- package/dist/esm/manager.d.ts.map +1 -0
- package/dist/esm/manager.js +427 -0
- package/dist/esm/manager.js.map +1 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/types.d.ts +237 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +2 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/utils.d.ts +43 -0
- package/dist/esm/utils.d.ts.map +1 -0
- package/dist/esm/utils.js +64 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/esm/validation.d.ts +37 -0
- package/dist/esm/validation.d.ts.map +1 -0
- package/dist/esm/validation.js +65 -0
- package/dist/esm/validation.js.map +1 -0
- package/package.json +29 -8
- package/dist/index.d.ts +0 -76
- package/dist/index.js +0 -165
- package/dist/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,254 +1,943 @@
|
|
|
1
|
-
|
|
1
|
+
# @anishhs/retryq
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A production-ready, zero-dependency retry queue manager for Node.js with support for concurrent job execution, priorities, exponential backoff, jitter, and **force cancellation**.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
- **Priority queueing**: higher priority jobs run first
|
|
9
|
-
- **Cancellation**: cancel pending retries and sleep waits
|
|
10
|
-
- **Introspection**: list jobs, find by id/label
|
|
11
|
-
- **TypeScript** ready with bundled types
|
|
5
|
+
[](https://www.npmjs.com/package/@anishhs/retryq)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://opensource.org/licenses/ISC)
|
|
12
8
|
|
|
9
|
+
## Features
|
|
13
10
|
|
|
14
|
-
|
|
11
|
+
- ✅ **Concurrency control** - Limit concurrent job execution
|
|
12
|
+
- ✅ **Priority queue** - Higher priority jobs execute first
|
|
13
|
+
- ✅ **Exponential backoff** with configurable delay, multiplier, `maxDelay`, and jitter
|
|
14
|
+
- ✅ **Lifecycle events & hooks** - `retry`/`success`/`failure`/`cancel`/`idle` events + per-job callbacks
|
|
15
|
+
- ✅ **Conditional retries** - `shouldRetry(error, attempt)` predicate to skip non-retryable errors
|
|
16
|
+
- ✅ **Force cancellation** - Abort in-progress jobs with AbortController
|
|
17
|
+
- ✅ **Cooperative cancellation** - Graceful job termination
|
|
18
|
+
- ✅ **Real time limits** - `maxTime` (and `attemptTimeout`) actively abort in-flight attempts
|
|
19
|
+
- ✅ **Queue draining** - `onIdle()` / `drain()` to await all work
|
|
20
|
+
- ✅ **Memory safe** - Bounded job history with LRU eviction
|
|
21
|
+
- ✅ **Job introspection** - List, find, and track jobs by ID or label
|
|
22
|
+
- ✅ **TypeScript** - Generic, fully-typed API with bundled declarations
|
|
23
|
+
- ✅ **Dual ESM + CJS** - Ships both module formats with an `exports` map
|
|
24
|
+
- ✅ **Zero dependencies** - Minimal footprint, no external packages
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
15
27
|
|
|
16
28
|
```bash
|
|
17
29
|
npm install @anishhs/retryq
|
|
18
30
|
```
|
|
19
31
|
|
|
20
|
-
Node.js 16+
|
|
32
|
+
**Requirements**: Node.js 16+
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { RetryQManager } from '@anishhs/retryq';
|
|
38
|
+
|
|
39
|
+
// Create manager with 3 concurrent jobs max
|
|
40
|
+
const retryQ = new RetryQManager({ maxConcurrent: 3 });
|
|
41
|
+
|
|
42
|
+
// Create a job with retry logic
|
|
43
|
+
const job = retryQ.createJob(async (signal) => {
|
|
44
|
+
// Your async operation here
|
|
45
|
+
const response = await fetch('https://api.example.com/data', { signal });
|
|
46
|
+
return response.json();
|
|
47
|
+
}, {
|
|
48
|
+
retries: 5, // Retry up to 5 times
|
|
49
|
+
delay: 1000, // Initial delay 1s
|
|
50
|
+
backoff: 2, // Double delay each retry
|
|
51
|
+
jitter: 0.1, // ±10% randomization
|
|
52
|
+
maxTime: 30000, // Total timeout 30s
|
|
53
|
+
priority: 10, // Higher priority = runs sooner
|
|
54
|
+
label: 'fetch-data' // Human-readable identifier
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Wait for result
|
|
58
|
+
job.promise
|
|
59
|
+
.then(data => console.log('Success:', data))
|
|
60
|
+
.catch(err => console.error('Failed:', err));
|
|
61
|
+
|
|
62
|
+
// Cancel if needed
|
|
63
|
+
job.cancel(true); // Force abort in-progress execution
|
|
64
|
+
```
|
|
21
65
|
|
|
66
|
+
## Table of Contents
|
|
22
67
|
|
|
23
|
-
|
|
68
|
+
- [Core Concepts](#core-concepts)
|
|
69
|
+
- [API Reference](#api-reference)
|
|
70
|
+
- [Cancellation Modes](#cancellation-modes)
|
|
71
|
+
- [Usage Examples](#usage-examples)
|
|
72
|
+
- [Configuration Options](#configuration-options)
|
|
73
|
+
- [Best Practices](#best-practices)
|
|
74
|
+
- [Migration Guide](#migration-guide)
|
|
75
|
+
- [Changelog](#changelog)
|
|
24
76
|
|
|
25
|
-
|
|
26
|
-
import { RetryQManager } from "@anishhs/retryq";
|
|
77
|
+
---
|
|
27
78
|
|
|
28
|
-
|
|
29
|
-
const retryQ = new RetryQManager(3);
|
|
79
|
+
## Core Concepts
|
|
30
80
|
|
|
31
|
-
|
|
32
|
-
async function flakyTask() {
|
|
33
|
-
// ... do something that may fail
|
|
34
|
-
}
|
|
81
|
+
### Job Lifecycle
|
|
35
82
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
83
|
+
```
|
|
84
|
+
pending → running → completed
|
|
85
|
+
→ failed
|
|
86
|
+
→ cancelled
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
1. **Pending**: Job queued, waiting for available slot
|
|
90
|
+
2. **Running**: Job executing with retries
|
|
91
|
+
3. **Completed**: Job succeeded
|
|
92
|
+
4. **Failed**: Job exhausted all retries
|
|
93
|
+
5. **Cancelled**: Job cancelled by user
|
|
94
|
+
|
|
95
|
+
### Retry Logic
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Attempt 1: Execute immediately
|
|
99
|
+
↓ (fails)
|
|
100
|
+
Attempt 2: Wait delay * backoff^0 = 1000ms
|
|
101
|
+
↓ (fails)
|
|
102
|
+
Attempt 3: Wait delay * backoff^1 = 2000ms
|
|
103
|
+
↓ (fails)
|
|
104
|
+
Attempt 4: Wait delay * backoff^2 = 4000ms
|
|
105
|
+
...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Each delay includes jitter: `delay ± (delay * jitter)`
|
|
109
|
+
|
|
110
|
+
### Priority Queue
|
|
111
|
+
|
|
112
|
+
Jobs with higher `priority` values execute first:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
retryQ.createJob(taskA, { priority: 1 }); // Runs last
|
|
116
|
+
retryQ.createJob(taskB, { priority: 5 }); // Runs second
|
|
117
|
+
retryQ.createJob(taskC, { priority: 10 }); // Runs first
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## API Reference
|
|
123
|
+
|
|
124
|
+
### RetryQManager
|
|
125
|
+
|
|
126
|
+
#### Constructor
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
new RetryQManager(config?: RetryQManagerConfig | number)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Parameters**:
|
|
133
|
+
- `config.maxConcurrent` - Maximum concurrent jobs (default: `Infinity`)
|
|
134
|
+
- `config.maxHistorySize` - Maximum jobs in history (default: `1000`)
|
|
135
|
+
|
|
136
|
+
**Legacy**: Accepts number for `maxConcurrent` (backwards compatible)
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// New style (recommended)
|
|
140
|
+
const retryQ = new RetryQManager({
|
|
141
|
+
maxConcurrent: 5,
|
|
142
|
+
maxHistorySize: 1000
|
|
44
143
|
});
|
|
45
144
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
.then((value) => console.log("completed", value))
|
|
49
|
-
.catch((err) => console.error("failed", err));
|
|
145
|
+
// Old style (still works)
|
|
146
|
+
const retryQ = new RetryQManager(5);
|
|
50
147
|
```
|
|
51
148
|
|
|
149
|
+
---
|
|
52
150
|
|
|
53
|
-
|
|
54
|
-
- Jobs are queued and sorted by `priority` (higher first).
|
|
55
|
-
- Up to `maxConcurrent` jobs can run simultaneously.
|
|
56
|
-
- Each job retries up to `retries` times with exponential backoff starting from `delay` and multiplying by `backoff`.
|
|
57
|
-
- A random jitter (±`jitter` fraction) is applied to each wait to avoid thundering-herd patterns.
|
|
58
|
-
- The total elapsed time per job is capped by `maxTime`.
|
|
59
|
-
- `job.promise` resolves with the function’s resolved value, or rejects with the last error.
|
|
151
|
+
#### createJob()
|
|
60
152
|
|
|
153
|
+
```typescript
|
|
154
|
+
createJob(
|
|
155
|
+
fn: (signal?: AbortSignal) => Promise<any>,
|
|
156
|
+
options?: RetryQJobOptions
|
|
157
|
+
): RetryQJob
|
|
158
|
+
```
|
|
61
159
|
|
|
62
|
-
|
|
160
|
+
**Parameters**:
|
|
161
|
+
- `fn` - Async function to execute
|
|
162
|
+
- `signal` - Optional AbortSignal for force cancellation
|
|
163
|
+
- `options` - Job configuration (see [Configuration](#configuration-options))
|
|
63
164
|
|
|
64
|
-
|
|
165
|
+
**Returns**: `RetryQJob` object
|
|
65
166
|
|
|
66
|
-
|
|
67
|
-
|
|
167
|
+
```typescript
|
|
168
|
+
const job = retryQ.createJob(async (signal) => {
|
|
169
|
+
// Check signal to support force cancellation
|
|
170
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
68
171
|
|
|
69
|
-
|
|
70
|
-
|
|
172
|
+
return await doWork();
|
|
173
|
+
}, {
|
|
174
|
+
retries: 3,
|
|
175
|
+
delay: 1000,
|
|
176
|
+
label: 'my-job'
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
#### cancelJob()
|
|
71
183
|
|
|
72
|
-
|
|
73
|
-
|
|
184
|
+
```typescript
|
|
185
|
+
cancelJob(id: string, force?: boolean): void
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Parameters**:
|
|
189
|
+
- `id` - Job ID to cancel
|
|
190
|
+
- `force` - Enable force cancellation (default: `false`)
|
|
74
191
|
|
|
75
|
-
|
|
76
|
-
|
|
192
|
+
```typescript
|
|
193
|
+
// Cooperative cancellation (default)
|
|
194
|
+
retryQ.cancelJob(job.id);
|
|
195
|
+
|
|
196
|
+
// Force cancellation (aborts via AbortSignal)
|
|
197
|
+
retryQ.cancelJob(job.id, true);
|
|
198
|
+
```
|
|
77
199
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
#### listJobs()
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
listJobs(): {
|
|
206
|
+
pending: JobSummary[];
|
|
207
|
+
running: JobSummary[];
|
|
208
|
+
failed: JobSummary[];
|
|
209
|
+
completed: JobSummary[];
|
|
86
210
|
}
|
|
87
211
|
```
|
|
88
212
|
|
|
89
|
-
|
|
90
|
-
Returns the `RetryQJob` if found in any state, otherwise `null`.
|
|
213
|
+
**Returns**: Snapshot of all jobs grouped by state
|
|
91
214
|
|
|
92
|
-
|
|
93
|
-
|
|
215
|
+
```typescript
|
|
216
|
+
const { pending, running, failed, completed } = retryQ.listJobs();
|
|
217
|
+
console.log(`${running.length} jobs currently executing`);
|
|
218
|
+
```
|
|
94
219
|
|
|
220
|
+
---
|
|
95
221
|
|
|
96
|
-
|
|
222
|
+
#### findJobById()
|
|
97
223
|
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
maxTime?: number; // total allowed time in ms, default 5000
|
|
104
|
-
jitter?: number; // fraction, e.g., 0.1 = ±10%, default 0.1
|
|
105
|
-
label?: string; // human-readable tag
|
|
106
|
-
priority?: number; // higher runs sooner, default 1
|
|
107
|
-
};
|
|
224
|
+
```typescript
|
|
225
|
+
findJobById(id: string): RetryQJob | null
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Returns**: Job if found, otherwise `null`
|
|
108
229
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
| "failed"
|
|
114
|
-
| "cancelled";
|
|
115
|
-
|
|
116
|
-
export interface RetryQJob {
|
|
117
|
-
id: string;
|
|
118
|
-
label: string;
|
|
119
|
-
state: JobState;
|
|
120
|
-
priority: number;
|
|
121
|
-
retriesLeft: number;
|
|
122
|
-
promise: Promise<any>; // resolves to your function’s actual value
|
|
123
|
-
cancel: () => void; // convenience wrapper for cancelJob
|
|
124
|
-
fn: () => Promise<any>;
|
|
125
|
-
options: RetryQJobOptions;
|
|
126
|
-
createdAt: number;
|
|
127
|
-
startedAt?: number;
|
|
128
|
-
finishedAt?: number;
|
|
129
|
-
error?: any;
|
|
230
|
+
```typescript
|
|
231
|
+
const job = retryQ.findJobById('job-123');
|
|
232
|
+
if (job) {
|
|
233
|
+
console.log('Job state:', job.state);
|
|
130
234
|
}
|
|
131
235
|
```
|
|
132
236
|
|
|
237
|
+
---
|
|
133
238
|
|
|
134
|
-
|
|
239
|
+
#### findJobsByLabel()
|
|
135
240
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
241
|
+
```typescript
|
|
242
|
+
findJobsByLabel(label: string): RetryQJob[]
|
|
243
|
+
```
|
|
139
244
|
|
|
140
|
-
|
|
141
|
-
q.createJob(taskB, { label: "B", priority: 1 });
|
|
142
|
-
q.createJob(taskC, { label: "C", priority: 10 });
|
|
245
|
+
**Returns**: Array of jobs with matching label
|
|
143
246
|
|
|
144
|
-
|
|
247
|
+
```typescript
|
|
248
|
+
const emailJobs = retryQ.findJobsByLabel('send-email');
|
|
249
|
+
console.log(`${emailJobs.length} email jobs found`);
|
|
145
250
|
```
|
|
146
251
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
jitter: 0.2, // ±20%
|
|
154
|
-
maxTime: 8000,
|
|
155
|
-
});
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
#### clearHistory()
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
clearHistory(state?: JobState): void
|
|
156
258
|
```
|
|
157
259
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
260
|
+
**Parameters**:
|
|
261
|
+
- `state` - Optional state to clear (`'failed'` or `'completed'`)
|
|
262
|
+
- Omit to clear both
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// Clear completed jobs only
|
|
266
|
+
retryQ.clearHistory('completed');
|
|
161
267
|
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
// or
|
|
165
|
-
job.cancel();
|
|
268
|
+
// Clear all history
|
|
269
|
+
retryQ.clearHistory();
|
|
166
270
|
```
|
|
167
271
|
|
|
168
|
-
|
|
272
|
+
---
|
|
169
273
|
|
|
170
|
-
|
|
171
|
-
```ts
|
|
172
|
-
const { pending, running, failed, completed } = q.listJobs();
|
|
274
|
+
#### onIdle() / drain()
|
|
173
275
|
|
|
174
|
-
|
|
175
|
-
|
|
276
|
+
```typescript
|
|
277
|
+
onIdle(): Promise<void>
|
|
278
|
+
drain(): Promise<void> // alias
|
|
176
279
|
```
|
|
177
280
|
|
|
281
|
+
Resolves when the queue is fully idle (no pending **and** no running jobs).
|
|
282
|
+
Resolves immediately if already idle.
|
|
178
283
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
284
|
+
```typescript
|
|
285
|
+
for (const item of items) {
|
|
286
|
+
retryQ.createJob(() => process(item), { retries: 3 });
|
|
287
|
+
}
|
|
288
|
+
await retryQ.onIdle(); // wait for the whole batch to settle
|
|
289
|
+
```
|
|
182
290
|
|
|
291
|
+
---
|
|
183
292
|
|
|
184
|
-
|
|
185
|
-
- `retries`: 3
|
|
186
|
-
- `delay`: 1000 ms
|
|
187
|
-
- `backoff`: 2
|
|
188
|
-
- `maxTime`: 5000 ms
|
|
189
|
-
- `jitter`: 0.1 (±10%)
|
|
190
|
-
- `priority`: 1
|
|
191
|
-
- `maxConcurrent`: `Infinity` (constructor)
|
|
293
|
+
### Events
|
|
192
294
|
|
|
295
|
+
`RetryQManager` extends Node's `EventEmitter` and emits typed events:
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
retryQ.on('retry', ({ job, info }) => console.log(`retry #${info.attempt} in ${info.nextDelay}ms`));
|
|
299
|
+
retryQ.on('success', ({ job, result }) => console.log('done', job.label));
|
|
300
|
+
retryQ.on('failure', ({ job, error }) => console.error('failed', job.label, error));
|
|
301
|
+
retryQ.on('cancel', ({ job }) => console.log('cancelled', job.label));
|
|
302
|
+
retryQ.on('idle', () => console.log('queue drained'));
|
|
303
|
+
```
|
|
193
304
|
|
|
194
|
-
|
|
305
|
+
| Event | Payload | Fired when |
|
|
306
|
+
|-------|---------|-----------|
|
|
307
|
+
| `retry` | `{ job, info: RetryInfo }` | A failed attempt schedules another try |
|
|
308
|
+
| `success` | `{ job, result }` | A job completes successfully |
|
|
309
|
+
| `failure` | `{ job, error }` | A job fails terminally |
|
|
310
|
+
| `cancel` | `{ job }` | A job is cancelled |
|
|
311
|
+
| `idle` | _(none)_ | The queue transitions to fully idle |
|
|
195
312
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
313
|
+
Prefer per-job feedback? Use the `onRetry` / `onSuccess` / `onFailure` /
|
|
314
|
+
`onCancel` callbacks in `RetryQJobOptions`.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
### Conditional Retries
|
|
319
|
+
|
|
320
|
+
Skip retries for errors that will never succeed:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
retryQ.createJob(async (signal) => {
|
|
324
|
+
const res = await fetch(url, { signal });
|
|
325
|
+
if (!res.ok) throw Object.assign(new Error('HTTP'), { status: res.status });
|
|
201
326
|
return res.json();
|
|
327
|
+
}, {
|
|
328
|
+
retries: 5,
|
|
329
|
+
// Retry 5xx and network errors; give up on 4xx immediately.
|
|
330
|
+
shouldRetry: (err) => !(err?.status >= 400 && err?.status < 500),
|
|
331
|
+
});
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
### RetryQJob Interface
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
interface RetryQJob {
|
|
340
|
+
id: string; // Unique identifier
|
|
341
|
+
label: string; // Human-readable name
|
|
342
|
+
state: JobState; // Current state
|
|
343
|
+
priority: number; // Execution priority
|
|
344
|
+
retriesLeft: number; // Remaining attempts
|
|
345
|
+
promise: Promise<any>; // Result promise
|
|
346
|
+
cancel: (force?: boolean) => void; // Cancel method
|
|
347
|
+
fn: (signal?: AbortSignal) => Promise<any>;
|
|
348
|
+
options: RetryQJobOptions; // Configuration
|
|
349
|
+
createdAt: number; // Timestamp (ms)
|
|
350
|
+
startedAt?: number; // Execution start (ms)
|
|
351
|
+
finishedAt?: number; // Completion time (ms)
|
|
352
|
+
error?: any; // Last error
|
|
353
|
+
abortController?: AbortController; // Internal controller
|
|
202
354
|
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Cancellation Modes
|
|
360
|
+
|
|
361
|
+
### 1. Cooperative Cancellation (Default)
|
|
362
|
+
|
|
363
|
+
**Usage**: `job.cancel()` or `job.cancel(false)`
|
|
364
|
+
|
|
365
|
+
**Behavior**:
|
|
366
|
+
- ✅ Prevents future retries
|
|
367
|
+
- ✅ Interrupts sleep between retries
|
|
368
|
+
- ❌ Does NOT abort in-progress execution
|
|
369
|
+
|
|
370
|
+
**When to use**:
|
|
371
|
+
- Operations should complete cleanly
|
|
372
|
+
- Legacy code without signal support
|
|
373
|
+
- Database transactions
|
|
203
374
|
|
|
204
|
-
|
|
205
|
-
const job =
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
backoff: 2,
|
|
209
|
-
jitter: 0.15,
|
|
210
|
-
maxTime: 5000,
|
|
375
|
+
```typescript
|
|
376
|
+
const job = retryQ.createJob(async () => {
|
|
377
|
+
await database.transaction();
|
|
378
|
+
return 'done';
|
|
211
379
|
});
|
|
212
380
|
|
|
213
|
-
|
|
381
|
+
job.cancel(); // Waits for transaction to complete
|
|
214
382
|
```
|
|
215
383
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
### 2. Force Cancellation ⭐ NEW!
|
|
387
|
+
|
|
388
|
+
**Usage**: `job.cancel(true)`
|
|
389
|
+
|
|
390
|
+
**Behavior**:
|
|
391
|
+
- ✅ Prevents future retries
|
|
392
|
+
- ✅ Interrupts sleep between retries
|
|
393
|
+
- ✅ **Aborts in-progress execution via AbortSignal**
|
|
394
|
+
|
|
395
|
+
**When to use**:
|
|
396
|
+
- HTTP requests (fetch, axios)
|
|
397
|
+
- Long-running computations
|
|
398
|
+
- File uploads/downloads
|
|
399
|
+
- Polling operations
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
const job = retryQ.createJob(async (signal) => {
|
|
403
|
+
// Check signal to enable force abort
|
|
404
|
+
for (let i = 0; i < 1000; i++) {
|
|
405
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
406
|
+
await processItem(i);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
job.cancel(true); // Immediately aborts execution
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
### External AbortController
|
|
416
|
+
|
|
417
|
+
Link your own `AbortController` to the job:
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
const controller = new AbortController();
|
|
421
|
+
|
|
422
|
+
const job = retryQ.createJob(async (signal) => {
|
|
423
|
+
return await longOperation(signal);
|
|
424
|
+
}, {
|
|
425
|
+
signal: controller.signal // Link external signal
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Cancel via external controller
|
|
429
|
+
controller.abort();
|
|
430
|
+
|
|
431
|
+
// Or via job method
|
|
432
|
+
job.cancel(true);
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## Usage Examples
|
|
438
|
+
|
|
439
|
+
### Example 1: HTTP Requests with Retries
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
async function fetchWithRetry(url: string) {
|
|
443
|
+
const retryQ = new RetryQManager({ maxConcurrent: 5 });
|
|
444
|
+
|
|
445
|
+
const job = retryQ.createJob(async (signal) => {
|
|
446
|
+
const response = await fetch(url, { signal });
|
|
447
|
+
|
|
448
|
+
if (!response.ok) {
|
|
449
|
+
throw new Error(`HTTP ${response.status}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return response.json();
|
|
453
|
+
}, {
|
|
454
|
+
retries: 5,
|
|
455
|
+
delay: 1000,
|
|
456
|
+
backoff: 2,
|
|
457
|
+
jitter: 0.15,
|
|
458
|
+
maxTime: 30000,
|
|
459
|
+
label: 'fetch-api'
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
return job.promise;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Use it
|
|
466
|
+
const data = await fetchWithRetry('https://api.example.com/data');
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
---
|
|
470
|
+
|
|
471
|
+
### Example 2: Batch Processing with Priority
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
const retryQ = new RetryQManager({ maxConcurrent: 3 });
|
|
475
|
+
|
|
476
|
+
const users = ['user1', 'user2', 'user3'];
|
|
219
477
|
|
|
220
478
|
for (const userId of users) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
479
|
+
retryQ.createJob(async (signal) => {
|
|
480
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
481
|
+
return await syncUser(userId);
|
|
482
|
+
}, {
|
|
483
|
+
label: `sync-${userId}`,
|
|
484
|
+
priority: userId === 'admin' ? 10 : 5, // Admin first
|
|
485
|
+
retries: 3
|
|
224
486
|
});
|
|
225
487
|
}
|
|
226
488
|
```
|
|
227
489
|
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
### Example 3: File Upload with Progress Tracking
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
const uploadJob = retryQ.createJob(async (signal) => {
|
|
496
|
+
const formData = new FormData();
|
|
497
|
+
formData.append('file', fileBlob);
|
|
498
|
+
|
|
499
|
+
const response = await fetch('/upload', {
|
|
500
|
+
method: 'POST',
|
|
501
|
+
body: formData,
|
|
502
|
+
signal // Abort upload on cancel
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
return response.json();
|
|
506
|
+
}, {
|
|
507
|
+
retries: 3,
|
|
508
|
+
delay: 2000,
|
|
509
|
+
label: 'file-upload'
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// User clicks cancel button
|
|
513
|
+
cancelButton.onclick = () => uploadJob.cancel(true);
|
|
514
|
+
|
|
515
|
+
// Track progress
|
|
516
|
+
uploadJob.promise
|
|
517
|
+
.then(result => console.log('Upload complete:', result))
|
|
518
|
+
.catch(err => console.log('Upload failed:', err.message));
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
### Example 4: Polling with Auto-Stop
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
const pollJob = retryQ.createJob(async (signal) => {
|
|
527
|
+
while (true) {
|
|
528
|
+
if (signal?.aborted) throw new Error('Polling stopped');
|
|
529
|
+
|
|
530
|
+
const status = await checkJobStatus(signal);
|
|
531
|
+
|
|
532
|
+
if (status === 'completed') {
|
|
533
|
+
return status;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
537
|
+
}
|
|
538
|
+
}, {
|
|
539
|
+
retries: 100,
|
|
540
|
+
delay: 5000,
|
|
541
|
+
maxTime: 300000, // 5 minutes total
|
|
542
|
+
label: 'poll-job-status'
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// Stop polling
|
|
546
|
+
setTimeout(() => pollJob.cancel(true), 60000);
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
### Example 5: Graceful Shutdown
|
|
228
552
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
- `maxTime` is a soft cap applied across the job’s lifetime. If elapsed time exceeds `maxTime`, the manager stops retrying and marks the job failed.
|
|
232
|
-
- The manager generates ids like `job-<timestamp>-<random>`; you can set a human-readable `label` for easier lookups.
|
|
553
|
+
```typescript
|
|
554
|
+
const jobs: RetryQJob[] = [];
|
|
233
555
|
|
|
556
|
+
// Queue multiple jobs
|
|
557
|
+
for (let i = 0; i < 100; i++) {
|
|
558
|
+
const job = retryQ.createJob(async (signal) => {
|
|
559
|
+
return await processItem(i, signal);
|
|
560
|
+
}, { retries: 3 });
|
|
561
|
+
|
|
562
|
+
jobs.push(job);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Handle shutdown signal
|
|
566
|
+
process.on('SIGTERM', async () => {
|
|
567
|
+
console.log('Shutting down gracefully...');
|
|
568
|
+
|
|
569
|
+
// Cancel all running jobs cooperatively
|
|
570
|
+
jobs.forEach(job => {
|
|
571
|
+
if (job.state === 'running' || job.state === 'pending') {
|
|
572
|
+
job.cancel(); // Cooperative
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Wait for jobs to finish (with timeout)
|
|
577
|
+
await Promise.race([
|
|
578
|
+
Promise.allSettled(jobs.map(j => j.promise)),
|
|
579
|
+
new Promise(resolve => setTimeout(resolve, 10000))
|
|
580
|
+
]);
|
|
581
|
+
|
|
582
|
+
process.exit(0);
|
|
583
|
+
});
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
---
|
|
587
|
+
|
|
588
|
+
## Configuration Options
|
|
589
|
+
|
|
590
|
+
### RetryQJobOptions
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
type RetryQJobOptions<T = unknown> = {
|
|
594
|
+
retries?: number; // Number of retry attempts (default: 3)
|
|
595
|
+
delay?: number; // Initial delay in ms (default: 1000)
|
|
596
|
+
backoff?: number; // Delay multiplier (default: 2)
|
|
597
|
+
maxTime?: number; // Total time limit in ms (default: 30000)
|
|
598
|
+
maxDelay?: number; // Cap for a single backoff delay (default: Infinity)
|
|
599
|
+
attemptTimeout?: number; // Per-attempt timeout in ms (default: Infinity)
|
|
600
|
+
jitter?: number; // Jitter fraction 0-1 (default: 0.1)
|
|
601
|
+
label?: string; // Human-readable identifier (default: job ID)
|
|
602
|
+
priority?: number; // Execution priority (default: 1)
|
|
603
|
+
signal?: AbortSignal; // External abort signal (optional)
|
|
604
|
+
|
|
605
|
+
// Conditional retry: return false to stop retrying immediately
|
|
606
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
607
|
+
|
|
608
|
+
// Per-job lifecycle callbacks
|
|
609
|
+
onRetry?: (info: RetryInfo) => void;
|
|
610
|
+
onSuccess?: (result: T) => void;
|
|
611
|
+
onFailure?: (error: unknown) => void;
|
|
612
|
+
onCancel?: () => void;
|
|
613
|
+
};
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### Default Values
|
|
617
|
+
|
|
618
|
+
| Option | Default | Description |
|
|
619
|
+
|--------|---------|-------------|
|
|
620
|
+
| `retries` | `3` | Number of retry attempts after initial try |
|
|
621
|
+
| `delay` | `1000` | Initial delay between retries (ms) |
|
|
622
|
+
| `backoff` | `2` | Multiplier for exponential backoff |
|
|
623
|
+
| `maxTime` | `30000` | Total execution time limit (30s), enforced during attempts |
|
|
624
|
+
| `maxDelay` | `Infinity` | Cap for a single backoff delay (ms) |
|
|
625
|
+
| `attemptTimeout` | `Infinity` | Per-attempt timeout (ms) |
|
|
626
|
+
| `jitter` | `0.1` | Random delay variation (±10%) |
|
|
627
|
+
| `priority` | `1` | Queue priority (higher = sooner) |
|
|
628
|
+
| `maxConcurrent` | `Infinity` | Concurrent job limit |
|
|
629
|
+
| `maxHistorySize` | `1000` | Jobs kept in history per state |
|
|
630
|
+
|
|
631
|
+
### Validation Rules
|
|
632
|
+
|
|
633
|
+
```typescript
|
|
634
|
+
// retries: 0 to 100
|
|
635
|
+
if (retries < 0) throw new Error('retries must be >= 0');
|
|
636
|
+
if (retries > 100) throw new Error('retries cannot exceed 100 (DoS protection)');
|
|
637
|
+
|
|
638
|
+
// delay: >= 0
|
|
639
|
+
if (delay < 0) throw new Error('delay must be >= 0');
|
|
640
|
+
|
|
641
|
+
// backoff: >= 1
|
|
642
|
+
if (backoff < 1) throw new Error('backoff must be >= 1');
|
|
643
|
+
|
|
644
|
+
// maxTime: > 0
|
|
645
|
+
if (maxTime <= 0) throw new Error('maxTime must be > 0');
|
|
646
|
+
|
|
647
|
+
// jitter: 0 to 1
|
|
648
|
+
if (jitter < 0 || jitter > 1) throw new Error('jitter must be between 0 and 1');
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
---
|
|
652
|
+
|
|
653
|
+
## Best Practices
|
|
654
|
+
|
|
655
|
+
### ✅ DO
|
|
656
|
+
|
|
657
|
+
**1. Use AbortSignal for force cancellation**
|
|
658
|
+
```typescript
|
|
659
|
+
async (signal) => {
|
|
660
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
661
|
+
await work();
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
**2. Set appropriate maxTime**
|
|
666
|
+
```typescript
|
|
667
|
+
// Long operations need higher limits
|
|
668
|
+
retryQ.createJob(fn, { maxTime: 60000 }); // 1 minute
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
**3. Use labels for tracking**
|
|
672
|
+
```typescript
|
|
673
|
+
retryQ.createJob(fn, { label: 'user-sync:123' });
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
**4. Clean up history periodically**
|
|
677
|
+
```typescript
|
|
678
|
+
setInterval(() => retryQ.clearHistory('completed'), 3600000); // Hourly
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
**5. Monitor queue depth**
|
|
682
|
+
```typescript
|
|
683
|
+
const { pending, running } = retryQ.listJobs();
|
|
684
|
+
console.log(`Queue: ${pending.length} pending, ${running.length} running`);
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
---
|
|
688
|
+
|
|
689
|
+
### ❌ DON'T
|
|
690
|
+
|
|
691
|
+
**1. Don't ignore signal parameter**
|
|
692
|
+
```typescript
|
|
693
|
+
// BAD - force cancel won't work
|
|
694
|
+
async () => await work();
|
|
695
|
+
|
|
696
|
+
// GOOD - supports force cancel
|
|
697
|
+
async (signal) => {
|
|
698
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
699
|
+
await work();
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
**2. Don't use infinite retries**
|
|
704
|
+
```typescript
|
|
705
|
+
// BAD - will retry forever
|
|
706
|
+
{ retries: Infinity }
|
|
707
|
+
|
|
708
|
+
// GOOD - capped at 100
|
|
709
|
+
{ retries: 10 }
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
**3. Don't leak secrets in errors**
|
|
713
|
+
```typescript
|
|
714
|
+
// BAD - error might contain API key
|
|
715
|
+
throw new Error(`Failed with key: ${apiKey}`);
|
|
716
|
+
|
|
717
|
+
// GOOD - sanitized error
|
|
718
|
+
throw new Error('API request failed');
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
---
|
|
722
|
+
|
|
723
|
+
## Migration Guide
|
|
724
|
+
|
|
725
|
+
### From v1.1.x to v1.2.x
|
|
726
|
+
|
|
727
|
+
**No breaking API changes.** All existing code keeps working; new options,
|
|
728
|
+
callbacks, events, and methods are additive. Two behavior fixes to be aware of:
|
|
729
|
+
|
|
730
|
+
- `listJobs().cancelled` now holds cancelled jobs — they no longer appear under
|
|
731
|
+
`failed`.
|
|
732
|
+
- `maxTime` now actively aborts an in-flight attempt once the budget is
|
|
733
|
+
exhausted (previously it only blocked starting a new attempt).
|
|
734
|
+
|
|
735
|
+
The package now ships **both ESM and CJS** with an `exports` map; `import` and
|
|
736
|
+
`require` both resolve automatically.
|
|
737
|
+
|
|
738
|
+
### From v1.0.x to v1.1.x
|
|
739
|
+
|
|
740
|
+
**No breaking changes!** All existing code works.
|
|
741
|
+
|
|
742
|
+
**To add force cancellation**:
|
|
743
|
+
|
|
744
|
+
```typescript
|
|
745
|
+
// Before (v1.0.x)
|
|
746
|
+
const job = retryQ.createJob(async () => {
|
|
747
|
+
await work();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// After (v1.1.x with force cancel)
|
|
751
|
+
const job = retryQ.createJob(async (signal) => {
|
|
752
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
753
|
+
await work();
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
job.cancel(true); // Now supports force abort!
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
---
|
|
760
|
+
|
|
761
|
+
## Performance
|
|
762
|
+
|
|
763
|
+
### Benchmarks
|
|
764
|
+
|
|
765
|
+
**Tested on**: MacBook Pro M1, 16GB RAM, Node.js 20
|
|
766
|
+
|
|
767
|
+
| Operation | Performance |
|
|
768
|
+
|-----------|------------|
|
|
769
|
+
| Create 1000 jobs | ~5ms |
|
|
770
|
+
| ID collision (1000 concurrent) | 0 collisions |
|
|
771
|
+
| Signal check (1M iterations) | ~2-3ms |
|
|
772
|
+
| Queue processing (100 jobs) | <1ms |
|
|
773
|
+
| Memory usage (10K jobs) | ~50MB |
|
|
774
|
+
|
|
775
|
+
### Memory Management
|
|
776
|
+
|
|
777
|
+
- **Bounded history**: LRU eviction at `maxHistorySize`
|
|
778
|
+
- **Registry cleanup**: Automatic cleanup after job completion
|
|
779
|
+
- **No leaks**: All references cleaned up properly
|
|
780
|
+
|
|
781
|
+
---
|
|
782
|
+
|
|
783
|
+
## TypeScript Support
|
|
784
|
+
|
|
785
|
+
Full type safety with bundled declarations:
|
|
786
|
+
|
|
787
|
+
```typescript
|
|
788
|
+
import {
|
|
789
|
+
RetryQManager,
|
|
790
|
+
RetryQJob,
|
|
791
|
+
RetryQJobOptions,
|
|
792
|
+
RetryQManagerConfig,
|
|
793
|
+
JobState,
|
|
794
|
+
CancelableFunction
|
|
795
|
+
} from '@anishhs/retryq';
|
|
796
|
+
|
|
797
|
+
const manager: RetryQManager = new RetryQManager({
|
|
798
|
+
maxConcurrent: 5,
|
|
799
|
+
maxHistorySize: 1000
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const job: RetryQJob = manager.createJob(
|
|
803
|
+
async (signal?: AbortSignal) => {
|
|
804
|
+
return 'result';
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
retries: 3,
|
|
808
|
+
delay: 1000
|
|
809
|
+
}
|
|
810
|
+
);
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
---
|
|
814
|
+
|
|
815
|
+
## Troubleshooting
|
|
816
|
+
|
|
817
|
+
### Issue: Jobs not executing
|
|
818
|
+
|
|
819
|
+
**Cause**: Exceeded `maxConcurrent` limit
|
|
820
|
+
|
|
821
|
+
**Solution**: Increase limit or wait for jobs to complete
|
|
822
|
+
```typescript
|
|
823
|
+
new RetryQManager({ maxConcurrent: 10 }); // Increase from default
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
---
|
|
827
|
+
|
|
828
|
+
### Issue: Memory growing unbounded
|
|
829
|
+
|
|
830
|
+
**Cause**: Too many jobs in history
|
|
831
|
+
|
|
832
|
+
**Solution**: Lower `maxHistorySize` or clear history
|
|
833
|
+
```typescript
|
|
834
|
+
new RetryQManager({ maxHistorySize: 500 }); // Lower limit
|
|
835
|
+
retryQ.clearHistory(); // Manual cleanup
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
### Issue: Force cancel not working
|
|
841
|
+
|
|
842
|
+
**Cause**: Job function doesn't check signal
|
|
843
|
+
|
|
844
|
+
**Solution**: Add signal checks
|
|
845
|
+
```typescript
|
|
846
|
+
async (signal) => {
|
|
847
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
848
|
+
// ... your code
|
|
849
|
+
}
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
---
|
|
853
|
+
|
|
854
|
+
## FAQ
|
|
855
|
+
|
|
856
|
+
**Q: Is this production-ready?**
|
|
857
|
+
A: Yes — covered by a `node:test` suite spanning concurrency, cancellation, events, timeouts, and retry semantics.
|
|
858
|
+
|
|
859
|
+
**Q: Does it work with TypeScript?**
|
|
860
|
+
A: Yes, full TypeScript support with bundled type definitions.
|
|
861
|
+
|
|
862
|
+
**Q: Can I use this in serverless (Lambda)?**
|
|
863
|
+
A: Yes, but jobs are in-memory only. They won't persist across cold starts.
|
|
864
|
+
|
|
865
|
+
**Q: Does it support distributed systems?**
|
|
866
|
+
A: No, it's single-process only. For distributed queues, use Redis/RabbitMQ.
|
|
867
|
+
|
|
868
|
+
**Q: What's the difference between cooperative and force cancellation?**
|
|
869
|
+
A: Cooperative prevents retries but allows current execution to complete. Force uses AbortSignal to interrupt in-progress execution.
|
|
870
|
+
|
|
871
|
+
**Q: Can I use this with fetch/axios?**
|
|
872
|
+
A: Yes! Pass the signal parameter directly to fetch() or axios.
|
|
873
|
+
|
|
874
|
+
---
|
|
875
|
+
|
|
876
|
+
## Examples Repository
|
|
877
|
+
|
|
878
|
+
More examples available at: [github.com/anishhs-gh/retryq-examples](https://github.com/anishhs-gh/retryq-examples) *(coming soon)*
|
|
879
|
+
|
|
880
|
+
---
|
|
234
881
|
|
|
235
882
|
## Development
|
|
236
883
|
|
|
237
|
-
Scripts:
|
|
238
884
|
```bash
|
|
239
|
-
#
|
|
240
|
-
npm run
|
|
885
|
+
npm install # install dev dependencies
|
|
886
|
+
npm run typecheck # tsc --noEmit
|
|
887
|
+
npm run build # emit dual ESM + CJS into dist/ (with .d.ts)
|
|
888
|
+
npm test # build (pretest) then run node:test suite
|
|
889
|
+
```
|
|
241
890
|
|
|
242
|
-
|
|
243
|
-
npm start
|
|
891
|
+
The package builds to both module formats:
|
|
244
892
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
893
|
+
- CommonJS → `dist/cjs` (`require`)
|
|
894
|
+
- ES modules → `dist/esm` (`import`)
|
|
895
|
+
|
|
896
|
+
resolved automatically via the `exports` map in `package.json`.
|
|
897
|
+
|
|
898
|
+
## CI / Release
|
|
248
899
|
|
|
249
|
-
|
|
900
|
+
| Workflow | Trigger | Purpose |
|
|
901
|
+
|----------|---------|---------|
|
|
902
|
+
| **Test** | push/PR to `develop`/`master` | Typecheck, build, and test on Node 16/18/20 |
|
|
903
|
+
| **Audit** | push/PR + weekly | `npm audit` of production dependencies |
|
|
904
|
+
| **Dry-run publish** | push to `develop`, PRs | Gated on Test + Audit; verifies the npm token authenticates and has publish permission, and that `npm publish` would succeed — without publishing |
|
|
905
|
+
| **Publish (manual)** | `workflow_dispatch` | Gated on Test + Audit; publishes to npm and creates a GitHub Release + version tag |
|
|
250
906
|
|
|
907
|
+
Releases are **manual**: bump the version in `package.json`, merge to `master`,
|
|
908
|
+
then run the **Publish** workflow from the Actions tab. Requires an `NPM_TOKEN`
|
|
909
|
+
repository secret with publish access to the `@anishhs` scope.
|
|
910
|
+
|
|
911
|
+
## Contributing
|
|
912
|
+
|
|
913
|
+
Contributions welcome! Please:
|
|
914
|
+
1. Fork the repository
|
|
915
|
+
2. Create a feature branch (off `develop`)
|
|
916
|
+
3. Add tests for new features (`tests/*.test.js`, `node:test`)
|
|
917
|
+
4. Ensure `npm test` passes
|
|
918
|
+
5. Submit a pull request into `develop`
|
|
919
|
+
|
|
920
|
+
---
|
|
251
921
|
|
|
252
922
|
## License
|
|
253
923
|
|
|
254
924
|
ISC © Anish Shekh
|
|
925
|
+
|
|
926
|
+
---
|
|
927
|
+
|
|
928
|
+
## Changelog
|
|
929
|
+
|
|
930
|
+
See [CHANGELOG.md](./CHANGELOG.md) for version history.
|
|
931
|
+
|
|
932
|
+
---
|
|
933
|
+
|
|
934
|
+
## Support
|
|
935
|
+
|
|
936
|
+
- **Issues**: [GitHub Issues](https://github.com/anishhs-gh/retryq/issues)
|
|
937
|
+
- **GitHub**: [anishhs-gh](https://github.com/anishhs-gh)
|
|
938
|
+
- **Website**: [anishhs.com](https://anishhs.com)
|
|
939
|
+
- **LinkedIn**: [linkedin.com/in/anishsh](https://linkedin.com/in/anishsh)
|
|
940
|
+
|
|
941
|
+
---
|
|
942
|
+
|
|
943
|
+
**Made with ❤️ by Anish Shekh**
|