@git-stunts/alfred 0.2.0 → 0.3.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/README.md +70 -58
- package/package.json +2 -2
- package/src/compose.js +4 -4
- package/src/index.d.ts +249 -14
- package/src/index.js +8 -4
- package/src/policies/bulkhead.js +60 -32
- package/src/policies/circuit-breaker.js +22 -13
- package/src/policies/hedge.js +123 -0
- package/src/policies/retry.js +34 -24
- package/src/policies/timeout.js +8 -5
- package/src/policy.js +15 -12
- package/src/telemetry.js +84 -1
- package/src/testing.d.ts +14 -3
- package/src/utils/clock.js +3 -3
- package/src/utils/resolvable.js +10 -0
package/src/index.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// @ts-self-types="./index.d.ts"
|
|
7
7
|
|
|
8
8
|
// Error types
|
|
9
|
-
export {
|
|
10
|
-
RetryExhaustedError,
|
|
11
|
-
CircuitOpenError,
|
|
9
|
+
export {
|
|
10
|
+
RetryExhaustedError,
|
|
11
|
+
CircuitOpenError,
|
|
12
12
|
TimeoutError,
|
|
13
|
-
BulkheadRejectedError
|
|
13
|
+
BulkheadRejectedError,
|
|
14
14
|
} from './errors.js';
|
|
15
15
|
|
|
16
16
|
// Resilience policies
|
|
@@ -18,6 +18,7 @@ export { retry } from './policies/retry.js';
|
|
|
18
18
|
export { circuitBreaker } from './policies/circuit-breaker.js';
|
|
19
19
|
export { timeout } from './policies/timeout.js';
|
|
20
20
|
export { bulkhead } from './policies/bulkhead.js';
|
|
21
|
+
export { hedge } from './policies/hedge.js';
|
|
21
22
|
|
|
22
23
|
// Composition utilities
|
|
23
24
|
export { compose, fallback, race } from './compose.js';
|
|
@@ -27,3 +28,6 @@ export { Policy, Policy as default } from './policy.js';
|
|
|
27
28
|
|
|
28
29
|
// Clock utilities
|
|
29
30
|
export { SystemClock, TestClock } from './utils/clock.js';
|
|
31
|
+
|
|
32
|
+
// Telemetry
|
|
33
|
+
export { InMemorySink, ConsoleSink, NoopSink, MultiSink, MetricsSink } from './telemetry.js';
|
package/src/policies/bulkhead.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { BulkheadRejectedError } from '../errors.js';
|
|
11
11
|
import { SystemClock } from '../utils/clock.js';
|
|
12
12
|
import { NoopSink } from '../telemetry.js';
|
|
13
|
+
import { resolve as resolveValue } from '../utils/resolvable.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* @typedef {Object} BulkheadOptions
|
|
@@ -28,11 +29,11 @@ import { NoopSink } from '../telemetry.js';
|
|
|
28
29
|
|
|
29
30
|
class BulkheadPolicy {
|
|
30
31
|
constructor(options) {
|
|
31
|
-
const {
|
|
32
|
-
limit,
|
|
33
|
-
queueLimit = 0,
|
|
32
|
+
const {
|
|
33
|
+
limit,
|
|
34
|
+
queueLimit = 0,
|
|
34
35
|
telemetry = new NoopSink(),
|
|
35
|
-
clock = new SystemClock()
|
|
36
|
+
clock = new SystemClock(),
|
|
36
37
|
} = options;
|
|
37
38
|
|
|
38
39
|
if (limit <= 0) {
|
|
@@ -49,24 +50,38 @@ class BulkheadPolicy {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
processQueue() {
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
const limit = resolveValue(this.limit);
|
|
54
|
+
if (this.active < limit && this.queue.length > 0) {
|
|
55
|
+
const { fn, resolve: promiseResolve, reject } = this.queue.shift();
|
|
54
56
|
this.active++;
|
|
55
|
-
|
|
57
|
+
|
|
56
58
|
this.emitEvent('bulkhead.execute', {
|
|
57
59
|
active: this.active,
|
|
58
|
-
pending: this.queue.length
|
|
60
|
+
pending: this.queue.length,
|
|
59
61
|
});
|
|
60
62
|
|
|
61
63
|
Promise.resolve()
|
|
62
64
|
.then(() => fn())
|
|
63
|
-
.then(
|
|
65
|
+
.then(
|
|
66
|
+
(result) => {
|
|
67
|
+
this.emitEvent('bulkhead.complete', {
|
|
68
|
+
active: this.active,
|
|
69
|
+
pending: this.queue.length,
|
|
70
|
+
metrics: { successes: 1 },
|
|
71
|
+
});
|
|
72
|
+
promiseResolve(result);
|
|
73
|
+
},
|
|
74
|
+
(error) => {
|
|
75
|
+
this.emitEvent('bulkhead.complete', {
|
|
76
|
+
active: this.active,
|
|
77
|
+
pending: this.queue.length,
|
|
78
|
+
metrics: { failures: 1 },
|
|
79
|
+
});
|
|
80
|
+
reject(error);
|
|
81
|
+
}
|
|
82
|
+
)
|
|
64
83
|
.finally(() => {
|
|
65
84
|
this.active--;
|
|
66
|
-
this.emitEvent('bulkhead.complete', {
|
|
67
|
-
active: this.active,
|
|
68
|
-
pending: this.queue.length
|
|
69
|
-
});
|
|
70
85
|
this.processQueue();
|
|
71
86
|
});
|
|
72
87
|
}
|
|
@@ -76,36 +91,48 @@ class BulkheadPolicy {
|
|
|
76
91
|
this.telemetry.emit({
|
|
77
92
|
type,
|
|
78
93
|
timestamp: this.clock.now(),
|
|
79
|
-
...data
|
|
94
|
+
...data,
|
|
80
95
|
});
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
async execute(fn) {
|
|
84
|
-
|
|
99
|
+
const limit = resolveValue(this.limit);
|
|
100
|
+
const queueLimit = resolveValue(this.queueLimit);
|
|
101
|
+
|
|
102
|
+
if (this.active < limit) {
|
|
85
103
|
this.active++;
|
|
86
104
|
this.emitEvent('bulkhead.execute', {
|
|
87
105
|
active: this.active,
|
|
88
|
-
pending: this.queue.length
|
|
106
|
+
pending: this.queue.length,
|
|
89
107
|
});
|
|
90
108
|
|
|
91
109
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
const result = await fn();
|
|
111
|
+
this.emitEvent('bulkhead.complete', {
|
|
112
|
+
active: this.active,
|
|
113
|
+
pending: this.queue.length,
|
|
114
|
+
metrics: { successes: 1 },
|
|
115
|
+
});
|
|
116
|
+
return result;
|
|
117
|
+
} catch (error) {
|
|
95
118
|
this.emitEvent('bulkhead.complete', {
|
|
96
119
|
active: this.active,
|
|
97
|
-
pending: this.queue.length
|
|
120
|
+
pending: this.queue.length,
|
|
121
|
+
metrics: { failures: 1 },
|
|
98
122
|
});
|
|
123
|
+
throw error;
|
|
124
|
+
} finally {
|
|
125
|
+
this.active--;
|
|
99
126
|
this.processQueue();
|
|
100
127
|
}
|
|
101
128
|
}
|
|
102
129
|
|
|
103
|
-
if (this.queue.length <
|
|
130
|
+
if (this.queue.length < queueLimit) {
|
|
104
131
|
this.emitEvent('bulkhead.queued', {
|
|
105
132
|
active: this.active,
|
|
106
|
-
pending: this.queue.length + 1
|
|
133
|
+
pending: this.queue.length + 1,
|
|
107
134
|
});
|
|
108
|
-
|
|
135
|
+
|
|
109
136
|
return new Promise((resolve, reject) => {
|
|
110
137
|
this.queue.push({ fn, resolve, reject });
|
|
111
138
|
});
|
|
@@ -113,16 +140,17 @@ class BulkheadPolicy {
|
|
|
113
140
|
|
|
114
141
|
this.emitEvent('bulkhead.reject', {
|
|
115
142
|
active: this.active,
|
|
116
|
-
pending: this.queue.length
|
|
143
|
+
pending: this.queue.length,
|
|
144
|
+
metrics: { bulkheadRejections: 1 },
|
|
117
145
|
});
|
|
118
|
-
throw new BulkheadRejectedError(
|
|
146
|
+
throw new BulkheadRejectedError(limit, queueLimit);
|
|
119
147
|
}
|
|
120
148
|
|
|
121
149
|
get stats() {
|
|
122
|
-
return {
|
|
123
|
-
active: this.active,
|
|
124
|
-
pending: this.queue.length,
|
|
125
|
-
available: Math.max(0, this.limit - this.active)
|
|
150
|
+
return {
|
|
151
|
+
active: this.active,
|
|
152
|
+
pending: this.queue.length,
|
|
153
|
+
available: Math.max(0, resolveValue(this.limit) - this.active),
|
|
126
154
|
};
|
|
127
155
|
}
|
|
128
156
|
}
|
|
@@ -135,11 +163,11 @@ class BulkheadPolicy {
|
|
|
135
163
|
*/
|
|
136
164
|
export function bulkhead(options) {
|
|
137
165
|
const policy = new BulkheadPolicy(options);
|
|
138
|
-
|
|
166
|
+
|
|
139
167
|
return {
|
|
140
168
|
execute: (fn) => policy.execute(fn),
|
|
141
169
|
get stats() {
|
|
142
170
|
return policy.stats;
|
|
143
|
-
}
|
|
171
|
+
},
|
|
144
172
|
};
|
|
145
|
-
}
|
|
173
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CircuitOpenError } from '../errors.js';
|
|
2
2
|
import { SystemClock } from '../utils/clock.js';
|
|
3
3
|
import { NoopSink } from '../telemetry.js';
|
|
4
|
+
import { resolve } from '../utils/resolvable.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Circuit breaker states.
|
|
@@ -10,7 +11,7 @@ import { NoopSink } from '../telemetry.js';
|
|
|
10
11
|
const State = {
|
|
11
12
|
CLOSED: 'CLOSED',
|
|
12
13
|
OPEN: 'OPEN',
|
|
13
|
-
HALF_OPEN: 'HALF_OPEN'
|
|
14
|
+
HALF_OPEN: 'HALF_OPEN',
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -43,7 +44,7 @@ class CircuitBreakerPolicy {
|
|
|
43
44
|
onClose,
|
|
44
45
|
onHalfOpen,
|
|
45
46
|
clock = new SystemClock(),
|
|
46
|
-
telemetry = new NoopSink()
|
|
47
|
+
telemetry = new NoopSink(),
|
|
47
48
|
} = options;
|
|
48
49
|
|
|
49
50
|
if (threshold === undefined || threshold === null) {
|
|
@@ -62,7 +63,7 @@ class CircuitBreakerPolicy {
|
|
|
62
63
|
onClose,
|
|
63
64
|
onHalfOpen,
|
|
64
65
|
clock,
|
|
65
|
-
telemetry
|
|
66
|
+
telemetry,
|
|
66
67
|
};
|
|
67
68
|
|
|
68
69
|
this._state = State.CLOSED;
|
|
@@ -79,7 +80,7 @@ class CircuitBreakerPolicy {
|
|
|
79
80
|
this.options.telemetry.emit({
|
|
80
81
|
type,
|
|
81
82
|
timestamp: this.options.clock.now(),
|
|
82
|
-
...data
|
|
83
|
+
...data,
|
|
83
84
|
});
|
|
84
85
|
}
|
|
85
86
|
|
|
@@ -87,7 +88,10 @@ class CircuitBreakerPolicy {
|
|
|
87
88
|
this._state = State.OPEN;
|
|
88
89
|
this.openedAt = new Date(this.options.clock.now());
|
|
89
90
|
this.options.onOpen?.();
|
|
90
|
-
this.emitEvent('circuit.open', {
|
|
91
|
+
this.emitEvent('circuit.open', {
|
|
92
|
+
failureCount: this.failureCount,
|
|
93
|
+
metrics: { circuitBreaks: 1 },
|
|
94
|
+
});
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
close() {
|
|
@@ -111,15 +115,18 @@ class CircuitBreakerPolicy {
|
|
|
111
115
|
return false;
|
|
112
116
|
}
|
|
113
117
|
const elapsed = this.options.clock.now() - this.openedAt.getTime();
|
|
114
|
-
return elapsed >= this.options.duration;
|
|
118
|
+
return elapsed >= resolve(this.options.duration);
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
recordSuccess() {
|
|
118
|
-
this.emitEvent('circuit.success', {
|
|
122
|
+
this.emitEvent('circuit.success', {
|
|
123
|
+
state: this._state,
|
|
124
|
+
metrics: { successes: 1 },
|
|
125
|
+
});
|
|
119
126
|
|
|
120
127
|
if (this._state === State.HALF_OPEN) {
|
|
121
128
|
this.successCount++;
|
|
122
|
-
if (this.successCount >= this.options.successThreshold) {
|
|
129
|
+
if (this.successCount >= resolve(this.options.successThreshold)) {
|
|
123
130
|
this.close();
|
|
124
131
|
}
|
|
125
132
|
} else if (this._state === State.CLOSED) {
|
|
@@ -134,14 +141,15 @@ class CircuitBreakerPolicy {
|
|
|
134
141
|
|
|
135
142
|
this.emitEvent('circuit.failure', {
|
|
136
143
|
error,
|
|
137
|
-
state: this._state
|
|
144
|
+
state: this._state,
|
|
145
|
+
metrics: { failures: 1 },
|
|
138
146
|
});
|
|
139
147
|
|
|
140
148
|
if (this._state === State.HALF_OPEN) {
|
|
141
149
|
this.open();
|
|
142
150
|
} else if (this._state === State.CLOSED) {
|
|
143
151
|
this.failureCount++;
|
|
144
|
-
if (this.failureCount >= this.options.threshold) {
|
|
152
|
+
if (this.failureCount >= resolve(this.options.threshold)) {
|
|
145
153
|
this.open();
|
|
146
154
|
}
|
|
147
155
|
}
|
|
@@ -155,7 +163,8 @@ class CircuitBreakerPolicy {
|
|
|
155
163
|
if (this._state === State.OPEN) {
|
|
156
164
|
this.emitEvent('circuit.reject', {
|
|
157
165
|
openedAt: this.openedAt,
|
|
158
|
-
failureCount: this.failureCount
|
|
166
|
+
failureCount: this.failureCount,
|
|
167
|
+
metrics: { circuitRejections: 1 },
|
|
159
168
|
});
|
|
160
169
|
throw new CircuitOpenError(this.openedAt, this.failureCount);
|
|
161
170
|
}
|
|
@@ -184,6 +193,6 @@ export function circuitBreaker(options) {
|
|
|
184
193
|
execute: (fn) => policy.execute(fn),
|
|
185
194
|
get state() {
|
|
186
195
|
return policy.state;
|
|
187
|
-
}
|
|
196
|
+
},
|
|
188
197
|
};
|
|
189
|
-
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Hedge policy for speculative execution.
|
|
3
|
+
*
|
|
4
|
+
* Starts concurrent "hedged" attempts if the primary attempt takes too long,
|
|
5
|
+
* helping to reduce tail latency in distributed systems.
|
|
6
|
+
*
|
|
7
|
+
* @module @git-stunts/alfred/policies/hedge
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { SystemClock } from '../utils/clock.js';
|
|
11
|
+
import { NoopSink } from '../telemetry.js';
|
|
12
|
+
import { resolve } from '../utils/resolvable.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} HedgeOptions
|
|
16
|
+
* @property {number} delay - Milliseconds to wait before spawning a hedge.
|
|
17
|
+
* @property {number} [maxHedges=1] - Maximum number of hedged attempts to spawn.
|
|
18
|
+
* @property {import('../telemetry.js').TelemetrySink} [telemetry] - Telemetry sink.
|
|
19
|
+
* @property {{ now(): number, sleep(ms: number): Promise<void> }} [clock] - Clock for testing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
class HedgeExecutor {
|
|
23
|
+
constructor(fn, options) {
|
|
24
|
+
this.fn = fn;
|
|
25
|
+
this.options = {
|
|
26
|
+
telemetry: new NoopSink(),
|
|
27
|
+
clock: new SystemClock(),
|
|
28
|
+
maxHedges: 1,
|
|
29
|
+
...options,
|
|
30
|
+
};
|
|
31
|
+
this.abortControllers = [];
|
|
32
|
+
this._finished = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async execute() {
|
|
36
|
+
const delay = resolve(this.options.delay);
|
|
37
|
+
const maxHedges = resolve(this.options.maxHedges);
|
|
38
|
+
const attempts = [];
|
|
39
|
+
|
|
40
|
+
// Start primary attempt
|
|
41
|
+
attempts.push(this.createAttempt(0));
|
|
42
|
+
|
|
43
|
+
// Schedule hedges
|
|
44
|
+
for (let i = 1; i <= maxHedges; i++) {
|
|
45
|
+
attempts.push(this.scheduleHedge(i, delay * i));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return await Promise.any(attempts);
|
|
50
|
+
} finally {
|
|
51
|
+
this.cancelAll();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
createAttempt(index) {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
this.abortControllers.push(controller);
|
|
58
|
+
const { clock, telemetry } = this.options;
|
|
59
|
+
|
|
60
|
+
const startTime = clock.now();
|
|
61
|
+
telemetry.emit({
|
|
62
|
+
type: 'hedge.attempt',
|
|
63
|
+
timestamp: startTime,
|
|
64
|
+
index,
|
|
65
|
+
metrics: index > 0 ? { hedges: 1 } : {},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return this.fn(controller.signal)
|
|
69
|
+
.then((result) => {
|
|
70
|
+
const endTime = clock.now();
|
|
71
|
+
telemetry.emit({
|
|
72
|
+
type: 'hedge.success',
|
|
73
|
+
timestamp: endTime,
|
|
74
|
+
index,
|
|
75
|
+
duration: endTime - startTime,
|
|
76
|
+
metrics: { successes: 1 },
|
|
77
|
+
});
|
|
78
|
+
return result;
|
|
79
|
+
})
|
|
80
|
+
.catch((error) => {
|
|
81
|
+
if (error.name !== 'AbortError') {
|
|
82
|
+
const endTime = clock.now();
|
|
83
|
+
telemetry.emit({
|
|
84
|
+
type: 'hedge.failure',
|
|
85
|
+
timestamp: endTime,
|
|
86
|
+
index,
|
|
87
|
+
error,
|
|
88
|
+
duration: endTime - startTime,
|
|
89
|
+
metrics: { failures: 1 },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
scheduleHedge(index, delayMs) {
|
|
97
|
+
return this.options.clock.sleep(delayMs).then(() => {
|
|
98
|
+
if (this._finished) {
|
|
99
|
+
return new Promise(() => {}); // Never resolve if we are done
|
|
100
|
+
}
|
|
101
|
+
return this.createAttempt(index);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
cancelAll() {
|
|
106
|
+
this._finished = true;
|
|
107
|
+
for (const controller of this.abortControllers) {
|
|
108
|
+
controller.abort();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates a Hedge policy.
|
|
115
|
+
*
|
|
116
|
+
* @param {HedgeOptions} options - Hedge configuration
|
|
117
|
+
* @returns {{ execute: <T>(fn: () => Promise<T>) => Promise<T> }}
|
|
118
|
+
*/
|
|
119
|
+
export function hedge(options) {
|
|
120
|
+
return {
|
|
121
|
+
execute: (fn) => new HedgeExecutor(fn, options).execute(),
|
|
122
|
+
};
|
|
123
|
+
}
|
package/src/policies/retry.js
CHANGED
|
@@ -11,6 +11,7 @@ import { SystemClock } from '../utils/clock.js';
|
|
|
11
11
|
import { createJitter } from '../utils/jitter.js';
|
|
12
12
|
import { RetryExhaustedError } from '../errors.js';
|
|
13
13
|
import { NoopSink } from '../telemetry.js';
|
|
14
|
+
import { resolve } from '../utils/resolvable.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* @typedef {'constant' | 'linear' | 'exponential'} BackoffStrategy
|
|
@@ -38,7 +39,7 @@ const DEFAULT_OPTIONS = {
|
|
|
38
39
|
delay: 1000,
|
|
39
40
|
maxDelay: 30000,
|
|
40
41
|
backoff: 'constant',
|
|
41
|
-
jitter: 'none'
|
|
42
|
+
jitter: 'none',
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
function calculateBackoff(strategy, baseDelay, attempt) {
|
|
@@ -59,34 +60,38 @@ class RetryExecutor {
|
|
|
59
60
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
60
61
|
this.clock = options.clock || new SystemClock();
|
|
61
62
|
this.telemetry = options.telemetry || new NoopSink();
|
|
62
|
-
this.applyJitter
|
|
63
|
-
this.prevDelay = this.options.delay;
|
|
63
|
+
// this.applyJitter is now created dynamically in calculateDelay
|
|
64
|
+
this.prevDelay = resolve(this.options.delay);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
calculateDelay(attempt) {
|
|
67
|
-
const
|
|
68
|
+
const backoff = resolve(this.options.backoff);
|
|
69
|
+
const baseDelay = resolve(this.options.delay);
|
|
70
|
+
const maxDelay = resolve(this.options.maxDelay);
|
|
71
|
+
const jitter = resolve(this.options.jitter);
|
|
72
|
+
|
|
68
73
|
const rawDelay = calculateBackoff(backoff, baseDelay, attempt);
|
|
74
|
+
const applyJitter = createJitter(jitter);
|
|
69
75
|
|
|
70
76
|
if (jitter === 'decorrelated') {
|
|
71
|
-
const actual =
|
|
77
|
+
const actual = applyJitter(baseDelay, this.prevDelay, maxDelay);
|
|
72
78
|
this.prevDelay = actual;
|
|
73
79
|
return actual;
|
|
74
80
|
}
|
|
75
|
-
|
|
76
|
-
return Math.min(
|
|
81
|
+
|
|
82
|
+
return Math.min(applyJitter(rawDelay), maxDelay);
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
async execute() {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
for (let attempt = 1; attempt <=
|
|
83
|
-
const shouldStop = await this.tryAttempt(attempt
|
|
86
|
+
// Loop condition: attempt <= (current_retries + 1)
|
|
87
|
+
// We start at 1.
|
|
88
|
+
for (let attempt = 1; attempt <= resolve(this.options.retries) + 1; attempt++) {
|
|
89
|
+
const shouldStop = await this.tryAttempt(attempt);
|
|
84
90
|
if (shouldStop) {
|
|
85
91
|
return shouldStop.result;
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
|
-
|
|
89
|
-
// Should be unreachable if logic is correct, but satisfied strict returns
|
|
94
|
+
|
|
90
95
|
throw new Error('Unexpected retry loop termination');
|
|
91
96
|
}
|
|
92
97
|
|
|
@@ -102,22 +107,24 @@ class RetryExecutor {
|
|
|
102
107
|
// But we need to calculate delay first
|
|
103
108
|
const delay = this.calculateDelay(attempt);
|
|
104
109
|
this.emitScheduled(attempt, delay, error);
|
|
105
|
-
|
|
110
|
+
|
|
106
111
|
if (this.options.onRetry) {
|
|
107
112
|
this.options.onRetry(error, attempt, delay);
|
|
108
113
|
}
|
|
109
|
-
|
|
114
|
+
|
|
110
115
|
await this.clock.sleep(delay);
|
|
111
116
|
return null; // Continue loop
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
emitSuccess(attempt, startTime) {
|
|
121
|
+
const endTime = this.clock.now();
|
|
116
122
|
this.telemetry.emit({
|
|
117
123
|
type: 'retry.success',
|
|
118
|
-
timestamp:
|
|
124
|
+
timestamp: endTime,
|
|
119
125
|
attempt,
|
|
120
|
-
duration:
|
|
126
|
+
duration: endTime - startTime,
|
|
127
|
+
metrics: { successes: 1 },
|
|
121
128
|
});
|
|
122
129
|
}
|
|
123
130
|
|
|
@@ -127,30 +134,33 @@ class RetryExecutor {
|
|
|
127
134
|
timestamp: this.clock.now(),
|
|
128
135
|
attempt,
|
|
129
136
|
delay,
|
|
130
|
-
error
|
|
137
|
+
error,
|
|
138
|
+
metrics: { retries: 1 },
|
|
131
139
|
});
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
handleFailure(error, attempt, startTime) {
|
|
143
|
+
const endTime = this.clock.now();
|
|
135
144
|
this.telemetry.emit({
|
|
136
145
|
type: 'retry.failure',
|
|
137
|
-
timestamp:
|
|
146
|
+
timestamp: endTime,
|
|
138
147
|
attempt,
|
|
139
148
|
error,
|
|
140
|
-
duration:
|
|
149
|
+
duration: endTime - startTime,
|
|
150
|
+
metrics: { failures: 1 },
|
|
141
151
|
});
|
|
142
152
|
|
|
143
153
|
if (this.options.shouldRetry && !this.options.shouldRetry(error)) {
|
|
144
154
|
throw error;
|
|
145
155
|
}
|
|
146
156
|
|
|
147
|
-
const totalAttempts = this.options.retries + 1;
|
|
157
|
+
const totalAttempts = resolve(this.options.retries) + 1;
|
|
148
158
|
if (attempt >= totalAttempts) {
|
|
149
159
|
this.telemetry.emit({
|
|
150
160
|
type: 'retry.exhausted',
|
|
151
|
-
timestamp:
|
|
161
|
+
timestamp: endTime,
|
|
152
162
|
attempts: attempt,
|
|
153
|
-
error
|
|
163
|
+
error,
|
|
154
164
|
});
|
|
155
165
|
throw new RetryExhaustedError(attempt, error);
|
|
156
166
|
}
|
|
@@ -167,4 +177,4 @@ class RetryExecutor {
|
|
|
167
177
|
*/
|
|
168
178
|
export async function retry(fn, options = {}) {
|
|
169
179
|
return new RetryExecutor(fn, options).execute();
|
|
170
|
-
}
|
|
180
|
+
}
|
package/src/policies/timeout.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { TimeoutError } from '../errors.js';
|
|
11
11
|
import { NoopSink } from '../telemetry.js';
|
|
12
|
+
import { resolve } from '../utils/resolvable.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* @typedef {Object} TimeoutOptions
|
|
@@ -45,6 +46,7 @@ import { NoopSink } from '../telemetry.js';
|
|
|
45
46
|
*/
|
|
46
47
|
export async function timeout(ms, fn, options = {}) {
|
|
47
48
|
const { onTimeout, telemetry = new NoopSink() } = options;
|
|
49
|
+
const timeoutMs = resolve(ms);
|
|
48
50
|
const controller = new AbortController();
|
|
49
51
|
const startTime = Date.now();
|
|
50
52
|
|
|
@@ -58,16 +60,17 @@ export async function timeout(ms, fn, options = {}) {
|
|
|
58
60
|
if (onTimeout) {
|
|
59
61
|
onTimeout(elapsed);
|
|
60
62
|
}
|
|
61
|
-
|
|
63
|
+
|
|
62
64
|
telemetry.emit({
|
|
63
65
|
type: 'timeout',
|
|
64
66
|
timestamp: Date.now(),
|
|
65
|
-
timeout:
|
|
66
|
-
elapsed
|
|
67
|
+
timeout: timeoutMs,
|
|
68
|
+
elapsed,
|
|
69
|
+
metrics: { timeouts: 1, failures: 1 },
|
|
67
70
|
});
|
|
68
71
|
|
|
69
|
-
reject(new TimeoutError(
|
|
70
|
-
},
|
|
72
|
+
reject(new TimeoutError(timeoutMs, elapsed));
|
|
73
|
+
}, timeoutMs);
|
|
71
74
|
});
|
|
72
75
|
|
|
73
76
|
try {
|
package/src/policy.js
CHANGED
|
@@ -34,6 +34,7 @@ import { retry } from './policies/retry.js';
|
|
|
34
34
|
import { circuitBreaker } from './policies/circuit-breaker.js';
|
|
35
35
|
import { timeout } from './policies/timeout.js';
|
|
36
36
|
import { bulkhead } from './policies/bulkhead.js';
|
|
37
|
+
import { hedge } from './policies/hedge.js';
|
|
37
38
|
import { compose, fallback, race } from './compose.js';
|
|
38
39
|
|
|
39
40
|
/**
|
|
@@ -134,6 +135,17 @@ export class Policy {
|
|
|
134
135
|
return new Policy((fn) => limiter.execute(fn));
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Creates a Policy that speculatively executes hedged attempts.
|
|
140
|
+
*
|
|
141
|
+
* @param {import('./policies/hedge.js').HedgeOptions} options
|
|
142
|
+
* @returns {Policy}
|
|
143
|
+
*/
|
|
144
|
+
static hedge(options) {
|
|
145
|
+
const hedger = hedge(options);
|
|
146
|
+
return new Policy((fn) => hedger.execute(fn));
|
|
147
|
+
}
|
|
148
|
+
|
|
137
149
|
/**
|
|
138
150
|
* Creates a no-op Policy that passes through to the function directly.
|
|
139
151
|
*
|
|
@@ -181,10 +193,7 @@ export class Policy {
|
|
|
181
193
|
return new Policy((fn) => {
|
|
182
194
|
// Compose: outer wraps inner
|
|
183
195
|
// When outer calls its "fn", that fn is actually inner's execution
|
|
184
|
-
return compose(
|
|
185
|
-
{ execute: outer },
|
|
186
|
-
{ execute: inner }
|
|
187
|
-
).execute(fn);
|
|
196
|
+
return compose({ execute: outer }, { execute: inner }).execute(fn);
|
|
188
197
|
});
|
|
189
198
|
}
|
|
190
199
|
|
|
@@ -212,10 +221,7 @@ export class Policy {
|
|
|
212
221
|
const secondary = otherPolicy._executor;
|
|
213
222
|
|
|
214
223
|
return new Policy((fn) => {
|
|
215
|
-
return fallback(
|
|
216
|
-
{ execute: primary },
|
|
217
|
-
{ execute: secondary }
|
|
218
|
-
).execute(fn);
|
|
224
|
+
return fallback({ execute: primary }, { execute: secondary }).execute(fn);
|
|
219
225
|
});
|
|
220
226
|
}
|
|
221
227
|
|
|
@@ -243,10 +249,7 @@ export class Policy {
|
|
|
243
249
|
const second = otherPolicy._executor;
|
|
244
250
|
|
|
245
251
|
return new Policy((fn) => {
|
|
246
|
-
return race(
|
|
247
|
-
{ execute: first },
|
|
248
|
-
{ execute: second }
|
|
249
|
-
).execute(fn);
|
|
252
|
+
return race({ execute: first }, { execute: second }).execute(fn);
|
|
250
253
|
});
|
|
251
254
|
}
|
|
252
255
|
|