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.
Files changed (151) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/MIGRATION.md +141 -0
  3. package/README.md +326 -218
  4. package/SECURITY.md +56 -0
  5. package/dist/core/act.cjs +406 -220
  6. package/dist/core/act.d.ts +6 -84
  7. package/dist/core/act.d.ts.map +1 -1
  8. package/dist/core/act.js +405 -222
  9. package/dist/core/executor.cjs +0 -27
  10. package/dist/core/executor.d.ts +0 -41
  11. package/dist/core/executor.d.ts.map +1 -1
  12. package/dist/core/executor.js +0 -28
  13. package/dist/core/health.cjs +146 -17
  14. package/dist/core/health.d.ts +20 -5
  15. package/dist/core/health.d.ts.map +1 -1
  16. package/dist/core/health.js +140 -18
  17. package/dist/core/shutdown.cjs +19 -8
  18. package/dist/core/shutdown.d.ts +1 -4
  19. package/dist/core/shutdown.d.ts.map +1 -1
  20. package/dist/core/shutdown.js +18 -9
  21. package/dist/core/tenant.cjs +61 -19
  22. package/dist/core/tenant.d.ts +5 -10
  23. package/dist/core/tenant.d.ts.map +1 -1
  24. package/dist/core/tenant.js +61 -20
  25. package/dist/errors.cjs +70 -103
  26. package/dist/errors.d.ts +33 -90
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +66 -103
  29. package/dist/index.cjs +25 -22
  30. package/dist/index.d.ts +12 -8
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -15
  33. package/dist/observability.cjs +0 -39
  34. package/dist/observability.d.ts +19 -78
  35. package/dist/observability.d.ts.map +1 -1
  36. package/dist/observability.js +0 -40
  37. package/dist/policies/bulkhead.cjs +44 -4
  38. package/dist/policies/bulkhead.d.ts +0 -0
  39. package/dist/policies/bulkhead.d.ts.map +1 -1
  40. package/dist/policies/bulkhead.js +44 -5
  41. package/dist/policies/cache.cjs +22 -142
  42. package/dist/policies/cache.d.ts +0 -46
  43. package/dist/policies/cache.d.ts.map +1 -1
  44. package/dist/policies/cache.js +22 -143
  45. package/dist/policies/circuitBreaker.cjs +125 -31
  46. package/dist/policies/circuitBreaker.d.ts +0 -0
  47. package/dist/policies/circuitBreaker.d.ts.map +1 -1
  48. package/dist/policies/circuitBreaker.js +125 -32
  49. package/dist/policies/dedupe.cjs +19 -66
  50. package/dist/policies/dedupe.d.ts +0 -23
  51. package/dist/policies/dedupe.d.ts.map +1 -1
  52. package/dist/policies/dedupe.js +19 -67
  53. package/dist/policies/noop.cjs +6 -0
  54. package/dist/policies/noop.d.ts +3 -0
  55. package/dist/policies/noop.d.ts.map +1 -0
  56. package/dist/policies/noop.js +3 -0
  57. package/dist/policies/rateLimit.cjs +15 -5
  58. package/dist/policies/rateLimit.d.ts +0 -0
  59. package/dist/policies/rateLimit.d.ts.map +1 -1
  60. package/dist/policies/rateLimit.js +15 -6
  61. package/dist/policies/retry.cjs +100 -65
  62. package/dist/policies/retry.d.ts +0 -27
  63. package/dist/policies/retry.d.ts.map +1 -1
  64. package/dist/policies/retry.js +100 -66
  65. package/dist/policies/timeout.cjs +32 -60
  66. package/dist/policies/timeout.d.ts +0 -13
  67. package/dist/policies/timeout.d.ts.map +1 -1
  68. package/dist/policies/timeout.js +32 -71
  69. package/dist/stores/base.cjs +0 -10
  70. package/dist/stores/base.d.ts +4 -67
  71. package/dist/stores/base.d.ts.map +1 -1
  72. package/dist/stores/base.js +0 -11
  73. package/dist/stores/memory.cjs +75 -88
  74. package/dist/stores/memory.d.ts +7 -74
  75. package/dist/stores/memory.d.ts.map +1 -1
  76. package/dist/stores/memory.js +75 -89
  77. package/dist/testing/index.cjs +56 -0
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.d.ts.map +1 -0
  80. package/dist/testing/index.js +52 -0
  81. package/dist/types/index.cjs +0 -1
  82. package/dist/types/index.d.ts +17 -307
  83. package/dist/types/index.d.ts.map +1 -1
  84. package/dist/types/index.js +0 -2
  85. package/dist/utils/abort.cjs +14 -111
  86. package/dist/utils/abort.d.ts +3 -74
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +14 -112
  89. package/dist/utils/abortPool.cjs +1 -2
  90. package/dist/utils/abortPool.d.ts +0 -0
  91. package/dist/utils/abortPool.d.ts.map +1 -1
  92. package/dist/utils/abortPool.js +1 -3
  93. package/dist/utils/backoff.cjs +3 -21
  94. package/dist/utils/backoff.d.ts +0 -11
  95. package/dist/utils/backoff.d.ts.map +1 -1
  96. package/dist/utils/backoff.js +3 -22
  97. package/dist/utils/decorator.cjs +44 -0
  98. package/dist/utils/decorator.d.ts +3 -0
  99. package/dist/utils/decorator.d.ts.map +1 -0
  100. package/dist/utils/decorator.js +41 -0
  101. package/dist/utils/key.cjs +13 -45
  102. package/dist/utils/key.d.ts +0 -27
  103. package/dist/utils/key.d.ts.map +1 -1
  104. package/dist/utils/key.js +13 -46
  105. package/dist/utils/limits.cjs +9 -27
  106. package/dist/utils/limits.d.ts +9 -27
  107. package/dist/utils/limits.d.ts.map +1 -1
  108. package/dist/utils/limits.js +9 -28
  109. package/dist/utils/safeCall.cjs +28 -0
  110. package/dist/utils/safeCall.d.ts +2 -0
  111. package/dist/utils/safeCall.d.ts.map +1 -0
  112. package/dist/utils/safeCall.js +25 -0
  113. package/dist/utils/sanitize.cjs +22 -1
  114. package/dist/utils/sanitize.d.ts +0 -0
  115. package/dist/utils/sanitize.d.ts.map +1 -1
  116. package/dist/utils/sanitize.js +22 -2
  117. package/dist/utils/validate.cjs +81 -18
  118. package/dist/utils/validate.d.ts +6 -19
  119. package/dist/utils/validate.d.ts.map +1 -1
  120. package/dist/utils/validate.js +81 -19
  121. package/package.json +38 -4
  122. package/dist/core/act.js.map +0 -1
  123. package/dist/core/executor.js.map +0 -1
  124. package/dist/core/health.js.map +0 -1
  125. package/dist/core/shutdown.js.map +0 -1
  126. package/dist/core/tenant.js.map +0 -1
  127. package/dist/errors.js.map +0 -1
  128. package/dist/index.js.map +0 -1
  129. package/dist/observability.js.map +0 -1
  130. package/dist/policies/bulkhead.js.map +0 -1
  131. package/dist/policies/cache.js.map +0 -1
  132. package/dist/policies/circuitBreaker.js.map +0 -1
  133. package/dist/policies/dedupe.js.map +0 -1
  134. package/dist/policies/rateLimit.js.map +0 -1
  135. package/dist/policies/retry.js.map +0 -1
  136. package/dist/policies/timeout.js.map +0 -1
  137. package/dist/state/store.cjs +0 -14
  138. package/dist/state/store.d.ts +0 -11
  139. package/dist/state/store.d.ts.map +0 -1
  140. package/dist/state/store.js +0 -11
  141. package/dist/state/store.js.map +0 -1
  142. package/dist/stores/base.js.map +0 -1
  143. package/dist/stores/memory.js.map +0 -1
  144. package/dist/types/index.js.map +0 -1
  145. package/dist/utils/abort.js.map +0 -1
  146. package/dist/utils/abortPool.js.map +0 -1
  147. package/dist/utils/backoff.js.map +0 -1
  148. package/dist/utils/key.js.map +0 -1
  149. package/dist/utils/limits.js.map +0 -1
  150. package/dist/utils/sanitize.js.map +0 -1
  151. package/dist/utils/validate.js.map +0 -1
@@ -1,90 +1,46 @@
1
1
  import { REQUIRES_SYNC_STORE } from '../core/executor.js';
2
2
  import { raceAbort } from '../utils/abort.js';
3
- // Namespace so dedupe keys never collide with cache keys in the shared store.
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
- const current = syncCtx.store.get(key);
101
- if (current && current.generation === generation) {
102
- syncCtx.store.delete(key);
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
- // Attach cleanup to the RAW promise (not the raceAbort-wrapped one)
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,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.noopPolicy = noopPolicy;
4
+ function noopPolicy() {
5
+ return (fn, _ctx) => fn;
6
+ }
@@ -0,0 +1,3 @@
1
+ import type { PolicyApplier } from '../types/index.js';
2
+ export declare function noopPolicy<T>(): PolicyApplier<T>;
3
+ //# sourceMappingURL=noop.d.ts.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"}
@@ -0,0 +1,3 @@
1
+ export function noopPolicy() {
2
+ return (fn, _ctx) => fn;
3
+ }
@@ -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
- state.timestamps = state.timestamps.filter(t => t > cutoff);
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,CA6B3E"}
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
- state.timestamps = state.timestamps.filter(t => t > cutoff);
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
@@ -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
- // ─── Default predicate ────────────────────────────────────────────────────────
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
- return !(0, abort_js_1.isAbortError)(error);
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 errors = [];
52
- let lastErr;
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
- return await fn(parentSignal);
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
- lastErr = err;
75
- errors.push(err);
76
- // Invoke shouldRetry. If the predicate throws, treat as "don't retry"
77
- // and surface the original fn error (not the predicate error).
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
- const delay = (0, backoff_js_1.computeDelay)(attempt, opts);
109
- // Emit onRetry before the delay sleep.
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
- // Unreachable: the loop either returns or throws on every iteration.
122
- throw lastErr;
157
+ throw new Error('Actly: retryPolicy reached unreachable state');
123
158
  };
124
159
  }
@@ -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;AAqB1F;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,CAAC,CAAC,CAuFnE"}
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"}