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
package/README.md CHANGED
@@ -1,137 +1,101 @@
1
1
  # actly
2
2
 
3
- A typed reliability kernel for async execution. Retry, timeout, dedupe, cache composable, zero-throw, with `AbortSignal` cancellation, jittered backoff, observability hooks, and a typed error taxonomy.
3
+ A typed reliability kernel for async TypeScript. Retry, timeout, circuit breaker, bulkhead, rate limit, dedupe, cache, hedge, fallback, and graceful shutdown, all composed through one `act()` call that never rejects.
4
4
 
5
5
  ```bash
6
6
  npm install actly
7
7
  ```
8
8
 
9
- > Requires Node 20+. Ships ESM + CJS. Zero runtime dependencies. Tree-shakeable.
9
+ Node 20+. ESM + CJS. Zero runtime deps. 57 KB tarball, 243 KB JS runtime (335 KB installed with type defs). 12 policies in one `act()` call.
10
10
 
11
- ---
11
+ > **Bundle note:** This is ~4× larger than [Cockatiel](https://github.com/vjkramer/cockatiel) (63 KB JS) because actly ships cache, dedupe, rate limit, hedge, audit log, multi-tenant isolation, drain, watchdog, and health check in the same package. If you only need retry/timeout/circuit-breaker/bulkhead/fallback and bundle size is critical, use Cockatiel. If you want the broader policy surface in one call, the size delta is the cost.
12
12
 
13
- ## What it does
14
-
15
- `act()` wraps an async function with configurable reliability policies. It always resolves with a result object — never rejects. You check `.ok` to branch on success or failure.
13
+ ## Quick start
16
14
 
17
15
  ```ts
18
16
  import { act } from 'actly'
19
17
 
20
- const result = await act(
21
- 'user:42',
22
- async (signal) => fetch(`/api/users/42`, { signal }),
23
- {
24
- retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
25
- timeout: { ms: 5_000 },
26
- totalTimeout: { ms: 12_000 },
27
- dedupe: true,
28
- cache: { ttl: 60_000 },
29
- },
30
- )
18
+ const r = await act('user:42', async (signal) => {
19
+ const res = await fetch('/api/users/42', { signal })
20
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
21
+ return res.json()
22
+ }, {
23
+ retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
24
+ timeout: { ms: 5_000 },
25
+ totalTimeout: { ms: 12_000 },
26
+ cache: { ttl: 60_000 },
27
+ })
31
28
 
32
- if (result.ok) {
33
- console.log(result.value) // T
34
- console.log(result.source) // 'fresh' | 'cache'
35
- console.log(result.attempts) // number (0 on cache hit)
36
- console.log(result.durationMs) // number (wall-clock ms)
29
+ if (r.ok) {
30
+ console.log(r.value, r.source, r.attempts, r.durationMs)
37
31
  } else {
38
- console.error(result.error) // typed — see Error taxonomy
32
+ console.error(r.error)
39
33
  }
40
34
  ```
41
35
 
42
- ---
43
-
44
- ## Compared to Cockatiel
45
-
46
- Both are typed, zero-dependency resilience libraries for Node/TypeScript. The core difference is scope and composition model.
47
-
48
- | | actly | Cockatiel |
49
- |---|---|---|
50
- | Retry (backoff + jitter) | ✅ exponential/linear/constant, 4 jitter modes | ✅ exponential, `ExponentialBackoff` |
51
- | Timeout (per-attempt) | | ✅ |
52
- | Total/wall-clock timeout | `totalTimeout` spans the whole retry loop | — (compose manually) |
53
- | Circuit breaker | ✅ half-open single-probe | |
54
- | Bulkhead | with optional queue + queue timeout | |
55
- | Rate limit | ✅ sliding window | — |
56
- | Cache (with single-flight) | built in, stampede-proof | |
57
- | Request dedupe (single-flight) | generation-safe, joiner-isolated | |
58
- | Hedged requests | | |
59
- | Fallback | (compose at call site) | ✅ |
60
- | Composition model | one `act()` call, declarative options object | explicit `wrap()` chaining of policy objects |
61
- | Cancellation | native `AbortSignal`, propagated through every policy | `CancellationToken` (custom, pre-dates broad `AbortSignal` adoption) |
62
- | Observability | opt-in hooks, zero-cost when unused | events per policy object |
63
- | State store | pluggable (`InMemoryStore` built in; bring your own for Redis etc.) | in-process only |
64
-
65
- Where the two overlap (retry, timeout, circuit breaker, bulkhead), the semantics are comparable and both are well-suited to production use. actly additionally folds cache, dedupe, rate limiting, and hedging into the same policy chain and result type, so a single `act()` call can express what would otherwise require composing several Cockatiel policies plus your own caching/dedupe layer. Cockatiel's `fallback` policy has no direct equivalent in actly — compose it at the call site by catching the returned `ActResult` and substituting a default when `!result.ok`.
66
-
67
- ### Measured overhead: actly vs Cockatiel
68
-
69
- Feature parity is only half the story — here are actual numbers, not marketing claims. Run it yourself: `npm run build && npm install --no-save cockatiel && node bench/compare-cockatiel.mjs`.
70
-
71
- Representative run (Node 20, single machine, trivial no-op `fn`, 20,000 iterations per case — see `bench/compare-cockatiel.mjs` for full methodology):
72
-
73
- | Policy (happy path) | actly | Cockatiel | Cockatiel is faster by |
74
- |---|---|---|---|
75
- | retry (3 attempts configured, succeeds on 1st) | 5.0 µs/op | 0.9 µs/op | ~5.5x |
76
- | timeout (5s limit, resolves fast) | 13.7 µs/op | 7.1 µs/op | ~1.9x |
77
- | circuit breaker (closed) | 3.5 µs/op | 0.5 µs/op | ~7.1x |
78
- | bulkhead (under cap) | 4.2 µs/op | 0.5 µs/op | ~8.0x |
79
- | retry + timeout + circuitBreaker composed | 14.5 µs/op | 7.9 µs/op | ~1.8x |
80
-
81
- **Honest read:** Cockatiel is faster, consistently, on every policy they both implement. Its policies are closures with no external state lookup. actly's policies go through a keyed state store (`Map` get/set per call, even for `InMemoryStore`) to support cross-call features Cockatiel doesn't have — a circuit breaker's failure count has to live somewhere addressable by `key` across separate `act()` calls, not captured in a closure. That indirection costs microseconds per call.
82
-
83
- For nearly all real workloads this difference is noise — both add single-digit-to-low-double-digit microseconds against I/O that costs milliseconds. It matters if you're calling a policy millions of times per second in a hot loop with no I/O; it does not matter wrapping network or database calls, which is what both libraries are for. Pick actly when you want cache/dedupe/rate-limit/hedge in the same call and don't want to hand-wire a state layer around Cockatiel; pick Cockatiel when you only need retry/timeout/circuit-breaker/bulkhead/fallback and want the leanest possible per-call cost.
84
-
85
- ---
36
+ `act()` always resolves. You branch on `r.ok` instead of try/catch around `await`. Failures are typed (see [Errors](#errors)) so you can switch on `r.error.code` for telemetry.
37
+
38
+ ## Features
39
+
40
+ - **Retry** with exponential/linear/constant backoff, four jitter modes, custom `backoffFn` with per-call state, and `shouldRetryResult` for retrying on returned values (HTTP 500 without throwing)
41
+ - **Per-attempt timeout** with `race` (aggressive) or `cooperative` (gentle) strategies
42
+ - **Total timeout** spanning the whole retry loop, not just one attempt
43
+ - **Circuit breaker** with `consecutive` or `count` (sliding-window ratio) strategies, half-open probe, idle reset
44
+ - **Bulkhead** with bounded queue, queue timeout, backpressure event
45
+ - **Rate limit** (sliding window, per key)
46
+ - **Cache** with stampede protection (single-flight on sync stores), TTL, LRU eviction
47
+ - **Dedupe** (single-flight) with generation-safe cleanup and joiner isolation
48
+ - **Hedge** with cancel-loser semantics, `outside-retry` (one hedge per call) or `inside-retry` (one per attempt) placement
49
+ - **Fallback** value or function
50
+ - **AbortSignal** cancellation everywhere, with cross-realm duck-typed signal support
51
+ - **Observability** hooks (10 event types) wrapped in `safeCall` so a buggy hook never crashes the main path
52
+ - **Health check**, **watchdog** for hung calls, **graceful drain** for shutdown
53
+ - **Multi-tenant** store isolation with LRU eviction
54
+ - **Pluggable state store** (built-in `InMemoryStore`; bring your own for Redis, DynamoDB, etc.)
55
+ - **`@usePolicy`** method decorator
56
+ - Typed error taxonomy with stable `.code` strings for cross-realm handling
86
57
 
87
58
  ## API
88
59
 
89
60
  ### `act(key, fn, options?)`
90
61
 
91
- Wraps `fn` with reliability policies. Returns `Promise<ActResult<T>>`.
92
-
93
- | Param | Type | Description |
94
- |-------|------|-------------|
95
- | `key` | `string` | Stable identifier. Scopes dedupe + cache. Validated for prototype pollution, control chars, CRLF, length (≤1024), reserved prefixes. |
96
- | `fn` | `(signal: AbortSignal) => Promise<T> \| T` | The async work. Signal aborts on caller cancel, per-attempt timeout, or total timeout. Legacy `() => Promise<T>` accepted (signal ignored). |
97
- | `options` | `ActOptions` | Policy configuration. All fields optional. |
98
-
99
- **Returns:** `Promise<ActResult<T>>` — always resolves, never rejects.
62
+ Wraps `fn` with reliability policies. Returns `Promise<ActResult<T>>`, always resolves.
100
63
 
101
64
  ```ts
102
65
  interface ActSuccess<T> {
103
66
  ok: true
104
67
  value: T
105
68
  source: 'fresh' | 'cache'
106
- attempts: number // 1-based; 0 on cache hit
107
- traceId?: string // present when observability or traceId option set
108
- durationMs?: number // wall-clock ms
69
+ attempts: number // 1-based; 0 on cache hit
70
+ traceId?: string
71
+ durationMs?: number
109
72
  }
110
73
 
111
74
  interface ActFailure {
112
75
  ok: false
113
- error: unknown // typed — see Error taxonomy
76
+ error: unknown // see Errors
114
77
  attempts: number
115
78
  traceId?: string
116
79
  durationMs?: number
117
80
  }
81
+
82
+ type ActResult<T> = ActSuccess<T> | ActFailure
118
83
  ```
119
84
 
120
- ### `invalidate(key)`
85
+ `key` scopes dedupe and cache. Validated for prototype pollution, control chars, CRLF, length (1024 max), and reserved prefixes (`dedupe:`, `cache:`, `inflight:`, `tenant:`).
121
86
 
122
- Removes the cached value for `key` from the default module-level store. Returns `true` if a cache entry was removed.
87
+ ### `invalidate(key)`
123
88
 
124
- Does **not** cancel in-flight dedupe entries those settle on their own.
89
+ Removes the cached value for `key` from the default store. Returns `true` if a cache entry was removed.
125
90
 
126
91
  ```ts
127
92
  await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
128
- // ... user updates their profile ...
129
93
  invalidate('user:42') // next call re-fetches
130
94
  ```
131
95
 
132
96
  ### `withStore(store)`
133
97
 
134
- Creates a scoped `act` function bound to an explicit store. Use for SSR request isolation, multi-tenant scenarios, or test isolation.
98
+ Returns a scoped `act` bound to an explicit store. Use for SSR request isolation, multi-tenant scenarios, or tests.
135
99
 
136
100
  ```ts
137
101
  import { withStore, InMemoryStore } from 'actly'
@@ -147,76 +111,186 @@ try {
147
111
  }
148
112
  ```
149
113
 
150
- For sync stores, `invalidate` returns `boolean`. For async stores, returns `Promise<boolean>`.
114
+ The scoped `act` has the same signature as the default `act`, plus an `invalidate(key)` method, a `store` property, and a `scope` property (the internal scope string for `createHealthCheck`).
151
115
 
152
116
  ### `execute(input)`
153
117
 
154
- Low-level execution engine for building custom policy chains. Accepts `{ key, fn, policies, store, meta, signal, observability }`. Most callers should use `act()` instead.
118
+ Low-level execution engine for custom policy chains. Accepts `{ key, fn, policies, store, meta, signal, observability }`. Most callers should use `act()`.
155
119
 
156
- ### `InMemoryStore`
120
+ ### `noopPolicy()`
157
121
 
158
- Reference `SyncStateStore` implementation. Bounded LRU with TTL, background cleanup, O(1) operations.
122
+ Passthrough policy. No retry, no timeout, no state. Useful for testing and conditional chains.
159
123
 
160
124
  ```ts
161
- import { InMemoryStore } from 'actly'
125
+ const retry = isProd
126
+ ? retryPolicy({ attempts: 3 })
127
+ : noopPolicy()
128
+ ```
129
+
130
+ ### `usePolicy(options)`
131
+
132
+ Method decorator. Wraps a class method with `act()`. Preserves `this` and the method `name`.
133
+
134
+ ```ts
135
+ import { usePolicy } from 'actly'
136
+
137
+ class UserService {
138
+ @usePolicy({ retry: { attempts: 3, delayMs: 100 }, timeout: { ms: 5000 } })
139
+ async fetchUser(signal: AbortSignal, id: string): Promise<User> {
140
+ const res = await fetch(`/api/users/${id}`, { signal })
141
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
142
+ return res.json()
143
+ }
144
+ }
145
+ ```
146
+
147
+ ### `createHealthCheck(store, options?)`
162
148
 
149
+ Returns a function that produces a `HealthStatus` snapshot.
150
+
151
+ ```ts
152
+ import { createHealthCheck, InMemoryStore } from 'actly'
153
+
154
+ const store = new InMemoryStore()
155
+ const health = createHealthCheck(store, {
156
+ scope: 'default',
157
+ probeIntervalMs: 30_000, // optional: warn on stuck inflight calls
158
+ })
159
+
160
+ const status = health()
161
+ // { storeSize, pendingInflight, uptimeMs, lastError?, lastSuccessAt? }
162
+ ```
163
+
164
+ ### `drain(timeoutMs, scope?)` and `drainAll(timeoutMs)`
165
+
166
+ Wait for in-flight `act()` calls to settle. `drainAll` drains all scopes in parallel.
167
+
168
+ ```ts
169
+ process.on('SIGTERM', async () => {
170
+ const ok = await drainAll(10_000)
171
+ process.exit(ok ? 0 : 1)
172
+ })
173
+ ```
174
+
175
+ ### `enableWatchdog(thresholdMs?, hooks?)`
176
+
177
+ Opt-in background watchdog. Fires `onWatchdog` when an in-flight call exceeds `thresholdMs` (default 60s). Catches hung `fn` calls that ignore the signal and have no timeout configured.
178
+
179
+ ```ts
180
+ enableWatchdog(30_000, {
181
+ onWatchdog: (e) => logger.warn({ elapsedMs: e.elapsedMs }, 'hung act() call'),
182
+ })
183
+ ```
184
+
185
+ ### `createTenantStore(options)` and `createAsyncTenantStore(factory)`
186
+
187
+ Per-tenant store isolation. Each tenant gets its own store instance (not a shared store with key prefixes). LRU-evicts idle tenants when `maxTenants` is reached.
188
+
189
+ ```ts
190
+ import { createTenantStore } from 'actly'
191
+
192
+ const tenants = createTenantStore({ maxSize: 1000, autoCleanup: true, maxTenants: 10_000 })
193
+ const tenant1Act = tenants.get('tenant-1')
194
+ await tenant1Act('user:42', async () => fetchUser(42))
195
+ tenants.evict('tenant-1') // destroys the store, releases resources
196
+ ```
197
+
198
+ ### `InMemoryStore`
199
+
200
+ Reference `SyncStateStore`. Bounded LRU with TTL, background sweep, O(1) operations. Default `maxSize` is 10,000 (bounded). Pass `Infinity` explicitly for unbounded storage.
201
+
202
+ ```ts
163
203
  const store = new InMemoryStore({
164
- maxSize: 10_000, // default Infinity
165
- autoCleanup: true, // default false
166
- cleanupIntervalMs: 30_000 // default 30s
204
+ maxSize: 10_000, // default 10_000; pass Infinity for unbounded
205
+ autoCleanup: true, // default false
206
+ cleanupIntervalMs: 30_000, // default 30s
207
+ memoryPressureCleanup: true, // react to Node 22+ 'memory' events
167
208
  })
209
+
210
+ store.destroy() // stops timer, clears map, removes listeners. Idempotent.
168
211
  ```
169
212
 
170
- ---
213
+ The store registers a `FinalizationRegistry` so the cleanup timer is cleared even if you forget `destroy()`. Still call `destroy()` explicitly when you can; GC timing is not deterministic.
171
214
 
172
215
  ## Options
173
216
 
174
- ### `retry`
217
+ All fields optional. Compose any combination.
175
218
 
176
- Retry `fn` on failure.
219
+ ### `retry`
177
220
 
178
221
  ```ts
179
222
  {
180
223
  retry: {
181
- attempts: 3, // total attempts including first call (1, ≤100)
182
- delayMs: 200, // base delay (0, ≤300_000)
224
+ attempts: 3, // total attempts including first (1-100)
225
+ delayMs: 200, // base delay (0-300_000)
183
226
  backoff: 'exponential', // 'none' | 'linear' | 'exponential' (default 'none')
184
227
  maxDelay: 30_000, // cap on computed delay (default Infinity)
185
228
  jitter: 'full', // 'none' | 'full' | 'equal' | 'decorrelated' (default 'full')
186
- shouldRetry: (error, attempt) => true // default: retry all except AbortError
229
+ shouldRetry: (error, attempt) => true,
230
+ shouldRetryResult: (value, attempt) => true, // retry on returned values (HTTP 500)
231
+ backoffFn: (attempt, error, state) => 1000, // custom backoff, overrides backoff+jitter
232
+ dangerouslyUnref: false, // unref sleep timer (CLI/scripts/tests)
187
233
  }
188
234
  }
189
235
  ```
190
236
 
191
- **Delay computation order:** backoff grows maxDelay caps jitter randomizes.
237
+ `shouldRetryResult` retries on returned values that are semantic failures. The common pattern: `fetch` returns a 500 without throwing.
192
238
 
193
- **Default `shouldRetry`:** retries on every error except `AbortError` (caller/timeout cancellation). Override to skip non-recoverable errors (HTTP 4xx, auth failures).
239
+ ```ts
240
+ await act('fetch-api', async (signal) => fetch('/api', { signal }), {
241
+ retry: {
242
+ attempts: 3,
243
+ shouldRetryResult: (res) => res.ok && res.status < 500,
244
+ },
245
+ })
246
+ ```
194
247
 
195
- **`attempts: 1` is a no-op** the retry policy is skipped (pure overhead if included).
248
+ `backoffFn` carries per-call state. Useful for honoring `Retry-After`.
249
+
250
+ ```ts
251
+ await act('fetch-retry-after', async (signal) => {
252
+ const res = await fetch('/api', { signal })
253
+ if (!res.ok) {
254
+ const ra = parseInt(res.headers.get('retry-after') ?? '0', 10)
255
+ if (ra > 0) state.retryAfter = ra * 1000
256
+ throw new Error(`HTTP ${res.status}`)
257
+ }
258
+ return res
259
+ }, {
260
+ retry: {
261
+ attempts: 5,
262
+ backoffFn: (_attempt, _err, state) => state.retryAfter ?? 1000,
263
+ },
264
+ })
265
+ ```
266
+
267
+ Default `shouldRetry` skips `AbortError`, `ACTLY_TIMEOUT`, and `ACTLY_TOTAL_TIMEOUT` so a slow endpoint with `timeout: 1000, retry: { attempts: 5 }` fails at 1s, not 5s. Override with `shouldRetry: () => true` to retry on everything.
268
+
269
+ `attempts: 1` is a no-op; the retry policy is skipped.
196
270
 
197
271
  ### `timeout`
198
272
 
199
273
  Per-attempt deadline. Each retry gets a fresh clock.
200
274
 
201
275
  ```ts
202
- { timeout: { ms: 5_000 } } // ms > 0, ≤100_000_000
276
+ { timeout: { ms: 5_000, strategy: 'race' } }
203
277
  ```
204
278
 
205
- If `fn` cooperates (passes `signal` to `fetch`, database drivers, etc.), the underlying work is cancelled. If `fn` ignores the signal, `act()` still returns promptly via a race but the underlying work continues in the background (resource leak).
279
+ `strategy: 'race'` (default) races `fn(signal)` against the abort event and returns promptly at `ms` whether `fn` cooperates or not.
280
+
281
+ `strategy: 'cooperative'` aborts the signal but waits for `fn` to settle. Gentler on downstreams that don't cooperate with `AbortSignal` (they get to clean up), but can run past `ms` if `fn` is slow to reject after the abort.
206
282
 
207
283
  ### `totalTimeout`
208
284
 
209
- Hard wall-clock budget over the entire operation including all retry attempts, delays, and per-attempt timeouts.
285
+ Hard wall-clock budget over the whole operation, including retries, delays, and per-attempt timeouts.
210
286
 
211
287
  ```ts
212
288
  { totalTimeout: { ms: 12_000 } }
213
289
  ```
214
290
 
215
- Use with `timeout` to express: "each attempt may take at most X ms, but the whole operation must finish within Y ms."
216
-
217
291
  ### `dedupe`
218
292
 
219
- Collapse concurrent calls with the same key into one in-flight Promise.
293
+ Collapses concurrent calls with the same key into one in-flight Promise.
220
294
 
221
295
  ```ts
222
296
  dedupe: true
@@ -224,186 +298,220 @@ dedupe: true
224
298
  dedupe: { enabled: true, inflightTtl: 30_000 }
225
299
  ```
226
300
 
227
- `inflightTtl` is a safety-net TTL for the in-flight entry. If the originator's promise doesn't settle within this window, the entry is removed so subsequent callers can start fresh. Default: `Infinity` (no safety net). Pair with `timeout` or `totalTimeout` for proper cancellation.
301
+ `inflightTtl` (default 5 minutes) is a safety-net TTL for the in-flight entry. If the originator's Promise doesn't settle within this window, the entry is removed so subsequent callers can start fresh. Pass `Infinity` explicitly for the old "wait forever" behavior.
228
302
 
229
- **Joiner isolation:** if the originator's caller aborts, joiners are NOT affected. Each joiner races the in-flight promise against their own signal.
303
+ Joiner isolation: if the originator's caller aborts, joiners are not affected. Each joiner races the in-flight Promise against their own signal.
230
304
 
231
- **Requires sync store.** `execute()` throws at chain-build time if an async store is used with dedupe.
305
+ Requires a sync store. `execute()` throws at chain-build time if an async store is used with dedupe.
232
306
 
233
307
  ### `cache`
234
308
 
235
- Store successful results for a TTL.
309
+ Stores successful results for a TTL.
236
310
 
237
311
  ```ts
238
- { cache: { ttl: 60_000 } } // ttl > 0, ≤86_400_000
312
+ { cache: { ttl: 60_000 } } // 0 < ttl <= 86_400_000
239
313
  ```
240
314
 
241
- **Failures are never cached.** Only successful values are stored.
315
+ Failures are never cached. Only successful values.
242
316
 
243
- **Single-flight (cache stampede prevention):** on sync stores, concurrent cache misses join a single in-flight Promise. On async stores, stampedes are a known limitation.
317
+ Single-flight (cache stampede protection): on sync stores, concurrent cache misses join one in-flight Promise. On async stores, stampedes are a known limitation.
244
318
 
245
- **Fail-open writes:** if `store.set()` throws (e.g. Redis transient error), the error is swallowed and the value is returned to the caller. Caching is an optimization, not a correctness requirement.
319
+ `store.set()` failures (e.g. Redis transient error) are swallowed; the value is still returned to the caller.
246
320
 
247
- ### `signal`
321
+ ### `circuitBreaker`
248
322
 
249
- Caller-provided `AbortSignal` for external cancellation.
323
+ Opens after threshold failures, rejects immediately while open, allows one probe call after cooldown.
250
324
 
251
325
  ```ts
252
- const controller = new AbortController()
253
- setTimeout(() => controller.abort(new Error('user-cancelled')), 5_000)
254
-
255
- await act('user:42', async (signal) => fetch(url, { signal }), {
256
- signal: controller.signal,
257
- })
326
+ {
327
+ circuitBreaker: {
328
+ threshold: 5, // consecutive failures before opening
329
+ cooldownMs: 30_000, // how long to stay open
330
+ resetTimeoutMs: 600_000, // optional: reset failure count after idle (default Infinity)
331
+ strategy: 'consecutive', // 'consecutive' (default) | 'count'
332
+ // For strategy: 'count':
333
+ countSize: 100, // window size
334
+ countThreshold: 0.5, // failure rate to trip (0-1)
335
+ countMinimumCalls: 100, // min calls before tripping
336
+ }
337
+ }
258
338
  ```
259
339
 
260
- ### `observability`
340
+ `consecutive` opens after N failures in a row. Resets on any success. Good for "downstream is fully down."
341
+
342
+ `count` is a sliding-window ratio breaker. Trips when the failure rate in the last `countSize` calls exceeds `countThreshold`. Use for "downstream is degraded" (e.g. trip when >30% of last 100 calls fail).
343
+
344
+ Half-open probe: probe failure re-opens immediately. Probe success resets the window (count strategy) or the failure counter (consecutive).
345
+
346
+ ### `bulkhead`
261
347
 
262
- Event hooks for metrics, logging, and tracing. Zero overhead when not registered.
348
+ Caps in-flight concurrency per key.
263
349
 
264
350
  ```ts
265
351
  {
266
- observability: {
267
- onAttempt: (e) => metrics.increment('act.attempt', { attempt: e.attempt }),
268
- onRetry: (e) => metrics.increment('act.retry', { attempt: e.attempt }),
269
- onCacheHit: (e) => metrics.increment('act.cache_hit', { ageMs: e.ageMs }),
270
- onCacheMiss: (e) => metrics.increment('act.cache_miss'),
271
- onDedupeJoin: (e) => metrics.increment('act.dedupe_join'),
272
- onTimeout: (e) => metrics.increment('act.timeout', { kind: e.kind }),
273
- onFinalSuccess: (e) => metrics.histogram('act.duration', e.durationMs),
274
- onFinalFailure: (e) => logger.error({ traceId: e.traceId, failedBy: e.failedBy }, 'act failed'),
352
+ bulkhead: {
353
+ maxConcurrent: 10,
354
+ maxQueueSize: 100, // default Infinity
355
+ queueTimeoutMs: 5_000, // default Infinity
275
356
  }
276
357
  }
277
358
  ```
278
359
 
279
- **Zero-cost contract:** when `observability` is `undefined` or an empty hooks object, no event objects are allocated and no function calls are made. Verified by benchmark: empty observability adds ~0.24 µs over fast path.
280
-
281
- ### `traceId`
282
-
283
- Correlation ID for logs/metrics. Auto-generated via `crypto.randomUUID()` on Node 20+. Falls back to timestamp+random string. Surfaces on every event and on `ActResult.traceId`.
284
-
285
- ---
360
+ When `maxConcurrent` is reached, new callers queue. When the queue is full or `queueTimeoutMs` elapses, callers get `BulkheadOverflowError`. `onBackpressure` observability hook fires when queue utilization crosses 80%.
286
361
 
287
- ## Error taxonomy
362
+ ### `rateLimit`
288
363
 
289
- Six error classes. All extend `ActlyError` which extends `Error`. Each carries a stable `.code` string for cross-realm telemetry (e.g. errors serialized over IPC).
364
+ Sliding window rate limit per key.
290
365
 
291
366
  ```ts
292
- import {
293
- ActlyError, // abstract base — instanceof ActlyError
294
- ActlyAbortError, // .code = 'ACTLY_ABORT' — caller/signal cancellation
295
- TimeoutError, // .code = 'ACTLY_TIMEOUT' — per-attempt deadline
296
- TotalTimeoutError, // .code = 'ACTLY_TOTAL_TIMEOUT' — operation-wide budget
297
- RetryExhaustedError, // .code = 'ACTLY_RETRY_EXHAUSTED' — all attempts failed
298
- ValidationError, // .code = 'ACTLY_VALIDATION' — programmer error (invalid options)
299
- } from 'actly'
367
+ { rateLimit: { maxCalls: 100, windowMs: 60_000 } }
300
368
  ```
301
369
 
302
- **`ValidationError` throws synchronously** — it's not wrapped in `ActFailure` because the caller's code is broken, not the runtime.
370
+ ### `hedge`
303
371
 
304
- **`RetryExhaustedError`** carries `attempts`, `lastError`, and `errors[]` for debugging retry patterns.
372
+ Sends a second call after `delayMs`, races them, cancels the loser.
305
373
 
306
374
  ```ts
307
- if (!result.ok && result.error instanceof RetryExhaustedError) {
308
- console.log(`failed after ${result.error.attempts} attempts`)
309
- console.log(`last error:`, result.error.lastError)
375
+ {
376
+ hedge: {
377
+ delayMs: 100, // > 0, <= 300_000
378
+ keepLoser: false, // default false; true = don't cancel
379
+ placement: 'outside-retry', // 'outside-retry' (default) | 'inside-retry'
380
+ }
310
381
  }
311
382
  ```
312
383
 
313
- **Cross-realm safety:** prefer `err.code === 'ACTLY_TIMEOUT'` over `err instanceof TimeoutError` when errors cross process/worker boundaries.
384
+ `outside-retry` (default) wraps the whole execute chain. One hedge per `act()` call regardless of retry count. `inside-retry` wraps `fn` directly, so each retry attempt can spawn its own hedge (multiplies downstream load; rarely what you want).
314
385
 
315
- ---
386
+ When one settles, the loser is aborted via its `AbortController`. If `fn` cooperates with the signal (passes it to `fetch`, DB drivers, etc.) the underlying work is cancelled. The winner's controller is not aborted, so downstream side-effects (streaming bodies, cursor cleanup) run to completion.
316
387
 
317
- ## Stores
388
+ ### `fallback`
318
389
 
319
- ### Sync vs Async
390
+ Returns a value (or function result) instead of the failure when `fn` fails.
320
391
 
321
- `SyncStateStore` — all operations complete synchronously. Required for `dedupePolicy` (the read-then-write that makes deduplication work must happen in a single synchronous frame).
392
+ ```ts
393
+ { fallback: { value: 'default' } }
394
+ // or
395
+ { fallback: { value: () => computeFallback() } }
396
+ ```
397
+
398
+ If the fallback function itself throws, the original `fn` error is surfaced. The fallback error is logged via `console.warn` in non-production and emitted via `onFinalFailure` so you can detect a broken fallback.
399
+
400
+ ### `observability`
322
401
 
323
- `AsyncStateStore` all operations return Promises. Compatible with `cachePolicy` only.
402
+ Hooks fire on lifecycle events. Wrapped in `safeCall` so a buggy hook never crashes the main path.
324
403
 
325
404
  ```ts
326
- import type { SyncStateStore, AsyncStateStore } from 'actly'
327
-
328
- // Sync: InMemoryStore is the reference implementation
329
- const syncStore: SyncStateStore = new InMemoryStore()
330
-
331
- // Async: implement the interface for Redis, DynamoDB, etc.
332
- const asyncStore: AsyncStateStore = {
333
- _sync: false as const,
334
- async get<T>(key: string): Promise<T | undefined> { /* ... */ },
335
- async set<T>(key: string, value: T, ttlMs?: number): Promise<void> { /* ... */ },
336
- async delete(key: string): Promise<void> { /* ... */ },
337
- async has(key: string): Promise<boolean> { /* ... */ },
338
- async clear(): Promise<void> { /* ... */ },
339
- async size(): Promise<number> { /* ... */ },
405
+ {
406
+ observability: {
407
+ onAttempt: (e) => ..., // before each attempt
408
+ onRetry: (e) => ..., // before each retry delay
409
+ onCacheHit: (e) => ...,
410
+ onCacheMiss: (e) => ...,
411
+ onDedupeJoin: (e) => ...,
412
+ onTimeout: (e) => ...,
413
+ onFinalSuccess: (e) => ...,
414
+ onFinalFailure: (e) => ...,
415
+ onBackpressure: (e) => ...,
416
+ onWatchdog: (e) => ...,
417
+ }
340
418
  }
341
419
  ```
342
420
 
343
- ### Default store
421
+ Each event carries `key`, `traceId`, `timestamp`. Pass `traceId` explicitly or let actly generate a UUID.
344
422
 
345
- `act()` without `withStore()` uses a module-level default `InMemoryStore` bounded at 10,000 entries with 60-second background cleanup.
423
+ ### `audit`
346
424
 
347
- **Multi-tenant warning:** the default store is shared across all callers in the process. For multi-tenant applications, use `withStore()` with per-tenant stores to prevent cross-tenant data leakage.
425
+ Single hook for structured logging of every call result.
348
426
 
349
- ### `InMemoryStore` internals
427
+ ```ts
428
+ {
429
+ audit: {
430
+ log: (entry: AuditEntry) => {
431
+ // { key, traceId, timestamp, durationMs, ok, attempts, failedBy?, error? }
432
+ logger.info(entry)
433
+ },
434
+ }
435
+ }
436
+ ```
350
437
 
351
- - **LRU:** doubly-linked list for O(1) reordering (no `delete + set` Map churn).
352
- - **TTL:** lazy on `get()`/`has()`, background sweep for unreferenced entries.
353
- - **`size()`:** O(1) via Map size counter.
354
- - **`destroy():** stops cleanup timer. Idempotent.
438
+ ### `signal`
355
439
 
356
- ---
440
+ Caller-supplied `AbortSignal`. Linked to the root controller. Aborting it cancels the entire operation.
357
441
 
358
- ## Policy order
442
+ ## Errors
359
443
 
360
- Policies are applied in a fixed order, outermost to innermost:
444
+ All actly-thrown errors extend `ActlyError` and carry a stable `.code` string. Use the code (not `instanceof`) for cross-realm handling. `isActlyError(e)` is a realm-safe predicate.
361
445
 
362
- ```
363
- totalTimeout → cache → dedupe → retry → timeout → fn
364
- ```
446
+ | Class | code | Meaning |
447
+ |---|---|---|
448
+ | `ActlyAbortError` | `ACTLY_ABORT` | caller signal, per-attempt timeout, or total timeout aborted |
449
+ | `TimeoutError` | `ACTLY_TIMEOUT` | per-attempt deadline fired |
450
+ | `TotalTimeoutError` | `ACTLY_TOTAL_TIMEOUT` | operation-wide budget exhausted |
451
+ | `RetryExhaustedError` | `ACTLY_RETRY_EXHAUSTED` | all retry attempts failed |
452
+ | `ValidationError` | `ACTLY_VALIDATION` | programmer error (invalid options) |
453
+ | `CircuitBreakerOpenError` | `ACTLY_CIRCUIT_OPEN` | breaker is open |
454
+ | `BulkheadOverflowError` | `ACTLY_BULKHEAD_FULL` | bulkhead queue full or timed out |
455
+ | `RateLimitError` | `ACTLY_RATE_LIMIT` | rate limit exceeded |
456
+ | `ResourceExhaustedError` | `ACTLY_RESOURCE_EXHAUSTED` | process-wide inflight budget exceeded |
457
+ | `HedgeTimeoutError` | `ACTLY_HEDGE_TIMEOUT` | hedge deadline fired before primary settled |
458
+
459
+ `ActlyError.toJSON({ redact?: true })` returns a plain object with `name`, `code`, `message`, `key`, `stack`, plus subclass-specific fields (`attempts`, `ms`, `current`, `limit`, etc.). Pass `redact: true` to HTML-escape and length-cap the message for log shipping.
365
460
 
366
- - `totalTimeout` is outermost — if it fires, no inner policy can extend the deadline.
367
- - `cache` short-circuits everything below it on a hit.
368
- - `dedupe` collapses concurrent callers before retry fires.
369
- - `retry` owns the attempt loop.
370
- - `timeout` is innermost — each attempt gets a fresh clock.
461
+ ## Compared to Cockatiel
371
462
 
372
- Policies with no effect (e.g. `retry.attempts: 1`) are skipped.
463
+ Both are typed, zero-dependency resilience libraries for Node/TypeScript. Both use native `AbortSignal` for cancellation. The core difference is scope *and* size.
373
464
 
374
- ---
465
+ | | actly | Cockatiel |
466
+ |---|---|---|
467
+ | Policies | retry, timeout, totalTimeout, circuitBreaker, bulkhead, rateLimit, cache, dedupe, hedge, fallback, noop, decorator | retry, timeout, circuitBreaker, bulkhead, fallback, noop |
468
+ | Cross-call state | Yes (keyed store: cache, dedupe, breaker counter, rate limit) | No (policies are stateless per-instance) |
469
+ | Multi-tenant isolation | Yes (`createTenantStore`) | No |
470
+ | Audit log / health / drain / watchdog | Built-in | External |
471
+ | Tarball size | 57 KB | ~30 KB |
472
+ | JS runtime (installed) | 243 KB | 63 KB (~4× smaller) |
473
+ | Per-call overhead (retry configured) | ~2.4 µs | ~0.35 µs (~7× faster) |
474
+ | Runtime deps | 0 | 0 |
375
475
 
376
- ## Limits
476
+ Where they overlap (retry, timeout, circuit breaker, bulkhead), semantics are comparable. actly uses a keyed state store (`Map` get/set per call) to support cross-call features (circuit breaker failure counts, cache, dedupe) that Cockatiel doesn't have. That indirection costs a few microseconds per call and the extra code adds bundle weight.
377
477
 
378
- All numeric inputs are bounded to prevent memory/CPU/timer exhaustion:
478
+ For wrapping network or database calls (what both libraries are for), the per-call overhead difference is noise — a single intra-region AWS RTT is ~1,000 µs, so a 2 µs delta is 0.2% of one network hop. But for in-process cache layers or routers at 100k+ calls/sec, the overhead and bundle size matter; pick Cockatiel there.
379
479
 
380
- | Limit | Value | Rationale |
381
- |-------|-------|-----------|
382
- | `MAX_KEY_LENGTH` | 1024 chars | Prevents multi-MB keys bloating stores |
383
- | `MAX_RETRY_ATTEMPTS` | 100 | ~100 fn invocations |
384
- | `MAX_TIMEOUT_MS` | 100,000,000 ms (~27h) | Prevents timer overflow |
385
- | `MAX_CACHE_TTL` | 86,400,000 ms (24h) | Prevents pathological TTLs |
386
- | `MAX_RETRY_DELAY_MS` | 300,000 ms (5 min) | Bounds worst-case latency per retry |
387
- | `MAX_INFLIGHT_TTL` | 86,400,000 ms (24h) | Bounds dedupe inflight window |
388
- | `DEFAULT_STORE_MAX_SIZE` | 10,000 entries | Bounds default store memory |
480
+ Pick actly when you want cache/dedupe/rate-limit/hedge/audit/multi-tenant in the same call and can absorb the bundle cost. Pick Cockatiel when you only need retry/timeout/circuit-breaker/bulkhead/fallback and want the leanest per-call cost and smallest install footprint.
389
481
 
390
- Exceeding a limit throws `RangeError`. If you need more, you likely have a bug.
482
+ ## Performance
391
483
 
392
- ---
484
+ Numbers from `npm run bench` on a 2023 MacBook Pro with Node 22. 20,000 iterations, 500 warmup. Live sandbox re-run (Node 24) confirms the same shape.
393
485
 
394
- ## Migration
486
+ | Configuration | ops/sec | µs/op | Notes |
487
+ |---|---|---|---|
488
+ | Fast path (zero options) | ~680,000 | ~1.5 | Just `act(key, fn)`, no policies applied |
489
+ | Cache hit | ~700,000 | ~1.4 | Single-flight in-memory store |
490
+ | Retry only (attempts=3) | ~415,000 | ~2.4 | Same as the Quick Start example |
491
+ | Circuit breaker (closed) | ~720,000 | ~1.4 | Happy path, breaker not tripped |
492
+ | Bulkhead (maxConcurrent=10) | ~313,000 | ~3.2 | Under cap, no queuing |
493
+ | Composed (retry+timeout+breaker) | ~84,000 | ~11.9 | The realistic case |
395
494
 
396
- ### From v1.1.x to v1.2.0
495
+ **Cockatiel comparison (same sandbox, same workload):**
397
496
 
398
- **Node 20+ required** (was 18+). Node 18 reached EOL April 2025.
497
+ | Configuration | actly ops/sec | Cockatiel ops/sec | actly overhead |
498
+ |---|---|---|---|
499
+ | Retry | 415,000 | 2,835,000 | 6.8× slower |
500
+ | Timeout | 87,000 | 177,000 | 2.0× slower |
501
+ | Circuit breaker | 722,000 | 2,272,000 | 3.1× slower |
502
+ | Bulkhead | 313,000 | 5,533,000 | 17.7× slower |
503
+ | Composed (3 policies) | 84,000 | 156,000 | 1.9× slower |
399
504
 
400
- **`retryPolicy` now throws `RetryExhaustedError`** when all attempts fail AND at least one retry happened. Previously, the raw last error was surfaced. The raw error is available on `err.lastError` and `err.errors[]`. If `shouldRetry` returned `false` on the first attempt (no retries), the raw error is still surfaced — no wrapping.
505
+ The composed case is the closest to a real workload. The 1.9× delta is microseconds, not milliseconds invisible behind a 50ms network call. The bulkhead 17.7× delta is the worst case and matters only for in-process routers; if your `fn` is a no-op or in-memory lookup, the bulkhead machinery dominates total time.
401
506
 
402
- **`TimeoutError` and `TotalTimeoutError` now extend `ActlyError`** (which extends `Error`). Existing `instanceof Error` and `instanceof TimeoutError` checks continue to work. Optional: switch to `err.code === 'ACTLY_TIMEOUT'` for cross-realm safety.
507
+ Observability hooks have zero overhead when not configured. When configured, `safeCall` wraps every hook invocation so a buggy hook never propagates into the main path.
403
508
 
404
- **`ActResult` has new optional fields**: `traceId?`, `durationMs?`. Existing code that reads only `ok`, `value`, `error`, `source`, `attempts` is unaffected.
509
+ ## Limitations
405
510
 
406
- ---
511
+ - `dedupe`, `circuitBreaker`, `bulkhead`, and `rateLimit` require a sync store. `cache` works with both sync and async stores.
512
+ - Async stores can't do single-flight for cache misses (no atomic check-and-publish). Use a sync store or accept the stampede risk.
513
+ - `cooperative` timeout strategy can wait past `ms` if `fn` doesn't reject after the abort. Pair with `totalTimeout` for a hard ceiling.
514
+ - `instanceof ActlyError` works only within the same realm (worker_threads, vm contexts break the prototype chain). Use `isActlyError(e)` or switch on `.code` for cross-realm handling.
407
515
 
408
516
  ## License
409
517