actly 1.1.5 → 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 (139) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/MIGRATION.md +141 -0
  3. package/README.md +334 -428
  4. package/SECURITY.md +56 -0
  5. package/dist/core/act.cjs +630 -126
  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 +630 -129
  9. package/dist/core/executor.cjs +1 -26
  10. package/dist/core/executor.d.ts +2 -38
  11. package/dist/core/executor.d.ts.map +1 -1
  12. package/dist/core/executor.js +1 -27
  13. package/dist/core/health.cjs +163 -0
  14. package/dist/core/health.d.ts +33 -0
  15. package/dist/core/health.d.ts.map +1 -0
  16. package/dist/core/health.js +150 -0
  17. package/dist/core/shutdown.cjs +71 -0
  18. package/dist/core/shutdown.d.ts +5 -0
  19. package/dist/core/shutdown.d.ts.map +1 -0
  20. package/dist/core/shutdown.js +65 -0
  21. package/dist/core/tenant.cjs +113 -0
  22. package/dist/core/tenant.d.ts +24 -0
  23. package/dist/core/tenant.d.ts.map +1 -0
  24. package/dist/core/tenant.js +109 -0
  25. package/dist/errors.cjs +149 -0
  26. package/dist/errors.d.ts +94 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +134 -0
  29. package/dist/index.cjs +51 -8
  30. package/dist/index.d.ts +22 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +14 -6
  33. package/dist/observability.cjs +6 -0
  34. package/dist/observability.d.ts +86 -0
  35. package/dist/observability.d.ts.map +1 -0
  36. package/dist/observability.js +3 -0
  37. package/dist/policies/bulkhead.cjs +137 -0
  38. package/dist/policies/bulkhead.d.ts +3 -0
  39. package/dist/policies/bulkhead.d.ts.map +1 -0
  40. package/dist/policies/bulkhead.js +134 -0
  41. package/dist/policies/cache.cjs +84 -67
  42. package/dist/policies/cache.d.ts +0 -33
  43. package/dist/policies/cache.d.ts.map +1 -1
  44. package/dist/policies/cache.js +84 -68
  45. package/dist/policies/circuitBreaker.cjs +179 -0
  46. package/dist/policies/circuitBreaker.d.ts +3 -0
  47. package/dist/policies/circuitBreaker.d.ts.map +1 -0
  48. package/dist/policies/circuitBreaker.js +176 -0
  49. package/dist/policies/dedupe.cjs +46 -64
  50. package/dist/policies/dedupe.d.ts +0 -41
  51. package/dist/policies/dedupe.d.ts.map +1 -1
  52. package/dist/policies/dedupe.js +46 -65
  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 +45 -0
  58. package/dist/policies/rateLimit.d.ts +3 -0
  59. package/dist/policies/rateLimit.d.ts.map +1 -0
  60. package/dist/policies/rateLimit.js +42 -0
  61. package/dist/policies/retry.cjs +129 -57
  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 +129 -58
  65. package/dist/policies/timeout.cjs +47 -103
  66. package/dist/policies/timeout.d.ts +2 -46
  67. package/dist/policies/timeout.d.ts.map +1 -1
  68. package/dist/policies/timeout.js +46 -102
  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 +147 -87
  74. package/dist/stores/memory.d.ts +11 -59
  75. package/dist/stores/memory.d.ts.map +1 -1
  76. package/dist/stores/memory.js +146 -88
  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 +62 -179
  83. package/dist/types/index.d.ts.map +1 -1
  84. package/dist/types/index.js +0 -2
  85. package/dist/utils/abort.cjs +46 -73
  86. package/dist/utils/abort.d.ts +4 -51
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +46 -74
  89. package/dist/utils/abortPool.cjs +25 -0
  90. package/dist/utils/abortPool.d.ts +4 -0
  91. package/dist/utils/abortPool.d.ts.map +1 -0
  92. package/dist/utils/abortPool.js +20 -0
  93. package/dist/utils/backoff.cjs +8 -15
  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 +8 -16
  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 +45 -0
  102. package/dist/utils/key.d.ts +2 -0
  103. package/dist/utils/key.d.ts.map +1 -0
  104. package/dist/utils/key.js +42 -0
  105. package/dist/utils/limits.cjs +22 -0
  106. package/dist/utils/limits.d.ts +21 -0
  107. package/dist/utils/limits.d.ts.map +1 -0
  108. package/dist/utils/limits.js +19 -0
  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 +50 -0
  114. package/dist/utils/sanitize.d.ts +3 -0
  115. package/dist/utils/sanitize.d.ts.map +1 -0
  116. package/dist/utils/sanitize.js +46 -0
  117. package/dist/utils/validate.cjs +163 -26
  118. package/dist/utils/validate.d.ts +6 -8
  119. package/dist/utils/validate.d.ts.map +1 -1
  120. package/dist/utils/validate.js +158 -27
  121. package/package.json +42 -7
  122. package/dist/core/act.js.map +0 -1
  123. package/dist/core/executor.js.map +0 -1
  124. package/dist/index.js.map +0 -1
  125. package/dist/policies/cache.js.map +0 -1
  126. package/dist/policies/dedupe.js.map +0 -1
  127. package/dist/policies/retry.js.map +0 -1
  128. package/dist/policies/timeout.js.map +0 -1
  129. package/dist/state/store.cjs +0 -14
  130. package/dist/state/store.d.ts +0 -11
  131. package/dist/state/store.d.ts.map +0 -1
  132. package/dist/state/store.js +0 -11
  133. package/dist/state/store.js.map +0 -1
  134. package/dist/stores/base.js.map +0 -1
  135. package/dist/stores/memory.js.map +0 -1
  136. package/dist/types/index.js.map +0 -1
  137. package/dist/utils/abort.js.map +0 -1
  138. package/dist/utils/backoff.js.map +0 -1
  139. package/dist/utils/validate.js.map +0 -1
@@ -3,85 +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
- // ─── Default predicate ────────────────────────────────────────────────────────
7
- /**
8
- * Default `shouldRetry`: retry on any error EXCEPT abort errors.
9
- *
10
- * Abort errors indicate the caller or a timeout cancelled the operation —
11
- * retrying would just abort again on the next attempt, wasting delay budget.
12
- *
13
- * User-supplied `shouldRetry` overrides this entirely.
14
- */
6
+ const safeCall_js_1 = require("../utils/safeCall.js");
7
+ const errors_js_1 = require("../errors.js");
8
+ const limits_js_1 = require("../utils/limits.js");
15
9
  function defaultShouldRetry(error, _attempt) {
16
- 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;
17
18
  }
18
- // ─── Policy ───────────────────────────────────────────────────────────────────
19
- /**
20
- * Retry `fn` up to `opts.attempts` times on retryable errors.
21
- *
22
- * Writes the live attempt count into `ctx.meta.attempts` so `act()` can
23
- * report it in the final `ActResult`.
24
- *
25
- * # Signal awareness
26
- *
27
- * - Before each attempt, checks `parentSignal.aborted`. If the parent (e.g.
28
- * `totalTimeout` or caller) has aborted, throws the parent's reason
29
- * immediately — no more attempts.
30
- * - Sleeps between attempts use `sleep(delay, parentSignal)`. If the parent
31
- * aborts mid-delay, the sleep rejects immediately instead of blocking
32
- * the loop until the timer would have elapsed.
33
- *
34
- * # `shouldRetry` invocation
35
- *
36
- * Called after EVERY failure, including the last attempt. This preserves the
37
- * predicate's contract for observers / metrics that rely on it being called
38
- * per-attempt. The return value is only consulted when `attempt < max`.
39
- *
40
- * # Default predicate
41
- *
42
- * If `shouldRetry` is omitted, uses `defaultShouldRetry` which retries on
43
- * every error EXCEPT abort errors (so timeouts and caller cancellations
44
- * don't waste retry budget).
45
- */
46
19
  function retryPolicy(opts) {
47
20
  const max = Math.max(1, Math.floor(opts.attempts));
48
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;
49
26
  return (fn, ctx) => async (parentSignal) => {
50
- let lastErr;
27
+ const backoffState = {};
28
+ let errors;
29
+ let retriedAtLeastOnce = false;
30
+ const obs = ctx.observability;
51
31
  for (let attempt = 1; attempt <= max; attempt++) {
52
- // Parent (totalTimeout or caller signal) already aborted — bail.
53
32
  if (parentSignal.aborted)
54
33
  throw parentSignal.reason;
55
34
  ctx.meta.attempts = attempt;
35
+ const attemptStart = Date.now();
56
36
  try {
57
- return await fn(parentSignal);
37
+ if (obs) {
38
+ (0, safeCall_js_1.safeCall)(obs.hooks.onAttempt, {
39
+ type: 'attempt', key: ctx.key, traceId: obs.traceId,
40
+ timestamp: attemptStart, attempt,
41
+ });
42
+ }
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;
58
101
  }
59
102
  catch (err) {
60
- lastErr = err;
61
- // Always invoke shouldRetry so observers see every failure.
62
- // The return value only matters when there are attempts left.
63
- const retryable = shouldRetry(err, attempt);
64
- if (attempt >= max) {
65
- // Last attempt — surface the error regardless of retryable.
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
+ }
112
+ let retryable;
113
+ try {
114
+ retryable = shouldRetry(err, attempt);
115
+ }
116
+ catch {
66
117
  throw err;
67
118
  }
68
- if (!retryable) {
69
- // Non-retryable error — bail immediately without consuming
70
- // remaining attempts. The error surfaces exactly as-is.
119
+ if (attempt >= max) {
120
+ if (retriedAtLeastOnce) {
121
+ throw new errors_js_1.RetryExhaustedError({
122
+ key: ctx.key,
123
+ attempts: attempt,
124
+ lastError: err,
125
+ errors: errors ?? [],
126
+ });
127
+ }
71
128
  throw err;
72
129
  }
73
- // Parent aborted mid-attempt — don't sleep, bail.
130
+ if (!retryable)
131
+ throw err;
132
+ retriedAtLeastOnce = true;
74
133
  if (parentSignal.aborted)
75
134
  throw parentSignal.reason;
76
- const delay = (0, backoff_js_1.computeDelay)(attempt, opts);
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);
146
+ if (obs) {
147
+ (0, safeCall_js_1.safeCall)(obs.hooks.onRetry, {
148
+ type: 'retry', key: ctx.key, traceId: obs.traceId,
149
+ timestamp: Date.now(), attempt, delayMs: delay, error: err,
150
+ });
151
+ }
77
152
  if (delay > 0) {
78
- // Sleep is signal-aware: rejects immediately if parent aborts.
79
- await (0, abort_js_1.sleep)(delay, parentSignal);
153
+ await (0, abort_js_1.sleep)(delay, parentSignal, sleepOpts);
80
154
  }
81
155
  }
82
156
  }
83
- // Unreachable: the loop either returns or throws on every iteration.
84
- // The cast satisfies the type checker without a `throw` after the loop.
85
- throw lastErr;
157
+ throw new Error('Actly: retryPolicy reached unreachable state');
86
158
  };
87
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;AAoB1F;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,CAAC,CAAC,CAiDnE"}
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"}
@@ -1,85 +1,156 @@
1
1
  import { computeDelay } from '../utils/backoff.js';
2
2
  import { isAbortError, sleep } from '../utils/abort.js';
3
- // ─── Default predicate ────────────────────────────────────────────────────────
4
- /**
5
- * Default `shouldRetry`: retry on any error EXCEPT abort errors.
6
- *
7
- * Abort errors indicate the caller or a timeout cancelled the operation —
8
- * retrying would just abort again on the next attempt, wasting delay budget.
9
- *
10
- * User-supplied `shouldRetry` overrides this entirely.
11
- */
3
+ import { safeCall } from '../utils/safeCall.js';
4
+ import { RetryExhaustedError } from '../errors.js';
5
+ import { LIMITS } from '../utils/limits.js';
12
6
  function defaultShouldRetry(error, _attempt) {
13
- 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;
14
15
  }
15
- // ─── Policy ───────────────────────────────────────────────────────────────────
16
- /**
17
- * Retry `fn` up to `opts.attempts` times on retryable errors.
18
- *
19
- * Writes the live attempt count into `ctx.meta.attempts` so `act()` can
20
- * report it in the final `ActResult`.
21
- *
22
- * # Signal awareness
23
- *
24
- * - Before each attempt, checks `parentSignal.aborted`. If the parent (e.g.
25
- * `totalTimeout` or caller) has aborted, throws the parent's reason
26
- * immediately — no more attempts.
27
- * - Sleeps between attempts use `sleep(delay, parentSignal)`. If the parent
28
- * aborts mid-delay, the sleep rejects immediately instead of blocking
29
- * the loop until the timer would have elapsed.
30
- *
31
- * # `shouldRetry` invocation
32
- *
33
- * Called after EVERY failure, including the last attempt. This preserves the
34
- * predicate's contract for observers / metrics that rely on it being called
35
- * per-attempt. The return value is only consulted when `attempt < max`.
36
- *
37
- * # Default predicate
38
- *
39
- * If `shouldRetry` is omitted, uses `defaultShouldRetry` which retries on
40
- * every error EXCEPT abort errors (so timeouts and caller cancellations
41
- * don't waste retry budget).
42
- */
43
16
  export function retryPolicy(opts) {
44
17
  const max = Math.max(1, Math.floor(opts.attempts));
45
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;
46
23
  return (fn, ctx) => async (parentSignal) => {
47
- let lastErr;
24
+ const backoffState = {};
25
+ let errors;
26
+ let retriedAtLeastOnce = false;
27
+ const obs = ctx.observability;
48
28
  for (let attempt = 1; attempt <= max; attempt++) {
49
- // Parent (totalTimeout or caller signal) already aborted — bail.
50
29
  if (parentSignal.aborted)
51
30
  throw parentSignal.reason;
52
31
  ctx.meta.attempts = attempt;
32
+ const attemptStart = Date.now();
53
33
  try {
54
- return await fn(parentSignal);
34
+ if (obs) {
35
+ safeCall(obs.hooks.onAttempt, {
36
+ type: 'attempt', key: ctx.key, traceId: obs.traceId,
37
+ timestamp: attemptStart, attempt,
38
+ });
39
+ }
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;
55
98
  }
56
99
  catch (err) {
57
- lastErr = err;
58
- // Always invoke shouldRetry so observers see every failure.
59
- // The return value only matters when there are attempts left.
60
- const retryable = shouldRetry(err, attempt);
61
- if (attempt >= max) {
62
- // Last attempt — surface the error regardless of retryable.
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
+ }
109
+ let retryable;
110
+ try {
111
+ retryable = shouldRetry(err, attempt);
112
+ }
113
+ catch {
63
114
  throw err;
64
115
  }
65
- if (!retryable) {
66
- // Non-retryable error — bail immediately without consuming
67
- // remaining attempts. The error surfaces exactly as-is.
116
+ if (attempt >= max) {
117
+ if (retriedAtLeastOnce) {
118
+ throw new RetryExhaustedError({
119
+ key: ctx.key,
120
+ attempts: attempt,
121
+ lastError: err,
122
+ errors: errors ?? [],
123
+ });
124
+ }
68
125
  throw err;
69
126
  }
70
- // Parent aborted mid-attempt — don't sleep, bail.
127
+ if (!retryable)
128
+ throw err;
129
+ retriedAtLeastOnce = true;
71
130
  if (parentSignal.aborted)
72
131
  throw parentSignal.reason;
73
- const delay = computeDelay(attempt, opts);
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);
143
+ if (obs) {
144
+ safeCall(obs.hooks.onRetry, {
145
+ type: 'retry', key: ctx.key, traceId: obs.traceId,
146
+ timestamp: Date.now(), attempt, delayMs: delay, error: err,
147
+ });
148
+ }
74
149
  if (delay > 0) {
75
- // Sleep is signal-aware: rejects immediately if parent aborts.
76
- await sleep(delay, parentSignal);
150
+ await sleep(delay, parentSignal, sleepOpts);
77
151
  }
78
152
  }
79
153
  }
80
- // Unreachable: the loop either returns or throws on every iteration.
81
- // The cast satisfies the type checker without a `throw` after the loop.
82
- throw lastErr;
154
+ throw new Error('Actly: retryPolicy reached unreachable state');
83
155
  };
84
156
  }
85
- //# sourceMappingURL=retry.js.map
@@ -4,124 +4,68 @@ 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
- // ─── Errors ───────────────────────────────────────────────────────────────────
8
- /**
9
- * Thrown when a per-attempt `timeout` deadline fires.
10
- *
11
- * Carries the configured `ms` so callers can log/alert precisely:
12
- *
13
- * ```ts
14
- * if (!result.ok && result.error instanceof TimeoutError) {
15
- * console.log(`attempt timed out after ${result.error.ms}ms`)
16
- * }
17
- * ```
18
- */
19
- class TimeoutError extends Error {
20
- ms;
21
- constructor(ms) {
22
- super(`ACT timed out after ${ms}ms`);
23
- this.name = 'TimeoutError';
24
- this.ms = ms;
25
- }
26
- }
27
- exports.TimeoutError = TimeoutError;
28
- /**
29
- * Thrown when the operation-wide `totalTimeout` budget fires.
30
- *
31
- * Distinct from `TimeoutError` (per-attempt) so callers can `instanceof`-check
32
- * which deadline fired:
33
- *
34
- * ```ts
35
- * if (result.error instanceof TotalTimeoutError) {
36
- * // whole operation budget exhausted
37
- * } else if (result.error instanceof TimeoutError) {
38
- * // last attempt's per-attempt deadline fired
39
- * }
40
- * ```
41
- */
42
- class TotalTimeoutError extends Error {
43
- ms;
44
- constructor(ms) {
45
- super(`ACT total timeout exceeded after ${ms}ms`);
46
- this.name = 'TotalTimeoutError';
47
- this.ms = ms;
48
- }
49
- }
50
- exports.TotalTimeoutError = TotalTimeoutError;
51
- // ─── Policy ───────────────────────────────────────────────────────────────────
52
- /**
53
- * Build a timeout policy that throws `ErrorCtor` on deadline.
54
- *
55
- * # Cancellation contract
56
- *
57
- * Each invocation:
58
- * 1. Creates a fresh `AbortController` for this attempt.
59
- * 2. Arms a `setTimeout` that aborts the controller with a fresh `ErrorCtor(ms)`.
60
- * 3. Links the parent signal: if the parent aborts (e.g. `totalTimeout` or
61
- * caller cancellation), the child aborts with the parent's reason.
62
- * 4. Races `fn(childSignal)` against the abort event.
63
- *
64
- * The race is critical: it ensures `act()` returns promptly even if `fn`
65
- * ignores the signal. The underlying `fn` may keep running in the background
66
- * (resource leak), but the caller is unblocked. This is the best JavaScript
67
- * can do without cooperation from `fn`.
68
- *
69
- * If `fn` cooperates (passes `signal` to `fetch`, `AbortController`, etc.),
70
- * the underlying work is cancelled properly — no leak.
71
- *
72
- * # Error attribution
73
- *
74
- * If the per-attempt timer fires, we throw `ErrorCtor(ms)` regardless of
75
- * what `fn` does. If the parent signal fires first, we throw the parent's
76
- * reason (could be `TotalTimeoutError`, an `AbortError`, or anything else).
77
- */
78
- function makeTimeoutPolicy(opts, ErrorCtor) {
79
- return (fn, _ctx) => async (parentSignal) => {
7
+ const safeCall_js_1 = require("../utils/safeCall.js");
8
+ const errors_js_1 = require("../errors.js");
9
+ Object.defineProperty(exports, "TimeoutError", { enumerable: true, get: function () { return errors_js_1.TimeoutError; } });
10
+ Object.defineProperty(exports, "TotalTimeoutError", { enumerable: true, get: function () { return errors_js_1.TotalTimeoutError; } });
11
+ function makeTimeoutPolicy(opts, errorCtor, kind) {
12
+ const strategy = opts.strategy ?? 'race';
13
+ return (fn, ctx) => async (parentSignal) => {
80
14
  const controller = new AbortController();
81
- const timerError = new ErrorCtor(opts.ms);
82
- // Arm the per-attempt timer. The error object is allocated once so the
83
- // stack trace points here (the policy frame), not at setTimeout's
84
- // internal callback.
85
- const timer = setTimeout(() => controller.abort(timerError), opts.ms);
86
- // Link parent -> child. If parent is already aborted, child aborts
87
- // synchronously with parent's reason.
88
- (0, abort_js_1.linkSignal)(parentSignal, controller);
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);
28
+ const unlink = (0, abort_js_1.linkSignal)(parentSignal, controller);
89
29
  try {
90
- // Race fn against the abort event. If fn settles first, we get its
91
- // result/error. If the signal aborts first, we reject with reason.
92
- //
93
- // We do NOT use AbortSignal.timeout() here because we need to throw
94
- // 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
+ }
95
44
  return await new Promise((resolve, reject) => {
96
45
  if (controller.signal.aborted) {
97
46
  reject(controller.signal.reason);
98
47
  return;
99
48
  }
100
- controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true });
101
- Promise.resolve(fn(controller.signal)).then((value) => resolve(value), (error) => reject(error));
49
+ const onAbort = () => reject(controller.signal.reason);
50
+ controller.signal.addEventListener('abort', onAbort, { once: true });
51
+ Promise.resolve(fn(controller.signal)).then((value) => {
52
+ controller.signal.removeEventListener('abort', onAbort);
53
+ resolve(value);
54
+ }, (error) => {
55
+ controller.signal.removeEventListener('abort', onAbort);
56
+ reject(error);
57
+ });
102
58
  });
103
59
  }
104
60
  finally {
105
61
  clearTimeout(timer);
62
+ unlink();
106
63
  }
107
64
  };
108
65
  }
109
- /**
110
- * Per-attempt timeout. Races `fn` against a deadline that resets on retry.
111
- *
112
- * Place this INSIDE `retryPolicy` (closer to `fn`) so each attempt has its
113
- * own clock.
114
- */
115
66
  function timeoutPolicy(opts) {
116
- return makeTimeoutPolicy(opts, TimeoutError);
67
+ return makeTimeoutPolicy(opts, errors_js_1.TimeoutError, 'per-attempt');
117
68
  }
118
- /**
119
- * Operation-wide timeout. Races the ENTIRE chain (all retry attempts +
120
- * delays) against a hard budget that does NOT reset.
121
- *
122
- * Place this as the OUTERMOST policy so the clock starts before any other
123
- * policy runs and stops regardless of what the inner chain is doing.
124
- */
125
69
  function totalTimeoutPolicy(opts) {
126
- return makeTimeoutPolicy(opts, TotalTimeoutError);
70
+ return makeTimeoutPolicy(opts, errors_js_1.TotalTimeoutError, 'total');
127
71
  }