actly 1.2.0 → 1.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/CHANGELOG.md +234 -0
- package/MIGRATION.md +141 -0
- package/README.md +326 -218
- package/SECURITY.md +56 -0
- package/dist/core/act.cjs +406 -220
- package/dist/core/act.d.ts +6 -84
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +405 -222
- package/dist/core/executor.cjs +0 -27
- package/dist/core/executor.d.ts +0 -41
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +0 -28
- package/dist/core/health.cjs +146 -17
- package/dist/core/health.d.ts +20 -5
- package/dist/core/health.d.ts.map +1 -1
- package/dist/core/health.js +140 -18
- package/dist/core/shutdown.cjs +19 -8
- package/dist/core/shutdown.d.ts +1 -4
- package/dist/core/shutdown.d.ts.map +1 -1
- package/dist/core/shutdown.js +18 -9
- package/dist/core/tenant.cjs +61 -19
- package/dist/core/tenant.d.ts +5 -10
- package/dist/core/tenant.d.ts.map +1 -1
- package/dist/core/tenant.js +61 -20
- package/dist/errors.cjs +70 -103
- package/dist/errors.d.ts +33 -90
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +66 -103
- package/dist/index.cjs +25 -22
- package/dist/index.d.ts +12 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -15
- package/dist/observability.cjs +0 -39
- package/dist/observability.d.ts +19 -78
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +0 -40
- package/dist/policies/bulkhead.cjs +44 -4
- package/dist/policies/bulkhead.d.ts +0 -0
- package/dist/policies/bulkhead.d.ts.map +1 -1
- package/dist/policies/bulkhead.js +44 -5
- package/dist/policies/cache.cjs +22 -142
- package/dist/policies/cache.d.ts +0 -46
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +22 -143
- package/dist/policies/circuitBreaker.cjs +125 -31
- package/dist/policies/circuitBreaker.d.ts +0 -0
- package/dist/policies/circuitBreaker.d.ts.map +1 -1
- package/dist/policies/circuitBreaker.js +125 -32
- package/dist/policies/dedupe.cjs +19 -66
- package/dist/policies/dedupe.d.ts +0 -23
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +19 -67
- package/dist/policies/noop.cjs +6 -0
- package/dist/policies/noop.d.ts +3 -0
- package/dist/policies/noop.d.ts.map +1 -0
- package/dist/policies/noop.js +3 -0
- package/dist/policies/rateLimit.cjs +15 -5
- package/dist/policies/rateLimit.d.ts +0 -0
- package/dist/policies/rateLimit.d.ts.map +1 -1
- package/dist/policies/rateLimit.js +15 -6
- package/dist/policies/retry.cjs +100 -65
- package/dist/policies/retry.d.ts +0 -27
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +100 -66
- package/dist/policies/timeout.cjs +32 -60
- package/dist/policies/timeout.d.ts +0 -13
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +32 -71
- package/dist/stores/base.cjs +0 -10
- package/dist/stores/base.d.ts +4 -67
- package/dist/stores/base.d.ts.map +1 -1
- package/dist/stores/base.js +0 -11
- package/dist/stores/memory.cjs +75 -88
- package/dist/stores/memory.d.ts +7 -74
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +75 -89
- package/dist/testing/index.cjs +56 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +52 -0
- package/dist/types/index.cjs +0 -1
- package/dist/types/index.d.ts +17 -307
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -2
- package/dist/utils/abort.cjs +14 -111
- package/dist/utils/abort.d.ts +3 -74
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +14 -112
- package/dist/utils/abortPool.cjs +1 -2
- package/dist/utils/abortPool.d.ts +0 -0
- package/dist/utils/abortPool.d.ts.map +1 -1
- package/dist/utils/abortPool.js +1 -3
- package/dist/utils/backoff.cjs +3 -21
- package/dist/utils/backoff.d.ts +0 -11
- package/dist/utils/backoff.d.ts.map +1 -1
- package/dist/utils/backoff.js +3 -22
- package/dist/utils/decorator.cjs +44 -0
- package/dist/utils/decorator.d.ts +3 -0
- package/dist/utils/decorator.d.ts.map +1 -0
- package/dist/utils/decorator.js +41 -0
- package/dist/utils/key.cjs +13 -45
- package/dist/utils/key.d.ts +0 -27
- package/dist/utils/key.d.ts.map +1 -1
- package/dist/utils/key.js +13 -46
- package/dist/utils/limits.cjs +9 -27
- package/dist/utils/limits.d.ts +9 -27
- package/dist/utils/limits.d.ts.map +1 -1
- package/dist/utils/limits.js +9 -28
- package/dist/utils/safeCall.cjs +28 -0
- package/dist/utils/safeCall.d.ts +2 -0
- package/dist/utils/safeCall.d.ts.map +1 -0
- package/dist/utils/safeCall.js +25 -0
- package/dist/utils/sanitize.cjs +22 -1
- package/dist/utils/sanitize.d.ts +0 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +22 -2
- package/dist/utils/validate.cjs +81 -18
- package/dist/utils/validate.d.ts +6 -19
- package/dist/utils/validate.d.ts.map +1 -1
- package/dist/utils/validate.js +81 -19
- package/package.json +38 -4
- package/dist/core/act.js.map +0 -1
- package/dist/core/executor.js.map +0 -1
- package/dist/core/health.js.map +0 -1
- package/dist/core/shutdown.js.map +0 -1
- package/dist/core/tenant.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/observability.js.map +0 -1
- package/dist/policies/bulkhead.js.map +0 -1
- package/dist/policies/cache.js.map +0 -1
- package/dist/policies/circuitBreaker.js.map +0 -1
- package/dist/policies/dedupe.js.map +0 -1
- package/dist/policies/rateLimit.js.map +0 -1
- package/dist/policies/retry.js.map +0 -1
- package/dist/policies/timeout.js.map +0 -1
- package/dist/state/store.cjs +0 -14
- package/dist/state/store.d.ts +0 -11
- package/dist/state/store.d.ts.map +0 -1
- package/dist/state/store.js +0 -11
- package/dist/state/store.js.map +0 -1
- package/dist/stores/base.js.map +0 -1
- package/dist/stores/memory.js.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/utils/abort.js.map +0 -1
- package/dist/utils/abortPool.js.map +0 -1
- package/dist/utils/backoff.js.map +0 -1
- package/dist/utils/key.js.map +0 -1
- package/dist/utils/limits.js.map +0 -1
- package/dist/utils/sanitize.js.map +0 -1
- package/dist/utils/validate.js.map +0 -1
package/dist/policies/dedupe.js
CHANGED
|
@@ -1,90 +1,46 @@
|
|
|
1
1
|
import { REQUIRES_SYNC_STORE } from '../core/executor.js';
|
|
2
2
|
import { raceAbort } from '../utils/abort.js';
|
|
3
|
-
|
|
3
|
+
import { safeCall } from '../utils/safeCall.js';
|
|
4
|
+
import { LIMITS } from '../utils/limits.js';
|
|
4
5
|
const NS = 'dedupe:';
|
|
5
|
-
/**
|
|
6
|
-
* Module-level generation counter. Monotonic — guarantees uniqueness
|
|
7
|
-
* across the lifetime of the process. Wraps at `Number.MAX_SAFE_INTEGER`
|
|
8
|
-
* (which would take ~285 000 years at 1M increments/sec).
|
|
9
|
-
*/
|
|
10
6
|
let generationCounter = 0;
|
|
11
7
|
function nextGeneration() {
|
|
12
8
|
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
13
9
|
return generationCounter;
|
|
14
10
|
}
|
|
15
|
-
/**
|
|
16
|
-
* Collapse concurrent calls that share the same key into one in-flight Promise.
|
|
17
|
-
*
|
|
18
|
-
* # Properties
|
|
19
|
-
*
|
|
20
|
-
* - **Generation-safe cleanup**: stale originators never delete newer
|
|
21
|
-
* entries when `inflightTtl` triggers replacement.
|
|
22
|
-
* - **Joiner isolation**: originator's caller-signal abort does NOT
|
|
23
|
-
* propagate to joiners. Each joiner races the shared in-flight promise
|
|
24
|
-
* against their OWN signal only.
|
|
25
|
-
* - **Truthful joiner attempts**: joiners that abort before the originator
|
|
26
|
-
* settles report `attempts: 0` (they did no work), not the originator's
|
|
27
|
-
* in-progress count.
|
|
28
|
-
*
|
|
29
|
-
* # INVARIANT: requires SyncStateStore
|
|
30
|
-
*
|
|
31
|
-
* The read-then-write that makes deduplication work must happen in a single
|
|
32
|
-
* synchronous frame. An async store would introduce an `await` between
|
|
33
|
-
* `get()` and `set()`, letting two concurrent callers both see a miss and
|
|
34
|
-
* both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
|
|
35
|
-
* `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
|
|
36
|
-
* that bypass TypeScript.
|
|
37
|
-
*/
|
|
38
11
|
export function dedupePolicy(opts = { enabled: true }) {
|
|
39
|
-
const inflightTtl = opts.inflightTtl;
|
|
12
|
+
const inflightTtl = opts.inflightTtl ?? LIMITS.DEFAULT_INFLIGHT_TTL;
|
|
40
13
|
const applier = (fn, ctx) => {
|
|
41
|
-
// Cast is safe: execute() verifies isSyncStore(ctx.store) before calling
|
|
42
|
-
// any policy tagged with REQUIRES_SYNC_STORE.
|
|
43
14
|
const syncCtx = ctx;
|
|
44
15
|
return async (signal) => {
|
|
45
16
|
const key = NS + syncCtx.key;
|
|
46
|
-
// ─── Fast path: an in-flight promise already exists. Join it. ──────
|
|
47
|
-
//
|
|
48
|
-
// The joiner races the in-flight promise against THEIR OWN signal.
|
|
49
|
-
// They do NOT inherit the originator's signal state — if the
|
|
50
|
-
// originator's caller cancels, joiners continue waiting (or get
|
|
51
|
-
// the eventual settled value).
|
|
52
17
|
const existing = syncCtx.store.get(key);
|
|
53
18
|
if (existing) {
|
|
19
|
+
const obs = syncCtx.observability;
|
|
20
|
+
if (obs) {
|
|
21
|
+
obs.joinerCounter++;
|
|
22
|
+
safeCall(obs.hooks.onDedupeJoin, {
|
|
23
|
+
type: 'dedupe-join', key: syncCtx.key, traceId: obs.traceId,
|
|
24
|
+
timestamp: Date.now(), joinerPosition: obs.joinerCounter,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
54
27
|
try {
|
|
55
28
|
const value = await raceAbort(existing.promise, signal);
|
|
56
|
-
// Success — copy originator's final meta (attempts, source)
|
|
57
|
-
// so the joiner's ActResult reflects the real effort.
|
|
58
29
|
syncCtx.meta.attempts = existing.meta.attempts;
|
|
59
30
|
syncCtx.meta.source = existing.meta.source;
|
|
60
31
|
return value;
|
|
61
32
|
}
|
|
62
33
|
catch (err) {
|
|
63
|
-
// Joiner either aborted (their own signal) or got the
|
|
64
|
-
// originator's settled error.
|
|
65
34
|
if (signal.aborted) {
|
|
66
|
-
// Joiner's own signal aborted before originator settled.
|
|
67
|
-
// They did no work — report `attempts: 0` (truthful).
|
|
68
35
|
syncCtx.meta.attempts = 0;
|
|
69
|
-
// source stays 'fresh' (default) — joiner didn't read from cache
|
|
70
36
|
}
|
|
71
37
|
else {
|
|
72
|
-
// Originator settled with failure — copy its final meta.
|
|
73
38
|
syncCtx.meta.attempts = existing.meta.attempts;
|
|
74
39
|
syncCtx.meta.source = existing.meta.source;
|
|
75
40
|
}
|
|
76
41
|
throw err;
|
|
77
42
|
}
|
|
78
43
|
}
|
|
79
|
-
// ─── Originator path: start the work and publish the promise. ──────
|
|
80
|
-
//
|
|
81
|
-
// The stored promise is the RAW fn(signal) — NOT raceAbort-wrapped.
|
|
82
|
-
// This is critical: if we stored raceAbort(fn, signal), an
|
|
83
|
-
// originator signal abort would cause the stored promise to reject,
|
|
84
|
-
// which would propagate to all joiners. Instead:
|
|
85
|
-
// - stored: raw fn(signal) — joiners await this
|
|
86
|
-
// - originator's await: raceAbort(stored, signal) — originator
|
|
87
|
-
// can bail out on their own signal without affecting joiners
|
|
88
44
|
const generation = nextGeneration();
|
|
89
45
|
const rawPromise = Promise.resolve(fn(signal));
|
|
90
46
|
const entry = {
|
|
@@ -93,24 +49,20 @@ export function dedupePolicy(opts = { enabled: true }) {
|
|
|
93
49
|
generation,
|
|
94
50
|
};
|
|
95
51
|
syncCtx.store.set(key, entry, inflightTtl);
|
|
96
|
-
// Cleanup on settle: only delete if generation matches. If a newer
|
|
97
|
-
// originator has replaced this entry (because inflightTtl expired),
|
|
98
|
-
// our cleanup is a no-op — the newer entry stays.
|
|
99
52
|
const cleanup = () => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
53
|
+
try {
|
|
54
|
+
const current = syncCtx.store.get(key);
|
|
55
|
+
if (current && current.generation === generation) {
|
|
56
|
+
syncCtx.store.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
103
60
|
}
|
|
104
61
|
};
|
|
105
|
-
|
|
106
|
-
// so cleanup runs whenever fn settles, regardless of originator abort.
|
|
107
|
-
rawPromise.then(cleanup, cleanup);
|
|
108
|
-
// Originator races the raw promise against their own signal —
|
|
109
|
-
// they can bail out without affecting joiners.
|
|
62
|
+
rawPromise.then(cleanup, cleanup).catch(() => { });
|
|
110
63
|
return raceAbort(rawPromise, signal);
|
|
111
64
|
};
|
|
112
65
|
};
|
|
113
66
|
applier[REQUIRES_SYNC_STORE] = true;
|
|
114
67
|
return applier;
|
|
115
68
|
}
|
|
116
|
-
//# sourceMappingURL=dedupe.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"noop.d.ts","sourceRoot":"","sources":["../../src/policies/noop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,MAAM,mBAAmB,CAAA;AAoB5E,wBAAgB,UAAU,CAAC,CAAC,KAAK,aAAa,CAAC,CAAC,CAAC,CAEhD"}
|
|
@@ -7,8 +7,8 @@ const NS = 'rl:';
|
|
|
7
7
|
function getState(store, key) {
|
|
8
8
|
return store.get(NS + key) ?? { timestamps: [] };
|
|
9
9
|
}
|
|
10
|
-
function setState(store, key, state) {
|
|
11
|
-
store.set(NS + key, state);
|
|
10
|
+
function setState(store, key, state, ttlMs) {
|
|
11
|
+
store.set(NS + key, state, ttlMs);
|
|
12
12
|
}
|
|
13
13
|
function rateLimitPolicy(opts) {
|
|
14
14
|
const maxCalls = Math.max(1, Math.floor(opts.maxCalls));
|
|
@@ -16,17 +16,27 @@ function rateLimitPolicy(opts) {
|
|
|
16
16
|
const applier = (fn, ctx) => {
|
|
17
17
|
const syncCtx = ctx;
|
|
18
18
|
return async (signal) => {
|
|
19
|
+
if (signal.aborted)
|
|
20
|
+
return Promise.reject(signal.reason);
|
|
19
21
|
const key = syncCtx.key;
|
|
20
22
|
const now = Date.now();
|
|
21
23
|
const state = getState(syncCtx.store, key);
|
|
22
24
|
const cutoff = now - windowMs;
|
|
23
|
-
|
|
25
|
+
const ts = state.timestamps;
|
|
26
|
+
if (ts.length > 0 && ts[0] <= cutoff) {
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < ts.length && ts[i] <= cutoff)
|
|
29
|
+
i++;
|
|
30
|
+
if (i > 0) {
|
|
31
|
+
state.timestamps = i === ts.length ? [] : ts.slice(i);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
24
34
|
if (state.timestamps.length >= maxCalls) {
|
|
25
|
-
setState(syncCtx.store, key, state);
|
|
35
|
+
setState(syncCtx.store, key, state, windowMs);
|
|
26
36
|
throw new errors_js_1.RateLimitError(key, maxCalls, windowMs);
|
|
27
37
|
}
|
|
28
38
|
state.timestamps.push(now);
|
|
29
|
-
setState(syncCtx.store, key, state);
|
|
39
|
+
setState(syncCtx.store, key, state, windowMs);
|
|
30
40
|
return fn(signal);
|
|
31
41
|
};
|
|
32
42
|
};
|
|
File without changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rateLimit.d.ts","sourceRoot":"","sources":["../../src/policies/rateLimit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAmB9F,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,EAAE,gBAAgB,GAAG,aAAa,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"rateLimit.d.ts","sourceRoot":"","sources":["../../src/policies/rateLimit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAmB9F,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,EAAE,gBAAgB,GAAG,aAAa,CAAC,CAAC,CAAC,CA6C3E"}
|
|
@@ -4,8 +4,8 @@ const NS = 'rl:';
|
|
|
4
4
|
function getState(store, key) {
|
|
5
5
|
return store.get(NS + key) ?? { timestamps: [] };
|
|
6
6
|
}
|
|
7
|
-
function setState(store, key, state) {
|
|
8
|
-
store.set(NS + key, state);
|
|
7
|
+
function setState(store, key, state, ttlMs) {
|
|
8
|
+
store.set(NS + key, state, ttlMs);
|
|
9
9
|
}
|
|
10
10
|
export function rateLimitPolicy(opts) {
|
|
11
11
|
const maxCalls = Math.max(1, Math.floor(opts.maxCalls));
|
|
@@ -13,21 +13,30 @@ export function rateLimitPolicy(opts) {
|
|
|
13
13
|
const applier = (fn, ctx) => {
|
|
14
14
|
const syncCtx = ctx;
|
|
15
15
|
return async (signal) => {
|
|
16
|
+
if (signal.aborted)
|
|
17
|
+
return Promise.reject(signal.reason);
|
|
16
18
|
const key = syncCtx.key;
|
|
17
19
|
const now = Date.now();
|
|
18
20
|
const state = getState(syncCtx.store, key);
|
|
19
21
|
const cutoff = now - windowMs;
|
|
20
|
-
|
|
22
|
+
const ts = state.timestamps;
|
|
23
|
+
if (ts.length > 0 && ts[0] <= cutoff) {
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < ts.length && ts[i] <= cutoff)
|
|
26
|
+
i++;
|
|
27
|
+
if (i > 0) {
|
|
28
|
+
state.timestamps = i === ts.length ? [] : ts.slice(i);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
21
31
|
if (state.timestamps.length >= maxCalls) {
|
|
22
|
-
setState(syncCtx.store, key, state);
|
|
32
|
+
setState(syncCtx.store, key, state, windowMs);
|
|
23
33
|
throw new RateLimitError(key, maxCalls, windowMs);
|
|
24
34
|
}
|
|
25
35
|
state.timestamps.push(now);
|
|
26
|
-
setState(syncCtx.store, key, state);
|
|
36
|
+
setState(syncCtx.store, key, state, windowMs);
|
|
27
37
|
return fn(signal);
|
|
28
38
|
};
|
|
29
39
|
};
|
|
30
40
|
applier[REQUIRES_SYNC_STORE] = true;
|
|
31
41
|
return applier;
|
|
32
42
|
}
|
|
33
|
-
//# sourceMappingURL=rateLimit.js.map
|
package/dist/policies/retry.cjs
CHANGED
|
@@ -3,122 +3,157 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.retryPolicy = retryPolicy;
|
|
4
4
|
const backoff_js_1 = require("../utils/backoff.js");
|
|
5
5
|
const abort_js_1 = require("../utils/abort.js");
|
|
6
|
+
const safeCall_js_1 = require("../utils/safeCall.js");
|
|
6
7
|
const errors_js_1 = require("../errors.js");
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Default `shouldRetry`: retry on any error EXCEPT abort errors.
|
|
10
|
-
*
|
|
11
|
-
* Abort errors indicate the caller or a timeout cancelled the operation —
|
|
12
|
-
* retrying would just abort again on the next attempt, wasting delay budget.
|
|
13
|
-
*
|
|
14
|
-
* User-supplied `shouldRetry` overrides this entirely.
|
|
15
|
-
*/
|
|
8
|
+
const limits_js_1 = require("../utils/limits.js");
|
|
16
9
|
function defaultShouldRetry(error, _attempt) {
|
|
17
|
-
|
|
10
|
+
if ((0, abort_js_1.isAbortError)(error))
|
|
11
|
+
return false;
|
|
12
|
+
if (typeof error === 'object' && error !== null) {
|
|
13
|
+
const code = error.code;
|
|
14
|
+
if (code === 'ACTLY_TIMEOUT' || code === 'ACTLY_TOTAL_TIMEOUT')
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
18
|
}
|
|
19
|
-
// ─── Policy ───────────────────────────────────────────────────────────────────
|
|
20
|
-
/**
|
|
21
|
-
* Retry `fn` up to `opts.attempts` times on retryable errors.
|
|
22
|
-
*
|
|
23
|
-
* Writes the live attempt count into `ctx.meta.attempts` so `act()` can
|
|
24
|
-
* report it in the final `ActResult`.
|
|
25
|
-
*
|
|
26
|
-
* # Signal awareness
|
|
27
|
-
*
|
|
28
|
-
* - Before each attempt, checks `parentSignal.aborted`. If the parent (e.g.
|
|
29
|
-
* `totalTimeout` or caller) has aborted, throws the parent's reason
|
|
30
|
-
* immediately — no more attempts.
|
|
31
|
-
* - Sleeps between attempts use `sleep(delay, parentSignal)`. If the parent
|
|
32
|
-
* aborts mid-delay, the sleep rejects immediately instead of blocking
|
|
33
|
-
* the loop until the timer would have elapsed.
|
|
34
|
-
*
|
|
35
|
-
* # `shouldRetry` invocation
|
|
36
|
-
*
|
|
37
|
-
* Called after EVERY failure, including the last attempt. This preserves the
|
|
38
|
-
* predicate's contract for observers / metrics that rely on it being called
|
|
39
|
-
* per-attempt. The return value is only consulted when `attempt < max`.
|
|
40
|
-
*
|
|
41
|
-
* # Default predicate
|
|
42
|
-
*
|
|
43
|
-
* If `shouldRetry` is omitted, uses `defaultShouldRetry` which retries on
|
|
44
|
-
* every error EXCEPT abort errors (so timeouts and caller cancellations
|
|
45
|
-
* don't waste retry budget).
|
|
46
|
-
*/
|
|
47
19
|
function retryPolicy(opts) {
|
|
48
20
|
const max = Math.max(1, Math.floor(opts.attempts));
|
|
49
21
|
const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
|
|
22
|
+
const shouldRetryResult = opts.shouldRetryResult;
|
|
23
|
+
const dangerouslyUnref = opts.dangerouslyUnref === true;
|
|
24
|
+
const sleepOpts = dangerouslyUnref ? { unref: true } : undefined;
|
|
25
|
+
const backoffFn = opts.backoffFn;
|
|
50
26
|
return (fn, ctx) => async (parentSignal) => {
|
|
51
|
-
const
|
|
52
|
-
let
|
|
27
|
+
const backoffState = {};
|
|
28
|
+
let errors;
|
|
53
29
|
let retriedAtLeastOnce = false;
|
|
54
30
|
const obs = ctx.observability;
|
|
55
31
|
for (let attempt = 1; attempt <= max; attempt++) {
|
|
56
|
-
// Parent (totalTimeout or caller signal) already aborted — bail.
|
|
57
32
|
if (parentSignal.aborted)
|
|
58
33
|
throw parentSignal.reason;
|
|
59
34
|
ctx.meta.attempts = attempt;
|
|
60
35
|
const attemptStart = Date.now();
|
|
61
36
|
try {
|
|
62
|
-
// Emit onAttempt before each attempt. (We could emit
|
|
63
|
-
// after with duration/error, but before lets observers track
|
|
64
|
-
// in-flight attempts in real time.)
|
|
65
37
|
if (obs) {
|
|
66
|
-
obs.hooks.onAttempt
|
|
38
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onAttempt, {
|
|
67
39
|
type: 'attempt', key: ctx.key, traceId: obs.traceId,
|
|
68
40
|
timestamp: attemptStart, attempt,
|
|
69
41
|
});
|
|
70
42
|
}
|
|
71
|
-
|
|
43
|
+
const value = await fn(parentSignal);
|
|
44
|
+
if (shouldRetryResult) {
|
|
45
|
+
let accept;
|
|
46
|
+
try {
|
|
47
|
+
const raw = shouldRetryResult(value, attempt);
|
|
48
|
+
accept = typeof raw === 'boolean' ? raw : true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
if (accept)
|
|
54
|
+
return value;
|
|
55
|
+
const syntheticError = new Error(`Actly: shouldRetryResult returned false on attempt ${attempt}`);
|
|
56
|
+
if (errors === undefined)
|
|
57
|
+
errors = [];
|
|
58
|
+
if (errors.length < 10) {
|
|
59
|
+
errors.push(syntheticError);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
errors.shift();
|
|
63
|
+
errors.push(syntheticError);
|
|
64
|
+
}
|
|
65
|
+
if (attempt >= max) {
|
|
66
|
+
if (obs) {
|
|
67
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onRetry, {
|
|
68
|
+
type: 'retry', key: ctx.key, traceId: obs.traceId,
|
|
69
|
+
timestamp: Date.now(), attempt, delayMs: 0,
|
|
70
|
+
error: syntheticError,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
retriedAtLeastOnce = true;
|
|
76
|
+
if (parentSignal.aborted)
|
|
77
|
+
throw parentSignal.reason;
|
|
78
|
+
let rawDelay;
|
|
79
|
+
try {
|
|
80
|
+
rawDelay = backoffFn
|
|
81
|
+
? backoffFn(attempt, syntheticError, backoffState)
|
|
82
|
+
: (0, backoff_js_1.computeDelay)(attempt, opts);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
rawDelay = (0, backoff_js_1.computeDelay)(attempt, opts);
|
|
86
|
+
}
|
|
87
|
+
const safeDelay = Number.isFinite(rawDelay) ? rawDelay : 0;
|
|
88
|
+
const delay = Math.min(Math.max(0, safeDelay), limits_js_1.LIMITS.MAX_RETRY_DELAY_MS);
|
|
89
|
+
if (obs) {
|
|
90
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onRetry, {
|
|
91
|
+
type: 'retry', key: ctx.key, traceId: obs.traceId,
|
|
92
|
+
timestamp: Date.now(), attempt, delayMs: delay,
|
|
93
|
+
error: syntheticError,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (delay > 0)
|
|
97
|
+
await (0, abort_js_1.sleep)(delay, parentSignal, sleepOpts);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
72
101
|
}
|
|
73
102
|
catch (err) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
103
|
+
if (errors === undefined)
|
|
104
|
+
errors = [];
|
|
105
|
+
if (errors.length < 10) {
|
|
106
|
+
errors.push(err);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
errors.shift();
|
|
110
|
+
errors.push(err);
|
|
111
|
+
}
|
|
78
112
|
let retryable;
|
|
79
113
|
try {
|
|
80
114
|
retryable = shouldRetry(err, attempt);
|
|
81
115
|
}
|
|
82
116
|
catch {
|
|
83
|
-
// Predicate threw — don't retry, surface original error
|
|
84
117
|
throw err;
|
|
85
118
|
}
|
|
86
119
|
if (attempt >= max) {
|
|
87
|
-
// Last attempt. If we retried at least once (predicate
|
|
88
|
-
// allowed retries), wrap in RetryExhaustedError for context.
|
|
89
120
|
if (retriedAtLeastOnce) {
|
|
90
121
|
throw new errors_js_1.RetryExhaustedError({
|
|
91
122
|
key: ctx.key,
|
|
92
123
|
attempts: attempt,
|
|
93
124
|
lastError: err,
|
|
94
|
-
errors,
|
|
125
|
+
errors: errors ?? [],
|
|
95
126
|
});
|
|
96
127
|
}
|
|
97
128
|
throw err;
|
|
98
129
|
}
|
|
99
|
-
if (!retryable)
|
|
100
|
-
// Non-retryable error — bail immediately.
|
|
130
|
+
if (!retryable)
|
|
101
131
|
throw err;
|
|
102
|
-
}
|
|
103
|
-
// Mark that we retried at least once — affects final wrap.
|
|
104
132
|
retriedAtLeastOnce = true;
|
|
105
|
-
// Parent aborted mid-attempt — don't sleep, bail.
|
|
106
133
|
if (parentSignal.aborted)
|
|
107
134
|
throw parentSignal.reason;
|
|
108
|
-
|
|
109
|
-
|
|
135
|
+
let rawDelay;
|
|
136
|
+
try {
|
|
137
|
+
rawDelay = backoffFn
|
|
138
|
+
? backoffFn(attempt, err, backoffState)
|
|
139
|
+
: (0, backoff_js_1.computeDelay)(attempt, opts);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
rawDelay = (0, backoff_js_1.computeDelay)(attempt, opts);
|
|
143
|
+
}
|
|
144
|
+
const safeDelay = Number.isFinite(rawDelay) ? rawDelay : 0;
|
|
145
|
+
const delay = Math.min(Math.max(0, safeDelay), limits_js_1.LIMITS.MAX_RETRY_DELAY_MS);
|
|
110
146
|
if (obs) {
|
|
111
|
-
obs.hooks.onRetry
|
|
147
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onRetry, {
|
|
112
148
|
type: 'retry', key: ctx.key, traceId: obs.traceId,
|
|
113
149
|
timestamp: Date.now(), attempt, delayMs: delay, error: err,
|
|
114
150
|
});
|
|
115
151
|
}
|
|
116
152
|
if (delay > 0) {
|
|
117
|
-
await (0, abort_js_1.sleep)(delay, parentSignal);
|
|
153
|
+
await (0, abort_js_1.sleep)(delay, parentSignal, sleepOpts);
|
|
118
154
|
}
|
|
119
155
|
}
|
|
120
156
|
}
|
|
121
|
-
|
|
122
|
-
throw lastErr;
|
|
157
|
+
throw new Error('Actly: retryPolicy reached unreachable state');
|
|
123
158
|
};
|
|
124
159
|
}
|
package/dist/policies/retry.d.ts
CHANGED
|
@@ -1,30 +1,3 @@
|
|
|
1
1
|
import type { PolicyApplier, RetryOptions } from '../types/index.js';
|
|
2
|
-
/**
|
|
3
|
-
* Retry `fn` up to `opts.attempts` times on retryable errors.
|
|
4
|
-
*
|
|
5
|
-
* Writes the live attempt count into `ctx.meta.attempts` so `act()` can
|
|
6
|
-
* report it in the final `ActResult`.
|
|
7
|
-
*
|
|
8
|
-
* # Signal awareness
|
|
9
|
-
*
|
|
10
|
-
* - Before each attempt, checks `parentSignal.aborted`. If the parent (e.g.
|
|
11
|
-
* `totalTimeout` or caller) has aborted, throws the parent's reason
|
|
12
|
-
* immediately — no more attempts.
|
|
13
|
-
* - Sleeps between attempts use `sleep(delay, parentSignal)`. If the parent
|
|
14
|
-
* aborts mid-delay, the sleep rejects immediately instead of blocking
|
|
15
|
-
* the loop until the timer would have elapsed.
|
|
16
|
-
*
|
|
17
|
-
* # `shouldRetry` invocation
|
|
18
|
-
*
|
|
19
|
-
* Called after EVERY failure, including the last attempt. This preserves the
|
|
20
|
-
* predicate's contract for observers / metrics that rely on it being called
|
|
21
|
-
* per-attempt. The return value is only consulted when `attempt < max`.
|
|
22
|
-
*
|
|
23
|
-
* # Default predicate
|
|
24
|
-
*
|
|
25
|
-
* If `shouldRetry` is omitted, uses `defaultShouldRetry` which retries on
|
|
26
|
-
* every error EXCEPT abort errors (so timeouts and caller cancellations
|
|
27
|
-
* don't waste retry budget).
|
|
28
|
-
*/
|
|
29
2
|
export declare function retryPolicy<T>(opts: RetryOptions): PolicyApplier<T>;
|
|
30
3
|
//# sourceMappingURL=retry.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../src/policies/retry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,YAAY,EAAE,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../src/policies/retry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAsC1F,wBAAgB,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,CAAC,CAAC,CA4KnE"}
|