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.
- package/CHANGELOG.md +234 -0
- package/MIGRATION.md +141 -0
- package/README.md +326 -218
- package/SECURITY.md +56 -0
- package/dist/core/act.cjs +406 -220
- package/dist/core/act.d.ts +6 -84
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +405 -222
- package/dist/core/executor.cjs +0 -27
- package/dist/core/executor.d.ts +0 -41
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +0 -28
- package/dist/core/health.cjs +146 -17
- package/dist/core/health.d.ts +20 -5
- package/dist/core/health.d.ts.map +1 -1
- package/dist/core/health.js +140 -18
- package/dist/core/shutdown.cjs +19 -8
- package/dist/core/shutdown.d.ts +1 -4
- package/dist/core/shutdown.d.ts.map +1 -1
- package/dist/core/shutdown.js +18 -9
- package/dist/core/tenant.cjs +61 -19
- package/dist/core/tenant.d.ts +5 -10
- package/dist/core/tenant.d.ts.map +1 -1
- package/dist/core/tenant.js +61 -20
- package/dist/errors.cjs +70 -103
- package/dist/errors.d.ts +33 -90
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +66 -103
- package/dist/index.cjs +25 -22
- package/dist/index.d.ts +12 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -15
- package/dist/observability.cjs +0 -39
- package/dist/observability.d.ts +19 -78
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +0 -40
- package/dist/policies/bulkhead.cjs +44 -4
- package/dist/policies/bulkhead.d.ts +0 -0
- package/dist/policies/bulkhead.d.ts.map +1 -1
- package/dist/policies/bulkhead.js +44 -5
- package/dist/policies/cache.cjs +22 -142
- package/dist/policies/cache.d.ts +0 -46
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +22 -143
- package/dist/policies/circuitBreaker.cjs +125 -31
- package/dist/policies/circuitBreaker.d.ts +0 -0
- package/dist/policies/circuitBreaker.d.ts.map +1 -1
- package/dist/policies/circuitBreaker.js +125 -32
- package/dist/policies/dedupe.cjs +19 -66
- package/dist/policies/dedupe.d.ts +0 -23
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +19 -67
- package/dist/policies/noop.cjs +6 -0
- package/dist/policies/noop.d.ts +3 -0
- package/dist/policies/noop.d.ts.map +1 -0
- package/dist/policies/noop.js +3 -0
- package/dist/policies/rateLimit.cjs +15 -5
- package/dist/policies/rateLimit.d.ts +0 -0
- package/dist/policies/rateLimit.d.ts.map +1 -1
- package/dist/policies/rateLimit.js +15 -6
- package/dist/policies/retry.cjs +100 -65
- package/dist/policies/retry.d.ts +0 -27
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +100 -66
- package/dist/policies/timeout.cjs +32 -60
- package/dist/policies/timeout.d.ts +0 -13
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +32 -71
- package/dist/stores/base.cjs +0 -10
- package/dist/stores/base.d.ts +4 -67
- package/dist/stores/base.d.ts.map +1 -1
- package/dist/stores/base.js +0 -11
- package/dist/stores/memory.cjs +75 -88
- package/dist/stores/memory.d.ts +7 -74
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +75 -89
- package/dist/testing/index.cjs +56 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +52 -0
- package/dist/types/index.cjs +0 -1
- package/dist/types/index.d.ts +17 -307
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -2
- package/dist/utils/abort.cjs +14 -111
- package/dist/utils/abort.d.ts +3 -74
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +14 -112
- package/dist/utils/abortPool.cjs +1 -2
- package/dist/utils/abortPool.d.ts +0 -0
- package/dist/utils/abortPool.d.ts.map +1 -1
- package/dist/utils/abortPool.js +1 -3
- package/dist/utils/backoff.cjs +3 -21
- package/dist/utils/backoff.d.ts +0 -11
- package/dist/utils/backoff.d.ts.map +1 -1
- package/dist/utils/backoff.js +3 -22
- package/dist/utils/decorator.cjs +44 -0
- package/dist/utils/decorator.d.ts +3 -0
- package/dist/utils/decorator.d.ts.map +1 -0
- package/dist/utils/decorator.js +41 -0
- package/dist/utils/key.cjs +13 -45
- package/dist/utils/key.d.ts +0 -27
- package/dist/utils/key.d.ts.map +1 -1
- package/dist/utils/key.js +13 -46
- package/dist/utils/limits.cjs +9 -27
- package/dist/utils/limits.d.ts +9 -27
- package/dist/utils/limits.d.ts.map +1 -1
- package/dist/utils/limits.js +9 -28
- package/dist/utils/safeCall.cjs +28 -0
- package/dist/utils/safeCall.d.ts +2 -0
- package/dist/utils/safeCall.d.ts.map +1 -0
- package/dist/utils/safeCall.js +25 -0
- package/dist/utils/sanitize.cjs +22 -1
- package/dist/utils/sanitize.d.ts +0 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +22 -2
- package/dist/utils/validate.cjs +81 -18
- package/dist/utils/validate.d.ts +6 -19
- package/dist/utils/validate.d.ts.map +1 -1
- package/dist/utils/validate.js +81 -19
- package/package.json +38 -4
- package/dist/core/act.js.map +0 -1
- package/dist/core/executor.js.map +0 -1
- package/dist/core/health.js.map +0 -1
- package/dist/core/shutdown.js.map +0 -1
- package/dist/core/tenant.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/observability.js.map +0 -1
- package/dist/policies/bulkhead.js.map +0 -1
- package/dist/policies/cache.js.map +0 -1
- package/dist/policies/circuitBreaker.js.map +0 -1
- package/dist/policies/dedupe.js.map +0 -1
- package/dist/policies/rateLimit.js.map +0 -1
- package/dist/policies/retry.js.map +0 -1
- package/dist/policies/timeout.js.map +0 -1
- package/dist/state/store.cjs +0 -14
- package/dist/state/store.d.ts +0 -11
- package/dist/state/store.d.ts.map +0 -1
- package/dist/state/store.js +0 -11
- package/dist/state/store.js.map +0 -1
- package/dist/stores/base.js.map +0 -1
- package/dist/stores/memory.js.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/utils/abort.js.map +0 -1
- package/dist/utils/abortPool.js.map +0 -1
- package/dist/utils/backoff.js.map +0 -1
- package/dist/utils/key.js.map +0 -1
- package/dist/utils/limits.js.map +0 -1
- package/dist/utils/sanitize.js.map +0 -1
- 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
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
|
21
|
-
'
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 (
|
|
33
|
-
console.log(
|
|
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(
|
|
32
|
+
console.error(r.error)
|
|
39
33
|
}
|
|
40
34
|
```
|
|
41
35
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
##
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
107
|
-
traceId?: string
|
|
108
|
-
durationMs?: number
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
87
|
+
### `invalidate(key)`
|
|
123
88
|
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
### `
|
|
120
|
+
### `noopPolicy()`
|
|
157
121
|
|
|
158
|
-
|
|
122
|
+
Passthrough policy. No retry, no timeout, no state. Useful for testing and conditional chains.
|
|
159
123
|
|
|
160
124
|
```ts
|
|
161
|
-
|
|
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,
|
|
165
|
-
autoCleanup: true,
|
|
166
|
-
cleanupIntervalMs: 30_000
|
|
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
|
-
|
|
217
|
+
All fields optional. Compose any combination.
|
|
175
218
|
|
|
176
|
-
|
|
219
|
+
### `retry`
|
|
177
220
|
|
|
178
221
|
```ts
|
|
179
222
|
{
|
|
180
223
|
retry: {
|
|
181
|
-
attempts: 3, // total attempts including first
|
|
182
|
-
delayMs: 200, // base delay (
|
|
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
|
|
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
|
-
|
|
237
|
+
`shouldRetryResult` retries on returned values that are semantic failures. The common pattern: `fetch` returns a 500 without throwing.
|
|
192
238
|
|
|
193
|
-
|
|
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
|
-
|
|
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 } }
|
|
276
|
+
{ timeout: { ms: 5_000, strategy: 'race' } }
|
|
203
277
|
```
|
|
204
278
|
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
+
Stores successful results for a TTL.
|
|
236
310
|
|
|
237
311
|
```ts
|
|
238
|
-
{ cache: { ttl: 60_000 } } // ttl
|
|
312
|
+
{ cache: { ttl: 60_000 } } // 0 < ttl <= 86_400_000
|
|
239
313
|
```
|
|
240
314
|
|
|
241
|
-
|
|
315
|
+
Failures are never cached. Only successful values.
|
|
242
316
|
|
|
243
|
-
|
|
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
|
-
|
|
319
|
+
`store.set()` failures (e.g. Redis transient error) are swallowed; the value is still returned to the caller.
|
|
246
320
|
|
|
247
|
-
### `
|
|
321
|
+
### `circuitBreaker`
|
|
248
322
|
|
|
249
|
-
|
|
323
|
+
Opens after threshold failures, rejects immediately while open, allows one probe call after cooldown.
|
|
250
324
|
|
|
251
325
|
```ts
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
+
Caps in-flight concurrency per key.
|
|
263
349
|
|
|
264
350
|
```ts
|
|
265
351
|
{
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
+
### `rateLimit`
|
|
288
363
|
|
|
289
|
-
|
|
364
|
+
Sliding window rate limit per key.
|
|
290
365
|
|
|
291
366
|
```ts
|
|
292
|
-
|
|
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
|
-
|
|
370
|
+
### `hedge`
|
|
303
371
|
|
|
304
|
-
|
|
372
|
+
Sends a second call after `delayMs`, races them, cancels the loser.
|
|
305
373
|
|
|
306
374
|
```ts
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
+
### `fallback`
|
|
318
389
|
|
|
319
|
-
|
|
390
|
+
Returns a value (or function result) instead of the failure when `fn` fails.
|
|
320
391
|
|
|
321
|
-
|
|
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
|
-
|
|
402
|
+
Hooks fire on lifecycle events. Wrapped in `safeCall` so a buggy hook never crashes the main path.
|
|
324
403
|
|
|
325
404
|
```ts
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
421
|
+
Each event carries `key`, `traceId`, `timestamp`. Pass `traceId` explicitly or let actly generate a UUID.
|
|
344
422
|
|
|
345
|
-
|
|
423
|
+
### `audit`
|
|
346
424
|
|
|
347
|
-
|
|
425
|
+
Single hook for structured logging of every call result.
|
|
348
426
|
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
442
|
+
## Errors
|
|
359
443
|
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
+
**Cockatiel comparison (same sandbox, same workload):**
|
|
397
496
|
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|