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,122 +1,156 @@
1
1
  import { computeDelay } from '../utils/backoff.js';
2
2
  import { isAbortError, sleep } from '../utils/abort.js';
3
+ import { safeCall } from '../utils/safeCall.js';
3
4
  import { RetryExhaustedError } from '../errors.js';
4
- // ─── Default predicate ────────────────────────────────────────────────────────
5
- /**
6
- * Default `shouldRetry`: retry on any error EXCEPT abort errors.
7
- *
8
- * Abort errors indicate the caller or a timeout cancelled the operation —
9
- * retrying would just abort again on the next attempt, wasting delay budget.
10
- *
11
- * User-supplied `shouldRetry` overrides this entirely.
12
- */
5
+ import { LIMITS } from '../utils/limits.js';
13
6
  function defaultShouldRetry(error, _attempt) {
14
- return !isAbortError(error);
7
+ if (isAbortError(error))
8
+ return false;
9
+ if (typeof error === 'object' && error !== null) {
10
+ const code = error.code;
11
+ if (code === 'ACTLY_TIMEOUT' || code === 'ACTLY_TOTAL_TIMEOUT')
12
+ return false;
13
+ }
14
+ return true;
15
15
  }
16
- // ─── Policy ───────────────────────────────────────────────────────────────────
17
- /**
18
- * Retry `fn` up to `opts.attempts` times on retryable errors.
19
- *
20
- * Writes the live attempt count into `ctx.meta.attempts` so `act()` can
21
- * report it in the final `ActResult`.
22
- *
23
- * # Signal awareness
24
- *
25
- * - Before each attempt, checks `parentSignal.aborted`. If the parent (e.g.
26
- * `totalTimeout` or caller) has aborted, throws the parent's reason
27
- * immediately — no more attempts.
28
- * - Sleeps between attempts use `sleep(delay, parentSignal)`. If the parent
29
- * aborts mid-delay, the sleep rejects immediately instead of blocking
30
- * the loop until the timer would have elapsed.
31
- *
32
- * # `shouldRetry` invocation
33
- *
34
- * Called after EVERY failure, including the last attempt. This preserves the
35
- * predicate's contract for observers / metrics that rely on it being called
36
- * per-attempt. The return value is only consulted when `attempt < max`.
37
- *
38
- * # Default predicate
39
- *
40
- * If `shouldRetry` is omitted, uses `defaultShouldRetry` which retries on
41
- * every error EXCEPT abort errors (so timeouts and caller cancellations
42
- * don't waste retry budget).
43
- */
44
16
  export function retryPolicy(opts) {
45
17
  const max = Math.max(1, Math.floor(opts.attempts));
46
18
  const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
19
+ const shouldRetryResult = opts.shouldRetryResult;
20
+ const dangerouslyUnref = opts.dangerouslyUnref === true;
21
+ const sleepOpts = dangerouslyUnref ? { unref: true } : undefined;
22
+ const backoffFn = opts.backoffFn;
47
23
  return (fn, ctx) => async (parentSignal) => {
48
- const errors = [];
49
- let lastErr;
24
+ const backoffState = {};
25
+ let errors;
50
26
  let retriedAtLeastOnce = false;
51
27
  const obs = ctx.observability;
52
28
  for (let attempt = 1; attempt <= max; attempt++) {
53
- // Parent (totalTimeout or caller signal) already aborted — bail.
54
29
  if (parentSignal.aborted)
55
30
  throw parentSignal.reason;
56
31
  ctx.meta.attempts = attempt;
57
32
  const attemptStart = Date.now();
58
33
  try {
59
- // Emit onAttempt before each attempt. (We could emit
60
- // after with duration/error, but before lets observers track
61
- // in-flight attempts in real time.)
62
34
  if (obs) {
63
- obs.hooks.onAttempt?.({
35
+ safeCall(obs.hooks.onAttempt, {
64
36
  type: 'attempt', key: ctx.key, traceId: obs.traceId,
65
37
  timestamp: attemptStart, attempt,
66
38
  });
67
39
  }
68
- return await fn(parentSignal);
40
+ const value = await fn(parentSignal);
41
+ if (shouldRetryResult) {
42
+ let accept;
43
+ try {
44
+ const raw = shouldRetryResult(value, attempt);
45
+ accept = typeof raw === 'boolean' ? raw : true;
46
+ }
47
+ catch {
48
+ return value;
49
+ }
50
+ if (accept)
51
+ return value;
52
+ const syntheticError = new Error(`Actly: shouldRetryResult returned false on attempt ${attempt}`);
53
+ if (errors === undefined)
54
+ errors = [];
55
+ if (errors.length < 10) {
56
+ errors.push(syntheticError);
57
+ }
58
+ else {
59
+ errors.shift();
60
+ errors.push(syntheticError);
61
+ }
62
+ if (attempt >= max) {
63
+ if (obs) {
64
+ safeCall(obs.hooks.onRetry, {
65
+ type: 'retry', key: ctx.key, traceId: obs.traceId,
66
+ timestamp: Date.now(), attempt, delayMs: 0,
67
+ error: syntheticError,
68
+ });
69
+ }
70
+ return value;
71
+ }
72
+ retriedAtLeastOnce = true;
73
+ if (parentSignal.aborted)
74
+ throw parentSignal.reason;
75
+ let rawDelay;
76
+ try {
77
+ rawDelay = backoffFn
78
+ ? backoffFn(attempt, syntheticError, backoffState)
79
+ : computeDelay(attempt, opts);
80
+ }
81
+ catch {
82
+ rawDelay = computeDelay(attempt, opts);
83
+ }
84
+ const safeDelay = Number.isFinite(rawDelay) ? rawDelay : 0;
85
+ const delay = Math.min(Math.max(0, safeDelay), LIMITS.MAX_RETRY_DELAY_MS);
86
+ if (obs) {
87
+ safeCall(obs.hooks.onRetry, {
88
+ type: 'retry', key: ctx.key, traceId: obs.traceId,
89
+ timestamp: Date.now(), attempt, delayMs: delay,
90
+ error: syntheticError,
91
+ });
92
+ }
93
+ if (delay > 0)
94
+ await sleep(delay, parentSignal, sleepOpts);
95
+ continue;
96
+ }
97
+ return value;
69
98
  }
70
99
  catch (err) {
71
- lastErr = err;
72
- errors.push(err);
73
- // Invoke shouldRetry. If the predicate throws, treat as "don't retry"
74
- // and surface the original fn error (not the predicate error).
100
+ if (errors === undefined)
101
+ errors = [];
102
+ if (errors.length < 10) {
103
+ errors.push(err);
104
+ }
105
+ else {
106
+ errors.shift();
107
+ errors.push(err);
108
+ }
75
109
  let retryable;
76
110
  try {
77
111
  retryable = shouldRetry(err, attempt);
78
112
  }
79
113
  catch {
80
- // Predicate threw — don't retry, surface original error
81
114
  throw err;
82
115
  }
83
116
  if (attempt >= max) {
84
- // Last attempt. If we retried at least once (predicate
85
- // allowed retries), wrap in RetryExhaustedError for context.
86
117
  if (retriedAtLeastOnce) {
87
118
  throw new RetryExhaustedError({
88
119
  key: ctx.key,
89
120
  attempts: attempt,
90
121
  lastError: err,
91
- errors,
122
+ errors: errors ?? [],
92
123
  });
93
124
  }
94
125
  throw err;
95
126
  }
96
- if (!retryable) {
97
- // Non-retryable error — bail immediately.
127
+ if (!retryable)
98
128
  throw err;
99
- }
100
- // Mark that we retried at least once — affects final wrap.
101
129
  retriedAtLeastOnce = true;
102
- // Parent aborted mid-attempt — don't sleep, bail.
103
130
  if (parentSignal.aborted)
104
131
  throw parentSignal.reason;
105
- const delay = computeDelay(attempt, opts);
106
- // Emit onRetry before the delay sleep.
132
+ let rawDelay;
133
+ try {
134
+ rawDelay = backoffFn
135
+ ? backoffFn(attempt, err, backoffState)
136
+ : computeDelay(attempt, opts);
137
+ }
138
+ catch {
139
+ rawDelay = computeDelay(attempt, opts);
140
+ }
141
+ const safeDelay = Number.isFinite(rawDelay) ? rawDelay : 0;
142
+ const delay = Math.min(Math.max(0, safeDelay), LIMITS.MAX_RETRY_DELAY_MS);
107
143
  if (obs) {
108
- obs.hooks.onRetry?.({
144
+ safeCall(obs.hooks.onRetry, {
109
145
  type: 'retry', key: ctx.key, traceId: obs.traceId,
110
146
  timestamp: Date.now(), attempt, delayMs: delay, error: err,
111
147
  });
112
148
  }
113
149
  if (delay > 0) {
114
- await sleep(delay, parentSignal);
150
+ await sleep(delay, parentSignal, sleepOpts);
115
151
  }
116
152
  }
117
153
  }
118
- // Unreachable: the loop either returns or throws on every iteration.
119
- throw lastErr;
154
+ throw new Error('Actly: retryPolicy reached unreachable state');
120
155
  };
121
156
  }
122
- //# sourceMappingURL=retry.js.map
@@ -4,58 +4,43 @@ exports.TotalTimeoutError = exports.TimeoutError = void 0;
4
4
  exports.timeoutPolicy = timeoutPolicy;
5
5
  exports.totalTimeoutPolicy = totalTimeoutPolicy;
6
6
  const abort_js_1 = require("../utils/abort.js");
7
+ const safeCall_js_1 = require("../utils/safeCall.js");
7
8
  const errors_js_1 = require("../errors.js");
8
9
  Object.defineProperty(exports, "TimeoutError", { enumerable: true, get: function () { return errors_js_1.TimeoutError; } });
9
10
  Object.defineProperty(exports, "TotalTimeoutError", { enumerable: true, get: function () { return errors_js_1.TotalTimeoutError; } });
10
- // ─── Policy ───────────────────────────────────────────────────────────────────
11
- /**
12
- * Build a timeout policy that throws `ErrorCtor` on deadline.
13
- *
14
- * # Cancellation contract
15
- *
16
- * Each invocation:
17
- * 1. Creates a fresh `AbortController` for this attempt.
18
- * 2. Arms a `setTimeout` that aborts the controller with a fresh `ErrorCtor(ms)`.
19
- * 3. Links the parent signal: if the parent aborts (e.g. `totalTimeout` or
20
- * caller cancellation), the child aborts with the parent's reason.
21
- * 4. Races `fn(childSignal)` against the abort event.
22
- *
23
- * The race is critical: it ensures `act()` returns promptly even if `fn`
24
- * ignores the signal. The underlying `fn` may keep running in the background
25
- * (resource leak), but the caller is unblocked. This is the best JavaScript
26
- * can do without cooperation from `fn`.
27
- *
28
- * If `fn` cooperates (passes `signal` to `fetch`, `AbortController`, etc.),
29
- * the underlying work is cancelled properly — no leak.
30
- *
31
- * # Error attribution
32
- *
33
- * If the per-attempt timer fires, we throw `ErrorCtor(ms)` regardless of
34
- * what `fn` does. If the parent signal fires first, we throw the parent's
35
- * reason (could be `TotalTimeoutError`, an `AbortError`, or anything else).
36
- */
37
- function makeTimeoutPolicy(opts, ErrorCtor) {
11
+ function makeTimeoutPolicy(opts, errorCtor, kind) {
12
+ const strategy = opts.strategy ?? 'race';
38
13
  return (fn, ctx) => async (parentSignal) => {
39
14
  const controller = new AbortController();
40
- // Pass key to the error ctor for better debugging context.
41
- const timerError = new ErrorCtor(opts.ms, { key: ctx.key });
42
- // Arm the per-attempt timer. The error object is allocated once so the
43
- // stack trace points here (the policy frame), not at setTimeout's
44
- // internal callback.
45
- const timer = setTimeout(() => controller.abort(timerError), opts.ms);
46
- // NOTE: do NOT `unref()` this timer. The timeout IS the operation
47
- // the caller is awaiting. unref'ing would let Node exit the process
48
- // while a timeout was pending — silently dropping the operation.
49
- // The timer is cleared in the finally block below.
50
- // Link parent → child. Capture the unlink function so we can clean
51
- // up the listener on success path (contract).
15
+ const timerError = new errorCtor(opts.ms, { key: ctx.key });
16
+ const obs = ctx.observability;
17
+ let timedOut = false;
18
+ const timer = setTimeout(() => {
19
+ timedOut = true;
20
+ if (obs) {
21
+ (0, safeCall_js_1.safeCall)(obs.hooks.onTimeout, {
22
+ type: 'timeout', key: ctx.key, traceId: obs.traceId,
23
+ timestamp: Date.now(), kind, ms: opts.ms,
24
+ });
25
+ }
26
+ controller.abort(timerError);
27
+ }, opts.ms);
52
28
  const unlink = (0, abort_js_1.linkSignal)(parentSignal, controller);
53
29
  try {
54
- // Race fn against the abort event. If fn settles first, we get its
55
- // result/error. If the signal aborts first, we reject with reason.
56
- //
57
- // We do NOT use AbortSignal.timeout() here because we need to throw
58
- // our own ErrorCtor, not a DOMException named "TimeoutError".
30
+ if (strategy === 'cooperative') {
31
+ if (parentSignal.aborted) {
32
+ throw parentSignal.reason;
33
+ }
34
+ try {
35
+ const value = await fn(controller.signal);
36
+ return value;
37
+ }
38
+ catch (err) {
39
+ if (timedOut)
40
+ throw timerError;
41
+ throw err;
42
+ }
43
+ }
59
44
  return await new Promise((resolve, reject) => {
60
45
  if (controller.signal.aborted) {
61
46
  reject(controller.signal.reason);
@@ -78,22 +63,9 @@ function makeTimeoutPolicy(opts, ErrorCtor) {
78
63
  }
79
64
  };
80
65
  }
81
- /**
82
- * Per-attempt timeout. Races `fn` against a deadline that resets on retry.
83
- *
84
- * Place this INSIDE `retryPolicy` (closer to `fn`) so each attempt has its
85
- * own clock.
86
- */
87
66
  function timeoutPolicy(opts) {
88
- return makeTimeoutPolicy(opts, errors_js_1.TimeoutError);
67
+ return makeTimeoutPolicy(opts, errors_js_1.TimeoutError, 'per-attempt');
89
68
  }
90
- /**
91
- * Operation-wide timeout. Races the ENTIRE chain (all retry attempts +
92
- * delays) against a hard budget that does NOT reset.
93
- *
94
- * Place this as the OUTERMOST policy so the clock starts before any other
95
- * policy runs and stops regardless of what the inner chain is doing.
96
- */
97
69
  function totalTimeoutPolicy(opts) {
98
- return makeTimeoutPolicy(opts, errors_js_1.TotalTimeoutError);
70
+ return makeTimeoutPolicy(opts, errors_js_1.TotalTimeoutError, 'total');
99
71
  }
@@ -1,19 +1,6 @@
1
1
  import type { PolicyApplier, TimeoutOptions } from '../types/index.js';
2
2
  import { TimeoutError, TotalTimeoutError } from '../errors.js';
3
3
  export { TimeoutError, TotalTimeoutError };
4
- /**
5
- * Per-attempt timeout. Races `fn` against a deadline that resets on retry.
6
- *
7
- * Place this INSIDE `retryPolicy` (closer to `fn`) so each attempt has its
8
- * own clock.
9
- */
10
4
  export declare function timeoutPolicy<T>(opts: TimeoutOptions): PolicyApplier<T>;
11
- /**
12
- * Operation-wide timeout. Races the ENTIRE chain (all retry attempts +
13
- * delays) against a hard budget that does NOT reset.
14
- *
15
- * Place this as the OUTERMOST policy so the clock starts before any other
16
- * policy runs and stops regardless of what the inner chain is doing.
17
- */
18
5
  export declare function totalTimeoutPolicy<T>(opts: TimeoutOptions): PolicyApplier<T>;
19
6
  //# sourceMappingURL=timeout.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"timeout.d.ts","sourceRoot":"","sources":["../../src/policies/timeout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAE5F,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAa9D,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,CAAA;AAyF1C;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,GAAG,aAAa,CAAC,CAAC,CAAC,CAEvE;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,GAAG,aAAa,CAAC,CAAC,CAAC,CAE5E"}
1
+ {"version":3,"file":"timeout.d.ts","sourceRoot":"","sources":["../../src/policies/timeout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAG5F,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAI9D,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,CAAA;AAyG1C,wBAAgB,aAAa,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,GAAG,aAAa,CAAC,CAAC,CAAC,CAEvE;AAMD,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,GAAG,aAAa,CAAC,CAAC,CAAC,CAE5E"}
@@ -1,65 +1,40 @@
1
1
  import { linkSignal } from '../utils/abort.js';
2
+ import { safeCall } from '../utils/safeCall.js';
2
3
  import { TimeoutError, TotalTimeoutError } from '../errors.js';
3
- // ─── Errors ───────────────────────────────────────────────────────────────────
4
- //
5
- // TimeoutError and TotalTimeoutError live in `src/errors.ts` and
6
- // now extend `ActlyError` (which extends `Error`). Existing `instanceof
7
- // Error` and `instanceof TimeoutError` checks continue to work; new
8
- // `instanceof ActlyError` and `.code` field give consumers a stable
9
- // discriminator for telemetry.
10
- //
11
- // Re-exported here for backwards compatibility with code that imports
12
- // from `'actly/policies/timeout'` (the deep path).
13
4
  export { TimeoutError, TotalTimeoutError };
14
- // ─── Policy ───────────────────────────────────────────────────────────────────
15
- /**
16
- * Build a timeout policy that throws `ErrorCtor` on deadline.
17
- *
18
- * # Cancellation contract
19
- *
20
- * Each invocation:
21
- * 1. Creates a fresh `AbortController` for this attempt.
22
- * 2. Arms a `setTimeout` that aborts the controller with a fresh `ErrorCtor(ms)`.
23
- * 3. Links the parent signal: if the parent aborts (e.g. `totalTimeout` or
24
- * caller cancellation), the child aborts with the parent's reason.
25
- * 4. Races `fn(childSignal)` against the abort event.
26
- *
27
- * The race is critical: it ensures `act()` returns promptly even if `fn`
28
- * ignores the signal. The underlying `fn` may keep running in the background
29
- * (resource leak), but the caller is unblocked. This is the best JavaScript
30
- * can do without cooperation from `fn`.
31
- *
32
- * If `fn` cooperates (passes `signal` to `fetch`, `AbortController`, etc.),
33
- * the underlying work is cancelled properly — no leak.
34
- *
35
- * # Error attribution
36
- *
37
- * If the per-attempt timer fires, we throw `ErrorCtor(ms)` regardless of
38
- * what `fn` does. If the parent signal fires first, we throw the parent's
39
- * reason (could be `TotalTimeoutError`, an `AbortError`, or anything else).
40
- */
41
- function makeTimeoutPolicy(opts, ErrorCtor) {
5
+ function makeTimeoutPolicy(opts, errorCtor, kind) {
6
+ const strategy = opts.strategy ?? 'race';
42
7
  return (fn, ctx) => async (parentSignal) => {
43
8
  const controller = new AbortController();
44
- // Pass key to the error ctor for better debugging context.
45
- const timerError = new ErrorCtor(opts.ms, { key: ctx.key });
46
- // Arm the per-attempt timer. The error object is allocated once so the
47
- // stack trace points here (the policy frame), not at setTimeout's
48
- // internal callback.
49
- const timer = setTimeout(() => controller.abort(timerError), opts.ms);
50
- // NOTE: do NOT `unref()` this timer. The timeout IS the operation
51
- // the caller is awaiting. unref'ing would let Node exit the process
52
- // while a timeout was pending — silently dropping the operation.
53
- // The timer is cleared in the finally block below.
54
- // Link parent → child. Capture the unlink function so we can clean
55
- // up the listener on success path (contract).
9
+ const timerError = new errorCtor(opts.ms, { key: ctx.key });
10
+ const obs = ctx.observability;
11
+ let timedOut = false;
12
+ const timer = setTimeout(() => {
13
+ timedOut = true;
14
+ if (obs) {
15
+ safeCall(obs.hooks.onTimeout, {
16
+ type: 'timeout', key: ctx.key, traceId: obs.traceId,
17
+ timestamp: Date.now(), kind, ms: opts.ms,
18
+ });
19
+ }
20
+ controller.abort(timerError);
21
+ }, opts.ms);
56
22
  const unlink = linkSignal(parentSignal, controller);
57
23
  try {
58
- // Race fn against the abort event. If fn settles first, we get its
59
- // result/error. If the signal aborts first, we reject with reason.
60
- //
61
- // We do NOT use AbortSignal.timeout() here because we need to throw
62
- // our own ErrorCtor, not a DOMException named "TimeoutError".
24
+ if (strategy === 'cooperative') {
25
+ if (parentSignal.aborted) {
26
+ throw parentSignal.reason;
27
+ }
28
+ try {
29
+ const value = await fn(controller.signal);
30
+ return value;
31
+ }
32
+ catch (err) {
33
+ if (timedOut)
34
+ throw timerError;
35
+ throw err;
36
+ }
37
+ }
63
38
  return await new Promise((resolve, reject) => {
64
39
  if (controller.signal.aborted) {
65
40
  reject(controller.signal.reason);
@@ -82,23 +57,9 @@ function makeTimeoutPolicy(opts, ErrorCtor) {
82
57
  }
83
58
  };
84
59
  }
85
- /**
86
- * Per-attempt timeout. Races `fn` against a deadline that resets on retry.
87
- *
88
- * Place this INSIDE `retryPolicy` (closer to `fn`) so each attempt has its
89
- * own clock.
90
- */
91
60
  export function timeoutPolicy(opts) {
92
- return makeTimeoutPolicy(opts, TimeoutError);
61
+ return makeTimeoutPolicy(opts, TimeoutError, 'per-attempt');
93
62
  }
94
- /**
95
- * Operation-wide timeout. Races the ENTIRE chain (all retry attempts +
96
- * delays) against a hard budget that does NOT reset.
97
- *
98
- * Place this as the OUTERMOST policy so the clock starts before any other
99
- * policy runs and stops regardless of what the inner chain is doing.
100
- */
101
63
  export function totalTimeoutPolicy(opts) {
102
- return makeTimeoutPolicy(opts, TotalTimeoutError);
64
+ return makeTimeoutPolicy(opts, TotalTimeoutError, 'total');
103
65
  }
104
- //# sourceMappingURL=timeout.js.map
@@ -1,20 +1,10 @@
1
1
  "use strict";
2
- // ─── Store interfaces ─────────────────────────────────────────────────────────
3
2
  Object.defineProperty(exports, "__esModule", { value: true });
4
3
  exports.isSyncStore = isSyncStore;
5
4
  exports.isAsyncStore = isAsyncStore;
6
- // ─── Type guards ──────────────────────────────────────────────────────────────
7
- /**
8
- * Narrows `AnyStateStore` to `SyncStateStore` via the `_sync` discriminant.
9
- * Used by `execute()` and `cachePolicy` to branch between sync and async paths.
10
- */
11
5
  function isSyncStore(store) {
12
6
  return store._sync === true;
13
7
  }
14
- /**
15
- * Narrows `AnyStateStore` to `AsyncStateStore` via the `_sync` discriminant.
16
- * Provided for symmetry; prefer `isSyncStore` for the common guard pattern.
17
- */
18
8
  function isAsyncStore(store) {
19
9
  return store._sync === false;
20
10
  }
@@ -1,88 +1,25 @@
1
- /**
2
- * Synchronous key-value store. All operations complete in the same tick.
3
- *
4
- * # Why synchronous?
5
- *
6
- * `dedupePolicy` must read an in-flight Promise from the store and, if absent,
7
- * write a new one — all within a single synchronous frame. If `get()` were
8
- * async, two concurrent callers could both observe a miss before either
9
- * write lands, defeating deduplication entirely. There is no lock primitive
10
- * in JavaScript that can paper over this: the constraint is structural, not
11
- * implementation-level.
12
- *
13
- * # The `_sync` discriminant
14
- *
15
- * `_sync: true` is a runtime tag that lets `execute()` enforce the dedupe
16
- * constraint for plain-JS callers who bypass TypeScript. It is NOT part of
17
- * the semantic contract and MUST NOT be used for anything beyond that guard.
18
- * Implementations must set it as a `readonly` literal (`true as const`).
19
- *
20
- * # Lifecycle
21
- *
22
- * Implementations that hold background resources (timers, connections) should
23
- * expose a `destroy()` method as a convention. See `InMemoryStore` for the
24
- * reference pattern.
25
- */
26
1
  export interface SyncStateStore {
27
- /** Runtime discriminant. Must be `true as const`. */
28
2
  readonly _sync: true;
29
3
  get<T>(key: string): T | undefined;
30
4
  set<T>(key: string, value: T, ttlMs?: number): void;
31
5
  delete(key: string): void;
32
6
  has(key: string): boolean;
33
- /**
34
- * Remove all entries synchronously.
35
- * After `clear()` returns, `size()` returns 0.
36
- */
7
+ deleteIfExists?(key: string): boolean;
37
8
  clear(): void;
38
- /**
39
- * Return the count of live (non-expired) entries.
40
- * Side-effect free: does not mutate the store. Implementations MAY evict
41
- * expired entries opportunistically during this call, but MUST NOT have
42
- * observable side effects beyond internal cleanup.
43
- */
44
9
  size(): number;
10
+ destroy?(): void;
45
11
  }
46
- /**
47
- * Asynchronous key-value store. All operations return Promises.
48
- *
49
- * # Policy compatibility
50
- *
51
- * Compatible with `cachePolicy` only. Passing an `AsyncStateStore` to a
52
- * policy chain that includes `dedupePolicy` is a TypeScript error and a
53
- * runtime error — `execute()` throws at chain-build time. See
54
- * `SyncStateStore` for why dedupe requires synchronous access.
55
- *
56
- * # TTL semantics
57
- *
58
- * The store is responsible for honouring `ttlMs`. Actly passes it as a hint.
59
- * Implementations may delegate to a native TTL mechanism (e.g. Redis EXPIRE).
60
- *
61
- * # The `_sync` discriminant
62
- *
63
- * `_sync: false` mirrors the discriminant on `SyncStateStore`. Must be set
64
- * as a `readonly` literal (`false as const`).
65
- */
66
12
  export interface AsyncStateStore {
67
- /** Runtime discriminant. Must be `false as const`. */
68
13
  readonly _sync: false;
69
14
  get<T>(key: string): Promise<T | undefined>;
70
15
  set<T>(key: string, value: T, ttlMs?: number): Promise<void>;
71
16
  delete(key: string): Promise<void>;
72
17
  has(key: string): Promise<boolean>;
73
- /** Remove all entries managed by this store. */
18
+ deleteIfExists?(key: string): Promise<boolean>;
74
19
  clear(): Promise<void>;
75
- /** Return the count of live (non-expired) entries. */
76
20
  size(): Promise<number>;
21
+ destroy?(): void | Promise<void>;
77
22
  }
78
- /**
79
- * Narrows `AnyStateStore` to `SyncStateStore` via the `_sync` discriminant.
80
- * Used by `execute()` and `cachePolicy` to branch between sync and async paths.
81
- */
82
23
  export declare function isSyncStore(store: SyncStateStore | AsyncStateStore): store is SyncStateStore;
83
- /**
84
- * Narrows `AnyStateStore` to `AsyncStateStore` via the `_sync` discriminant.
85
- * Provided for symmetry; prefer `isSyncStore` for the common guard pattern.
86
- */
87
24
  export declare function isAsyncStore(store: SyncStateStore | AsyncStateStore): store is AsyncStateStore;
88
25
  //# sourceMappingURL=base.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"base.d.ts","sourceRoot":"","sources":["../../src/stores/base.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,WAAW,cAAc;IAC7B,qDAAqD;IACrD,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;IAEpB,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAAA;IAClC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACnD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;IAEzB;;;OAGG;IACH,KAAK,IAAI,IAAI,CAAA;IAEb;;;;;OAKG;IACH,IAAI,IAAI,MAAM,CAAA;CACf;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,eAAe;IAC9B,sDAAsD;IACtD,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IAErB,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAA;IAC3C,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5D,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAElC,gDAAgD;IAChD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IAEtB,sDAAsD;IACtD,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;CACxB;AAID;;;GAGG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,cAAc,GAAG,eAAe,GACtC,KAAK,IAAI,cAAc,CAEzB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,cAAc,GAAG,eAAe,GACtC,KAAK,IAAI,eAAe,CAE1B"}
1
+ {"version":3,"file":"base.d.ts","sourceRoot":"","sources":["../../src/stores/base.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,cAAc;IAE7B,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;IAEpB,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAAA;IAClC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACnD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;IASzB,cAAc,CAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;IAGrC,KAAK,IAAI,IAAI,CAAA;IAMb,IAAI,IAAI,MAAM,CAAA;IAOd,OAAO,CAAC,IAAI,IAAI,CAAA;CACjB;AAcD,MAAM,WAAW,eAAe;IAE9B,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IAErB,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAA;IAC3C,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5D,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAMlC,cAAc,CAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAG9C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IAGtB,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;IAMvB,OAAO,CAAC,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACjC;AAQD,wBAAgB,WAAW,CACzB,KAAK,EAAE,cAAc,GAAG,eAAe,GACtC,KAAK,IAAI,cAAc,CAEzB;AAMD,wBAAgB,YAAY,CAC1B,KAAK,EAAE,cAAc,GAAG,eAAe,GACtC,KAAK,IAAI,eAAe,CAE1B"}
@@ -1,17 +1,6 @@
1
- // ─── Store interfaces ─────────────────────────────────────────────────────────
2
- // ─── Type guards ──────────────────────────────────────────────────────────────
3
- /**
4
- * Narrows `AnyStateStore` to `SyncStateStore` via the `_sync` discriminant.
5
- * Used by `execute()` and `cachePolicy` to branch between sync and async paths.
6
- */
7
1
  export function isSyncStore(store) {
8
2
  return store._sync === true;
9
3
  }
10
- /**
11
- * Narrows `AnyStateStore` to `AsyncStateStore` via the `_sync` discriminant.
12
- * Provided for symmetry; prefer `isSyncStore` for the common guard pattern.
13
- */
14
4
  export function isAsyncStore(store) {
15
5
  return store._sync === false;
16
6
  }
17
- //# sourceMappingURL=base.js.map