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
package/README.md CHANGED
@@ -1,611 +1,517 @@
1
1
  # actly
2
2
 
3
- **Reliability primitive for async functions.**
4
- Retry. Timeout. Dedupe. Cache. Composable, typed, zero-throw — with proper `AbortSignal` cancellation, single-flight cache, LRU store, and jittered backoff.
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.
5
4
 
6
5
  ```bash
7
6
  npm install actly
8
7
  ```
9
8
 
10
- > Requires Node 18+. Ships ESM + CJS. Zero dependencies.
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.
11
10
 
12
- ---
13
-
14
- ## The problem
15
-
16
- Every async call can fail. Networks blip. Services time out. The same UI mounts three times and fires the same fetch in parallel. You need retry logic, but not for 4xx errors. You need timeouts, but not the kind that let retry loops run forever.
17
-
18
- These patterns are solved the same way every time. `actly` solves them once.
19
-
20
- ---
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.
21
12
 
22
13
  ## Quick start
23
14
 
24
15
  ```ts
25
16
  import { act } from 'actly'
26
17
 
27
- const result = await act(
28
- 'user:42',
29
- async (signal) => fetch(`/api/users/42`, { signal }),
30
- {
31
- retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
32
- timeout: { ms: 5_000 },
33
- totalTimeout: { ms: 12_000 },
34
- dedupe: true,
35
- cache: { ttl: 60_000 },
36
- },
37
- )
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
+ })
38
28
 
39
- if (result.ok) {
40
- console.log(result.value) // T
41
- console.log(result.source) // 'fresh' | 'cache'
42
- console.log(result.attempts) // number (0 on cache hit)
29
+ if (r.ok) {
30
+ console.log(r.value, r.source, r.attempts, r.durationMs)
43
31
  } else {
44
- console.error(result.error) // unknown — never throws
32
+ console.error(r.error)
45
33
  }
46
34
  ```
47
35
 
48
- `act` always resolves. It never rejects. You check `.ok` and move on.
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
49
57
 
50
- The `signal` passed to `fn` is your escape hatch: wire it through to `fetch`, `AbortController`, database drivers, or any primitive that accepts one. If a timeout fires (per-attempt or total), or the caller aborts via `options.signal`, `fn`'s signal aborts and the operation rejects promptly — no leaked resources, no hung retry loops.
58
+ ## API
51
59
 
52
- ---
60
+ ### `act(key, fn, options?)`
53
61
 
54
- ## API
62
+ Wraps `fn` with reliability policies. Returns `Promise<ActResult<T>>`, always resolves.
55
63
 
56
64
  ```ts
57
- act<T>(key: string, fn: ActFn<T>, options?: ActOptions): Promise<ActResult<T>>
58
- ```
59
-
60
- | Argument | Type | Description |
61
- |---|---|---|
62
- | `key` | `string` | Stable, unique identifier. Scopes dedupe and cache state. Must be non-empty and must not start with reserved prefixes (`dedupe:`, `cache:`, `__inflight:`). |
63
- | `fn` | `(signal: AbortSignal) => Promise<T> \| T` | The async function to execute. Receives an `AbortSignal` for cooperative cancellation. Backwards compatible: `() => Promise<T>` is accepted (the signal is simply ignored). |
64
- | `options` | `ActOptions` | All optional. See policies below. |
65
+ interface ActSuccess<T> {
66
+ ok: true
67
+ value: T
68
+ source: 'fresh' | 'cache'
69
+ attempts: number // 1-based; 0 on cache hit
70
+ traceId?: string
71
+ durationMs?: number
72
+ }
65
73
 
66
- ### Result shape
74
+ interface ActFailure {
75
+ ok: false
76
+ error: unknown // see Errors
77
+ attempts: number
78
+ traceId?: string
79
+ durationMs?: number
80
+ }
67
81
 
68
- ```ts
69
- type ActResult<T> =
70
- | { ok: true; value: T; source: 'fresh' | 'cache'; attempts: number }
71
- | { ok: false; error: unknown; attempts: number }
82
+ type ActResult<T> = ActSuccess<T> | ActFailure
72
83
  ```
73
84
 
74
- `attempts` semantics:
75
- - Fresh success on first try: `1`
76
- - Fresh success after N retries: `N`
77
- - Cache hit: `0` (no work was performed)
78
- - Dedupe joiner: mirrors the originator's attempt count (v1.1.5 fix — see changelog)
85
+ `key` scopes dedupe and cache. Validated for prototype pollution, control chars, CRLF, length (1024 max), and reserved prefixes (`dedupe:`, `cache:`, `inflight:`, `tenant:`).
79
86
 
80
- ---
87
+ ### `invalidate(key)`
81
88
 
82
- ## Policies
89
+ Removes the cached value for `key` from the default store. Returns `true` if a cache entry was removed.
83
90
 
84
- All policies are optional and compose freely. The execution order is **fixed**:
85
-
86
- ```
87
- totalTimeout → cache → dedupe → retry → timeout
91
+ ```ts
92
+ await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
93
+ invalidate('user:42') // next call re-fetches
88
94
  ```
89
95
 
90
- ### Retry
96
+ ### `withStore(store)`
97
+
98
+ Returns a scoped `act` bound to an explicit store. Use for SSR request isolation, multi-tenant scenarios, or tests.
91
99
 
92
100
  ```ts
93
- const result = await act('payments:charge', async (signal) => chargeCard(payload, signal), {
94
- retry: {
95
- attempts: 3, // total attempts (including first). Must be integer >= 1.
96
- delayMs: 200, // base delay between attempts (ms)
97
- backoff: 'exponential',// 'none' | 'linear' | 'exponential'
98
- maxDelay: 30_000, // cap (ms) — prevents 8.5-minute waits on long exponential chains
99
- jitter: 'full', // 'none' | 'full' | 'equal' | 'decorrelated' (default: 'full')
100
- },
101
- })
102
- ```
101
+ import { withStore, InMemoryStore } from 'actly'
103
102
 
104
- `result.attempts` tells you how many tries it took.
103
+ const store = new InMemoryStore({ maxSize: 1000, autoCleanup: true })
104
+ const act = withStore(store)
105
105
 
106
- #### Jitter (default: `'full'`)
106
+ try {
107
+ await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
108
+ act.invalidate('user:42')
109
+ } finally {
110
+ store.destroy()
111
+ }
112
+ ```
107
113
 
108
- Jitter prevents thundering-herd retry storms when many callers fail simultaneously. Without jitter, all callers retry on the same tick after an upstream outage recovers. The default `'full'` jitter randomises each delay to `[0, computed]`.
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`).
109
115
 
110
- | Strategy | Formula |
111
- |---|---|
112
- | `'none'` | `delay` |
113
- | `'full'` | `random() * delay` |
114
- | `'equal'` | `delay/2 + random() * delay/2` |
115
- | `'decorrelated'` | `base + random() * (delay - base)` |
116
+ ### `execute(input)`
116
117
 
117
- #### Selective retry with `shouldRetry`
118
+ Low-level execution engine for custom policy chains. Accepts `{ key, fn, policies, store, meta, signal, observability }`. Most callers should use `act()`.
118
119
 
119
- By default, `actly` retries on every error EXCEPT `AbortError` (which indicates the caller or a timeout cancelled the operation — retrying would just abort again). Use `shouldRetry` to skip retrying other permanent errors — a `404` will never recover, a `503` might.
120
+ ### `noopPolicy()`
121
+
122
+ Passthrough policy. No retry, no timeout, no state. Useful for testing and conditional chains.
120
123
 
121
124
  ```ts
122
- const result = await act('api:resource', async (signal) => fetchResource(id, signal), {
123
- retry: {
124
- attempts: 4,
125
- delayMs: 150,
126
- backoff: 'exponential',
127
- shouldRetry: (err, attempt) => {
128
- if (err instanceof HttpError && err.status < 500) return false
129
- return true
130
- },
131
- },
132
- })
125
+ const retry = isProd
126
+ ? retryPolicy({ attempts: 3 })
127
+ : noopPolicy()
133
128
  ```
134
129
 
135
- `shouldRetry(error, attempt)` receives the error and the 1-based attempt number. Return `false` to surface the error immediately.
130
+ ### `usePolicy(options)`
136
131
 
137
- > **v1.1.5 change:** `shouldRetry` is now called on EVERY failure, including the final attempt. This preserves the predicate's contract for observers / metrics. The return value is only consulted when there are remaining attempts.
132
+ Method decorator. Wraps a class method with `act()`. Preserves `this` and the method `name`.
138
133
 
139
- ---
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
+ ```
140
146
 
141
- ### Timeout
147
+ ### `createHealthCheck(store, options?)`
142
148
 
143
- Per-attempt deadline. Each retry gets a fresh clock. Implemented via `AbortController` + race — `act()` returns promptly even if `fn` ignores the signal (the underlying work may continue in the background, but the caller is unblocked).
149
+ Returns a function that produces a `HealthStatus` snapshot.
144
150
 
145
151
  ```ts
146
- import { act, TimeoutError } from 'actly'
152
+ import { createHealthCheck, InMemoryStore } from 'actly'
147
153
 
148
- const result = await act('geo:lookup', async (signal) => lookupCoords(ip, signal), {
149
- timeout: { ms: 3_000 },
154
+ const store = new InMemoryStore()
155
+ const health = createHealthCheck(store, {
156
+ scope: 'default',
157
+ probeIntervalMs: 30_000, // optional: warn on stuck inflight calls
150
158
  })
151
159
 
152
- if (!result.ok && result.error instanceof TimeoutError) {
153
- console.log(`timed out after ${result.error.ms}ms`)
154
- }
160
+ const status = health()
161
+ // { storeSize, pendingInflight, uptimeMs, lastError?, lastSuccessAt? }
155
162
  ```
156
163
 
157
- For cooperative cancellation, pass `signal` through to your async primitive (`fetch`, `AbortController`, etc.). `actly` will abort the signal when the deadline fires — `fn` rejects immediately, no background leak.
158
-
159
- ---
164
+ ### `drain(timeoutMs, scope?)` and `drainAll(timeoutMs)`
160
165
 
161
- ### Total timeout
162
-
163
- Hard budget over the **entire** operation — all attempts, delays, and per-attempt timeouts combined. The per-attempt `timeout` resets on retry; `totalTimeout` does not.
164
-
165
- **v1.1.5 fix:** When `totalTimeout` fires, the inner retry loop is cancelled — no more attempts fire, no more delays are slept. Previous versions would continue running `fn` in the background after `TotalTimeoutError` was returned to the caller.
166
+ Wait for in-flight `act()` calls to settle. `drainAll` drains all scopes in parallel.
166
167
 
167
168
  ```ts
168
- import { act, TimeoutError, TotalTimeoutError } from 'actly'
169
-
170
- const result = await act('search:query', async (signal) => runQuery(q, signal), {
171
- retry: { attempts: 5, delayMs: 100, backoff: 'linear' },
172
- timeout: { ms: 2_000 }, // per attempt
173
- totalTimeout: { ms: 8_000 }, // whole operation
169
+ process.on('SIGTERM', async () => {
170
+ const ok = await drainAll(10_000)
171
+ process.exit(ok ? 0 : 1)
174
172
  })
175
-
176
- if (!result.ok) {
177
- if (result.error instanceof TotalTimeoutError) {
178
- console.log(`budget exhausted after ${result.error.ms}ms`)
179
- } else if (result.error instanceof TimeoutError) {
180
- console.log(`last attempt timed out after ${result.error.ms}ms`)
181
- }
182
- }
183
173
  ```
184
174
 
185
- `TotalTimeoutError` and `TimeoutError` are separate classes — `instanceof` distinguishes which deadline fired.
186
-
187
- ---
175
+ ### `enableWatchdog(thresholdMs?, hooks?)`
188
176
 
189
- ### Dedupe
190
-
191
- Concurrent calls with the same key collapse into one in-flight Promise. The first caller executes; the rest wait and receive the same result. After settlement, the next call starts fresh.
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.
192
178
 
193
179
  ```ts
194
- const result = await act('config:load', async (signal) => loadRemoteConfig(signal), {
195
- dedupe: true,
180
+ enableWatchdog(30_000, {
181
+ onWatchdog: (e) => logger.warn({ elapsedMs: e.elapsedMs }, 'hung act() call'),
196
182
  })
197
183
  ```
198
184
 
199
- `dedupe: { enabled: true }` is also valid — object form for forward compatibility.
185
+ ### `createTenantStore(options)` and `createAsyncTenantStore(factory)`
200
186
 
201
- #### v1.1.5 fixes
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.
202
188
 
203
- - **Shared `attempts`**: joiners now mirror the originator's attempt count. If the originator retried twice before succeeding, all joiners report `attempts: 3` (was `1` in v1.0 — a misleading default).
204
- - **Abort safety**: joiners race the in-flight promise against their own `AbortSignal`. If a joiner's signal aborts (e.g. their `totalTimeout` fires), they reject immediately — they don't block on a hung originator.
205
- - **In-flight TTL** (optional): `dedupe: { enabled: true, inflightTtl: 30_000 }` removes the store entry after 30s if the originator never settles, so subsequent callers can start fresh. Pair with `timeout` / `totalTimeout` for proper cancellation in production.
189
+ ```ts
190
+ import { createTenantStore } from 'actly'
206
191
 
207
- ---
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
+ ```
208
197
 
209
- ### Cache
198
+ ### `InMemoryStore`
210
199
 
211
- Stores successful results for `ttl` milliseconds. Failures are never cached. A cache hit short-circuits everything dedupe, retry, and timeout are all skipped.
200
+ Reference `SyncStateStore`. Bounded LRU with TTL, background sweep, O(1) operations. Default `maxSize` is 10,000 (bounded). Pass `Infinity` explicitly for unbounded storage.
212
201
 
213
202
  ```ts
214
- const result = await act('flags:all', async (signal) => fetchFeatureFlags(signal), {
215
- cache: { ttl: 60_000 },
203
+ const store = new InMemoryStore({
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
216
208
  })
217
209
 
218
- console.log(result.source) // 'fresh' on miss, 'cache' on hit
219
- console.log(result.attempts) // 1 on miss, 0 on hit (v1.1.5 fix)
210
+ store.destroy() // stops timer, clears map, removes listeners. Idempotent.
220
211
  ```
221
212
 
222
- #### v1.1.5 fixes
223
-
224
- - **Single-flight (cache stampede prevention)**: on a sync store, concurrent cache misses for the same key now collapse into a single `fn` invocation. Previously, 10 concurrent callers would all run `fn` (10x redundant work). Now they share one in-flight Promise.
225
- - **`attempts: 0` on cache hit**: cache hits now report `attempts: 0` (was `1` in v1.0), consistent with the documented meaning ("number of attempts made before success").
226
- - **Fail-open writes**: if `store.set()` throws (e.g. Redis transient error), the value is still returned to the caller. Caching is an optimisation, not a correctness requirement.
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.
227
214
 
228
- #### Invalidation
215
+ ## Options
229
216
 
230
- ```ts
231
- import { act, invalidate } from 'actly'
217
+ All fields optional. Compose any combination.
232
218
 
233
- await act('user:42', async (signal) => fetchUser(42, signal), { cache: { ttl: 60_000 } })
234
- // ... user updates their profile ...
235
- invalidate('user:42') // returns true if a cache entry was removed
236
- await act('user:42', ...) // re-fetches
237
- ```
238
-
239
- For scoped stores (see `withStore` below), use the scoped `invalidate`:
219
+ ### `retry`
240
220
 
241
221
  ```ts
242
- const scopedAct = withStore(myStore)
243
- scopedAct.invalidate('user:42') // sync store: returns boolean
244
- // async store: returns Promise<boolean>
222
+ {
223
+ retry: {
224
+ attempts: 3, // total attempts including first (1-100)
225
+ delayMs: 200, // base delay (0-300_000)
226
+ backoff: 'exponential', // 'none' | 'linear' | 'exponential' (default 'none')
227
+ maxDelay: 30_000, // cap on computed delay (default Infinity)
228
+ jitter: 'full', // 'none' | 'full' | 'equal' | 'decorrelated' (default 'full')
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)
233
+ }
234
+ }
245
235
  ```
246
236
 
247
- ---
248
-
249
- ### Combining policies
237
+ `shouldRetryResult` retries on returned values that are semantic failures. The common pattern: `fetch` returns a 500 without throwing.
250
238
 
251
239
  ```ts
252
- const result = await act('product:detail', async (signal) => fetchProduct(id, signal), {
253
- totalTimeout: { ms: 10_000 }, // outermost wall
254
- cache: { ttl: 30_000 }, // short-circuits on hit
255
- dedupe: true, // collapses concurrent calls
256
- retry: { attempts: 3, delayMs: 100, backoff: 'linear' },
257
- timeout: { ms: 3_000 }, // per attempt
258
- signal: userSignal, // caller-provided cancellation
240
+ await act('fetch-api', async (signal) => fetch('/api', { signal }), {
241
+ retry: {
242
+ attempts: 3,
243
+ shouldRetryResult: (res) => res.ok && res.status < 500,
244
+ },
259
245
  })
260
246
  ```
261
247
 
262
- ---
263
-
264
- ## Cancellation: `options.signal`
265
-
266
- `act()` accepts an `AbortSignal` for caller-driven cancellation:
248
+ `backoffFn` carries per-call state. Useful for honoring `Retry-After`.
267
249
 
268
250
  ```ts
269
- const controller = new AbortController()
270
- const promise = act('search:big', async (signal) => runBigQuery(q, signal), {
271
- signal: controller.signal,
272
- timeout: { ms: 30_000 },
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
+ },
273
264
  })
274
-
275
- // User clicks "Cancel":
276
- controller.abort(new Error('user-cancelled'))
277
-
278
- const r = await promise
279
- // r.ok === false, r.error.message === 'user-cancelled'
280
265
  ```
281
266
 
282
- When `signal` aborts:
283
- - If the operation hasn't started, `act()` returns immediately with `attempts: 0` and the signal's `reason` as the error.
284
- - If it's in progress, `fn`'s inner signal aborts (cooperative). `fn` should propagate the signal to its primitives (`fetch`, `AbortController`, etc.) for proper cleanup.
285
- - If it has already settled, the result is returned as normal.
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.
286
268
 
287
- The signal propagates through the entire chain: `totalTimeout`, `retry` (interrupts delay sleeps), and `timeout` all observe it. No more attempts fire after abort.
269
+ `attempts: 1` is a no-op; the retry policy is skipped.
288
270
 
289
- ---
271
+ ### `timeout`
290
272
 
291
- ## Options reference
273
+ Per-attempt deadline. Each retry gets a fresh clock.
292
274
 
293
275
  ```ts
294
- interface ActOptions {
295
- retry?: RetryOptions
296
- timeout?: TimeoutOptions // per-attempt
297
- totalTimeout?: TimeoutOptions // entire operation
298
- dedupe?: boolean | DedupeOptions
299
- cache?: CacheOptions
300
- signal?: AbortSignal // caller-provided cancellation
301
- }
302
-
303
- interface RetryOptions {
304
- attempts: number // integer >= 1
305
- delayMs?: number // non-negative finite
306
- backoff?: 'none' | 'linear' | 'exponential'
307
- maxDelay?: number // cap, default Infinity
308
- jitter?: 'none' | 'full' | 'equal' | 'decorrelated' // default 'full'
309
- shouldRetry?: (error: unknown, attempt: number) => boolean
310
- }
311
-
312
- interface TimeoutOptions { ms: number } // positive finite
313
- interface DedupeOptions {
314
- enabled: boolean
315
- inflightTtl?: number // safety-net TTL for in-flight entry, default Infinity
316
- }
317
- interface CacheOptions { ttl: number } // positive finite
276
+ { timeout: { ms: 5_000, strategy: 'race' } }
318
277
  ```
319
278
 
320
- All options are validated upfront. Invalid input (negative `attempts`, non-positive `ms`, empty `key`, etc.) throws `RangeError` or `TypeError` these are programmer errors, not runtime failures, so they surface as thrown exceptions rather than `ActFailure`.
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.
321
282
 
322
- ---
283
+ ### `totalTimeout`
323
284
 
324
- ## Exported values
285
+ Hard wall-clock budget over the whole operation, including retries, delays, and per-attempt timeouts.
325
286
 
326
287
  ```ts
327
- import {
328
- // Primary API
329
- act, // execute with reliability policies
330
- invalidate, // clear cache for a key on the default store
331
- withStore, // create a scoped act() with explicit store
332
-
333
- // Execution engine (for custom policy chains)
334
- execute, // run a policy chain with explicit store + signal
335
- REQUIRES_SYNC_STORE, // symbol stamped on dedupe policy
336
-
337
- // Stores
338
- InMemoryStore, // sync store with LRU + maxSize + autoCleanup
339
- isSyncStore, // type guard
340
- isAsyncStore, // type guard
341
-
342
- // Error classes
343
- TimeoutError, // per-attempt deadline (.ms)
344
- TotalTimeoutError, // total budget exhausted (.ms)
345
- } from 'actly'
288
+ { totalTimeout: { ms: 12_000 } }
346
289
  ```
347
290
 
348
- ---
291
+ ### `dedupe`
349
292
 
350
- ## Exported types
293
+ Collapses concurrent calls with the same key into one in-flight Promise.
351
294
 
352
295
  ```ts
353
- import type {
354
- ActResult, ActSuccess, ActFailure, ActSource,
355
- ActOptions, ActFn,
356
- RetryOptions, TimeoutOptions, DedupeOptions, CacheOptions,
357
- // Policy internals (for custom policy authors)
358
- PolicyApplier, PolicyContext, RunMeta,
359
- // Store interfaces
360
- SyncStateStore,
361
- AsyncStateStore,
362
- AnyStateStore,
363
- InMemoryStoreOptions,
364
- // v1.0 alias — still valid, zero migration needed
365
- StateStore,
366
- // withStore result types
367
- ScopedActSync,
368
- ScopedActAsync,
369
- } from 'actly'
296
+ dedupe: true
297
+ // or
298
+ dedupe: { enabled: true, inflightTtl: 30_000 }
370
299
  ```
371
300
 
372
- ---
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.
373
302
 
374
- ## Custom store
303
+ Joiner isolation: if the originator's caller aborts, joiners are not affected. Each joiner races the in-flight Promise against their own signal.
375
304
 
376
- By default `act` uses a module-level `InMemoryStore`. For SSR request isolation, multi-tenant scenarios, or test control, use `withStore()`:
305
+ Requires a sync store. `execute()` throws at chain-build time if an async store is used with dedupe.
377
306
 
378
- ### `withStore(store)`
307
+ ### `cache`
379
308
 
380
- Returns a scoped `act` function bound to the provided store, with an `invalidate(key)` method and a `store` reference attached.
309
+ Stores successful results for a TTL.
381
310
 
382
311
  ```ts
383
- import { withStore, InMemoryStore } from 'actly'
312
+ { cache: { ttl: 60_000 } } // 0 < ttl <= 86_400_000
313
+ ```
384
314
 
385
- // Basic
386
- const store = new InMemoryStore()
387
- const act = withStore(store)
315
+ Failures are never cached. Only successful values.
388
316
 
389
- // With LRU + background cleanup (long-lived server store)
390
- const serverStore = new InMemoryStore({
391
- maxSize: 10_000, // evict least-recently-used when exceeded
392
- autoCleanup: true,
393
- cleanupIntervalMs: 60_000, // sweep every 60s (default: 30s)
394
- })
395
- const serverAct = withStore(serverStore)
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.
396
318
 
397
- try {
398
- await serverAct('user:42', async (signal) => fetchUser(42, signal), {
399
- cache: { ttl: 60_000 },
400
- })
319
+ `store.set()` failures (e.g. Redis transient error) are swallowed; the value is still returned to the caller.
401
320
 
402
- // ... user updates their profile ...
403
- serverAct.invalidate('user:42') // next call re-fetches
321
+ ### `circuitBreaker`
404
322
 
405
- // Access the underlying store if needed:
406
- console.log(serverAct.store.size())
407
- } finally {
408
- serverStore.destroy() // stop the cleanup timer
323
+ Opens after threshold failures, rejects immediately while open, allows one probe call after cooldown.
324
+
325
+ ```ts
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
+ }
409
337
  }
410
338
  ```
411
339
 
412
- For sync stores, `invalidate` returns `boolean` synchronously.
413
- For async stores, `invalidate` returns `Promise<boolean>`.
414
-
415
- ### `execute()` directly
340
+ `consecutive` opens after N failures in a row. Resets on any success. Good for "downstream is fully down."
416
341
 
417
- For full control (custom policy chains, custom meta, custom signal composition), call `execute()` directly:
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).
418
343
 
419
- ```ts
420
- import { execute, InMemoryStore, retryPolicy, timeoutPolicy } from 'actly'
421
-
422
- const store = new InMemoryStore()
423
- const meta = { attempts: 1, source: 'fresh' as const }
424
-
425
- const value = await execute({
426
- key: 'custom:chain',
427
- fn: async (signal) => fetchThing(signal),
428
- policies: [
429
- retryPolicy({ attempts: 3, delayMs: 100 }),
430
- timeoutPolicy({ ms: 5_000 }),
431
- ],
432
- store,
433
- meta,
434
- signal: new AbortController().signal,
435
- })
344
+ Half-open probe: probe failure re-opens immediately. Probe success resets the window (count strategy) or the failure counter (consecutive).
436
345
 
437
- console.log(meta.attempts) // final attempt count
438
- ```
346
+ ### `bulkhead`
439
347
 
440
- ### InMemoryStore options
348
+ Caps in-flight concurrency per key.
441
349
 
442
350
  ```ts
443
- new InMemoryStore({
444
- // Periodically sweep expired entries. Default: false (lazy eviction on access).
445
- autoCleanup?: boolean,
446
- cleanupIntervalMs?: number, // default 30_000
447
-
448
- // Bounded cache with LRU eviction. Default: Infinity (unbounded).
449
- // On set(), if size would exceed maxSize, the least-recently-used entry
450
- // is evicted before the new one is inserted.
451
- maxSize?: number,
452
- })
351
+ {
352
+ bulkhead: {
353
+ maxConcurrent: 10,
354
+ maxQueueSize: 100, // default Infinity
355
+ queueTimeoutMs: 5_000, // default Infinity
356
+ }
357
+ }
453
358
  ```
454
359
 
455
- `InMemoryStore` satisfies `SyncStateStore`. Call `destroy()` to stop the background timer and prevent leaks.
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%.
456
361
 
457
- ### Async store (v1.1+)
362
+ ### `rateLimit`
458
363
 
459
- For external cache backends (Redis, Upstash, etc.), implement `AsyncStateStore`:
364
+ Sliding window rate limit per key.
460
365
 
461
366
  ```ts
462
- import type { AsyncStateStore } from 'actly'
463
-
464
- class RedisStore implements AsyncStateStore {
465
- readonly _sync = false as const
466
-
467
- async get<T>(key: string): Promise<T | undefined> { /* ... */ }
468
- async set<T>(key: string, value: T, ttlMs?: number): Promise<void> { /* ... */ }
469
- async delete(key: string): Promise<void> { /* ... */ }
470
- async has(key: string): Promise<boolean> { /* ... */ }
471
- async clear(): Promise<void> { /* ... */ }
472
- async size(): Promise<number> { /* ... */ }
473
- }
367
+ { rateLimit: { maxCalls: 100, windowMs: 60_000 } }
474
368
  ```
475
369
 
476
- `AsyncStateStore` is compatible with `cache` only. Using it with `dedupe` is a TypeScript error and a runtime error — `execute()` throws at chain-build time. Dedupe requires synchronous store access (the read-then-write that makes deduplication work must happen in a single tick; an async `await` between `get()` and `set()` would let two concurrent callers both see a miss).
477
-
478
- ---
370
+ ### `hedge`
479
371
 
480
- ## Store interfaces
372
+ Sends a second call after `delayMs`, races them, cancels the loser.
481
373
 
482
374
  ```ts
483
- interface SyncStateStore {
484
- readonly _sync: true
485
- get<T>(key: string): T | undefined
486
- set<T>(key: string, value: T, ttlMs?: number): void
487
- delete(key: string): void
488
- has(key: string): boolean
489
- clear(): void
490
- size(): number
491
- }
492
-
493
- interface AsyncStateStore {
494
- readonly _sync: false
495
- get<T>(key: string): Promise<T | undefined>
496
- set<T>(key: string, value: T, ttlMs?: number): Promise<void>
497
- delete(key: string): Promise<void>
498
- has(key: string): Promise<boolean>
499
- clear(): Promise<void>
500
- size(): Promise<number>
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
+ }
501
381
  }
502
382
  ```
503
383
 
504
- The `_sync` discriminant is read at runtime by `execute()` to enforce the dedupe constraint. Set it as a `readonly` literal `true as const` or `false as const`.
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).
505
385
 
506
- ---
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.
507
387
 
508
- ## Zero-throw contract
388
+ ### `fallback`
509
389
 
510
- `act()` always resolves. Under any condition network error, thrown exception, timeout, total budget exhaustion, caller abort — the result is an `ActFailure`, not a rejected promise. This contract holds across all versions.
390
+ Returns a value (or function result) instead of the failure when `fn` fails.
511
391
 
512
- The only exceptions are programmer errors (invalid options, invalid keys) — these throw synchronously so bugs surface at development time rather than degrading silently in production.
392
+ ```ts
393
+ { fallback: { value: 'default' } }
394
+ // or
395
+ { fallback: { value: () => computeFallback() } }
396
+ ```
513
397
 
514
- ---
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.
515
399
 
516
- ## Philosophy
400
+ ### `observability`
517
401
 
518
- One function. One return type. No exceptions in userland.
402
+ Hooks fire on lifecycle events. Wrapped in `safeCall` so a buggy hook never crashes the main path.
403
+
404
+ ```ts
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
+ }
418
+ }
419
+ ```
519
420
 
520
- The key is your responsibility. Make it stable and specific. `user:42` is good. `fetch` is not. (Empty keys and reserved prefixes are rejected — see validation.)
421
+ Each event carries `key`, `traceId`, `timestamp`. Pass `traceId` explicitly or let actly generate a UUID.
521
422
 
522
- Policies are composable but intentionally constrained. There is no builder API, no middleware system, no hooks. If you need something `act` doesn't do, write a wrapper around it — that's the right boundary.
423
+ ### `audit`
523
424
 
524
- Cancellation is cooperative: pass the `signal` through. `act()` will return promptly regardless (via internal race), but only you can stop `fn` from leaking resources.
425
+ Single hook for structured logging of every call result.
525
426
 
526
- ---
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
+ ```
527
437
 
528
- ## Changelog
438
+ ### `signal`
529
439
 
530
- ### v1.1.5 2026-06-25
440
+ Caller-supplied `AbortSignal`. Linked to the root controller. Aborting it cancels the entire operation.
531
441
 
532
- **Critical correctness fixes**
442
+ ## Errors
533
443
 
534
- - **`totalTimeout` now cancels the inner retry loop.** Previously, when `totalTimeout` fired, `act()` returned `TotalTimeoutError` to the caller but the inner `retryPolicy` continued firing attempts and sleeping delays in the background — leaking resources for the full retry budget. Now, the abort propagates through the chain: no more attempts fire, delay sleeps reject immediately. *(Fixes C-1.)*
535
- - **`timeout` (per-attempt) cancels `fn` via `AbortSignal`.** `fn` now receives a signal that aborts when the per-attempt deadline fires. If `fn` cooperates (passes signal to `fetch`, `AbortController`, etc.), the underlying work is cancelled properly. `act()` returns promptly even if `fn` ignores the signal (via internal race). *(Fixes C-2.)*
536
- - **Dedupe joiners can abort independently.** Previously, if the originator's `fn` hung, all joiners blocked forever waiting for the in-flight promise. Now, joiners race the in-flight promise against their own `AbortSignal` — they reject immediately when their signal aborts, without waiting for the originator. *(Fixes C-3.)*
537
- - **Cache stampede prevention (single-flight).** Concurrent cache misses for the same key on a sync store now collapse into a single `fn` invocation. Previously, 10 concurrent callers would all run `fn` (10x redundant work). Now they share one in-flight Promise via an internal `__inflight:cache:<key>` slot. *(Fixes C-4.)*
538
- - **Dedupe joiners mirror originator's `attempts`.** Previously, joiners reported `attempts: 1` regardless of how many retries the originator performed. Now, joiners copy the originator's final `attempts` and `source` from a shared `RunMeta` reference. *(Fixes C-5.)*
539
- - **Cache hit reports `attempts: 0`.** Previously, cache hits reported `attempts: 1`, inconsistent with the documented meaning ("number of attempts made before success"). Now: `0` on hit, `1+` on miss. *(Fixes C-6.)*
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.
540
445
 
541
- **Public API additions (fixes A-1, A-2, m-8)**
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 |
542
458
 
543
- - **`execute()` is now exported.** Call it directly with a custom policy chain, store, meta, and signal for full control (SSR isolation, multi-tenant scenarios, custom policy composition).
544
- - **`isSyncStore()` / `isAsyncStore()` are now exported.** Type guards for `AnyStateStore`, useful when building custom policy chains or store adapters.
545
- - **`invalidate(key)`** clears cache entries on the default store. Returns `boolean` (true if an entry was removed).
546
- - **`withStore(store)`** returns a scoped `act` function bound to an explicit store, with `invalidate(key)` and `store` attached. Overloads for sync (`invalidate: boolean`) vs async (`invalidate: Promise<boolean>`) stores.
547
- - **`REQUIRES_SYNC_STORE` symbol** is now exported for consumers building custom policy chains that need to declare sync-store requirements.
548
- - **`ScopedActSync` / `ScopedActAsync` types** exported for typed `withStore` results.
549
- - **`PolicyApplier`, `PolicyContext`, `RunMeta`, `AnyStateStore`** are now exported as public types (previously internal).
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.
550
460
 
551
- **New options**
461
+ ## Compared to Cockatiel
552
462
 
553
- - **`options.signal: AbortSignal`** — caller-provided cancellation signal. Propagates through the entire chain: `totalTimeout`, `retry` (interrupts delay sleeps), and `timeout` all observe it. `fn` receives the composite signal.
554
- - **`RetryOptions.maxDelay`** — cap on computed delay. Prevents 8.5-minute waits on long exponential chains (`delayMs: 1000, attempts: 10, exponential` → 256s between attempts 9 and 10 without a cap).
555
- - **`RetryOptions.jitter`** — jitter strategy: `'none' | 'full' | 'equal' | 'decorrelated'`. Default: `'full'` (random in `[0, delay]`). Prevents thundering-herd retry storms.
556
- - **`DedupeOptions.inflightTtl`** — safety-net TTL for the in-flight entry. If the originator never settles, subsequent callers can start fresh after the TTL expires.
557
- - **`InMemoryStoreOptions.maxSize`** — bounded cache with LRU eviction. On `set()`, if size would exceed `maxSize`, the least-recently-used entry is evicted before the new one is inserted. Default: `Infinity` (unbounded).
463
+ Both are typed, zero-dependency resilience libraries for Node/TypeScript. Both use native `AbortSignal` for cancellation. The core difference is scope *and* size.
558
464
 
559
- **Behaviour changes (non-breaking)**
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 |
560
475
 
561
- - **`shouldRetry` is now called on every failure, including the final attempt.** Previously, it was skipped on the last attempt. The return value is still only consulted when there are remaining attempts but observers / metrics now see every failure. *(Fixes M-2.)*
562
- - **Default `shouldRetry` skips abort errors.** Previously, the default retried on every error. Now it skips `AbortError` (which indicates cancellation — retrying would just abort again, wasting delay budget). User-supplied `shouldRetry` overrides this entirely.
563
- - **Input validation throws on invalid options.** Empty keys, reserved prefixes (`dedupe:`, `cache:`, `__inflight:`), non-integer `attempts`, non-positive `ms`/`ttl`, non-finite delays — all throw `RangeError` or `TypeError` synchronously, rather than degrading silently. *(Fixes M-3, M-4.)*
564
- - **`RetryOptions.attempts: 1` is a documented no-op.** The retry policy is not added to the chain when `attempts <= 1` (would be pure overhead). This was already the behaviour; it's now explicitly documented.
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.
565
477
 
566
- **Internal improvements**
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.
567
479
 
568
- - **`ActFn<T>` signature changed to `(signal: AbortSignal) => Promise<T> | T`.** Backwards compatible: `() => Promise<T>` is assignable (signal arg is ignored). New code can opt-in to cooperative cancellation.
569
- - **`cachePolicy` fails open on `store.set()` errors.** If the cache write throws (e.g. Redis transient error), the value is still returned to the caller. Caching is an optimisation, not a correctness requirement. *(Fixes M-6.)*
570
- - **`InMemoryStore.size()` is now side-effect free** with respect to LRU order. Previously, it called `has()` (which delegated to `get()`), refreshing LRU positions. Now it scans without touching order. *(Fixes m-1.)*
571
- - **`InMemoryStore` implements LRU** via `Map` insertion-order semantics: `delete` + `set` on every access moves the key to the most-recent position. *(Fixes P-3.)*
572
- - **`retryPolicy` checks `parentSignal.aborted` before each attempt and before each delay sleep.** When `totalTimeout` or caller signal aborts, the retry loop bails immediately — no more attempts, no more delays. Delay sleeps use a signal-aware `sleep()` helper that rejects early on abort.
573
- - **`package.json` `engines.node` set to `>=18`** (was missing — README claimed Node 18+ but it wasn't enforced). *(Fixes D-7.)*
574
- - **`package.json` `sideEffects: false`** for tree-shaking. *(Fixes D-8.)*
575
- - **`package.json` `exports` field:** `types` condition is now first (was last) — required for correct TypeScript resolution in some bundlers. *(Fixes A-3.)*
576
- - **`tsconfig` updated:** target ES2022, `lib: ["ES2022"]`, `types: ["node"]`. Removed `DOM` lib (was unnecessary for a Node-targeted library). *(Fixes D-9, D-11, D-12.)*
577
- - **`tsconfig.cjs.json` `moduleResolution: "Node"`** (was `"Bundler"` — incompatible with `module: "CommonJS"`). *(Fixes D-9.)*
578
- - **Test suite added.** 64 tests covering: API surface, zero-throw contract, input validation, retry (jitter, maxDelay, shouldRetry on final attempt, abort-skip), timeout (cooperative + race fallback), totalTimeout (cancellation, delay interrupt), dedupe (collapse, shared meta, abort safety, hung originator), cache (single-flight, attempts=0, fail-open, TTL), invalidate, withStore (sync + async, isolation, scoped invalidate), InMemoryStore (LRU, maxSize, TTL, size() purity, destroy), AbortSignal integration, execute() public API. *(Fixes D-1.)*
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.
579
481
 
580
- **Non-breaking changes**
482
+ ## Performance
581
483
 
582
- - `StateStore` preserved as type alias for `SyncStateStore` all v1.0 code compiles without changes.
583
- - All new exports are purely additive.
584
- - `ActFn<T>` signature widened to accept `(signal) => ...` — existing `() => Promise<T>` is assignable.
585
- - `withStore()` is a new function — no impact on existing `act()` callers.
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.
586
485
 
587
- ---
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 |
588
494
 
589
- ### v1.1.0 2026-06-01
495
+ **Cockatiel comparison (same sandbox, same workload):**
590
496
 
591
- - `AsyncStateStore` interface for plug-in async backends (Redis, Upstash, Cloudflare KV).
592
- - `SyncStateStore` canonical name; `StateStore` preserved as alias.
593
- - `InMemoryStore` public export with `InMemoryStoreOptions` (`autoCleanup`, `cleanupIntervalMs`).
594
- - `TotalTimeoutError` class distinct from `TimeoutError`.
595
- - `totalTimeout` option for hard wall-clock budget.
596
- - `dedupe: true` shorthand.
597
- - `isSyncStore()` / `isAsyncStore()` type guards.
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 |
598
504
 
599
- ---
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.
600
506
 
601
- ### v1.0.1
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.
602
508
 
603
- - Initial stable release.
604
- - `act()`, `retry`, `timeout`, `totalTimeout`, `dedupe`, `cache`.
605
- - `TimeoutError` exported for `instanceof` checks.
606
- - Zero dependencies. ESM + CJS. Node 18+.
509
+ ## Limitations
607
510
 
608
- ---
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.
609
515
 
610
516
  ## License
611
517