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.
- package/CHANGELOG.md +234 -0
- package/MIGRATION.md +141 -0
- package/README.md +334 -428
- package/SECURITY.md +56 -0
- package/dist/core/act.cjs +630 -126
- package/dist/core/act.d.ts +6 -84
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +630 -129
- package/dist/core/executor.cjs +1 -26
- package/dist/core/executor.d.ts +2 -38
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +1 -27
- package/dist/core/health.cjs +163 -0
- package/dist/core/health.d.ts +33 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +150 -0
- package/dist/core/shutdown.cjs +71 -0
- package/dist/core/shutdown.d.ts +5 -0
- package/dist/core/shutdown.d.ts.map +1 -0
- package/dist/core/shutdown.js +65 -0
- package/dist/core/tenant.cjs +113 -0
- package/dist/core/tenant.d.ts +24 -0
- package/dist/core/tenant.d.ts.map +1 -0
- package/dist/core/tenant.js +109 -0
- package/dist/errors.cjs +149 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +134 -0
- package/dist/index.cjs +51 -8
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/observability.cjs +6 -0
- package/dist/observability.d.ts +86 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +3 -0
- package/dist/policies/bulkhead.cjs +137 -0
- package/dist/policies/bulkhead.d.ts +3 -0
- package/dist/policies/bulkhead.d.ts.map +1 -0
- package/dist/policies/bulkhead.js +134 -0
- package/dist/policies/cache.cjs +84 -67
- package/dist/policies/cache.d.ts +0 -33
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +84 -68
- package/dist/policies/circuitBreaker.cjs +179 -0
- package/dist/policies/circuitBreaker.d.ts +3 -0
- package/dist/policies/circuitBreaker.d.ts.map +1 -0
- package/dist/policies/circuitBreaker.js +176 -0
- package/dist/policies/dedupe.cjs +46 -64
- package/dist/policies/dedupe.d.ts +0 -41
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +46 -65
- 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 +45 -0
- package/dist/policies/rateLimit.d.ts +3 -0
- package/dist/policies/rateLimit.d.ts.map +1 -0
- package/dist/policies/rateLimit.js +42 -0
- package/dist/policies/retry.cjs +129 -57
- package/dist/policies/retry.d.ts +0 -27
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +129 -58
- package/dist/policies/timeout.cjs +47 -103
- package/dist/policies/timeout.d.ts +2 -46
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +46 -102
- 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 +147 -87
- package/dist/stores/memory.d.ts +11 -59
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +146 -88
- 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 +62 -179
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -2
- package/dist/utils/abort.cjs +46 -73
- package/dist/utils/abort.d.ts +4 -51
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +46 -74
- package/dist/utils/abortPool.cjs +25 -0
- package/dist/utils/abortPool.d.ts +4 -0
- package/dist/utils/abortPool.d.ts.map +1 -0
- package/dist/utils/abortPool.js +20 -0
- package/dist/utils/backoff.cjs +8 -15
- package/dist/utils/backoff.d.ts +0 -11
- package/dist/utils/backoff.d.ts.map +1 -1
- package/dist/utils/backoff.js +8 -16
- 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 +45 -0
- package/dist/utils/key.d.ts +2 -0
- package/dist/utils/key.d.ts.map +1 -0
- package/dist/utils/key.js +42 -0
- package/dist/utils/limits.cjs +22 -0
- package/dist/utils/limits.d.ts +21 -0
- package/dist/utils/limits.d.ts.map +1 -0
- package/dist/utils/limits.js +19 -0
- 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 +50 -0
- package/dist/utils/sanitize.d.ts +3 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +46 -0
- package/dist/utils/validate.cjs +163 -26
- package/dist/utils/validate.d.ts +6 -8
- package/dist/utils/validate.d.ts.map +1 -1
- package/dist/utils/validate.js +158 -27
- package/package.json +42 -7
- package/dist/core/act.js.map +0 -1
- package/dist/core/executor.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/policies/cache.js.map +0 -1
- package/dist/policies/dedupe.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/backoff.js.map +0 -1
- package/dist/utils/validate.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,611 +1,517 @@
|
|
|
1
1
|
# actly
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
28
|
-
'
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 (
|
|
40
|
-
console.log(
|
|
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(
|
|
32
|
+
console.error(r.error)
|
|
45
33
|
}
|
|
46
34
|
```
|
|
47
35
|
|
|
48
|
-
`act` always resolves.
|
|
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
|
-
|
|
58
|
+
## API
|
|
51
59
|
|
|
52
|
-
|
|
60
|
+
### `act(key, fn, options?)`
|
|
53
61
|
|
|
54
|
-
|
|
62
|
+
Wraps `fn` with reliability policies. Returns `Promise<ActResult<T>>`, always resolves.
|
|
55
63
|
|
|
56
64
|
```ts
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
74
|
+
interface ActFailure {
|
|
75
|
+
ok: false
|
|
76
|
+
error: unknown // see Errors
|
|
77
|
+
attempts: number
|
|
78
|
+
traceId?: string
|
|
79
|
+
durationMs?: number
|
|
80
|
+
}
|
|
67
81
|
|
|
68
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
89
|
+
Removes the cached value for `key` from the default store. Returns `true` if a cache entry was removed.
|
|
83
90
|
|
|
84
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
const store = new InMemoryStore({ maxSize: 1000, autoCleanup: true })
|
|
104
|
+
const act = withStore(store)
|
|
105
105
|
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
`
|
|
130
|
+
### `usePolicy(options)`
|
|
136
131
|
|
|
137
|
-
|
|
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
|
-
###
|
|
147
|
+
### `createHealthCheck(store, options?)`
|
|
142
148
|
|
|
143
|
-
|
|
149
|
+
Returns a function that produces a `HealthStatus` snapshot.
|
|
144
150
|
|
|
145
151
|
```ts
|
|
146
|
-
import {
|
|
152
|
+
import { createHealthCheck, InMemoryStore } from 'actly'
|
|
147
153
|
|
|
148
|
-
const
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
160
|
+
const status = health()
|
|
161
|
+
// { storeSize, pendingInflight, uptimeMs, lastError?, lastSuccessAt? }
|
|
155
162
|
```
|
|
156
163
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
---
|
|
164
|
+
### `drain(timeoutMs, scope?)` and `drainAll(timeoutMs)`
|
|
160
165
|
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
---
|
|
175
|
+
### `enableWatchdog(thresholdMs?, hooks?)`
|
|
188
176
|
|
|
189
|
-
|
|
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
|
-
|
|
195
|
-
|
|
180
|
+
enableWatchdog(30_000, {
|
|
181
|
+
onWatchdog: (e) => logger.warn({ elapsedMs: e.elapsedMs }, 'hung act() call'),
|
|
196
182
|
})
|
|
197
183
|
```
|
|
198
184
|
|
|
199
|
-
`
|
|
185
|
+
### `createTenantStore(options)` and `createAsyncTenantStore(factory)`
|
|
200
186
|
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
###
|
|
198
|
+
### `InMemoryStore`
|
|
210
199
|
|
|
211
|
-
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
+
## Options
|
|
229
216
|
|
|
230
|
-
|
|
231
|
-
import { act, invalidate } from 'actly'
|
|
217
|
+
All fields optional. Compose any combination.
|
|
232
218
|
|
|
233
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
+
`attempts: 1` is a no-op; the retry policy is skipped.
|
|
288
270
|
|
|
289
|
-
|
|
271
|
+
### `timeout`
|
|
290
272
|
|
|
291
|
-
|
|
273
|
+
Per-attempt deadline. Each retry gets a fresh clock.
|
|
292
274
|
|
|
293
275
|
```ts
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
+
Hard wall-clock budget over the whole operation, including retries, delays, and per-attempt timeouts.
|
|
325
286
|
|
|
326
287
|
```ts
|
|
327
|
-
|
|
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
|
-
|
|
293
|
+
Collapses concurrent calls with the same key into one in-flight Promise.
|
|
351
294
|
|
|
352
295
|
```ts
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
+
Requires a sync store. `execute()` throws at chain-build time if an async store is used with dedupe.
|
|
377
306
|
|
|
378
|
-
### `
|
|
307
|
+
### `cache`
|
|
379
308
|
|
|
380
|
-
|
|
309
|
+
Stores successful results for a TTL.
|
|
381
310
|
|
|
382
311
|
```ts
|
|
383
|
-
|
|
312
|
+
{ cache: { ttl: 60_000 } } // 0 < ttl <= 86_400_000
|
|
313
|
+
```
|
|
384
314
|
|
|
385
|
-
|
|
386
|
-
const store = new InMemoryStore()
|
|
387
|
-
const act = withStore(store)
|
|
315
|
+
Failures are never cached. Only successful values.
|
|
388
316
|
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
serverAct.invalidate('user:42') // next call re-fetches
|
|
321
|
+
### `circuitBreaker`
|
|
404
322
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
```
|
|
346
|
+
### `bulkhead`
|
|
439
347
|
|
|
440
|
-
|
|
348
|
+
Caps in-flight concurrency per key.
|
|
441
349
|
|
|
442
350
|
```ts
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
`
|
|
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
|
-
###
|
|
362
|
+
### `rateLimit`
|
|
458
363
|
|
|
459
|
-
|
|
364
|
+
Sliding window rate limit per key.
|
|
460
365
|
|
|
461
366
|
```ts
|
|
462
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
---
|
|
370
|
+
### `hedge`
|
|
479
371
|
|
|
480
|
-
|
|
372
|
+
Sends a second call after `delayMs`, races them, cancels the loser.
|
|
481
373
|
|
|
482
374
|
```ts
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
+
### `fallback`
|
|
509
389
|
|
|
510
|
-
|
|
390
|
+
Returns a value (or function result) instead of the failure when `fn` fails.
|
|
511
391
|
|
|
512
|
-
|
|
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
|
-
|
|
400
|
+
### `observability`
|
|
517
401
|
|
|
518
|
-
|
|
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
|
-
|
|
421
|
+
Each event carries `key`, `traceId`, `timestamp`. Pass `traceId` explicitly or let actly generate a UUID.
|
|
521
422
|
|
|
522
|
-
|
|
423
|
+
### `audit`
|
|
523
424
|
|
|
524
|
-
|
|
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
|
-
|
|
438
|
+
### `signal`
|
|
529
439
|
|
|
530
|
-
|
|
440
|
+
Caller-supplied `AbortSignal`. Linked to the root controller. Aborting it cancels the entire operation.
|
|
531
441
|
|
|
532
|
-
|
|
442
|
+
## Errors
|
|
533
443
|
|
|
534
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
+
## Compared to Cockatiel
|
|
552
462
|
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
+
## Performance
|
|
581
483
|
|
|
582
|
-
|
|
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
|
-
|
|
495
|
+
**Cockatiel comparison (same sandbox, same workload):**
|
|
590
496
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|