clutchit 0.0.3 → 0.0.6
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 +530 -0
- package/dist/circuit/{circuit-breaker.d.ts → circuit.breaker.d.ts} +14 -10
- package/dist/circuit/{circuit-breaker.js → circuit.breaker.js} +7 -12
- package/dist/circuit/index.d.ts +7 -4
- package/dist/circuit/index.js +3 -2
- package/dist/concurrency/bulkhead.d.ts +10 -9
- package/dist/concurrency/bulkhead.js +4 -8
- package/dist/concurrency/index.d.ts +12 -5
- package/dist/concurrency/index.js +2 -0
- package/dist/concurrency/rate-limiter.d.ts +13 -9
- package/dist/concurrency/rate-limiter.js +4 -10
- package/dist/queue/base.queue.js +1 -1
- package/dist/queue/bounded.queue.d.ts +2 -2
- package/dist/queue/bounded.queue.js +2 -2
- package/dist/queue/faults.d.ts +22 -18
- package/dist/queue/faults.js +6 -15
- package/dist/queue/index.d.ts +12 -8
- package/dist/queue/index.js +7 -6
- package/dist/retry/index.d.ts +5 -3
- package/dist/retry/index.js +2 -2
- package/dist/schedule/index.d.ts +19 -13
- package/dist/schedule/index.js +11 -10
- package/dist/schedule/runner.d.ts +10 -9
- package/dist/schedule/runner.js +5 -9
- package/dist/schedule/schedule.d.ts +4 -0
- package/dist/schedule/schedule.js +12 -0
- package/dist/timeout/index.d.ts +1 -10
- package/dist/timeout/index.js +2 -2
- package/dist/timeout/timeout.d.ts +13 -9
- package/dist/timeout/timeout.js +4 -10
- package/dist/unthrow/fault.d.ts +9 -24
- package/dist/unthrow/fault.js +18 -13
- package/dist/unthrow/index.d.ts +17 -16
- package/dist/unthrow/index.js +12 -19
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
# clutchit
|
|
2
|
+
|
|
3
|
+
Resilience primitives for TypeScript — retry, circuit breaker, timeout, rate limiting, and more with typed errors.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/clutchit)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install clutchit
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Example
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { ResultAsync, Fault } from 'clutchit/unthrow';
|
|
18
|
+
import { Retry } from 'clutchit/retry';
|
|
19
|
+
import { Timeout } from 'clutchit/timeout';
|
|
20
|
+
import { Schedule } from 'clutchit/schedule';
|
|
21
|
+
|
|
22
|
+
class ApiFault extends Fault.Tagged('API_ERROR', 'infrastructure', true)<{
|
|
23
|
+
status: number;
|
|
24
|
+
}>() {
|
|
25
|
+
readonly message = `API returned ${this.status}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fetchUser(id: string, signal: AbortSignal) {
|
|
29
|
+
return ResultAsync.fromPromise(
|
|
30
|
+
fetch(`/api/users/${id}`, { signal }).then((r) => r.json()),
|
|
31
|
+
(err) => new ApiFault({ status: 500 }),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const retry = new Retry.Policy({
|
|
36
|
+
schedule: Schedule.exponential(200).pipe(Schedule.recurs(3)),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const result = Timeout.wrap(
|
|
40
|
+
(signal) => retry.execute((s) => fetchUser('123', s)),
|
|
41
|
+
5000,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
await result.match({
|
|
45
|
+
ok: (user) => console.log(user),
|
|
46
|
+
err: (fault) => console.error(fault.code, fault.message),
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Modules
|
|
51
|
+
|
|
52
|
+
| Import | Description |
|
|
53
|
+
|--------|-------------|
|
|
54
|
+
| `clutchit/unthrow` | `Result<T, E>`, `ResultAsync<T, E>`, `Fault`, Do notation |
|
|
55
|
+
| `clutchit/schedule` | Composable timing schedules (exponential, linear, fibonacci, cron, ...) |
|
|
56
|
+
| `clutchit/retry` | Retry policies with configurable backoff and cancellation |
|
|
57
|
+
| `clutchit/timeout` | Timeout wrappers with `AbortSignal` propagation |
|
|
58
|
+
| `clutchit/concurrency` | Semaphore, RateLimiter, Bulkhead, Ref |
|
|
59
|
+
| `clutchit/circuit` | Circuit breaker with progressive reset |
|
|
60
|
+
| `clutchit/queue` | Bounded, Dropping, and Sliding queues with backpressure |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## `clutchit/unthrow`
|
|
65
|
+
|
|
66
|
+
Typed error handling without exceptions. `Result<T, E>` for sync, `ResultAsync<T, E>` for async.
|
|
67
|
+
|
|
68
|
+
### Result
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { Result } from 'clutchit/unthrow';
|
|
72
|
+
|
|
73
|
+
const ok = Result.ok(42); // Ok<number, never>
|
|
74
|
+
const fail = Result.err('oops'); // Err<never, string>
|
|
75
|
+
|
|
76
|
+
ok.map((n) => n * 2); // Ok(84)
|
|
77
|
+
ok.andThen((n) => Result.ok(n)); // Ok(42)
|
|
78
|
+
ok.unwrapOr(0); // 42
|
|
79
|
+
fail.unwrapOr(0); // 0
|
|
80
|
+
|
|
81
|
+
ok.match({
|
|
82
|
+
ok: (value) => `got ${value}`,
|
|
83
|
+
err: (error) => `failed: ${error}`,
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Methods: `map`, `mapErr`, `andThen`, `orElse`, `unwrapOr`, `match`, `isOk`, `isErr`
|
|
88
|
+
|
|
89
|
+
### ResultAsync
|
|
90
|
+
|
|
91
|
+
Wraps `Promise<Result<T, E>>` with the same chainable methods. Awaitable via `PromiseLike`.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { ResultAsync } from 'clutchit/unthrow';
|
|
95
|
+
|
|
96
|
+
const result = ResultAsync.fromPromise(
|
|
97
|
+
fetch('/api/data').then((r) => r.json()),
|
|
98
|
+
(err) => ({ code: 'FETCH_FAILED', message: String(err) }),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const final = result
|
|
102
|
+
.map((data) => data.items)
|
|
103
|
+
.andThen((items) => validateItems(items))
|
|
104
|
+
.mapErr((err) => normalize(err));
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Helpers
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { Result, ResultAsync } from 'clutchit/unthrow';
|
|
111
|
+
|
|
112
|
+
// Wrap a throwable sync function
|
|
113
|
+
Result.fromThrowable(() => JSON.parse(raw), (err) => ParseFault(String(err)));
|
|
114
|
+
|
|
115
|
+
// Combine results — fail on first error
|
|
116
|
+
Result.combine([resultA, resultB, resultC]); // Result<[A, B, C], E>
|
|
117
|
+
ResultAsync.combine([asyncA, asyncB]); // ResultAsync<[A, B], E>
|
|
118
|
+
|
|
119
|
+
// Combine results — accumulate all errors
|
|
120
|
+
Result.combineWithAllErrors([resultA, resultB]); // Result<[A, B], E[]>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Do Notation
|
|
124
|
+
|
|
125
|
+
Generator-based monadic syntax. Yields unwrap `Ok` values; any `Err` short-circuits.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { Result, ResultAsync } from 'clutchit/unthrow';
|
|
129
|
+
|
|
130
|
+
// Sync
|
|
131
|
+
const result = Result.Do(function* () {
|
|
132
|
+
const user = yield* findUser(id);
|
|
133
|
+
const perms = yield* getPermissions(user);
|
|
134
|
+
return { user, perms };
|
|
135
|
+
});
|
|
136
|
+
// Result<{ user: User; perms: Permissions }, NotFoundFault | PermissionFault>
|
|
137
|
+
|
|
138
|
+
// Async
|
|
139
|
+
const result = ResultAsync.Do(async function* () {
|
|
140
|
+
const user = yield* findUser(id);
|
|
141
|
+
const order = yield* createOrder(user, items);
|
|
142
|
+
yield* sendConfirmation(order);
|
|
143
|
+
return order;
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## `clutchit/schedule`
|
|
150
|
+
|
|
151
|
+
Composable timing primitive. Determines delay and continuation for each attempt. Used internally by retry and circuit breaker — also useful standalone.
|
|
152
|
+
|
|
153
|
+
### Factories
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
import { Schedule } from 'clutchit/schedule';
|
|
157
|
+
|
|
158
|
+
Schedule.spaced(1000); // Fixed 1s delay
|
|
159
|
+
Schedule.exponential(200); // 200, 400, 800, 1600, ...
|
|
160
|
+
Schedule.exponential(200, 3); // 200, 600, 1800, ... (custom multiplier)
|
|
161
|
+
Schedule.linear(100); // 100, 200, 300, 400, ...
|
|
162
|
+
Schedule.fibonacci(100); // 100, 100, 200, 300, 500, ...
|
|
163
|
+
Schedule.constant(5000); // Alias for spaced
|
|
164
|
+
Schedule.fixed(1000); // Fixed 1s intervals (compensates for execution time)
|
|
165
|
+
Schedule.windowed(60_000); // Aligned to wall-clock minute boundaries
|
|
166
|
+
Schedule.cron('*/5 * * * *'); // Every 5 minutes (cron expression)
|
|
167
|
+
Schedule.cron('0 4 * * MON-FRI', { timezone: 'UTC' }); // 4am weekdays UTC
|
|
168
|
+
Schedule.forever; // Zero-delay, never stops
|
|
169
|
+
Schedule.once; // Single attempt then stop
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Operators
|
|
173
|
+
|
|
174
|
+
Compose via `.pipe()`:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
const schedule = Schedule.exponential(200)
|
|
178
|
+
.pipe(Schedule.recurs(5)) // Max 5 attempts
|
|
179
|
+
.pipe(Schedule.cappedDelay(10_000)) // Cap at 10s
|
|
180
|
+
.pipe(Schedule.jittered()) // +/- 25% random jitter
|
|
181
|
+
.pipe(Schedule.upTo(60_000)); // Stop after 60s total
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
| Operator | Description |
|
|
185
|
+
|----------|-------------|
|
|
186
|
+
| `recurs(n)` | Limit to `n` total attempts |
|
|
187
|
+
| `cappedDelay(ms)` | Cap individual delay |
|
|
188
|
+
| `jittered(factor?)` | Random jitter (default +/- 25%) |
|
|
189
|
+
| `upTo(ms)` | Stop after total elapsed time |
|
|
190
|
+
| `andThen(schedule)` | Run first schedule, then second |
|
|
191
|
+
| `union(schedule)` | Shorter delay wins, either continues |
|
|
192
|
+
| `intersect(schedule)` | Longer delay wins, both must continue |
|
|
193
|
+
| `whileInput(pred)` | Continue while input matches |
|
|
194
|
+
| `whileOutput(pred)` | Continue while output matches |
|
|
195
|
+
| `map(fn)` | Transform schedule output |
|
|
196
|
+
|
|
197
|
+
### Repeat
|
|
198
|
+
|
|
199
|
+
Execute a function repeatedly on a schedule:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
const schedule = Schedule.spaced(5000).pipe(Schedule.recurs(10));
|
|
203
|
+
|
|
204
|
+
await Schedule.repeat(
|
|
205
|
+
schedule,
|
|
206
|
+
(signal) => pollForUpdates(signal),
|
|
207
|
+
{ signal: controller.signal },
|
|
208
|
+
);
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Returns `ScheduleInterruptedFault` if cancelled via signal.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## `clutchit/retry`
|
|
216
|
+
|
|
217
|
+
Retry policies with configurable backoff, error filtering, and cancellation.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { Retry } from 'clutchit/retry';
|
|
221
|
+
import { Schedule } from 'clutchit/schedule';
|
|
222
|
+
|
|
223
|
+
const policy = new Retry.Policy({
|
|
224
|
+
schedule: Schedule.exponential(200).pipe(Schedule.recurs(3)),
|
|
225
|
+
retryOn: (error) => Fault.isTransient(error),
|
|
226
|
+
onRetry: (error, attempt, delay) => {
|
|
227
|
+
console.log(`Retry #${attempt} in ${delay}ms: ${error.code}`);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const result = policy.execute((signal) => fetchData(signal));
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### RetryOptions
|
|
235
|
+
|
|
236
|
+
| Option | Type | Default |
|
|
237
|
+
|--------|------|---------|
|
|
238
|
+
| `schedule` | `Schedule` | `exponential(200) \| recurs(3)` |
|
|
239
|
+
| `retryOn` | `(error: E) => boolean` | Retries transient errors (`_transient === true`) |
|
|
240
|
+
| `onRetry` | `(error, attempt, delay) => void` | — |
|
|
241
|
+
| `signal` | `AbortSignal` | — |
|
|
242
|
+
|
|
243
|
+
Per-call options override constructor defaults.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## `clutchit/timeout`
|
|
248
|
+
|
|
249
|
+
Wrap async operations with a timeout. Propagates cancellation via `AbortSignal`.
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
import { Timeout } from 'clutchit/timeout';
|
|
253
|
+
|
|
254
|
+
// Inline
|
|
255
|
+
const result = Timeout.wrap(
|
|
256
|
+
(signal) => fetchData(signal),
|
|
257
|
+
5000,
|
|
258
|
+
);
|
|
259
|
+
// ResultAsync<Data, FetchFault | TimeoutFault>
|
|
260
|
+
|
|
261
|
+
// Reusable wrapper
|
|
262
|
+
const withTimeout = Timeout.create(5000);
|
|
263
|
+
const result = withTimeout((signal) => fetchData(signal));
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`TimeoutFault` is `transient: true` — it will be retried by default when composed with `Retry.Policy`.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## `clutchit/concurrency`
|
|
271
|
+
|
|
272
|
+
### Semaphore
|
|
273
|
+
|
|
274
|
+
Limit concurrent access to a resource:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
import { Concurrency } from 'clutchit/concurrency';
|
|
278
|
+
|
|
279
|
+
const sem = new Concurrency.Semaphore({ permits: 3 });
|
|
280
|
+
|
|
281
|
+
const result = sem.execute(() => fetchData());
|
|
282
|
+
|
|
283
|
+
sem.available; // remaining permits
|
|
284
|
+
sem.waiting; // queued requests
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### RateLimiter
|
|
288
|
+
|
|
289
|
+
Sliding-window rate limiting:
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
const limiter = new Concurrency.RateLimiter({
|
|
293
|
+
limit: 100,
|
|
294
|
+
window: 60_000, // 100 requests per minute
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const result = limiter.execute(() => callApi());
|
|
298
|
+
// Err(RateLimitFault) if limit exceeded
|
|
299
|
+
|
|
300
|
+
limiter.remaining; // available capacity
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
`RateLimitFault` is `transient: true` with a `retryAfter` field.
|
|
304
|
+
|
|
305
|
+
### Bulkhead
|
|
306
|
+
|
|
307
|
+
Isolate failures by limiting concurrent executions with an optional queue:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
const bulkhead = new Concurrency.Bulkhead({
|
|
311
|
+
concurrency: 5,
|
|
312
|
+
queue: 10, // default: 0
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = bulkhead.execute(() => processRequest());
|
|
316
|
+
// Err(BulkheadRejectedFault) if capacity + queue full
|
|
317
|
+
|
|
318
|
+
bulkhead.activeCount; // currently executing
|
|
319
|
+
bulkhead.queueSize; // waiting in queue
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Ref
|
|
323
|
+
|
|
324
|
+
Thread-safe mutable reference (atomic via internal mutex):
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const counter = new Concurrency.Ref(0);
|
|
328
|
+
|
|
329
|
+
await counter.update((n) => n + 1);
|
|
330
|
+
await counter.set(0);
|
|
331
|
+
|
|
332
|
+
const prev = await counter.modify((n) => [n + 1, n]); // [newValue, result]
|
|
333
|
+
counter.get(); // current value (non-atomic read)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## `clutchit/circuit`
|
|
339
|
+
|
|
340
|
+
Circuit breaker with three states: **closed** (normal), **open** (rejecting), **half-open** (testing recovery).
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
import { Circuit } from 'clutchit/circuit';
|
|
344
|
+
import { Schedule } from 'clutchit/schedule';
|
|
345
|
+
|
|
346
|
+
const breaker = new Circuit.Breaker({
|
|
347
|
+
failureThreshold: 5,
|
|
348
|
+
resetSchedule: Schedule.exponential(30_000).pipe(Schedule.cappedDelay(300_000)),
|
|
349
|
+
halfOpenAttempts: 1,
|
|
350
|
+
isFailure: (error) => error.code !== 'NOT_FOUND',
|
|
351
|
+
onStateChange: (from, to) => console.log(`Circuit: ${from} -> ${to}`),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const result = breaker.protect(() => callService());
|
|
355
|
+
// Err(CircuitOpenFault) when circuit is open
|
|
356
|
+
|
|
357
|
+
breaker.state; // 'closed' | 'open' | 'half-open'
|
|
358
|
+
breaker.failureCount; // consecutive failures
|
|
359
|
+
breaker.reset(); // force-close
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### State Machine
|
|
363
|
+
|
|
364
|
+
```
|
|
365
|
+
CLOSED ──(failures >= threshold)──> OPEN
|
|
366
|
+
OPEN ──(reset delay expires)───> HALF-OPEN
|
|
367
|
+
HALF-OPEN ──(success)────────────> CLOSED
|
|
368
|
+
HALF-OPEN ──(failure)────────────> OPEN (progressive delay increase)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### CircuitBreakerOptions
|
|
372
|
+
|
|
373
|
+
| Option | Type | Default |
|
|
374
|
+
|--------|------|---------|
|
|
375
|
+
| `failureThreshold` | `number` | `5` |
|
|
376
|
+
| `resetSchedule` | `Schedule` | `constant(30_000)` |
|
|
377
|
+
| `halfOpenAttempts` | `number` | `1` |
|
|
378
|
+
| `isFailure` | `(error) => boolean` | All errors count |
|
|
379
|
+
| `onStateChange` | `(from, to) => void` | — |
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## `clutchit/queue`
|
|
384
|
+
|
|
385
|
+
In-memory bounded queues with backpressure. All blocking operations support `AbortSignal`.
|
|
386
|
+
|
|
387
|
+
### BoundedQueue
|
|
388
|
+
|
|
389
|
+
Both producers and consumers can block:
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
import { Queue } from 'clutchit/queue';
|
|
393
|
+
|
|
394
|
+
const q = new Queue.Bounded<Job>({ capacity: 100 });
|
|
395
|
+
|
|
396
|
+
q.offer(job); // Result<void, QueueFullFault> — non-blocking
|
|
397
|
+
await q.put(job); // blocks until space available
|
|
398
|
+
await q.put(job, signal); // cancellable
|
|
399
|
+
|
|
400
|
+
const item = await q.take(); // blocks until item available
|
|
401
|
+
const batch = await q.takeBatch(10); // at least 1, up to 10
|
|
402
|
+
const maybe = q.poll(); // Result<T, QueueEmptyFault> — non-blocking
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### DroppingQueue
|
|
406
|
+
|
|
407
|
+
Drops the **newest** item when full. Producer never blocks:
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
const q = new Queue.Dropping<Event>({ capacity: 1000 });
|
|
411
|
+
|
|
412
|
+
q.offer(event); // boolean — true if accepted, false if dropped
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### SlidingQueue
|
|
416
|
+
|
|
417
|
+
Drops the **oldest** item when full. Producer never blocks:
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
const q = new Queue.Sliding<Event>({ capacity: 1000 });
|
|
421
|
+
|
|
422
|
+
q.offer(event); // void — always accepts, evicts oldest if needed
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## Fault Model
|
|
428
|
+
|
|
429
|
+
Faults are classes created via `Fault.Tagged(code, category, transient?)`. Each class serves as both a value (constructor) and a type — no separate `type X = ReturnType<typeof X>` needed. Discriminated via `code` string literal and `instanceof`.
|
|
430
|
+
|
|
431
|
+
### Defining Faults
|
|
432
|
+
|
|
433
|
+
```typescript
|
|
434
|
+
import { Fault } from 'clutchit/unthrow';
|
|
435
|
+
|
|
436
|
+
// Domain fault with fields
|
|
437
|
+
class NotFoundFault extends Fault.Tagged('NOT_FOUND', 'domain')<{
|
|
438
|
+
resource: string;
|
|
439
|
+
id: string;
|
|
440
|
+
}>() {
|
|
441
|
+
readonly message = `${this.resource} "${this.id}" not found`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Infrastructure fault, no fields
|
|
445
|
+
class ServiceDownFault extends Fault.Tagged('SERVICE_DOWN', 'infrastructure')() {
|
|
446
|
+
readonly message = 'Service is unavailable';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Transient fault (eligible for automatic retry)
|
|
450
|
+
class TimeoutFault extends Fault.Tagged('TIMEOUT', 'infrastructure', true)<{
|
|
451
|
+
duration: number;
|
|
452
|
+
}>() {
|
|
453
|
+
readonly message = `Operation timed out after ${this.duration}ms`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Usage
|
|
457
|
+
const fault = new NotFoundFault({ resource: 'User', id: '123' });
|
|
458
|
+
fault.code; // 'NOT_FOUND'
|
|
459
|
+
fault.resource; // 'User'
|
|
460
|
+
fault instanceof NotFoundFault; // true
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Categories
|
|
464
|
+
|
|
465
|
+
| Category | `_category` | `_transient` | Meaning |
|
|
466
|
+
|----------|------------|-------------|---------|
|
|
467
|
+
| Domain | `'domain'` | `false` | Business rule violation — not retryable |
|
|
468
|
+
| Infrastructure | `'infrastructure'` | `false` | System failure — not retryable |
|
|
469
|
+
| Transient | `'infrastructure'` | `true` | Temporary failure — safe to retry |
|
|
470
|
+
|
|
471
|
+
### Type Guards
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
Fault.isTransient(fault); // infrastructure + transient
|
|
475
|
+
Fault.isDomain(fault); // domain fault
|
|
476
|
+
Fault.isInfrastructure(fault); // infrastructure (transient or not)
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### Built-in Faults
|
|
480
|
+
|
|
481
|
+
| Fault | Code | Transient | Module |
|
|
482
|
+
|-------|------|-----------|--------|
|
|
483
|
+
| `TimeoutFault` | `TIMEOUT` | yes | `clutchit/timeout` |
|
|
484
|
+
| `RateLimitFault` | `RATE_LIMITED` | yes | `clutchit/concurrency` |
|
|
485
|
+
| `CircuitOpenFault` | `CIRCUIT_OPEN` | no | `clutchit/circuit` |
|
|
486
|
+
| `BulkheadRejectedFault` | `BULKHEAD_REJECTED` | no | `clutchit/concurrency` |
|
|
487
|
+
| `ScheduleInterruptedFault` | `SCHEDULE_INTERRUPTED` | no | `clutchit/schedule` |
|
|
488
|
+
| `QueueFullFault` | `QUEUE_FULL` | no | `clutchit/queue` |
|
|
489
|
+
| `QueueEmptyFault` | `QUEUE_EMPTY` | no | `clutchit/queue` |
|
|
490
|
+
|
|
491
|
+
---
|
|
492
|
+
|
|
493
|
+
## Composing Primitives
|
|
494
|
+
|
|
495
|
+
Retry, timeout, and circuit breaker compose naturally:
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
import { Retry } from 'clutchit/retry';
|
|
499
|
+
import { Timeout } from 'clutchit/timeout';
|
|
500
|
+
import { Circuit } from 'clutchit/circuit';
|
|
501
|
+
import { Schedule } from 'clutchit/schedule';
|
|
502
|
+
|
|
503
|
+
const retry = new Retry.Policy({
|
|
504
|
+
schedule: Schedule.exponential(200).pipe(
|
|
505
|
+
Schedule.recurs(3),
|
|
506
|
+
Schedule.cappedDelay(5000),
|
|
507
|
+
Schedule.jittered(),
|
|
508
|
+
),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const breaker = new Circuit.Breaker({
|
|
512
|
+
failureThreshold: 5,
|
|
513
|
+
resetSchedule: Schedule.exponential(30_000),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
function callService() {
|
|
517
|
+
return breaker.protect(() =>
|
|
518
|
+
Timeout.wrap(
|
|
519
|
+
(signal) => retry.execute(
|
|
520
|
+
(signal) => doRequest(signal),
|
|
521
|
+
),
|
|
522
|
+
10_000,
|
|
523
|
+
),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
## License
|
|
529
|
+
|
|
530
|
+
[MIT](./LICENSE)
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { ResultAsync } from '../unthrow/index.js';
|
|
2
2
|
import { Schedule } from '../schedule/index.js';
|
|
3
3
|
export type CircuitState = 'closed' | 'open' | 'half-open';
|
|
4
|
-
|
|
5
|
-
code: "CIRCUIT_OPEN";
|
|
6
|
-
_category: "infrastructure";
|
|
7
|
-
_transient: false;
|
|
8
|
-
message: string;
|
|
9
|
-
} & Omit<{
|
|
10
|
-
message: string;
|
|
4
|
+
declare const CircuitOpenFault_base: abstract new (fields: {
|
|
11
5
|
remainingTimeout: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
6
|
+
}) => Readonly<{
|
|
7
|
+
remainingTimeout: number;
|
|
8
|
+
}> & {
|
|
9
|
+
readonly code: "CIRCUIT_OPEN";
|
|
10
|
+
readonly _category: "infrastructure";
|
|
11
|
+
readonly _transient: false;
|
|
12
|
+
readonly message: string;
|
|
13
|
+
};
|
|
14
|
+
export declare class CircuitOpenFault extends CircuitOpenFault_base {
|
|
15
|
+
readonly message: string;
|
|
16
|
+
}
|
|
14
17
|
export interface CircuitBreakerOptions {
|
|
15
18
|
/** Number of consecutive failures before opening. Default: 5 */
|
|
16
19
|
failureThreshold?: number;
|
|
@@ -44,4 +47,5 @@ export declare class CircuitBreaker {
|
|
|
44
47
|
private scheduleReset;
|
|
45
48
|
private transition;
|
|
46
49
|
}
|
|
47
|
-
|
|
50
|
+
export {};
|
|
51
|
+
//# sourceMappingURL=circuit.breaker.d.ts.map
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { Fault, ResultAsync } from '../unthrow/index.js';
|
|
2
2
|
import { Schedule } from '../schedule/index.js';
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
create: (remainingTimeout) => ({
|
|
7
|
-
message: `Circuit breaker is open. Retry after ${remainingTimeout}ms`,
|
|
8
|
-
remainingTimeout,
|
|
9
|
-
}),
|
|
10
|
-
});
|
|
3
|
+
export class CircuitOpenFault extends Fault.Tagged('CIRCUIT_OPEN', 'infrastructure')() {
|
|
4
|
+
message = `Circuit breaker is open. Retry after ${this.remainingTimeout}ms`;
|
|
5
|
+
}
|
|
11
6
|
export class CircuitBreaker {
|
|
12
7
|
_state = 'closed';
|
|
13
8
|
_failureCount = 0;
|
|
@@ -33,15 +28,15 @@ export class CircuitBreaker {
|
|
|
33
28
|
this._halfOpenTrials = 0;
|
|
34
29
|
}
|
|
35
30
|
else {
|
|
36
|
-
const
|
|
31
|
+
const remainingTimeout = this._nextResetTime
|
|
37
32
|
? Math.max(0, this._nextResetTime - Date.now())
|
|
38
33
|
: 0;
|
|
39
|
-
return ResultAsync.err(CircuitOpenFault(
|
|
34
|
+
return ResultAsync.err(new CircuitOpenFault({ remainingTimeout }));
|
|
40
35
|
}
|
|
41
36
|
}
|
|
42
37
|
if (this._state === 'half-open') {
|
|
43
38
|
if (this._halfOpenTrials >= this._maxHalfOpenAttempts) {
|
|
44
|
-
return ResultAsync.err(CircuitOpenFault(0));
|
|
39
|
+
return ResultAsync.err(new CircuitOpenFault({ remainingTimeout: 0 }));
|
|
45
40
|
}
|
|
46
41
|
this._halfOpenTrials++;
|
|
47
42
|
}
|
|
@@ -111,4 +106,4 @@ export class CircuitBreaker {
|
|
|
111
106
|
}
|
|
112
107
|
}
|
|
113
108
|
}
|
|
114
|
-
//# sourceMappingURL=circuit
|
|
109
|
+
//# sourceMappingURL=circuit.breaker.js.map
|
package/dist/circuit/index.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { CircuitBreaker
|
|
2
|
-
|
|
3
|
-
export
|
|
1
|
+
import { CircuitBreaker } from './circuit.breaker.js';
|
|
2
|
+
import type { CircuitState, CircuitBreakerOptions } from './circuit.breaker.js';
|
|
3
|
+
export { CircuitOpenFault } from './circuit.breaker.js';
|
|
4
4
|
export declare namespace Circuit {
|
|
5
|
-
const Breaker: typeof
|
|
5
|
+
const Breaker: typeof CircuitBreaker;
|
|
6
|
+
type Breaker = CircuitBreaker;
|
|
7
|
+
type BreakerOptions = CircuitBreakerOptions;
|
|
8
|
+
type State = CircuitState;
|
|
6
9
|
}
|
|
7
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/circuit/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { CircuitBreaker
|
|
1
|
+
import { CircuitBreaker } from './circuit.breaker.js';
|
|
2
|
+
export { CircuitOpenFault } from './circuit.breaker.js';
|
|
2
3
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
3
4
|
export var Circuit;
|
|
4
5
|
(function (Circuit) {
|
|
5
|
-
Circuit.Breaker =
|
|
6
|
+
Circuit.Breaker = CircuitBreaker;
|
|
6
7
|
})(Circuit || (Circuit = {}));
|
|
7
8
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { ResultAsync } from '../unthrow/index.js';
|
|
2
|
-
|
|
3
|
-
code: "BULKHEAD_REJECTED";
|
|
4
|
-
_category: "infrastructure";
|
|
5
|
-
_transient: false;
|
|
6
|
-
message: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
declare const BulkheadRejectedFault_base: abstract new () => Readonly<Record<string, unknown>> & {
|
|
3
|
+
readonly code: "BULKHEAD_REJECTED";
|
|
4
|
+
readonly _category: "infrastructure";
|
|
5
|
+
readonly _transient: false;
|
|
6
|
+
readonly message: string;
|
|
7
|
+
};
|
|
8
|
+
export declare class BulkheadRejectedFault extends BulkheadRejectedFault_base {
|
|
9
|
+
readonly message = "Bulkhead capacity exceeded";
|
|
10
|
+
}
|
|
11
11
|
export interface BulkheadOptions {
|
|
12
12
|
concurrency: number;
|
|
13
13
|
queue?: number;
|
|
@@ -24,4 +24,5 @@ export declare class Bulkhead {
|
|
|
24
24
|
private acquire;
|
|
25
25
|
private release;
|
|
26
26
|
}
|
|
27
|
+
export {};
|
|
27
28
|
//# sourceMappingURL=bulkhead.d.ts.map
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { Fault, ResultAsync } from '../unthrow/index.js';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
create: () => ({
|
|
6
|
-
message: 'Bulkhead capacity exceeded',
|
|
7
|
-
}),
|
|
8
|
-
});
|
|
2
|
+
export class BulkheadRejectedFault extends Fault.Tagged('BULKHEAD_REJECTED', 'infrastructure')() {
|
|
3
|
+
message = 'Bulkhead capacity exceeded';
|
|
4
|
+
}
|
|
9
5
|
export class Bulkhead {
|
|
10
6
|
_active = 0;
|
|
11
7
|
_maxConcurrency;
|
|
@@ -18,7 +14,7 @@ export class Bulkhead {
|
|
|
18
14
|
execute(fn) {
|
|
19
15
|
if (this._active >= this._maxConcurrency &&
|
|
20
16
|
this._queue.length >= this._maxQueue) {
|
|
21
|
-
return ResultAsync.err(BulkheadRejectedFault());
|
|
17
|
+
return ResultAsync.err(new BulkheadRejectedFault());
|
|
22
18
|
}
|
|
23
19
|
return ResultAsync.fromPromise((async () => {
|
|
24
20
|
await this.acquire();
|