@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Retry policy for resilient async operations.
|
|
3
|
+
*
|
|
4
|
+
* Provides configurable retry logic with multiple backoff strategies
|
|
5
|
+
* and jitter options to prevent thundering herd problems.
|
|
6
|
+
*
|
|
7
|
+
* @module @git-stunts/alfred/policies/retry
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { SystemClock } from '../utils/clock.js';
|
|
11
|
+
import { createJitter } from '../utils/jitter.js';
|
|
12
|
+
import { RetryExhaustedError } from '../errors.js';
|
|
13
|
+
import { NoopSink } from '../telemetry.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {'constant' | 'linear' | 'exponential'} BackoffStrategy
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {'none' | 'full' | 'equal' | 'decorrelated'} JitterStrategy
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} RetryOptions
|
|
25
|
+
* @property {number} [retries=3] - Maximum number of retry attempts
|
|
26
|
+
* @property {number} [delay=1000] - Base delay in milliseconds
|
|
27
|
+
* @property {number} [maxDelay=30000] - Maximum delay cap in milliseconds
|
|
28
|
+
* @property {BackoffStrategy} [backoff='constant'] - Backoff strategy
|
|
29
|
+
* @property {JitterStrategy} [jitter='none'] - Jitter strategy
|
|
30
|
+
* @property {(error: Error) => boolean} [shouldRetry] - Predicate to determine if error is retryable
|
|
31
|
+
* @property {(error: Error, attempt: number, delay: number) => void} [onRetry] - Callback invoked before each retry
|
|
32
|
+
* @property {{ now(): number, sleep(ms: number): Promise<void> }} [clock] - Clock for testing
|
|
33
|
+
* @property {import('../telemetry.js').TelemetrySink} [telemetry] - Telemetry sink
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const DEFAULT_OPTIONS = {
|
|
37
|
+
retries: 3,
|
|
38
|
+
delay: 1000,
|
|
39
|
+
maxDelay: 30000,
|
|
40
|
+
backoff: 'constant',
|
|
41
|
+
jitter: 'none'
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function calculateBackoff(strategy, baseDelay, attempt) {
|
|
45
|
+
switch (strategy) {
|
|
46
|
+
case 'linear':
|
|
47
|
+
return baseDelay * attempt;
|
|
48
|
+
case 'exponential':
|
|
49
|
+
return baseDelay * Math.pow(2, attempt - 1);
|
|
50
|
+
case 'constant':
|
|
51
|
+
default:
|
|
52
|
+
return baseDelay;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class RetryExecutor {
|
|
57
|
+
constructor(fn, options) {
|
|
58
|
+
this.fn = fn;
|
|
59
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
60
|
+
this.clock = options.clock || new SystemClock();
|
|
61
|
+
this.telemetry = options.telemetry || new NoopSink();
|
|
62
|
+
this.applyJitter = createJitter(this.options.jitter);
|
|
63
|
+
this.prevDelay = this.options.delay;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
calculateDelay(attempt) {
|
|
67
|
+
const { backoff, delay: baseDelay, maxDelay, jitter } = this.options;
|
|
68
|
+
const rawDelay = calculateBackoff(backoff, baseDelay, attempt);
|
|
69
|
+
|
|
70
|
+
if (jitter === 'decorrelated') {
|
|
71
|
+
const actual = this.applyJitter(baseDelay, this.prevDelay, maxDelay);
|
|
72
|
+
this.prevDelay = actual;
|
|
73
|
+
return actual;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Math.min(this.applyJitter(rawDelay), maxDelay);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async execute() {
|
|
80
|
+
const totalAttempts = this.options.retries + 1;
|
|
81
|
+
|
|
82
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
83
|
+
const shouldStop = await this.tryAttempt(attempt, totalAttempts);
|
|
84
|
+
if (shouldStop) {
|
|
85
|
+
return shouldStop.result;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Should be unreachable if logic is correct, but satisfied strict returns
|
|
90
|
+
throw new Error('Unexpected retry loop termination');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async tryAttempt(attempt) {
|
|
94
|
+
const startTime = this.clock.now();
|
|
95
|
+
try {
|
|
96
|
+
const result = await this.fn();
|
|
97
|
+
this.emitSuccess(attempt, startTime);
|
|
98
|
+
return { result };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.handleFailure(error, attempt, startTime);
|
|
101
|
+
// If we didn't throw in handleFailure, we need to wait
|
|
102
|
+
// But we need to calculate delay first
|
|
103
|
+
const delay = this.calculateDelay(attempt);
|
|
104
|
+
this.emitScheduled(attempt, delay, error);
|
|
105
|
+
|
|
106
|
+
if (this.options.onRetry) {
|
|
107
|
+
this.options.onRetry(error, attempt, delay);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await this.clock.sleep(delay);
|
|
111
|
+
return null; // Continue loop
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
emitSuccess(attempt, startTime) {
|
|
116
|
+
this.telemetry.emit({
|
|
117
|
+
type: 'retry.success',
|
|
118
|
+
timestamp: this.clock.now(),
|
|
119
|
+
attempt,
|
|
120
|
+
duration: this.clock.now() - startTime
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
emitScheduled(attempt, delay, error) {
|
|
125
|
+
this.telemetry.emit({
|
|
126
|
+
type: 'retry.scheduled',
|
|
127
|
+
timestamp: this.clock.now(),
|
|
128
|
+
attempt,
|
|
129
|
+
delay,
|
|
130
|
+
error
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
handleFailure(error, attempt, startTime) {
|
|
135
|
+
this.telemetry.emit({
|
|
136
|
+
type: 'retry.failure',
|
|
137
|
+
timestamp: this.clock.now(),
|
|
138
|
+
attempt,
|
|
139
|
+
error,
|
|
140
|
+
duration: this.clock.now() - startTime
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (this.options.shouldRetry && !this.options.shouldRetry(error)) {
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const totalAttempts = this.options.retries + 1;
|
|
148
|
+
if (attempt >= totalAttempts) {
|
|
149
|
+
this.telemetry.emit({
|
|
150
|
+
type: 'retry.exhausted',
|
|
151
|
+
timestamp: this.clock.now(),
|
|
152
|
+
attempts: attempt,
|
|
153
|
+
error
|
|
154
|
+
});
|
|
155
|
+
throw new RetryExhaustedError(attempt, error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Executes an async function with configurable retry logic.
|
|
162
|
+
*
|
|
163
|
+
* @template T
|
|
164
|
+
* @param {() => Promise<T>} fn - Async function to execute
|
|
165
|
+
* @param {RetryOptions} [options={}] - Retry configuration
|
|
166
|
+
* @returns {Promise<T>} Result of the successful execution
|
|
167
|
+
*/
|
|
168
|
+
export async function retry(fn, options = {}) {
|
|
169
|
+
return new RetryExecutor(fn, options).execute();
|
|
170
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Timeout policy for async operations.
|
|
3
|
+
*
|
|
4
|
+
* Provides time-limited execution with AbortSignal support for
|
|
5
|
+
* cooperative cancellation of in-flight operations.
|
|
6
|
+
*
|
|
7
|
+
* @module @git-stunts/alfred/policies/timeout
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { TimeoutError } from '../errors.js';
|
|
11
|
+
import { NoopSink } from '../telemetry.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} TimeoutOptions
|
|
15
|
+
* @property {(elapsed: number) => void} [onTimeout] - Callback invoked when timeout occurs
|
|
16
|
+
* @property {import('../telemetry.js').TelemetrySink} [telemetry] - Telemetry sink
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Executes a function with a timeout limit.
|
|
21
|
+
*
|
|
22
|
+
* If the function accepts an argument, an AbortSignal is passed to allow
|
|
23
|
+
* cooperative cancellation of in-flight operations (e.g., fetch requests).
|
|
24
|
+
*
|
|
25
|
+
* @template T
|
|
26
|
+
* @param {number} ms - Timeout duration in milliseconds
|
|
27
|
+
* @param {((signal: AbortSignal) => Promise<T>) | (() => Promise<T>)} fn - Function to execute
|
|
28
|
+
* @param {TimeoutOptions} [options={}] - Optional configuration
|
|
29
|
+
* @returns {Promise<T>} - Result of the function if it completes in time
|
|
30
|
+
* @throws {TimeoutError} - If the operation exceeds the timeout
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* // Simple usage
|
|
34
|
+
* const result = await timeout(5000, () => slowOperation());
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* // With AbortSignal for fetch
|
|
38
|
+
* const result = await timeout(5000, (signal) => fetch(url, { signal }));
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // With onTimeout callback
|
|
42
|
+
* const result = await timeout(5000, () => slowOperation(), {
|
|
43
|
+
* onTimeout: (elapsed) => console.log(`Timed out after ${elapsed}ms`)
|
|
44
|
+
* });
|
|
45
|
+
*/
|
|
46
|
+
export async function timeout(ms, fn, options = {}) {
|
|
47
|
+
const { onTimeout, telemetry = new NoopSink() } = options;
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const startTime = Date.now();
|
|
50
|
+
|
|
51
|
+
let timeoutId;
|
|
52
|
+
|
|
53
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
54
|
+
timeoutId = setTimeout(() => {
|
|
55
|
+
controller.abort();
|
|
56
|
+
const elapsed = Date.now() - startTime;
|
|
57
|
+
|
|
58
|
+
if (onTimeout) {
|
|
59
|
+
onTimeout(elapsed);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
telemetry.emit({
|
|
63
|
+
type: 'timeout',
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
timeout: ms,
|
|
66
|
+
elapsed
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
reject(new TimeoutError(ms, elapsed));
|
|
70
|
+
}, ms);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Check if fn expects an argument (signal)
|
|
75
|
+
const fnAcceptsSignal = fn.length > 0;
|
|
76
|
+
const operationPromise = fnAcceptsSignal ? fn(controller.signal) : fn();
|
|
77
|
+
|
|
78
|
+
const result = await Promise.race([operationPromise, timeoutPromise]);
|
|
79
|
+
return result;
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timeoutId);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {(fn: () => Promise<T>) => Promise<T>} Executor
|
|
3
|
+
* @template T
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} RetryOptions
|
|
8
|
+
* @property {number} [retries=3] - Maximum retry attempts
|
|
9
|
+
* @property {number} [delay=1000] - Base delay in milliseconds
|
|
10
|
+
* @property {number} [maxDelay=30000] - Maximum delay cap
|
|
11
|
+
* @property {'constant' | 'linear' | 'exponential'} [backoff='constant'] - Backoff strategy
|
|
12
|
+
* @property {'none' | 'full' | 'equal' | 'decorrelated'} [jitter='none'] - Jitter strategy
|
|
13
|
+
* @property {(error: Error) => boolean} [shouldRetry] - Predicate to filter retryable errors
|
|
14
|
+
* @property {(error: Error, attempt: number, delay: number) => void} [onRetry] - Callback on each retry
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} CircuitBreakerOptions
|
|
19
|
+
* @property {number} threshold - Failures before opening
|
|
20
|
+
* @property {number} duration - How long to stay open (ms)
|
|
21
|
+
* @property {number} [successThreshold=1] - Successes to close from half-open
|
|
22
|
+
* @property {(error: Error) => boolean} [shouldTrip] - Which errors count as failures
|
|
23
|
+
* @property {() => void} [onOpen] - Called when circuit opens
|
|
24
|
+
* @property {() => void} [onClose] - Called when circuit closes
|
|
25
|
+
* @property {() => void} [onHalfOpen] - Called when entering half-open
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} TimeoutOptions
|
|
30
|
+
* @property {(elapsed: number) => void} [onTimeout] - Called when operation times out
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { retry } from './policies/retry.js';
|
|
34
|
+
import { circuitBreaker } from './policies/circuit-breaker.js';
|
|
35
|
+
import { timeout } from './policies/timeout.js';
|
|
36
|
+
import { bulkhead } from './policies/bulkhead.js';
|
|
37
|
+
import { compose, fallback, race } from './compose.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fluent API for building resilience policies.
|
|
41
|
+
*
|
|
42
|
+
* Provides a chainable, immutable interface for composing retry, circuit breaker,
|
|
43
|
+
* timeout, and other resilience patterns.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // Build a resilient policy with retry, timeout, and fallback
|
|
47
|
+
* const policy = Policy.retry({ retries: 3, backoff: 'exponential' })
|
|
48
|
+
* .wrap(Policy.timeout(5000))
|
|
49
|
+
* .or(Policy.retry({ retries: 1, delay: 5000 }));
|
|
50
|
+
*
|
|
51
|
+
* const result = await policy.execute(() => fetch(url));
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* // Race two strategies
|
|
55
|
+
* const policy = Policy.timeout(1000)
|
|
56
|
+
* .race(Policy.timeout(2000));
|
|
57
|
+
*
|
|
58
|
+
* // First to complete wins
|
|
59
|
+
* const result = await policy.execute(() => fetch(url));
|
|
60
|
+
*/
|
|
61
|
+
export class Policy {
|
|
62
|
+
/**
|
|
63
|
+
* Creates a new Policy with the given executor function.
|
|
64
|
+
* @param {Executor<any>} executor - Function that takes fn and returns promise
|
|
65
|
+
* @private
|
|
66
|
+
*/
|
|
67
|
+
constructor(executor) {
|
|
68
|
+
this._executor = executor;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Static Factory Methods
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Creates a Policy that retries failed operations.
|
|
77
|
+
*
|
|
78
|
+
* @param {RetryOptions} [options={}] - Retry configuration
|
|
79
|
+
* @returns {Policy} A new Policy wrapping retry behavior
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* const policy = Policy.retry({ retries: 3, backoff: 'exponential' });
|
|
83
|
+
* await policy.execute(() => unstableOperation());
|
|
84
|
+
*/
|
|
85
|
+
static retry(options = {}) {
|
|
86
|
+
return new Policy((fn) => retry(fn, options));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Creates a Policy that fails fast when a service is degraded.
|
|
91
|
+
*
|
|
92
|
+
* Note: Circuit breakers are stateful. Each call to this factory creates
|
|
93
|
+
* a new circuit breaker instance with its own state.
|
|
94
|
+
*
|
|
95
|
+
* @param {CircuitBreakerOptions} options - Circuit breaker configuration
|
|
96
|
+
* @returns {Policy} A new Policy wrapping circuit breaker behavior
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* const policy = Policy.circuitBreaker({ threshold: 5, duration: 60000 });
|
|
100
|
+
* await policy.execute(() => callExternalService());
|
|
101
|
+
*/
|
|
102
|
+
static circuitBreaker(options) {
|
|
103
|
+
const breaker = circuitBreaker(options);
|
|
104
|
+
return new Policy((fn) => breaker.execute(fn));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Creates a Policy that enforces a time limit on operations.
|
|
109
|
+
*
|
|
110
|
+
* @param {number} ms - Timeout in milliseconds
|
|
111
|
+
* @param {TimeoutOptions} [options={}] - Timeout configuration
|
|
112
|
+
* @returns {Policy} A new Policy wrapping timeout behavior
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* const policy = Policy.timeout(5000);
|
|
116
|
+
* await policy.execute(() => slowOperation());
|
|
117
|
+
*/
|
|
118
|
+
static timeout(ms, options = {}) {
|
|
119
|
+
return new Policy((fn) => timeout(ms, fn, options));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates a Policy that limits concurrent executions.
|
|
124
|
+
*
|
|
125
|
+
* @param {import('./policies/bulkhead.js').BulkheadOptions} options - Bulkhead configuration
|
|
126
|
+
* @returns {Policy} A new Policy wrapping bulkhead behavior
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* const policy = Policy.bulkhead({ limit: 10, queueLimit: 50 });
|
|
130
|
+
* await policy.execute(() => heavyOperation());
|
|
131
|
+
*/
|
|
132
|
+
static bulkhead(options) {
|
|
133
|
+
const limiter = bulkhead(options);
|
|
134
|
+
return new Policy((fn) => limiter.execute(fn));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Creates a no-op Policy that passes through to the function directly.
|
|
139
|
+
*
|
|
140
|
+
* Useful as a starting point for building policies or for conditional
|
|
141
|
+
* composition where you might want to skip certain policies.
|
|
142
|
+
*
|
|
143
|
+
* @returns {Policy} A pass-through Policy
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* const base = Policy.noop();
|
|
147
|
+
* const withRetry = shouldRetry ? base.wrap(Policy.retry()) : base;
|
|
148
|
+
*/
|
|
149
|
+
static noop() {
|
|
150
|
+
return new Policy((fn) => fn());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Instance Methods (Immutable - return new Policy)
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Wraps this policy with another, creating sequential composition.
|
|
159
|
+
*
|
|
160
|
+
* The outer policy (this) wraps the inner policy (other). Execution flows
|
|
161
|
+
* from outer to inner: this policy is applied first, and when it calls
|
|
162
|
+
* the function, that function is actually the other policy's execution.
|
|
163
|
+
*
|
|
164
|
+
* Equivalent to the `+` operator in ninelives DSL.
|
|
165
|
+
*
|
|
166
|
+
* @param {Policy} otherPolicy - The inner policy to wrap
|
|
167
|
+
* @returns {Policy} A new Policy representing the composition
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* // Retry wraps timeout: retries will include timeout behavior
|
|
171
|
+
* const policy = Policy.retry({ retries: 3 })
|
|
172
|
+
* .wrap(Policy.timeout(5000));
|
|
173
|
+
*
|
|
174
|
+
* // Execution: retry -> timeout -> fn
|
|
175
|
+
* await policy.execute(() => fetch(url));
|
|
176
|
+
*/
|
|
177
|
+
wrap(otherPolicy) {
|
|
178
|
+
const outer = this._executor;
|
|
179
|
+
const inner = otherPolicy._executor;
|
|
180
|
+
|
|
181
|
+
return new Policy((fn) => {
|
|
182
|
+
// Compose: outer wraps inner
|
|
183
|
+
// When outer calls its "fn", that fn is actually inner's execution
|
|
184
|
+
return compose(
|
|
185
|
+
{ execute: outer },
|
|
186
|
+
{ execute: inner }
|
|
187
|
+
).execute(fn);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Creates a fallback composition with another policy.
|
|
193
|
+
*
|
|
194
|
+
* If this policy fails, the other policy is tried. This enables graceful
|
|
195
|
+
* degradation strategies.
|
|
196
|
+
*
|
|
197
|
+
* Equivalent to the `|` operator in ninelives DSL.
|
|
198
|
+
*
|
|
199
|
+
* @param {Policy} otherPolicy - The fallback policy
|
|
200
|
+
* @returns {Policy} A new Policy with fallback behavior
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* // Try fast, fall back to slow
|
|
204
|
+
* const policy = Policy.timeout(1000)
|
|
205
|
+
* .or(Policy.timeout(10000));
|
|
206
|
+
*
|
|
207
|
+
* // If the first times out, try again with longer timeout
|
|
208
|
+
* await policy.execute(() => fetch(url));
|
|
209
|
+
*/
|
|
210
|
+
or(otherPolicy) {
|
|
211
|
+
const primary = this._executor;
|
|
212
|
+
const secondary = otherPolicy._executor;
|
|
213
|
+
|
|
214
|
+
return new Policy((fn) => {
|
|
215
|
+
return fallback(
|
|
216
|
+
{ execute: primary },
|
|
217
|
+
{ execute: secondary }
|
|
218
|
+
).execute(fn);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Races this policy against another, returning the first to complete.
|
|
224
|
+
*
|
|
225
|
+
* Both policies execute concurrently. The first successful result wins.
|
|
226
|
+
* If both fail, the error from the primary (this) policy is thrown.
|
|
227
|
+
*
|
|
228
|
+
* Equivalent to the `&` operator in ninelives DSL.
|
|
229
|
+
*
|
|
230
|
+
* @param {Policy} otherPolicy - The policy to race against
|
|
231
|
+
* @returns {Policy} A new Policy with race behavior
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* // Race two different strategies
|
|
235
|
+
* const policy = Policy.retry({ retries: 2, delay: 100 })
|
|
236
|
+
* .race(Policy.timeout(500));
|
|
237
|
+
*
|
|
238
|
+
* // First to succeed wins
|
|
239
|
+
* await policy.execute(() => fetch(url));
|
|
240
|
+
*/
|
|
241
|
+
race(otherPolicy) {
|
|
242
|
+
const first = this._executor;
|
|
243
|
+
const second = otherPolicy._executor;
|
|
244
|
+
|
|
245
|
+
return new Policy((fn) => {
|
|
246
|
+
return race(
|
|
247
|
+
{ execute: first },
|
|
248
|
+
{ execute: second }
|
|
249
|
+
).execute(fn);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Executes a function through this policy chain.
|
|
255
|
+
*
|
|
256
|
+
* @template T
|
|
257
|
+
* @param {() => Promise<T>} fn - The async function to execute
|
|
258
|
+
* @returns {Promise<T>} The result of the function
|
|
259
|
+
* @throws {Error} Any error from the function or policy (e.g., TimeoutError, CircuitOpenError)
|
|
260
|
+
*
|
|
261
|
+
* @example
|
|
262
|
+
* const policy = Policy.retry({ retries: 3 });
|
|
263
|
+
* const data = await policy.execute(async () => {
|
|
264
|
+
* const response = await fetch(url);
|
|
265
|
+
* return response.json();
|
|
266
|
+
* });
|
|
267
|
+
*/
|
|
268
|
+
execute(fn) {
|
|
269
|
+
return this._executor(fn);
|
|
270
|
+
}
|
|
271
|
+
}
|
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Telemetry system for observing resilience policy behavior.
|
|
3
|
+
* Provides composable sinks for capturing events.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} TelemetryEvent
|
|
8
|
+
* @property {string} type - Event type (e.g. 'retry', 'circuit.open')
|
|
9
|
+
* @property {number} timestamp - Event timestamp
|
|
10
|
+
* @property {Object} [metadata] - Additional event data
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @interface TelemetrySink
|
|
15
|
+
* @method emit(event: TelemetryEvent) => void
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sink that stores events in memory. Useful for testing and debugging.
|
|
20
|
+
* @implements {TelemetrySink}
|
|
21
|
+
*/
|
|
22
|
+
export class InMemorySink {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.events = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
emit(event) {
|
|
28
|
+
this.events.push(event);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
clear() {
|
|
32
|
+
this.events = [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sink that logs events to console.
|
|
38
|
+
* @implements {TelemetrySink}
|
|
39
|
+
*/
|
|
40
|
+
export class ConsoleSink {
|
|
41
|
+
emit(event) {
|
|
42
|
+
const { type, timestamp = Date.now(), ...rest } = event;
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.log(`[${type}] ${new Date(timestamp).toISOString()}`, rest);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sink that does nothing. Default.
|
|
50
|
+
* @implements {TelemetrySink}
|
|
51
|
+
*/
|
|
52
|
+
export class NoopSink {
|
|
53
|
+
emit(_event) {
|
|
54
|
+
// No-op
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sink that broadcasts to multiple other sinks.
|
|
60
|
+
* @implements {TelemetrySink}
|
|
61
|
+
*/
|
|
62
|
+
export class MultiSink {
|
|
63
|
+
/**
|
|
64
|
+
* @param {TelemetrySink[]} sinks
|
|
65
|
+
*/
|
|
66
|
+
constructor(sinks = []) {
|
|
67
|
+
this.sinks = sinks;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
emit(event) {
|
|
71
|
+
for (const sink of this.sinks) {
|
|
72
|
+
sink.emit(event);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|