@git-stunts/alfred 0.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/LICENSE +190 -0
- package/README.md +332 -0
- package/package.json +55 -0
- package/src/compose.js +153 -0
- package/src/errors.js +63 -0
- package/src/index.d.ts +136 -0
- package/src/index.js +29 -0
- package/src/policies/bulkhead.js +145 -0
- package/src/policies/circuit-breaker.js +189 -0
- package/src/policies/retry.js +170 -0
- package/src/policies/timeout.js +83 -0
- package/src/policy.js +271 -0
- package/src/telemetry.js +75 -0
- package/src/testing.d.ts +136 -0
- package/src/testing.js +8 -0
- package/src/utils/clock.js +109 -0
- package/src/utils/jitter.js +74 -0
package/src/compose.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Composition utilities for combining resilience policies.
|
|
3
|
+
*
|
|
4
|
+
* Provides functions to compose, fallback, and race policies, enabling
|
|
5
|
+
* complex resilience strategies through simple primitives.
|
|
6
|
+
*
|
|
7
|
+
* @module @git-stunts/alfred/compose
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} Policy
|
|
12
|
+
* @property {<T>(fn: () => Promise<T>) => Promise<T>} execute - Executes the function with the policy applied
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Composes multiple policies into a single policy.
|
|
17
|
+
*
|
|
18
|
+
* Policies are applied from outermost to innermost. The first policy wraps
|
|
19
|
+
* the second, which wraps the third, and so on. Each policy's execute method
|
|
20
|
+
* receives a function that invokes the next policy in the chain.
|
|
21
|
+
*
|
|
22
|
+
* @param {...Policy} policies - Policies to compose (outer to inner order)
|
|
23
|
+
* @returns {Policy} - Combined policy
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const resilient = compose(
|
|
27
|
+
* { execute: (fn) => timeout(5000, fn) },
|
|
28
|
+
* { execute: (fn) => retry(fn, { retries: 3 }) }
|
|
29
|
+
* );
|
|
30
|
+
* // timeout wraps retry: timeout(5000, () => retry(fn, { retries: 3 }))
|
|
31
|
+
* await resilient.execute(() => fetch(url));
|
|
32
|
+
*/
|
|
33
|
+
export function compose(...policies) {
|
|
34
|
+
if (policies.length === 0) {
|
|
35
|
+
return {
|
|
36
|
+
execute: (fn) => fn()
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
/**
|
|
42
|
+
* @template T
|
|
43
|
+
* @param {() => Promise<T>} fn - Function to execute
|
|
44
|
+
* @returns {Promise<T>}
|
|
45
|
+
*/
|
|
46
|
+
execute(fn) {
|
|
47
|
+
// Build the chain from innermost to outermost
|
|
48
|
+
// policies[0] is outermost, policies[n-1] is innermost
|
|
49
|
+
let chain = fn;
|
|
50
|
+
|
|
51
|
+
for (let i = policies.length - 1; i >= 0; i--) {
|
|
52
|
+
const policy = policies[i];
|
|
53
|
+
const next = chain;
|
|
54
|
+
chain = () => policy.execute(next);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return chain();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a fallback policy that tries a secondary policy if the primary fails.
|
|
64
|
+
*
|
|
65
|
+
* @param {Policy} primary - Primary policy to attempt first
|
|
66
|
+
* @param {Policy} secondary - Secondary policy to attempt on failure
|
|
67
|
+
* @returns {Policy} - Fallback policy
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* const withFallback = fallback(
|
|
71
|
+
* { execute: (fn) => fastCache.get(key) ?? fn() },
|
|
72
|
+
* { execute: (fn) => slowDatabase.get(key) ?? fn() }
|
|
73
|
+
* );
|
|
74
|
+
* await withFallback.execute(() => computeExpensiveValue());
|
|
75
|
+
*/
|
|
76
|
+
export function fallback(primary, secondary) {
|
|
77
|
+
return {
|
|
78
|
+
/**
|
|
79
|
+
* @template T
|
|
80
|
+
* @param {() => Promise<T>} fn - Function to execute
|
|
81
|
+
* @returns {Promise<T>}
|
|
82
|
+
*/
|
|
83
|
+
async execute(fn) {
|
|
84
|
+
try {
|
|
85
|
+
return await primary.execute(fn);
|
|
86
|
+
} catch {
|
|
87
|
+
return await secondary.execute(fn);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Creates a racing policy that runs two policies concurrently.
|
|
95
|
+
*
|
|
96
|
+
* Returns the result of whichever policy succeeds first.
|
|
97
|
+
* If both fail, throws the error from the first policy.
|
|
98
|
+
*
|
|
99
|
+
* @param {Policy} policyA - First policy to race
|
|
100
|
+
* @param {Policy} policyB - Second policy to race
|
|
101
|
+
* @returns {Policy} - Racing policy
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* const fastest = race(
|
|
105
|
+
* { execute: (fn) => primaryServer.fetch(url) },
|
|
106
|
+
* { execute: (fn) => backupServer.fetch(url) }
|
|
107
|
+
* );
|
|
108
|
+
* await fastest.execute(() => defaultFetch(url));
|
|
109
|
+
*/
|
|
110
|
+
export function race(policyA, policyB) {
|
|
111
|
+
return {
|
|
112
|
+
/**
|
|
113
|
+
* @template T
|
|
114
|
+
* @param {() => Promise<T>} fn - Function to execute
|
|
115
|
+
* @returns {Promise<T>}
|
|
116
|
+
*/
|
|
117
|
+
execute(fn) {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
let settled = false;
|
|
120
|
+
/** @type {Error | null} */
|
|
121
|
+
let firstError = null;
|
|
122
|
+
let failureCount = 0;
|
|
123
|
+
|
|
124
|
+
const handleSuccess = (result) => {
|
|
125
|
+
if (!settled) {
|
|
126
|
+
settled = true;
|
|
127
|
+
resolve(result);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const handleFailure = (error, isFirst) => {
|
|
132
|
+
if (settled) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isFirst) {
|
|
137
|
+
firstError = error;
|
|
138
|
+
}
|
|
139
|
+
failureCount++;
|
|
140
|
+
|
|
141
|
+
// If both have failed, reject with first error
|
|
142
|
+
if (failureCount === 2) {
|
|
143
|
+
settled = true;
|
|
144
|
+
reject(firstError);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
policyA.execute(fn).then(handleSuccess, (e) => handleFailure(e, true));
|
|
149
|
+
policyB.execute(fn).then(handleSuccess, (e) => handleFailure(e, false));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown when all retry attempts are exhausted.
|
|
3
|
+
*/
|
|
4
|
+
export class RetryExhaustedError extends Error {
|
|
5
|
+
/**
|
|
6
|
+
* @param {number} attempts - Total attempts made
|
|
7
|
+
* @param {Error} cause - The last error that caused the failure
|
|
8
|
+
*/
|
|
9
|
+
constructor(attempts, cause) {
|
|
10
|
+
super(`Retry exhausted after ${attempts} attempts: ${cause.message}`);
|
|
11
|
+
this.name = 'RetryExhaustedError';
|
|
12
|
+
this.attempts = attempts;
|
|
13
|
+
this.cause = cause;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Error thrown when circuit breaker is open.
|
|
19
|
+
*/
|
|
20
|
+
export class CircuitOpenError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Date} openedAt - When the circuit opened
|
|
23
|
+
* @param {number} failureCount - Number of failures that triggered opening
|
|
24
|
+
*/
|
|
25
|
+
constructor(openedAt, failureCount) {
|
|
26
|
+
super(`Circuit breaker is open (since ${openedAt.toISOString()}, ${failureCount} failures)`);
|
|
27
|
+
this.name = 'CircuitOpenError';
|
|
28
|
+
this.openedAt = openedAt;
|
|
29
|
+
this.failureCount = failureCount;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Error thrown when an operation times out.
|
|
35
|
+
*/
|
|
36
|
+
export class TimeoutError extends Error {
|
|
37
|
+
/**
|
|
38
|
+
* @param {number} timeout - Configured timeout in ms
|
|
39
|
+
* @param {number} elapsed - Actual elapsed time in ms
|
|
40
|
+
*/
|
|
41
|
+
constructor(timeout, elapsed) {
|
|
42
|
+
super(`Operation timed out after ${elapsed}ms (limit: ${timeout}ms)`);
|
|
43
|
+
this.name = 'TimeoutError';
|
|
44
|
+
this.timeout = timeout;
|
|
45
|
+
this.elapsed = elapsed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Error thrown when bulkhead queue is full.
|
|
51
|
+
*/
|
|
52
|
+
export class BulkheadRejectedError extends Error {
|
|
53
|
+
/**
|
|
54
|
+
* @param {number} limit - Max concurrent executions
|
|
55
|
+
* @param {number} queueLimit - Max pending requests
|
|
56
|
+
*/
|
|
57
|
+
constructor(limit, queueLimit) {
|
|
58
|
+
super(`Bulkhead rejected: queue full (limit: ${limit}, queue: ${queueLimit})`);
|
|
59
|
+
this.name = 'BulkheadRejectedError';
|
|
60
|
+
this.limit = limit;
|
|
61
|
+
this.queueLimit = queueLimit;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
retries?: number;
|
|
3
|
+
delay?: number;
|
|
4
|
+
maxDelay?: number;
|
|
5
|
+
backoff?: 'constant' | 'linear' | 'exponential';
|
|
6
|
+
jitter?: 'none' | 'full' | 'equal' | 'decorrelated';
|
|
7
|
+
shouldRetry?: (error: Error) => boolean;
|
|
8
|
+
onRetry?: (error: Error, attempt: number, delay: number) => void;
|
|
9
|
+
telemetry?: TelemetrySink;
|
|
10
|
+
clock?: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CircuitBreakerOptions {
|
|
14
|
+
threshold: number;
|
|
15
|
+
duration: number;
|
|
16
|
+
successThreshold?: number;
|
|
17
|
+
shouldTrip?: (error: Error) => boolean;
|
|
18
|
+
onOpen?: () => void;
|
|
19
|
+
onClose?: () => void;
|
|
20
|
+
onHalfOpen?: () => void;
|
|
21
|
+
telemetry?: TelemetrySink;
|
|
22
|
+
clock?: any;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TimeoutOptions {
|
|
26
|
+
onTimeout?: (elapsed: number) => void;
|
|
27
|
+
telemetry?: TelemetrySink;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BulkheadOptions {
|
|
31
|
+
limit: number;
|
|
32
|
+
queueLimit?: number;
|
|
33
|
+
telemetry?: TelemetrySink;
|
|
34
|
+
clock?: any;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TelemetryEvent {
|
|
38
|
+
type: string;
|
|
39
|
+
timestamp: number;
|
|
40
|
+
[key: string]: any;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TelemetrySink {
|
|
44
|
+
emit(event: TelemetryEvent): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class InMemorySink implements TelemetrySink {
|
|
48
|
+
events: TelemetryEvent[];
|
|
49
|
+
emit(event: TelemetryEvent): void;
|
|
50
|
+
clear(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class ConsoleSink implements TelemetrySink {
|
|
54
|
+
emit(event: TelemetryEvent): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class NoopSink implements TelemetrySink {
|
|
58
|
+
emit(event: TelemetryEvent): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class MultiSink implements TelemetrySink {
|
|
62
|
+
constructor(sinks: TelemetrySink[]);
|
|
63
|
+
emit(event: TelemetryEvent): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class RetryExhaustedError extends Error {
|
|
67
|
+
attempts: number;
|
|
68
|
+
cause: Error;
|
|
69
|
+
constructor(attempts: number, cause: Error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class CircuitOpenError extends Error {
|
|
73
|
+
openedAt: Date;
|
|
74
|
+
failureCount: number;
|
|
75
|
+
constructor(openedAt: Date, failureCount: number);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class TimeoutError extends Error {
|
|
79
|
+
timeout: number;
|
|
80
|
+
elapsed: number;
|
|
81
|
+
constructor(timeout: number, elapsed: number);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class BulkheadRejectedError extends Error {
|
|
85
|
+
limit: number;
|
|
86
|
+
queueLimit: number;
|
|
87
|
+
constructor(limit: number, queueLimit: number);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function retry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
91
|
+
|
|
92
|
+
export interface CircuitBreaker {
|
|
93
|
+
execute<T>(fn: () => Promise<T>): Promise<T>;
|
|
94
|
+
readonly state: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function circuitBreaker(options: CircuitBreakerOptions): CircuitBreaker;
|
|
98
|
+
|
|
99
|
+
export function timeout<T>(ms: number, fn: ((signal: AbortSignal) => Promise<T>) | (() => Promise<T>), options?: TimeoutOptions): Promise<T>;
|
|
100
|
+
|
|
101
|
+
export interface Bulkhead {
|
|
102
|
+
execute<T>(fn: () => Promise<T>): Promise<T>;
|
|
103
|
+
readonly stats: { active: number; pending: number; available: number };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function bulkhead(options: BulkheadOptions): Bulkhead;
|
|
107
|
+
|
|
108
|
+
export function compose(...policies: any[]): { execute<T>(fn: () => Promise<T>): Promise<T> };
|
|
109
|
+
export function fallback(primary: any, secondary: any): { execute<T>(fn: () => Promise<T>): Promise<T> };
|
|
110
|
+
export function race(primary: any, secondary: any): { execute<T>(fn: () => Promise<T>): Promise<T> };
|
|
111
|
+
|
|
112
|
+
export class Policy {
|
|
113
|
+
constructor(executor: (fn: () => Promise<any>) => Promise<any>);
|
|
114
|
+
static retry(options?: RetryOptions): Policy;
|
|
115
|
+
static circuitBreaker(options: CircuitBreakerOptions): Policy;
|
|
116
|
+
static timeout(ms: number, options?: TimeoutOptions): Policy;
|
|
117
|
+
static bulkhead(options: BulkheadOptions): Policy;
|
|
118
|
+
static noop(): Policy;
|
|
119
|
+
|
|
120
|
+
wrap(otherPolicy: Policy): Policy;
|
|
121
|
+
or(otherPolicy: Policy): Policy;
|
|
122
|
+
race(otherPolicy: Policy): Policy;
|
|
123
|
+
execute<T>(fn: () => Promise<T>): Promise<T>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class SystemClock {
|
|
127
|
+
now(): number;
|
|
128
|
+
sleep(ms: number): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class TestClock {
|
|
132
|
+
now(): number;
|
|
133
|
+
sleep(ms: number): Promise<void>;
|
|
134
|
+
tick(ms?: number): Promise<void>;
|
|
135
|
+
advance(ms: number): Promise<void>;
|
|
136
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Main entry point for @git-stunts/alfred resilience library.
|
|
3
|
+
* Exports all public APIs for building resilient applications.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// @ts-self-types="./index.d.ts"
|
|
7
|
+
|
|
8
|
+
// Error types
|
|
9
|
+
export {
|
|
10
|
+
RetryExhaustedError,
|
|
11
|
+
CircuitOpenError,
|
|
12
|
+
TimeoutError,
|
|
13
|
+
BulkheadRejectedError
|
|
14
|
+
} from './errors.js';
|
|
15
|
+
|
|
16
|
+
// Resilience policies
|
|
17
|
+
export { retry } from './policies/retry.js';
|
|
18
|
+
export { circuitBreaker } from './policies/circuit-breaker.js';
|
|
19
|
+
export { timeout } from './policies/timeout.js';
|
|
20
|
+
export { bulkhead } from './policies/bulkhead.js';
|
|
21
|
+
|
|
22
|
+
// Composition utilities
|
|
23
|
+
export { compose, fallback, race } from './compose.js';
|
|
24
|
+
|
|
25
|
+
// Base policy class
|
|
26
|
+
export { Policy, Policy as default } from './policy.js';
|
|
27
|
+
|
|
28
|
+
// Clock utilities
|
|
29
|
+
export { SystemClock, TestClock } from './utils/clock.js';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Bulkhead policy for concurrency limiting.
|
|
3
|
+
*
|
|
4
|
+
* Limits the number of concurrent executions of an operation,
|
|
5
|
+
* optionally queuing excess requests up to a limit.
|
|
6
|
+
*
|
|
7
|
+
* @module @git-stunts/alfred/policies/bulkhead
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { BulkheadRejectedError } from '../errors.js';
|
|
11
|
+
import { SystemClock } from '../utils/clock.js';
|
|
12
|
+
import { NoopSink } from '../telemetry.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} BulkheadOptions
|
|
16
|
+
* @property {number} limit - Maximum concurrent executions
|
|
17
|
+
* @property {number} [queueLimit=0] - Maximum pending requests in queue
|
|
18
|
+
* @property {import('../telemetry.js').TelemetrySink} [telemetry] - Telemetry sink
|
|
19
|
+
* @property {{ now(): number }} [clock] - Clock for timestamps
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} BulkheadStats
|
|
24
|
+
* @property {number} active - Currently executing requests
|
|
25
|
+
* @property {number} pending - Requests waiting in queue
|
|
26
|
+
* @property {number} available - Remaining execution slots
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
class BulkheadPolicy {
|
|
30
|
+
constructor(options) {
|
|
31
|
+
const {
|
|
32
|
+
limit,
|
|
33
|
+
queueLimit = 0,
|
|
34
|
+
telemetry = new NoopSink(),
|
|
35
|
+
clock = new SystemClock()
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
if (limit <= 0) {
|
|
39
|
+
throw new Error('Bulkhead limit must be greater than 0');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.limit = limit;
|
|
43
|
+
this.queueLimit = queueLimit;
|
|
44
|
+
this.telemetry = telemetry;
|
|
45
|
+
this.clock = clock;
|
|
46
|
+
|
|
47
|
+
this.active = 0;
|
|
48
|
+
this.queue = [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
processQueue() {
|
|
52
|
+
if (this.active < this.limit && this.queue.length > 0) {
|
|
53
|
+
const { fn, resolve, reject } = this.queue.shift();
|
|
54
|
+
this.active++;
|
|
55
|
+
|
|
56
|
+
this.emitEvent('bulkhead.execute', {
|
|
57
|
+
active: this.active,
|
|
58
|
+
pending: this.queue.length
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
Promise.resolve()
|
|
62
|
+
.then(() => fn())
|
|
63
|
+
.then(resolve, reject)
|
|
64
|
+
.finally(() => {
|
|
65
|
+
this.active--;
|
|
66
|
+
this.emitEvent('bulkhead.complete', {
|
|
67
|
+
active: this.active,
|
|
68
|
+
pending: this.queue.length
|
|
69
|
+
});
|
|
70
|
+
this.processQueue();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
emitEvent(type, data) {
|
|
76
|
+
this.telemetry.emit({
|
|
77
|
+
type,
|
|
78
|
+
timestamp: this.clock.now(),
|
|
79
|
+
...data
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async execute(fn) {
|
|
84
|
+
if (this.active < this.limit) {
|
|
85
|
+
this.active++;
|
|
86
|
+
this.emitEvent('bulkhead.execute', {
|
|
87
|
+
active: this.active,
|
|
88
|
+
pending: this.queue.length
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
return await fn();
|
|
93
|
+
} finally {
|
|
94
|
+
this.active--;
|
|
95
|
+
this.emitEvent('bulkhead.complete', {
|
|
96
|
+
active: this.active,
|
|
97
|
+
pending: this.queue.length
|
|
98
|
+
});
|
|
99
|
+
this.processQueue();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (this.queue.length < this.queueLimit) {
|
|
104
|
+
this.emitEvent('bulkhead.queued', {
|
|
105
|
+
active: this.active,
|
|
106
|
+
pending: this.queue.length + 1
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
this.queue.push({ fn, resolve, reject });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.emitEvent('bulkhead.reject', {
|
|
115
|
+
active: this.active,
|
|
116
|
+
pending: this.queue.length
|
|
117
|
+
});
|
|
118
|
+
throw new BulkheadRejectedError(this.limit, this.queueLimit);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get stats() {
|
|
122
|
+
return {
|
|
123
|
+
active: this.active,
|
|
124
|
+
pending: this.queue.length,
|
|
125
|
+
available: Math.max(0, this.limit - this.active)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Creates a bulkhead policy.
|
|
132
|
+
*
|
|
133
|
+
* @param {BulkheadOptions} options - Bulkhead configuration
|
|
134
|
+
* @returns {{ execute: <T>(fn: () => Promise<T>) => Promise<T>, stats: BulkheadStats }}
|
|
135
|
+
*/
|
|
136
|
+
export function bulkhead(options) {
|
|
137
|
+
const policy = new BulkheadPolicy(options);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
execute: (fn) => policy.execute(fn),
|
|
141
|
+
get stats() {
|
|
142
|
+
return policy.stats;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { CircuitOpenError } from '../errors.js';
|
|
2
|
+
import { SystemClock } from '../utils/clock.js';
|
|
3
|
+
import { NoopSink } from '../telemetry.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Circuit breaker states.
|
|
7
|
+
* @readonly
|
|
8
|
+
* @enum {string}
|
|
9
|
+
*/
|
|
10
|
+
const State = {
|
|
11
|
+
CLOSED: 'CLOSED',
|
|
12
|
+
OPEN: 'OPEN',
|
|
13
|
+
HALF_OPEN: 'HALF_OPEN'
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} CircuitBreakerOptions
|
|
18
|
+
* @property {number} threshold - Number of failures before opening circuit (required)
|
|
19
|
+
* @property {number} duration - Milliseconds to stay open before transitioning to half-open (required)
|
|
20
|
+
* @property {number} [successThreshold=1] - Consecutive successes in half-open to close circuit
|
|
21
|
+
* @property {(error: Error) => boolean} [shouldTrip] - Predicate to determine if error should count as failure
|
|
22
|
+
* @property {() => void} [onOpen] - Callback when circuit opens
|
|
23
|
+
* @property {() => void} [onClose] - Callback when circuit closes
|
|
24
|
+
* @property {() => void} [onHalfOpen] - Callback when circuit transitions to half-open
|
|
25
|
+
* @property {{ now(): number }} [clock] - Clock implementation for testing
|
|
26
|
+
* @property {import('../telemetry.js').TelemetrySink} [telemetry] - Telemetry sink
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} CircuitBreaker
|
|
31
|
+
* @property {<T>(fn: () => Promise<T>) => Promise<T>} execute - Executes function with circuit breaker protection
|
|
32
|
+
* @property {string} state - Current circuit state (CLOSED, OPEN, HALF_OPEN)
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
class CircuitBreakerPolicy {
|
|
36
|
+
constructor(options) {
|
|
37
|
+
const {
|
|
38
|
+
threshold,
|
|
39
|
+
duration,
|
|
40
|
+
successThreshold = 1,
|
|
41
|
+
shouldTrip = () => true,
|
|
42
|
+
onOpen,
|
|
43
|
+
onClose,
|
|
44
|
+
onHalfOpen,
|
|
45
|
+
clock = new SystemClock(),
|
|
46
|
+
telemetry = new NoopSink()
|
|
47
|
+
} = options;
|
|
48
|
+
|
|
49
|
+
if (threshold === undefined || threshold === null) {
|
|
50
|
+
throw new Error('threshold is required');
|
|
51
|
+
}
|
|
52
|
+
if (duration === undefined || duration === null) {
|
|
53
|
+
throw new Error('duration is required');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.options = {
|
|
57
|
+
threshold,
|
|
58
|
+
duration,
|
|
59
|
+
successThreshold,
|
|
60
|
+
shouldTrip,
|
|
61
|
+
onOpen,
|
|
62
|
+
onClose,
|
|
63
|
+
onHalfOpen,
|
|
64
|
+
clock,
|
|
65
|
+
telemetry
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
this._state = State.CLOSED;
|
|
69
|
+
this.failureCount = 0;
|
|
70
|
+
this.successCount = 0;
|
|
71
|
+
this.openedAt = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get state() {
|
|
75
|
+
return this._state;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
emitEvent(type, data) {
|
|
79
|
+
this.options.telemetry.emit({
|
|
80
|
+
type,
|
|
81
|
+
timestamp: this.options.clock.now(),
|
|
82
|
+
...data
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
open() {
|
|
87
|
+
this._state = State.OPEN;
|
|
88
|
+
this.openedAt = new Date(this.options.clock.now());
|
|
89
|
+
this.options.onOpen?.();
|
|
90
|
+
this.emitEvent('circuit.open', { failureCount: this.failureCount });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
close() {
|
|
94
|
+
this._state = State.CLOSED;
|
|
95
|
+
this.failureCount = 0;
|
|
96
|
+
this.successCount = 0;
|
|
97
|
+
this.openedAt = null;
|
|
98
|
+
this.options.onClose?.();
|
|
99
|
+
this.emitEvent('circuit.close');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
halfOpen() {
|
|
103
|
+
this._state = State.HALF_OPEN;
|
|
104
|
+
this.successCount = 0;
|
|
105
|
+
this.options.onHalfOpen?.();
|
|
106
|
+
this.emitEvent('circuit.half-open');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
shouldAttemptReset() {
|
|
110
|
+
if (this._state !== State.OPEN || !this.openedAt) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const elapsed = this.options.clock.now() - this.openedAt.getTime();
|
|
114
|
+
return elapsed >= this.options.duration;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
recordSuccess() {
|
|
118
|
+
this.emitEvent('circuit.success', { state: this._state });
|
|
119
|
+
|
|
120
|
+
if (this._state === State.HALF_OPEN) {
|
|
121
|
+
this.successCount++;
|
|
122
|
+
if (this.successCount >= this.options.successThreshold) {
|
|
123
|
+
this.close();
|
|
124
|
+
}
|
|
125
|
+
} else if (this._state === State.CLOSED) {
|
|
126
|
+
this.failureCount = 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
recordFailure(error) {
|
|
131
|
+
if (!this.options.shouldTrip(error)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.emitEvent('circuit.failure', {
|
|
136
|
+
error,
|
|
137
|
+
state: this._state
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (this._state === State.HALF_OPEN) {
|
|
141
|
+
this.open();
|
|
142
|
+
} else if (this._state === State.CLOSED) {
|
|
143
|
+
this.failureCount++;
|
|
144
|
+
if (this.failureCount >= this.options.threshold) {
|
|
145
|
+
this.open();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async execute(fn) {
|
|
151
|
+
if (this.shouldAttemptReset()) {
|
|
152
|
+
this.halfOpen();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (this._state === State.OPEN) {
|
|
156
|
+
this.emitEvent('circuit.reject', {
|
|
157
|
+
openedAt: this.openedAt,
|
|
158
|
+
failureCount: this.failureCount
|
|
159
|
+
});
|
|
160
|
+
throw new CircuitOpenError(this.openedAt, this.failureCount);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const result = await fn();
|
|
165
|
+
this.recordSuccess();
|
|
166
|
+
return result;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.recordFailure(error);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Creates a circuit breaker that prevents cascading failures by failing fast
|
|
176
|
+
* when a dependency is unhealthy.
|
|
177
|
+
*
|
|
178
|
+
* @param {CircuitBreakerOptions} options - Configuration options
|
|
179
|
+
* @returns {CircuitBreaker} Circuit breaker instance
|
|
180
|
+
*/
|
|
181
|
+
export function circuitBreaker(options) {
|
|
182
|
+
const policy = new CircuitBreakerPolicy(options);
|
|
183
|
+
return {
|
|
184
|
+
execute: (fn) => policy.execute(fn),
|
|
185
|
+
get state() {
|
|
186
|
+
return policy.state;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|