@async-kit/retryx 0.1.2
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/CHANGELOG.md +9 -0
- package/README.md +348 -0
- package/dist/index.d.mts +143 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.js +224 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +216 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +49 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="https://capsule-render.vercel.app/api?type=rect&color=gradient&customColorList=12&height=120§ion=header&text=retryx&fontSize=60&fontColor=fff&animation=fadeIn&desc=%40async-kit%2Fretryx&descAlignY=75&descAlign=50" width="100%"/>
|
|
4
|
+
|
|
5
|
+
<br/>
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@async-kit/retryx)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
[](../../LICENSE)
|
|
10
|
+
[](https://bundlephobia.com/package/@async-kit/retryx)
|
|
11
|
+
[](https://nodejs.org/)
|
|
12
|
+
[](#compatibility)
|
|
13
|
+
|
|
14
|
+
**Smart async retry with exponential backoff, jitter strategies, circuit breaker, and AbortSignal support.**
|
|
15
|
+
|
|
16
|
+
*Make any async operation resilient in one line.*
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @async-kit/retryx
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { retry, createRetry, withRetry, CircuitBreaker } from '@async-kit/retryx';
|
|
32
|
+
|
|
33
|
+
// One-shot retry
|
|
34
|
+
const data = await retry(() => fetch('/api').then(r => r.json()), {
|
|
35
|
+
maxAttempts: 5,
|
|
36
|
+
jitter: 'full',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Reusable retry function
|
|
40
|
+
const resilient = createRetry({ maxAttempts: 3, initialDelay: 200 });
|
|
41
|
+
await resilient(() => callApi());
|
|
42
|
+
|
|
43
|
+
// Wrap any async function
|
|
44
|
+
const safeFetch = withRetry(fetch, { maxAttempts: 3 });
|
|
45
|
+
const resp = await safeFetch('/api/users', { method: 'GET' });
|
|
46
|
+
|
|
47
|
+
// Circuit breaker
|
|
48
|
+
const cb = new CircuitBreaker({ failureThreshold: 5, successThreshold: 2, openDurationMs: 10_000 });
|
|
49
|
+
const result = await cb.run(() => callExternalService());
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
### `retry(task, options?)`
|
|
55
|
+
|
|
56
|
+
| Option | Type | Default | Description |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `maxAttempts` | `number` | `3` | Total attempts including the first |
|
|
59
|
+
| `initialDelay` | `number` | `200` | Base delay in ms before the first retry |
|
|
60
|
+
| `maxDelay` | `number` | `30_000` | Maximum delay cap in ms |
|
|
61
|
+
| `factor` | `number` | `2` | Exponential backoff multiplier |
|
|
62
|
+
| `jitter` | `JitterStrategy` | `'equal'` | Randomization strategy (see below) |
|
|
63
|
+
| `retryIf` | `(err, ctx) => bool` | `() => true` | Return `false` to stop retrying; may be async |
|
|
64
|
+
| `onRetry` | `(n, err, ms, ctx) => void` | — | Hook called before each retry delay |
|
|
65
|
+
| `signal` | `AbortSignal` | — | Cancels pending retry delays |
|
|
66
|
+
| `timeoutMs` | `number` | — | Per-attempt timeout; throws `RetryxTimeoutError` |
|
|
67
|
+
|
|
68
|
+
### Jitter Strategies
|
|
69
|
+
|
|
70
|
+
| Strategy | Formula | Best For |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `'equal'` (default) | `cap/2 + random(0, cap/2)` | Preserves mean delay |
|
|
73
|
+
| `'full'` | `random(0, cap)` | AWS-recommended; highest spread |
|
|
74
|
+
| `'decorrelated'` | `min(cap, random(base, prev*3))` | Aggressive thundering-herd prevention |
|
|
75
|
+
| `'none'` | `cap` | Deterministic testing |
|
|
76
|
+
|
|
77
|
+
### `createRetry(defaults)`
|
|
78
|
+
|
|
79
|
+
Creates a reusable retry function. Per-call `overrides` are merged with defaults.
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
const resilient = createRetry({ maxAttempts: 5, jitter: 'full' });
|
|
83
|
+
await resilient(() => fetchData(), { maxAttempts: 3 }); // override for this call
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `withRetry(fn, options)`
|
|
87
|
+
|
|
88
|
+
Wraps an existing async function so every invocation is automatically retried.
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
const safeFetch = withRetry(fetch, { maxAttempts: 3 });
|
|
92
|
+
const resp = await safeFetch('/api/users', { method: 'GET' }); // retried transparently
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `RetryContext`
|
|
96
|
+
|
|
97
|
+
Passed to `retryIf` and `onRetry`:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
interface RetryContext {
|
|
101
|
+
attemptNumber: number; // 1-based attempt that just failed
|
|
102
|
+
totalAttempts: number;
|
|
103
|
+
elapsedMs: number; // wall-clock ms since first attempt
|
|
104
|
+
errors: unknown[]; // all errors so far, in order
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Circuit Breaker
|
|
109
|
+
|
|
110
|
+
Prevents cascading failures by fast-failing calls when a service is degraded.
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const cb = new CircuitBreaker({
|
|
114
|
+
failureThreshold: 5, // open after 5 consecutive failures
|
|
115
|
+
successThreshold: 2, // close after 2 successes in HALF_OPEN
|
|
116
|
+
openDurationMs: 10_000, // stay open 10 s before probing
|
|
117
|
+
volumeThreshold: 10, // require ≥ 10 calls before tripping
|
|
118
|
+
onStateChange: (from, to) => console.log(`${from} → ${to}`),
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
#### States
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
CLOSED ──(failures >= threshold)──► OPEN ──(after openDurationMs)──► HALF_OPEN
|
|
126
|
+
▲ │
|
|
127
|
+
└──────────(successes >= successThreshold)────────────────────────────┘
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| Method | Description |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `.run(task)` | Execute; throws `CircuitOpenError` when OPEN |
|
|
133
|
+
| `.reset()` | Force to CLOSED state |
|
|
134
|
+
| `.stats()` | Returns `{ failures, successes, calls, state }` |
|
|
135
|
+
| `.currentState` | Current `CircuitState` |
|
|
136
|
+
|
|
137
|
+
## Error Types
|
|
138
|
+
|
|
139
|
+
| Class | When |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `RetryxError` | All attempts exhausted — has `.attempts`, `.lastError`, `.allErrors` |
|
|
142
|
+
| `RetryxTimeoutError` | Per-attempt `timeoutMs` exceeded — has `.attempt`, `.timeoutMs` |
|
|
143
|
+
| `CircuitOpenError` | Call blocked by open circuit — has `.retryAfterMs` |
|
|
144
|
+
|
|
145
|
+
## Examples
|
|
146
|
+
|
|
147
|
+
### Retry only transient HTTP errors
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import { retry } from '@async-kit/retryx';
|
|
151
|
+
|
|
152
|
+
const data = await retry(
|
|
153
|
+
() => fetch('/api/orders').then(async r => {
|
|
154
|
+
if (!r.ok) throw Object.assign(new Error(r.statusText), { status: r.status });
|
|
155
|
+
return r.json();
|
|
156
|
+
}),
|
|
157
|
+
{
|
|
158
|
+
maxAttempts: 5,
|
|
159
|
+
jitter: 'full',
|
|
160
|
+
retryIf: (err: any) => err.status == null || err.status >= 500,
|
|
161
|
+
onRetry: (attempt, err: any, delayMs) => {
|
|
162
|
+
console.warn(`[attempt ${attempt}] ${err.message} — retrying in ${delayMs}ms`);
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### App-wide resilience factory
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import { createRetry } from '@async-kit/retryx';
|
|
172
|
+
|
|
173
|
+
// Define once, use everywhere
|
|
174
|
+
export const resilient = createRetry({
|
|
175
|
+
maxAttempts: 4,
|
|
176
|
+
initialDelay: 300,
|
|
177
|
+
maxDelay: 15_000,
|
|
178
|
+
jitter: 'equal',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// In your service layer
|
|
182
|
+
export const ordersApi = {
|
|
183
|
+
create: (payload: OrderPayload) =>
|
|
184
|
+
resilient(() => httpClient.post('/orders', payload)),
|
|
185
|
+
get: (id: string) =>
|
|
186
|
+
resilient(() => httpClient.get(`/orders/${id}`)),
|
|
187
|
+
};
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### `withRetry` — wrap third-party SDKs
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
import { withRetry } from '@async-kit/retryx';
|
|
194
|
+
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
195
|
+
|
|
196
|
+
const s3 = new S3Client({});
|
|
197
|
+
|
|
198
|
+
// Wrap the entire send method — every call is retried automatically
|
|
199
|
+
const resilientSend = withRetry(
|
|
200
|
+
s3.send.bind(s3),
|
|
201
|
+
{ maxAttempts: 4, jitter: 'decorrelated' }
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const response = await resilientSend(
|
|
205
|
+
new GetObjectCommand({ Bucket: 'my-bucket', Key: 'data.json' })
|
|
206
|
+
);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Per-attempt timeout to bound total latency
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import { retry, RetryxTimeoutError } from '@async-kit/retryx';
|
|
213
|
+
|
|
214
|
+
const result = await retry(
|
|
215
|
+
() => slowExternalService.query(params),
|
|
216
|
+
{
|
|
217
|
+
maxAttempts: 3,
|
|
218
|
+
timeoutMs: 2_000, // each attempt must finish within 2 s
|
|
219
|
+
initialDelay: 500,
|
|
220
|
+
}
|
|
221
|
+
).catch((err) => {
|
|
222
|
+
if (err instanceof RetryxTimeoutError)
|
|
223
|
+
console.error(`Attempt ${err.attempt} timed out after ${err.timeoutMs}ms`);
|
|
224
|
+
throw err;
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Cancellable polling with AbortSignal
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
import { retry } from '@async-kit/retryx';
|
|
232
|
+
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
|
|
235
|
+
// Cancel from the UI
|
|
236
|
+
document.getElementById('cancel')!.onclick = () => controller.abort();
|
|
237
|
+
|
|
238
|
+
const result = await retry(
|
|
239
|
+
() => pollJobStatus(jobId).then(s => {
|
|
240
|
+
if (s.status !== 'done') throw new Error('not ready');
|
|
241
|
+
return s;
|
|
242
|
+
}),
|
|
243
|
+
{
|
|
244
|
+
maxAttempts: 120,
|
|
245
|
+
initialDelay: 1_000,
|
|
246
|
+
maxDelay: 10_000,
|
|
247
|
+
signal: controller.signal,
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Circuit Breaker — protect a downstream service
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
import { CircuitBreaker, CircuitOpenError } from '@async-kit/retryx';
|
|
256
|
+
|
|
257
|
+
const inventoryCb = new CircuitBreaker({
|
|
258
|
+
failureThreshold: 5,
|
|
259
|
+
successThreshold: 2,
|
|
260
|
+
openDurationMs: 30_000,
|
|
261
|
+
onStateChange: (from, to) => {
|
|
262
|
+
logger.warn(`inventory circuit: ${from} → ${to}`);
|
|
263
|
+
metrics.increment(`circuit.inventory.${to.toLowerCase()}`);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
async function getInventory(sku: string) {
|
|
268
|
+
try {
|
|
269
|
+
return await inventoryCb.run(() => inventoryService.get(sku));
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err instanceof CircuitOpenError) {
|
|
272
|
+
// Serve from cache while circuit is open
|
|
273
|
+
return cache.get(`inventory:${sku}`) ?? { qty: 0 };
|
|
274
|
+
}
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Circuit Breaker + retry together
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
import { retry, CircuitBreaker, CircuitOpenError } from '@async-kit/retryx';
|
|
284
|
+
|
|
285
|
+
const cb = new CircuitBreaker({ failureThreshold: 3, successThreshold: 1, openDurationMs: 10_000 });
|
|
286
|
+
|
|
287
|
+
const data = await retry(
|
|
288
|
+
() => cb.run(() => externalService.fetch(id)),
|
|
289
|
+
{
|
|
290
|
+
maxAttempts: 5,
|
|
291
|
+
retryIf: (err) => !(err instanceof CircuitOpenError), // don't retry open-circuit errors
|
|
292
|
+
onRetry: (n, err) => console.log(`retry ${n}: ${err}`),
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Inspect all errors across attempts
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
import { retry, RetryxError } from '@async-kit/retryx';
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
await retry(() => unstableService.call(), { maxAttempts: 3 });
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (err instanceof RetryxError) {
|
|
306
|
+
console.error(`Failed after ${err.attempts} attempts`);
|
|
307
|
+
err.allErrors.forEach((e, i) =>
|
|
308
|
+
console.error(` Attempt ${i + 1}:`, e)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Types
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
import type {
|
|
318
|
+
JitterStrategy,
|
|
319
|
+
RetryContext,
|
|
320
|
+
RetryxOptions,
|
|
321
|
+
CircuitState,
|
|
322
|
+
CircuitBreakerOptions,
|
|
323
|
+
CircuitBreakerStats,
|
|
324
|
+
} from '@async-kit/retryx';
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Compatibility
|
|
328
|
+
|
|
329
|
+
| Environment | Support | Notes |
|
|
330
|
+
|---|---|---|
|
|
331
|
+
| **Node.js** | ≥ 18 | Recommended ≥ 24 for best performance |
|
|
332
|
+
| **Deno** | ✅ | Via npm specifier (`npm:@async-kit/retryx`) |
|
|
333
|
+
| **Bun** | ✅ | Full support |
|
|
334
|
+
| **Chrome** | ≥ 80 | ESM via bundler or native import |
|
|
335
|
+
| **Firefox** | ≥ 75 | ESM via bundler or native import |
|
|
336
|
+
| **Safari** | ≥ 13.1 | ESM via bundler or native import |
|
|
337
|
+
| **Edge** | ≥ 80 | ESM via bundler or native import |
|
|
338
|
+
| **React Native** | ✅ | Via Metro bundler |
|
|
339
|
+
| **Cloudflare Workers** | ✅ | ESM, `AbortSignal` natively supported |
|
|
340
|
+
| **Vercel Edge Runtime** | ✅ | ESM, no `process` / `fs` dependencies |
|
|
341
|
+
|
|
342
|
+
**No Node.js built-ins are used.** The package relies only on standard JavaScript (`Promise`, `setTimeout`, `clearTimeout`, `AbortSignal`, `DOMException`) — all available in any modern runtime.
|
|
343
|
+
|
|
344
|
+
> **`AbortSignal` / `DOMException`** are part of the Web Platform API. In Node.js they are globals since v15. In older environments (Node 14 or old browsers) you may need to polyfill `AbortController` — e.g. [`abortcontroller-polyfill`](https://www.npmjs.com/package/abortcontroller-polyfill).
|
|
345
|
+
|
|
346
|
+
## License
|
|
347
|
+
|
|
348
|
+
MIT © async-kit contributors · Part of the [async-kit](../../README.md) ecosystem
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jitter strategy for exponential backoff.
|
|
3
|
+
* - `'equal'` — `cap/2 + random(0, cap/2)` (default, preserves mean delay)
|
|
4
|
+
* - `'full'` — `random(0, cap)` (AWS recommended for highest spread)
|
|
5
|
+
* - `'decorrelated'` — `min(cap, random(base, prev * 3))` (aggressive spread)
|
|
6
|
+
* - `'none'` — no randomization
|
|
7
|
+
*/
|
|
8
|
+
type JitterStrategy = 'equal' | 'full' | 'decorrelated' | 'none';
|
|
9
|
+
/** Context object passed to `retryIf` and `onRetry` callbacks. */
|
|
10
|
+
interface RetryContext {
|
|
11
|
+
/** 1-based attempt number of the attempt that just failed. */
|
|
12
|
+
attemptNumber: number;
|
|
13
|
+
/** Total configured max attempts. */
|
|
14
|
+
totalAttempts: number;
|
|
15
|
+
/** Wall-clock ms elapsed since the first attempt started. */
|
|
16
|
+
elapsedMs: number;
|
|
17
|
+
/** All errors collected so far, in order. */
|
|
18
|
+
errors: unknown[];
|
|
19
|
+
}
|
|
20
|
+
/** Options for `retry()` and `createRetry()`. */
|
|
21
|
+
interface RetryxOptions {
|
|
22
|
+
/** Total attempts including the first. Default: `3`. */
|
|
23
|
+
maxAttempts?: number;
|
|
24
|
+
/** Base delay in ms before the first retry. Default: `200`. */
|
|
25
|
+
initialDelay?: number;
|
|
26
|
+
/** Maximum delay cap in ms. Default: `30_000`. */
|
|
27
|
+
maxDelay?: number;
|
|
28
|
+
/** Backoff multiplier. Default: `2`. */
|
|
29
|
+
factor?: number;
|
|
30
|
+
/** Jitter strategy. Default: `'equal'`. */
|
|
31
|
+
jitter?: JitterStrategy;
|
|
32
|
+
/**
|
|
33
|
+
* Return `false` to stop retrying immediately.
|
|
34
|
+
* Receives the error and full context. May be async.
|
|
35
|
+
*/
|
|
36
|
+
retryIf?: (error: unknown, context: RetryContext) => boolean | Promise<boolean>;
|
|
37
|
+
/** Called before each retry delay. */
|
|
38
|
+
onRetry?: (attemptNumber: number, error: unknown, delayMs: number, context: RetryContext) => void;
|
|
39
|
+
/** AbortSignal — cancels pending retry delays. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
/** Per-attempt timeout in ms. Throws `RetryxTimeoutError` if exceeded. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
/** All possible circuit breaker states. */
|
|
45
|
+
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
|
46
|
+
/** Constructor options for `CircuitBreaker`. */
|
|
47
|
+
interface CircuitBreakerOptions {
|
|
48
|
+
/** Number of failures before opening the circuit. */
|
|
49
|
+
failureThreshold: number;
|
|
50
|
+
/** Successes in HALF_OPEN state needed to re-close. */
|
|
51
|
+
successThreshold: number;
|
|
52
|
+
/** How long (ms) to stay OPEN before moving to HALF_OPEN. */
|
|
53
|
+
openDurationMs: number;
|
|
54
|
+
/** Minimum calls in the window before the circuit can trip. Default: `1`. */
|
|
55
|
+
volumeThreshold?: number;
|
|
56
|
+
/** Called whenever the circuit transitions between states. */
|
|
57
|
+
onStateChange?: (from: CircuitState, to: CircuitState) => void;
|
|
58
|
+
}
|
|
59
|
+
/** Snapshot of `CircuitBreaker` counters and state. */
|
|
60
|
+
interface CircuitBreakerStats {
|
|
61
|
+
failures: number;
|
|
62
|
+
successes: number;
|
|
63
|
+
calls: number;
|
|
64
|
+
state: CircuitState;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
declare class RetryxError extends Error {
|
|
68
|
+
readonly attempts: number;
|
|
69
|
+
readonly lastError: unknown;
|
|
70
|
+
/** Every error thrown across all attempts, in order. */
|
|
71
|
+
readonly allErrors: unknown[];
|
|
72
|
+
constructor(message: string, attempts: number, lastError: unknown,
|
|
73
|
+
/** Every error thrown across all attempts, in order. */
|
|
74
|
+
allErrors: unknown[]);
|
|
75
|
+
}
|
|
76
|
+
declare class RetryxTimeoutError extends Error {
|
|
77
|
+
readonly attempt: number;
|
|
78
|
+
readonly timeoutMs: number;
|
|
79
|
+
constructor(attempt: number, timeoutMs: number);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Retry an async operation with exponential backoff, jitter, and context-aware hooks.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const data = await retry(() => fetch('/api').then(r => r.json()), { maxAttempts: 5 });
|
|
86
|
+
*/
|
|
87
|
+
declare function retry<T>(task: () => Promise<T>, options?: RetryxOptions): Promise<T>;
|
|
88
|
+
/**
|
|
89
|
+
* Create a reusable retry function with pre-configured options.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* const resilient = createRetry({ maxAttempts: 5 });
|
|
93
|
+
* await resilient(() => callApi());
|
|
94
|
+
*/
|
|
95
|
+
declare function createRetry(defaults: RetryxOptions): <T>(task: () => Promise<T>, overrides?: RetryxOptions) => Promise<T>;
|
|
96
|
+
/**
|
|
97
|
+
* Wraps an async function so that every call is automatically retried.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* const resilientFetch = withRetry(fetch, { maxAttempts: 3 });
|
|
101
|
+
* const resp = await resilientFetch('/api/users'); // retried on failure
|
|
102
|
+
*/
|
|
103
|
+
declare function withRetry<TArgs extends unknown[], TReturn>(fn: (...args: TArgs) => Promise<TReturn>, options: RetryxOptions): (...args: TArgs) => Promise<TReturn>;
|
|
104
|
+
declare class CircuitOpenError extends Error {
|
|
105
|
+
readonly retryAfterMs: number;
|
|
106
|
+
constructor(retryAfterMs: number);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Circuit breaker wrapping any async operation.
|
|
110
|
+
*
|
|
111
|
+
* States:
|
|
112
|
+
* - **CLOSED** — calls pass through normally.
|
|
113
|
+
* - **OPEN** — calls fail fast with `CircuitOpenError`.
|
|
114
|
+
* - **HALF_OPEN** — one probe call is allowed; success closes, failure re-opens.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* const cb = new CircuitBreaker({ failureThreshold: 5, successThreshold: 2, openDurationMs: 10_000 });
|
|
118
|
+
* const result = await cb.run(() => callExternalService());
|
|
119
|
+
*/
|
|
120
|
+
declare class CircuitBreaker {
|
|
121
|
+
private state;
|
|
122
|
+
private failures;
|
|
123
|
+
private successes;
|
|
124
|
+
private calls;
|
|
125
|
+
private openedAt;
|
|
126
|
+
private readonly failureThreshold;
|
|
127
|
+
private readonly successThreshold;
|
|
128
|
+
private readonly openDurationMs;
|
|
129
|
+
private readonly volumeThreshold;
|
|
130
|
+
private readonly onStateChange?;
|
|
131
|
+
constructor(options: CircuitBreakerOptions);
|
|
132
|
+
get currentState(): CircuitState;
|
|
133
|
+
run<T>(task: () => Promise<T>): Promise<T>;
|
|
134
|
+
/** Manually reset the circuit to CLOSED state. */
|
|
135
|
+
reset(): void;
|
|
136
|
+
stats(): CircuitBreakerStats;
|
|
137
|
+
private checkHalfOpen;
|
|
138
|
+
private onSuccess;
|
|
139
|
+
private onFailure;
|
|
140
|
+
private transition;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { CircuitBreaker, type CircuitBreakerOptions, type CircuitBreakerStats, CircuitOpenError, type CircuitState, type JitterStrategy, type RetryContext, RetryxError, type RetryxOptions, RetryxTimeoutError, createRetry, retry, withRetry };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jitter strategy for exponential backoff.
|
|
3
|
+
* - `'equal'` — `cap/2 + random(0, cap/2)` (default, preserves mean delay)
|
|
4
|
+
* - `'full'` — `random(0, cap)` (AWS recommended for highest spread)
|
|
5
|
+
* - `'decorrelated'` — `min(cap, random(base, prev * 3))` (aggressive spread)
|
|
6
|
+
* - `'none'` — no randomization
|
|
7
|
+
*/
|
|
8
|
+
type JitterStrategy = 'equal' | 'full' | 'decorrelated' | 'none';
|
|
9
|
+
/** Context object passed to `retryIf` and `onRetry` callbacks. */
|
|
10
|
+
interface RetryContext {
|
|
11
|
+
/** 1-based attempt number of the attempt that just failed. */
|
|
12
|
+
attemptNumber: number;
|
|
13
|
+
/** Total configured max attempts. */
|
|
14
|
+
totalAttempts: number;
|
|
15
|
+
/** Wall-clock ms elapsed since the first attempt started. */
|
|
16
|
+
elapsedMs: number;
|
|
17
|
+
/** All errors collected so far, in order. */
|
|
18
|
+
errors: unknown[];
|
|
19
|
+
}
|
|
20
|
+
/** Options for `retry()` and `createRetry()`. */
|
|
21
|
+
interface RetryxOptions {
|
|
22
|
+
/** Total attempts including the first. Default: `3`. */
|
|
23
|
+
maxAttempts?: number;
|
|
24
|
+
/** Base delay in ms before the first retry. Default: `200`. */
|
|
25
|
+
initialDelay?: number;
|
|
26
|
+
/** Maximum delay cap in ms. Default: `30_000`. */
|
|
27
|
+
maxDelay?: number;
|
|
28
|
+
/** Backoff multiplier. Default: `2`. */
|
|
29
|
+
factor?: number;
|
|
30
|
+
/** Jitter strategy. Default: `'equal'`. */
|
|
31
|
+
jitter?: JitterStrategy;
|
|
32
|
+
/**
|
|
33
|
+
* Return `false` to stop retrying immediately.
|
|
34
|
+
* Receives the error and full context. May be async.
|
|
35
|
+
*/
|
|
36
|
+
retryIf?: (error: unknown, context: RetryContext) => boolean | Promise<boolean>;
|
|
37
|
+
/** Called before each retry delay. */
|
|
38
|
+
onRetry?: (attemptNumber: number, error: unknown, delayMs: number, context: RetryContext) => void;
|
|
39
|
+
/** AbortSignal — cancels pending retry delays. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
/** Per-attempt timeout in ms. Throws `RetryxTimeoutError` if exceeded. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
/** All possible circuit breaker states. */
|
|
45
|
+
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
|
46
|
+
/** Constructor options for `CircuitBreaker`. */
|
|
47
|
+
interface CircuitBreakerOptions {
|
|
48
|
+
/** Number of failures before opening the circuit. */
|
|
49
|
+
failureThreshold: number;
|
|
50
|
+
/** Successes in HALF_OPEN state needed to re-close. */
|
|
51
|
+
successThreshold: number;
|
|
52
|
+
/** How long (ms) to stay OPEN before moving to HALF_OPEN. */
|
|
53
|
+
openDurationMs: number;
|
|
54
|
+
/** Minimum calls in the window before the circuit can trip. Default: `1`. */
|
|
55
|
+
volumeThreshold?: number;
|
|
56
|
+
/** Called whenever the circuit transitions between states. */
|
|
57
|
+
onStateChange?: (from: CircuitState, to: CircuitState) => void;
|
|
58
|
+
}
|
|
59
|
+
/** Snapshot of `CircuitBreaker` counters and state. */
|
|
60
|
+
interface CircuitBreakerStats {
|
|
61
|
+
failures: number;
|
|
62
|
+
successes: number;
|
|
63
|
+
calls: number;
|
|
64
|
+
state: CircuitState;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
declare class RetryxError extends Error {
|
|
68
|
+
readonly attempts: number;
|
|
69
|
+
readonly lastError: unknown;
|
|
70
|
+
/** Every error thrown across all attempts, in order. */
|
|
71
|
+
readonly allErrors: unknown[];
|
|
72
|
+
constructor(message: string, attempts: number, lastError: unknown,
|
|
73
|
+
/** Every error thrown across all attempts, in order. */
|
|
74
|
+
allErrors: unknown[]);
|
|
75
|
+
}
|
|
76
|
+
declare class RetryxTimeoutError extends Error {
|
|
77
|
+
readonly attempt: number;
|
|
78
|
+
readonly timeoutMs: number;
|
|
79
|
+
constructor(attempt: number, timeoutMs: number);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Retry an async operation with exponential backoff, jitter, and context-aware hooks.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const data = await retry(() => fetch('/api').then(r => r.json()), { maxAttempts: 5 });
|
|
86
|
+
*/
|
|
87
|
+
declare function retry<T>(task: () => Promise<T>, options?: RetryxOptions): Promise<T>;
|
|
88
|
+
/**
|
|
89
|
+
* Create a reusable retry function with pre-configured options.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* const resilient = createRetry({ maxAttempts: 5 });
|
|
93
|
+
* await resilient(() => callApi());
|
|
94
|
+
*/
|
|
95
|
+
declare function createRetry(defaults: RetryxOptions): <T>(task: () => Promise<T>, overrides?: RetryxOptions) => Promise<T>;
|
|
96
|
+
/**
|
|
97
|
+
* Wraps an async function so that every call is automatically retried.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* const resilientFetch = withRetry(fetch, { maxAttempts: 3 });
|
|
101
|
+
* const resp = await resilientFetch('/api/users'); // retried on failure
|
|
102
|
+
*/
|
|
103
|
+
declare function withRetry<TArgs extends unknown[], TReturn>(fn: (...args: TArgs) => Promise<TReturn>, options: RetryxOptions): (...args: TArgs) => Promise<TReturn>;
|
|
104
|
+
declare class CircuitOpenError extends Error {
|
|
105
|
+
readonly retryAfterMs: number;
|
|
106
|
+
constructor(retryAfterMs: number);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Circuit breaker wrapping any async operation.
|
|
110
|
+
*
|
|
111
|
+
* States:
|
|
112
|
+
* - **CLOSED** — calls pass through normally.
|
|
113
|
+
* - **OPEN** — calls fail fast with `CircuitOpenError`.
|
|
114
|
+
* - **HALF_OPEN** — one probe call is allowed; success closes, failure re-opens.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* const cb = new CircuitBreaker({ failureThreshold: 5, successThreshold: 2, openDurationMs: 10_000 });
|
|
118
|
+
* const result = await cb.run(() => callExternalService());
|
|
119
|
+
*/
|
|
120
|
+
declare class CircuitBreaker {
|
|
121
|
+
private state;
|
|
122
|
+
private failures;
|
|
123
|
+
private successes;
|
|
124
|
+
private calls;
|
|
125
|
+
private openedAt;
|
|
126
|
+
private readonly failureThreshold;
|
|
127
|
+
private readonly successThreshold;
|
|
128
|
+
private readonly openDurationMs;
|
|
129
|
+
private readonly volumeThreshold;
|
|
130
|
+
private readonly onStateChange?;
|
|
131
|
+
constructor(options: CircuitBreakerOptions);
|
|
132
|
+
get currentState(): CircuitState;
|
|
133
|
+
run<T>(task: () => Promise<T>): Promise<T>;
|
|
134
|
+
/** Manually reset the circuit to CLOSED state. */
|
|
135
|
+
reset(): void;
|
|
136
|
+
stats(): CircuitBreakerStats;
|
|
137
|
+
private checkHalfOpen;
|
|
138
|
+
private onSuccess;
|
|
139
|
+
private onFailure;
|
|
140
|
+
private transition;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { CircuitBreaker, type CircuitBreakerOptions, type CircuitBreakerStats, CircuitOpenError, type CircuitState, type JitterStrategy, type RetryContext, RetryxError, type RetryxOptions, RetryxTimeoutError, createRetry, retry, withRetry };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/retryx.ts
|
|
4
|
+
var RetryxError = class extends Error {
|
|
5
|
+
constructor(message, attempts, lastError, allErrors) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.attempts = attempts;
|
|
8
|
+
this.lastError = lastError;
|
|
9
|
+
this.allErrors = allErrors;
|
|
10
|
+
this.name = "RetryxError";
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var RetryxTimeoutError = class extends Error {
|
|
14
|
+
constructor(attempt, timeoutMs) {
|
|
15
|
+
super(`Attempt ${attempt} timed out after ${timeoutMs}ms`);
|
|
16
|
+
this.attempt = attempt;
|
|
17
|
+
this.timeoutMs = timeoutMs;
|
|
18
|
+
this.name = "RetryxTimeoutError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function abortableDelay(ms, signal) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
if (signal?.aborted) {
|
|
24
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const timer = setTimeout(resolve, ms);
|
|
28
|
+
signal?.addEventListener(
|
|
29
|
+
"abort",
|
|
30
|
+
() => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
33
|
+
},
|
|
34
|
+
{ once: true }
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function raceTimeout(promise, ms, attempt) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const timer = setTimeout(() => reject(new RetryxTimeoutError(attempt, ms)), ms);
|
|
41
|
+
promise.then(
|
|
42
|
+
(v) => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
resolve(v);
|
|
45
|
+
},
|
|
46
|
+
(e) => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
reject(e);
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function computeDelay(attempt, prevDelay, opts) {
|
|
54
|
+
const cap = Math.min(opts.initialDelay * Math.pow(opts.factor, attempt), opts.maxDelay);
|
|
55
|
+
switch (opts.jitter) {
|
|
56
|
+
case "none":
|
|
57
|
+
return cap;
|
|
58
|
+
case "full":
|
|
59
|
+
return Math.floor(Math.random() * cap);
|
|
60
|
+
case "decorrelated":
|
|
61
|
+
return Math.min(opts.maxDelay, Math.floor(opts.initialDelay + Math.random() * (prevDelay * 3 - opts.initialDelay)));
|
|
62
|
+
case "equal":
|
|
63
|
+
default:
|
|
64
|
+
return Math.floor(cap / 2 + Math.random() * (cap / 2));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function retry(task, options = {}) {
|
|
68
|
+
const {
|
|
69
|
+
maxAttempts = 3,
|
|
70
|
+
initialDelay = 200,
|
|
71
|
+
maxDelay = 3e4,
|
|
72
|
+
factor = 2,
|
|
73
|
+
jitter = "equal",
|
|
74
|
+
retryIf = () => true,
|
|
75
|
+
onRetry,
|
|
76
|
+
signal,
|
|
77
|
+
timeoutMs
|
|
78
|
+
} = options;
|
|
79
|
+
const allErrors = [];
|
|
80
|
+
const startTime = Date.now();
|
|
81
|
+
let prevDelay = initialDelay;
|
|
82
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
83
|
+
try {
|
|
84
|
+
return timeoutMs != null ? await raceTimeout(task(), timeoutMs, attempt + 1) : await task();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
allErrors.push(err);
|
|
87
|
+
if (attempt === maxAttempts - 1) break;
|
|
88
|
+
const context = {
|
|
89
|
+
attemptNumber: attempt + 1,
|
|
90
|
+
totalAttempts: maxAttempts,
|
|
91
|
+
elapsedMs: Date.now() - startTime,
|
|
92
|
+
errors: [...allErrors]
|
|
93
|
+
};
|
|
94
|
+
const shouldRetry = await retryIf(err, context);
|
|
95
|
+
if (!shouldRetry) break;
|
|
96
|
+
const delayMs = computeDelay(attempt, prevDelay, { initialDelay, maxDelay, factor, jitter });
|
|
97
|
+
prevDelay = delayMs;
|
|
98
|
+
onRetry?.(attempt + 1, err, delayMs, context);
|
|
99
|
+
await abortableDelay(delayMs, signal);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw allErrors.length === 1 && allErrors[0] instanceof RetryxTimeoutError ? allErrors[0] : new RetryxError(
|
|
103
|
+
`All ${maxAttempts} attempts failed`,
|
|
104
|
+
maxAttempts,
|
|
105
|
+
allErrors[allErrors.length - 1],
|
|
106
|
+
allErrors
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
function createRetry(defaults) {
|
|
110
|
+
return (task, overrides) => retry(task, { ...defaults, ...overrides });
|
|
111
|
+
}
|
|
112
|
+
function withRetry(fn, options) {
|
|
113
|
+
return (...args) => retry(() => fn(...args), options);
|
|
114
|
+
}
|
|
115
|
+
var CircuitOpenError = class extends Error {
|
|
116
|
+
constructor(retryAfterMs) {
|
|
117
|
+
super(`Circuit is OPEN. Retry after ${retryAfterMs}ms`);
|
|
118
|
+
this.retryAfterMs = retryAfterMs;
|
|
119
|
+
this.name = "CircuitOpenError";
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var CircuitBreaker = class {
|
|
123
|
+
state = "CLOSED";
|
|
124
|
+
failures = 0;
|
|
125
|
+
successes = 0;
|
|
126
|
+
calls = 0;
|
|
127
|
+
openedAt = 0;
|
|
128
|
+
failureThreshold;
|
|
129
|
+
successThreshold;
|
|
130
|
+
openDurationMs;
|
|
131
|
+
volumeThreshold;
|
|
132
|
+
onStateChange;
|
|
133
|
+
constructor(options) {
|
|
134
|
+
this.failureThreshold = options.failureThreshold;
|
|
135
|
+
this.successThreshold = options.successThreshold;
|
|
136
|
+
this.openDurationMs = options.openDurationMs;
|
|
137
|
+
this.volumeThreshold = options.volumeThreshold ?? 1;
|
|
138
|
+
this.onStateChange = options.onStateChange;
|
|
139
|
+
}
|
|
140
|
+
get currentState() {
|
|
141
|
+
this.checkHalfOpen();
|
|
142
|
+
return this.state;
|
|
143
|
+
}
|
|
144
|
+
async run(task) {
|
|
145
|
+
this.checkHalfOpen();
|
|
146
|
+
if (this.state === "OPEN") {
|
|
147
|
+
const retryAfterMs = Math.max(0, this.openedAt + this.openDurationMs - Date.now());
|
|
148
|
+
throw new CircuitOpenError(retryAfterMs);
|
|
149
|
+
}
|
|
150
|
+
this.calls++;
|
|
151
|
+
try {
|
|
152
|
+
const result = await task();
|
|
153
|
+
this.onSuccess();
|
|
154
|
+
return result;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
this.onFailure();
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Manually reset the circuit to CLOSED state. */
|
|
161
|
+
reset() {
|
|
162
|
+
const prev = this.state;
|
|
163
|
+
this.state = "CLOSED";
|
|
164
|
+
this.failures = 0;
|
|
165
|
+
this.successes = 0;
|
|
166
|
+
this.calls = 0;
|
|
167
|
+
this.openedAt = 0;
|
|
168
|
+
if (prev !== "CLOSED") this.onStateChange?.(prev, "CLOSED");
|
|
169
|
+
}
|
|
170
|
+
stats() {
|
|
171
|
+
return {
|
|
172
|
+
failures: this.failures,
|
|
173
|
+
successes: this.successes,
|
|
174
|
+
calls: this.calls,
|
|
175
|
+
state: this.state
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
checkHalfOpen() {
|
|
179
|
+
if (this.state === "OPEN" && Date.now() >= this.openedAt + this.openDurationMs) {
|
|
180
|
+
this.transition("HALF_OPEN");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
onSuccess() {
|
|
184
|
+
this.failures = 0;
|
|
185
|
+
if (this.state === "HALF_OPEN") {
|
|
186
|
+
this.successes++;
|
|
187
|
+
if (this.successes >= this.successThreshold) {
|
|
188
|
+
this.transition("CLOSED");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
onFailure() {
|
|
193
|
+
this.failures++;
|
|
194
|
+
if (this.state === "HALF_OPEN") {
|
|
195
|
+
this.openedAt = Date.now();
|
|
196
|
+
this.transition("OPEN");
|
|
197
|
+
} else if (this.state === "CLOSED" && this.calls >= this.volumeThreshold && this.failures >= this.failureThreshold) {
|
|
198
|
+
this.openedAt = Date.now();
|
|
199
|
+
this.transition("OPEN");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
transition(next) {
|
|
203
|
+
const prev = this.state;
|
|
204
|
+
this.state = next;
|
|
205
|
+
if (next === "CLOSED") {
|
|
206
|
+
this.failures = 0;
|
|
207
|
+
this.successes = 0;
|
|
208
|
+
}
|
|
209
|
+
if (next === "HALF_OPEN") {
|
|
210
|
+
this.successes = 0;
|
|
211
|
+
}
|
|
212
|
+
this.onStateChange?.(prev, next);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
exports.CircuitBreaker = CircuitBreaker;
|
|
217
|
+
exports.CircuitOpenError = CircuitOpenError;
|
|
218
|
+
exports.RetryxError = RetryxError;
|
|
219
|
+
exports.RetryxTimeoutError = RetryxTimeoutError;
|
|
220
|
+
exports.createRetry = createRetry;
|
|
221
|
+
exports.retry = retry;
|
|
222
|
+
exports.withRetry = withRetry;
|
|
223
|
+
//# sourceMappingURL=index.js.map
|
|
224
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/retryx.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["export type { JitterStrategy, RetryContext, RetryxOptions, CircuitState, CircuitBreakerOptions, CircuitBreakerStats } from './types.js';\nimport type { JitterStrategy, RetryContext, RetryxOptions, CircuitState, CircuitBreakerOptions, CircuitBreakerStats } from './types.js';\n\n// ─── Error Types ─────────────────────────────────────────────────────────────\n\nexport class RetryxError extends Error {\n constructor(\n message: string,\n public readonly attempts: number,\n public readonly lastError: unknown,\n /** Every error thrown across all attempts, in order. */\n public readonly allErrors: unknown[]\n ) {\n super(message);\n this.name = 'RetryxError';\n }\n}\n\nexport class RetryxTimeoutError extends Error {\n constructor(\n public readonly attempt: number,\n public readonly timeoutMs: number\n ) {\n super(`Attempt ${attempt} timed out after ${timeoutMs}ms`);\n this.name = 'RetryxTimeoutError';\n }\n}\n\n// ─── Internal Helpers ────────────────────────────────────────────────────────\n\nfunction abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new DOMException('Aborted', 'AbortError'));\n return;\n }\n const timer = setTimeout(resolve, ms);\n signal?.addEventListener(\n 'abort',\n () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); },\n { once: true }\n );\n });\n}\n\nfunction raceTimeout<T>(promise: Promise<T>, ms: number, attempt: number): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new RetryxTimeoutError(attempt, ms)), ms);\n promise.then(\n (v) => { clearTimeout(timer); resolve(v); },\n (e) => { clearTimeout(timer); reject(e); }\n );\n });\n}\n\nfunction computeDelay(\n attempt: number,\n prevDelay: number,\n opts: Required<Pick<RetryxOptions, 'initialDelay' | 'maxDelay' | 'factor' | 'jitter'>>\n): number {\n const cap = Math.min(opts.initialDelay * Math.pow(opts.factor, attempt), opts.maxDelay);\n\n switch (opts.jitter) {\n case 'none':\n return cap;\n case 'full':\n return Math.floor(Math.random() * cap);\n case 'decorrelated':\n return Math.min(opts.maxDelay, Math.floor(opts.initialDelay + Math.random() * (prevDelay * 3 - opts.initialDelay)));\n case 'equal':\n default:\n return Math.floor(cap / 2 + Math.random() * (cap / 2));\n }\n}\n\n// ─── retry ───────────────────────────────────────────────────────────────────\n\n/**\n * Retry an async operation with exponential backoff, jitter, and context-aware hooks.\n *\n * @example\n * const data = await retry(() => fetch('/api').then(r => r.json()), { maxAttempts: 5 });\n */\nexport async function retry<T>(\n task: () => Promise<T>,\n options: RetryxOptions = {}\n): Promise<T> {\n const {\n maxAttempts = 3,\n initialDelay = 200,\n maxDelay = 30_000,\n factor = 2,\n jitter = 'equal',\n retryIf = () => true,\n onRetry,\n signal,\n timeoutMs,\n } = options;\n\n const allErrors: unknown[] = [];\n const startTime = Date.now();\n let prevDelay = initialDelay;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n try {\n return timeoutMs != null\n ? await raceTimeout(task(), timeoutMs, attempt + 1)\n : await task();\n } catch (err) {\n // Timeout errors always propagate — don't retry by default but respect retryIf\n allErrors.push(err);\n\n if (attempt === maxAttempts - 1) break;\n\n const context: RetryContext = {\n attemptNumber: attempt + 1,\n totalAttempts: maxAttempts,\n elapsedMs: Date.now() - startTime,\n errors: [...allErrors],\n };\n\n const shouldRetry = await retryIf(err, context);\n if (!shouldRetry) break;\n\n const delayMs = computeDelay(attempt, prevDelay, { initialDelay, maxDelay, factor, jitter });\n prevDelay = delayMs;\n\n onRetry?.(attempt + 1, err, delayMs, context);\n await abortableDelay(delayMs, signal);\n }\n }\n\n throw allErrors.length === 1 && allErrors[0] instanceof RetryxTimeoutError\n ? allErrors[0]\n : new RetryxError(\n `All ${maxAttempts} attempts failed`,\n maxAttempts,\n allErrors[allErrors.length - 1],\n allErrors\n );\n}\n\n// ─── createRetry ─────────────────────────────────────────────────────────────\n\n/**\n * Create a reusable retry function with pre-configured options.\n *\n * @example\n * const resilient = createRetry({ maxAttempts: 5 });\n * await resilient(() => callApi());\n */\nexport function createRetry(\n defaults: RetryxOptions\n): <T>(task: () => Promise<T>, overrides?: RetryxOptions) => Promise<T> {\n return (task, overrides) => retry(task, { ...defaults, ...overrides });\n}\n\n// ─── withRetry ───────────────────────────────────────────────────────────────\n\n/**\n * Wraps an async function so that every call is automatically retried.\n *\n * @example\n * const resilientFetch = withRetry(fetch, { maxAttempts: 3 });\n * const resp = await resilientFetch('/api/users'); // retried on failure\n */\nexport function withRetry<TArgs extends unknown[], TReturn>(\n fn: (...args: TArgs) => Promise<TReturn>,\n options: RetryxOptions\n): (...args: TArgs) => Promise<TReturn> {\n return (...args: TArgs) => retry(() => fn(...args), options);\n}\n\n// ─── CircuitBreaker ──────────────────────────────────────────────────────────\n\nexport class CircuitOpenError extends Error {\n constructor(public readonly retryAfterMs: number) {\n super(`Circuit is OPEN. Retry after ${retryAfterMs}ms`);\n this.name = 'CircuitOpenError';\n }\n}\n\n/**\n * Circuit breaker wrapping any async operation.\n *\n * States:\n * - **CLOSED** — calls pass through normally.\n * - **OPEN** — calls fail fast with `CircuitOpenError`.\n * - **HALF_OPEN** — one probe call is allowed; success closes, failure re-opens.\n *\n * @example\n * const cb = new CircuitBreaker({ failureThreshold: 5, successThreshold: 2, openDurationMs: 10_000 });\n * const result = await cb.run(() => callExternalService());\n */\nexport class CircuitBreaker {\n private state: CircuitState = 'CLOSED';\n private failures = 0;\n private successes = 0;\n private calls = 0;\n private openedAt = 0;\n\n private readonly failureThreshold: number;\n private readonly successThreshold: number;\n private readonly openDurationMs: number;\n private readonly volumeThreshold: number;\n private readonly onStateChange?: (from: CircuitState, to: CircuitState) => void;\n\n constructor(options: CircuitBreakerOptions) {\n this.failureThreshold = options.failureThreshold;\n this.successThreshold = options.successThreshold;\n this.openDurationMs = options.openDurationMs;\n this.volumeThreshold = options.volumeThreshold ?? 1;\n this.onStateChange = options.onStateChange;\n }\n\n get currentState(): CircuitState {\n this.checkHalfOpen();\n return this.state;\n }\n\n async run<T>(task: () => Promise<T>): Promise<T> {\n this.checkHalfOpen();\n\n if (this.state === 'OPEN') {\n const retryAfterMs = Math.max(0, this.openedAt + this.openDurationMs - Date.now());\n throw new CircuitOpenError(retryAfterMs);\n }\n\n this.calls++;\n\n try {\n const result = await task();\n this.onSuccess();\n return result;\n } catch (err) {\n this.onFailure();\n throw err;\n }\n }\n\n /** Manually reset the circuit to CLOSED state. */\n reset(): void {\n const prev = this.state;\n this.state = 'CLOSED';\n this.failures = 0;\n this.successes = 0;\n this.calls = 0;\n this.openedAt = 0;\n if (prev !== 'CLOSED') this.onStateChange?.(prev, 'CLOSED');\n }\n\n stats(): CircuitBreakerStats {\n return {\n failures: this.failures,\n successes: this.successes,\n calls: this.calls,\n state: this.state,\n };\n }\n\n private checkHalfOpen(): void {\n if (this.state === 'OPEN' && Date.now() >= this.openedAt + this.openDurationMs) {\n this.transition('HALF_OPEN');\n }\n }\n\n private onSuccess(): void {\n this.failures = 0;\n if (this.state === 'HALF_OPEN') {\n this.successes++;\n if (this.successes >= this.successThreshold) {\n this.transition('CLOSED');\n }\n }\n }\n\n private onFailure(): void {\n this.failures++;\n if (this.state === 'HALF_OPEN') {\n this.openedAt = Date.now();\n this.transition('OPEN');\n } else if (this.state === 'CLOSED' && this.calls >= this.volumeThreshold && this.failures >= this.failureThreshold) {\n this.openedAt = Date.now();\n this.transition('OPEN');\n }\n }\n\n private transition(next: CircuitState): void {\n const prev = this.state;\n this.state = next;\n if (next === 'CLOSED') { this.failures = 0; this.successes = 0; }\n if (next === 'HALF_OPEN') { this.successes = 0; }\n this.onStateChange?.(prev, next);\n }\n}\n"]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// src/retryx.ts
|
|
2
|
+
var RetryxError = class extends Error {
|
|
3
|
+
constructor(message, attempts, lastError, allErrors) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.attempts = attempts;
|
|
6
|
+
this.lastError = lastError;
|
|
7
|
+
this.allErrors = allErrors;
|
|
8
|
+
this.name = "RetryxError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var RetryxTimeoutError = class extends Error {
|
|
12
|
+
constructor(attempt, timeoutMs) {
|
|
13
|
+
super(`Attempt ${attempt} timed out after ${timeoutMs}ms`);
|
|
14
|
+
this.attempt = attempt;
|
|
15
|
+
this.timeoutMs = timeoutMs;
|
|
16
|
+
this.name = "RetryxTimeoutError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
function abortableDelay(ms, signal) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
if (signal?.aborted) {
|
|
22
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const timer = setTimeout(resolve, ms);
|
|
26
|
+
signal?.addEventListener(
|
|
27
|
+
"abort",
|
|
28
|
+
() => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
31
|
+
},
|
|
32
|
+
{ once: true }
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function raceTimeout(promise, ms, attempt) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const timer = setTimeout(() => reject(new RetryxTimeoutError(attempt, ms)), ms);
|
|
39
|
+
promise.then(
|
|
40
|
+
(v) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve(v);
|
|
43
|
+
},
|
|
44
|
+
(e) => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
reject(e);
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function computeDelay(attempt, prevDelay, opts) {
|
|
52
|
+
const cap = Math.min(opts.initialDelay * Math.pow(opts.factor, attempt), opts.maxDelay);
|
|
53
|
+
switch (opts.jitter) {
|
|
54
|
+
case "none":
|
|
55
|
+
return cap;
|
|
56
|
+
case "full":
|
|
57
|
+
return Math.floor(Math.random() * cap);
|
|
58
|
+
case "decorrelated":
|
|
59
|
+
return Math.min(opts.maxDelay, Math.floor(opts.initialDelay + Math.random() * (prevDelay * 3 - opts.initialDelay)));
|
|
60
|
+
case "equal":
|
|
61
|
+
default:
|
|
62
|
+
return Math.floor(cap / 2 + Math.random() * (cap / 2));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function retry(task, options = {}) {
|
|
66
|
+
const {
|
|
67
|
+
maxAttempts = 3,
|
|
68
|
+
initialDelay = 200,
|
|
69
|
+
maxDelay = 3e4,
|
|
70
|
+
factor = 2,
|
|
71
|
+
jitter = "equal",
|
|
72
|
+
retryIf = () => true,
|
|
73
|
+
onRetry,
|
|
74
|
+
signal,
|
|
75
|
+
timeoutMs
|
|
76
|
+
} = options;
|
|
77
|
+
const allErrors = [];
|
|
78
|
+
const startTime = Date.now();
|
|
79
|
+
let prevDelay = initialDelay;
|
|
80
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
81
|
+
try {
|
|
82
|
+
return timeoutMs != null ? await raceTimeout(task(), timeoutMs, attempt + 1) : await task();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
allErrors.push(err);
|
|
85
|
+
if (attempt === maxAttempts - 1) break;
|
|
86
|
+
const context = {
|
|
87
|
+
attemptNumber: attempt + 1,
|
|
88
|
+
totalAttempts: maxAttempts,
|
|
89
|
+
elapsedMs: Date.now() - startTime,
|
|
90
|
+
errors: [...allErrors]
|
|
91
|
+
};
|
|
92
|
+
const shouldRetry = await retryIf(err, context);
|
|
93
|
+
if (!shouldRetry) break;
|
|
94
|
+
const delayMs = computeDelay(attempt, prevDelay, { initialDelay, maxDelay, factor, jitter });
|
|
95
|
+
prevDelay = delayMs;
|
|
96
|
+
onRetry?.(attempt + 1, err, delayMs, context);
|
|
97
|
+
await abortableDelay(delayMs, signal);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw allErrors.length === 1 && allErrors[0] instanceof RetryxTimeoutError ? allErrors[0] : new RetryxError(
|
|
101
|
+
`All ${maxAttempts} attempts failed`,
|
|
102
|
+
maxAttempts,
|
|
103
|
+
allErrors[allErrors.length - 1],
|
|
104
|
+
allErrors
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
function createRetry(defaults) {
|
|
108
|
+
return (task, overrides) => retry(task, { ...defaults, ...overrides });
|
|
109
|
+
}
|
|
110
|
+
function withRetry(fn, options) {
|
|
111
|
+
return (...args) => retry(() => fn(...args), options);
|
|
112
|
+
}
|
|
113
|
+
var CircuitOpenError = class extends Error {
|
|
114
|
+
constructor(retryAfterMs) {
|
|
115
|
+
super(`Circuit is OPEN. Retry after ${retryAfterMs}ms`);
|
|
116
|
+
this.retryAfterMs = retryAfterMs;
|
|
117
|
+
this.name = "CircuitOpenError";
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
var CircuitBreaker = class {
|
|
121
|
+
state = "CLOSED";
|
|
122
|
+
failures = 0;
|
|
123
|
+
successes = 0;
|
|
124
|
+
calls = 0;
|
|
125
|
+
openedAt = 0;
|
|
126
|
+
failureThreshold;
|
|
127
|
+
successThreshold;
|
|
128
|
+
openDurationMs;
|
|
129
|
+
volumeThreshold;
|
|
130
|
+
onStateChange;
|
|
131
|
+
constructor(options) {
|
|
132
|
+
this.failureThreshold = options.failureThreshold;
|
|
133
|
+
this.successThreshold = options.successThreshold;
|
|
134
|
+
this.openDurationMs = options.openDurationMs;
|
|
135
|
+
this.volumeThreshold = options.volumeThreshold ?? 1;
|
|
136
|
+
this.onStateChange = options.onStateChange;
|
|
137
|
+
}
|
|
138
|
+
get currentState() {
|
|
139
|
+
this.checkHalfOpen();
|
|
140
|
+
return this.state;
|
|
141
|
+
}
|
|
142
|
+
async run(task) {
|
|
143
|
+
this.checkHalfOpen();
|
|
144
|
+
if (this.state === "OPEN") {
|
|
145
|
+
const retryAfterMs = Math.max(0, this.openedAt + this.openDurationMs - Date.now());
|
|
146
|
+
throw new CircuitOpenError(retryAfterMs);
|
|
147
|
+
}
|
|
148
|
+
this.calls++;
|
|
149
|
+
try {
|
|
150
|
+
const result = await task();
|
|
151
|
+
this.onSuccess();
|
|
152
|
+
return result;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
this.onFailure();
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Manually reset the circuit to CLOSED state. */
|
|
159
|
+
reset() {
|
|
160
|
+
const prev = this.state;
|
|
161
|
+
this.state = "CLOSED";
|
|
162
|
+
this.failures = 0;
|
|
163
|
+
this.successes = 0;
|
|
164
|
+
this.calls = 0;
|
|
165
|
+
this.openedAt = 0;
|
|
166
|
+
if (prev !== "CLOSED") this.onStateChange?.(prev, "CLOSED");
|
|
167
|
+
}
|
|
168
|
+
stats() {
|
|
169
|
+
return {
|
|
170
|
+
failures: this.failures,
|
|
171
|
+
successes: this.successes,
|
|
172
|
+
calls: this.calls,
|
|
173
|
+
state: this.state
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
checkHalfOpen() {
|
|
177
|
+
if (this.state === "OPEN" && Date.now() >= this.openedAt + this.openDurationMs) {
|
|
178
|
+
this.transition("HALF_OPEN");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
onSuccess() {
|
|
182
|
+
this.failures = 0;
|
|
183
|
+
if (this.state === "HALF_OPEN") {
|
|
184
|
+
this.successes++;
|
|
185
|
+
if (this.successes >= this.successThreshold) {
|
|
186
|
+
this.transition("CLOSED");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
onFailure() {
|
|
191
|
+
this.failures++;
|
|
192
|
+
if (this.state === "HALF_OPEN") {
|
|
193
|
+
this.openedAt = Date.now();
|
|
194
|
+
this.transition("OPEN");
|
|
195
|
+
} else if (this.state === "CLOSED" && this.calls >= this.volumeThreshold && this.failures >= this.failureThreshold) {
|
|
196
|
+
this.openedAt = Date.now();
|
|
197
|
+
this.transition("OPEN");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
transition(next) {
|
|
201
|
+
const prev = this.state;
|
|
202
|
+
this.state = next;
|
|
203
|
+
if (next === "CLOSED") {
|
|
204
|
+
this.failures = 0;
|
|
205
|
+
this.successes = 0;
|
|
206
|
+
}
|
|
207
|
+
if (next === "HALF_OPEN") {
|
|
208
|
+
this.successes = 0;
|
|
209
|
+
}
|
|
210
|
+
this.onStateChange?.(prev, next);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export { CircuitBreaker, CircuitOpenError, RetryxError, RetryxTimeoutError, createRetry, retry, withRetry };
|
|
215
|
+
//# sourceMappingURL=index.mjs.map
|
|
216
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/retryx.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["export type { JitterStrategy, RetryContext, RetryxOptions, CircuitState, CircuitBreakerOptions, CircuitBreakerStats } from './types.js';\nimport type { JitterStrategy, RetryContext, RetryxOptions, CircuitState, CircuitBreakerOptions, CircuitBreakerStats } from './types.js';\n\n// ─── Error Types ─────────────────────────────────────────────────────────────\n\nexport class RetryxError extends Error {\n constructor(\n message: string,\n public readonly attempts: number,\n public readonly lastError: unknown,\n /** Every error thrown across all attempts, in order. */\n public readonly allErrors: unknown[]\n ) {\n super(message);\n this.name = 'RetryxError';\n }\n}\n\nexport class RetryxTimeoutError extends Error {\n constructor(\n public readonly attempt: number,\n public readonly timeoutMs: number\n ) {\n super(`Attempt ${attempt} timed out after ${timeoutMs}ms`);\n this.name = 'RetryxTimeoutError';\n }\n}\n\n// ─── Internal Helpers ────────────────────────────────────────────────────────\n\nfunction abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new DOMException('Aborted', 'AbortError'));\n return;\n }\n const timer = setTimeout(resolve, ms);\n signal?.addEventListener(\n 'abort',\n () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); },\n { once: true }\n );\n });\n}\n\nfunction raceTimeout<T>(promise: Promise<T>, ms: number, attempt: number): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new RetryxTimeoutError(attempt, ms)), ms);\n promise.then(\n (v) => { clearTimeout(timer); resolve(v); },\n (e) => { clearTimeout(timer); reject(e); }\n );\n });\n}\n\nfunction computeDelay(\n attempt: number,\n prevDelay: number,\n opts: Required<Pick<RetryxOptions, 'initialDelay' | 'maxDelay' | 'factor' | 'jitter'>>\n): number {\n const cap = Math.min(opts.initialDelay * Math.pow(opts.factor, attempt), opts.maxDelay);\n\n switch (opts.jitter) {\n case 'none':\n return cap;\n case 'full':\n return Math.floor(Math.random() * cap);\n case 'decorrelated':\n return Math.min(opts.maxDelay, Math.floor(opts.initialDelay + Math.random() * (prevDelay * 3 - opts.initialDelay)));\n case 'equal':\n default:\n return Math.floor(cap / 2 + Math.random() * (cap / 2));\n }\n}\n\n// ─── retry ───────────────────────────────────────────────────────────────────\n\n/**\n * Retry an async operation with exponential backoff, jitter, and context-aware hooks.\n *\n * @example\n * const data = await retry(() => fetch('/api').then(r => r.json()), { maxAttempts: 5 });\n */\nexport async function retry<T>(\n task: () => Promise<T>,\n options: RetryxOptions = {}\n): Promise<T> {\n const {\n maxAttempts = 3,\n initialDelay = 200,\n maxDelay = 30_000,\n factor = 2,\n jitter = 'equal',\n retryIf = () => true,\n onRetry,\n signal,\n timeoutMs,\n } = options;\n\n const allErrors: unknown[] = [];\n const startTime = Date.now();\n let prevDelay = initialDelay;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n try {\n return timeoutMs != null\n ? await raceTimeout(task(), timeoutMs, attempt + 1)\n : await task();\n } catch (err) {\n // Timeout errors always propagate — don't retry by default but respect retryIf\n allErrors.push(err);\n\n if (attempt === maxAttempts - 1) break;\n\n const context: RetryContext = {\n attemptNumber: attempt + 1,\n totalAttempts: maxAttempts,\n elapsedMs: Date.now() - startTime,\n errors: [...allErrors],\n };\n\n const shouldRetry = await retryIf(err, context);\n if (!shouldRetry) break;\n\n const delayMs = computeDelay(attempt, prevDelay, { initialDelay, maxDelay, factor, jitter });\n prevDelay = delayMs;\n\n onRetry?.(attempt + 1, err, delayMs, context);\n await abortableDelay(delayMs, signal);\n }\n }\n\n throw allErrors.length === 1 && allErrors[0] instanceof RetryxTimeoutError\n ? allErrors[0]\n : new RetryxError(\n `All ${maxAttempts} attempts failed`,\n maxAttempts,\n allErrors[allErrors.length - 1],\n allErrors\n );\n}\n\n// ─── createRetry ─────────────────────────────────────────────────────────────\n\n/**\n * Create a reusable retry function with pre-configured options.\n *\n * @example\n * const resilient = createRetry({ maxAttempts: 5 });\n * await resilient(() => callApi());\n */\nexport function createRetry(\n defaults: RetryxOptions\n): <T>(task: () => Promise<T>, overrides?: RetryxOptions) => Promise<T> {\n return (task, overrides) => retry(task, { ...defaults, ...overrides });\n}\n\n// ─── withRetry ───────────────────────────────────────────────────────────────\n\n/**\n * Wraps an async function so that every call is automatically retried.\n *\n * @example\n * const resilientFetch = withRetry(fetch, { maxAttempts: 3 });\n * const resp = await resilientFetch('/api/users'); // retried on failure\n */\nexport function withRetry<TArgs extends unknown[], TReturn>(\n fn: (...args: TArgs) => Promise<TReturn>,\n options: RetryxOptions\n): (...args: TArgs) => Promise<TReturn> {\n return (...args: TArgs) => retry(() => fn(...args), options);\n}\n\n// ─── CircuitBreaker ──────────────────────────────────────────────────────────\n\nexport class CircuitOpenError extends Error {\n constructor(public readonly retryAfterMs: number) {\n super(`Circuit is OPEN. Retry after ${retryAfterMs}ms`);\n this.name = 'CircuitOpenError';\n }\n}\n\n/**\n * Circuit breaker wrapping any async operation.\n *\n * States:\n * - **CLOSED** — calls pass through normally.\n * - **OPEN** — calls fail fast with `CircuitOpenError`.\n * - **HALF_OPEN** — one probe call is allowed; success closes, failure re-opens.\n *\n * @example\n * const cb = new CircuitBreaker({ failureThreshold: 5, successThreshold: 2, openDurationMs: 10_000 });\n * const result = await cb.run(() => callExternalService());\n */\nexport class CircuitBreaker {\n private state: CircuitState = 'CLOSED';\n private failures = 0;\n private successes = 0;\n private calls = 0;\n private openedAt = 0;\n\n private readonly failureThreshold: number;\n private readonly successThreshold: number;\n private readonly openDurationMs: number;\n private readonly volumeThreshold: number;\n private readonly onStateChange?: (from: CircuitState, to: CircuitState) => void;\n\n constructor(options: CircuitBreakerOptions) {\n this.failureThreshold = options.failureThreshold;\n this.successThreshold = options.successThreshold;\n this.openDurationMs = options.openDurationMs;\n this.volumeThreshold = options.volumeThreshold ?? 1;\n this.onStateChange = options.onStateChange;\n }\n\n get currentState(): CircuitState {\n this.checkHalfOpen();\n return this.state;\n }\n\n async run<T>(task: () => Promise<T>): Promise<T> {\n this.checkHalfOpen();\n\n if (this.state === 'OPEN') {\n const retryAfterMs = Math.max(0, this.openedAt + this.openDurationMs - Date.now());\n throw new CircuitOpenError(retryAfterMs);\n }\n\n this.calls++;\n\n try {\n const result = await task();\n this.onSuccess();\n return result;\n } catch (err) {\n this.onFailure();\n throw err;\n }\n }\n\n /** Manually reset the circuit to CLOSED state. */\n reset(): void {\n const prev = this.state;\n this.state = 'CLOSED';\n this.failures = 0;\n this.successes = 0;\n this.calls = 0;\n this.openedAt = 0;\n if (prev !== 'CLOSED') this.onStateChange?.(prev, 'CLOSED');\n }\n\n stats(): CircuitBreakerStats {\n return {\n failures: this.failures,\n successes: this.successes,\n calls: this.calls,\n state: this.state,\n };\n }\n\n private checkHalfOpen(): void {\n if (this.state === 'OPEN' && Date.now() >= this.openedAt + this.openDurationMs) {\n this.transition('HALF_OPEN');\n }\n }\n\n private onSuccess(): void {\n this.failures = 0;\n if (this.state === 'HALF_OPEN') {\n this.successes++;\n if (this.successes >= this.successThreshold) {\n this.transition('CLOSED');\n }\n }\n }\n\n private onFailure(): void {\n this.failures++;\n if (this.state === 'HALF_OPEN') {\n this.openedAt = Date.now();\n this.transition('OPEN');\n } else if (this.state === 'CLOSED' && this.calls >= this.volumeThreshold && this.failures >= this.failureThreshold) {\n this.openedAt = Date.now();\n this.transition('OPEN');\n }\n }\n\n private transition(next: CircuitState): void {\n const prev = this.state;\n this.state = next;\n if (next === 'CLOSED') { this.failures = 0; this.successes = 0; }\n if (next === 'HALF_OPEN') { this.successes = 0; }\n this.onStateChange?.(prev, next);\n }\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@async-kit/retryx",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Smart async retry system with exponential backoff, jitter, and circuit breaker for JavaScript/TypeScript",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"async",
|
|
7
|
+
"retry",
|
|
8
|
+
"backoff",
|
|
9
|
+
"circuit-breaker",
|
|
10
|
+
"resilience",
|
|
11
|
+
"fault-tolerance"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/NexaLeaf/async-kit",
|
|
17
|
+
"directory": "packages/retryx"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/NexaLeaf/async-kit/tree/main/packages/retryx#readme",
|
|
20
|
+
"main": "./dist/index.cjs",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"require": {
|
|
30
|
+
"types": "./dist/index.d.cts",
|
|
31
|
+
"default": "./dist/index.cjs"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"sideEffects": false,
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.lib.json --noEmit"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {}
|
|
49
|
+
}
|