@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.
@@ -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
+ }
@@ -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
+ }