actly 1.1.0 → 1.2.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/LICENSE +0 -0
- package/README.md +262 -232
- package/dist/core/act.cjs +505 -39
- package/dist/core/act.d.ts +81 -10
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +504 -40
- package/dist/core/act.js.map +1 -1
- package/dist/core/executor.cjs +21 -9
- package/dist/core/executor.d.ts +31 -6
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +21 -9
- package/dist/core/executor.js.map +1 -1
- package/dist/core/health.cjs +34 -0
- package/dist/core/health.d.ts +18 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +28 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/shutdown.cjs +60 -0
- package/dist/core/shutdown.d.ts +8 -0
- package/dist/core/shutdown.d.ts.map +1 -0
- package/dist/core/shutdown.js +56 -0
- package/dist/core/shutdown.js.map +1 -0
- package/dist/core/tenant.cjs +71 -0
- package/dist/core/tenant.d.ts +29 -0
- package/dist/core/tenant.d.ts.map +1 -0
- package/dist/core/tenant.js +68 -0
- package/dist/core/tenant.js.map +1 -0
- package/dist/errors.cjs +182 -0
- package/dist/errors.d.ts +151 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +171 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.cjs +56 -6
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -4
- package/dist/index.js.map +1 -1
- package/dist/observability.cjs +45 -0
- package/dist/observability.d.ts +145 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +43 -0
- package/dist/observability.js.map +1 -0
- package/dist/policies/bulkhead.cjs +97 -0
- package/dist/policies/bulkhead.d.ts +3 -0
- package/dist/policies/bulkhead.d.ts.map +1 -0
- package/dist/policies/bulkhead.js +95 -0
- package/dist/policies/bulkhead.js.map +1 -0
- package/dist/policies/cache.cjs +223 -22
- package/dist/policies/cache.d.ts +42 -7
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +223 -22
- package/dist/policies/cache.js.map +1 -1
- package/dist/policies/circuitBreaker.cjs +85 -0
- package/dist/policies/circuitBreaker.d.ts +3 -0
- package/dist/policies/circuitBreaker.d.ts.map +1 -0
- package/dist/policies/circuitBreaker.js +83 -0
- package/dist/policies/circuitBreaker.js.map +1 -0
- package/dist/policies/dedupe.cjs +98 -21
- package/dist/policies/dedupe.d.ts +20 -13
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +98 -21
- package/dist/policies/dedupe.js.map +1 -1
- package/dist/policies/rateLimit.cjs +35 -0
- package/dist/policies/rateLimit.d.ts +3 -0
- package/dist/policies/rateLimit.d.ts.map +1 -0
- package/dist/policies/rateLimit.js +33 -0
- package/dist/policies/rateLimit.js.map +1 -0
- package/dist/policies/retry.cjs +103 -26
- package/dist/policies/retry.d.ts +25 -2
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +103 -26
- package/dist/policies/retry.js.map +1 -1
- package/dist/policies/timeout.cjs +78 -36
- package/dist/policies/timeout.d.ts +7 -18
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +84 -31
- package/dist/policies/timeout.js.map +1 -1
- package/dist/state/store.cjs +9 -2
- package/dist/state/store.d.ts +9 -0
- package/dist/state/store.d.ts.map +1 -1
- package/dist/state/store.js +9 -2
- package/dist/state/store.js.map +1 -1
- package/dist/stores/base.cjs +4 -4
- package/dist/stores/base.d.ts +32 -56
- package/dist/stores/base.d.ts.map +1 -1
- package/dist/stores/base.js +4 -4
- package/dist/stores/base.js.map +1 -1
- package/dist/stores/memory.cjs +161 -39
- package/dist/stores/memory.d.ts +64 -24
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +160 -39
- package/dist/stores/memory.js.map +1 -1
- package/dist/types/index.d.ts +315 -37
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/abort.cjs +212 -0
- package/dist/utils/abort.d.ts +79 -0
- package/dist/utils/abort.d.ts.map +1 -0
- package/dist/utils/abort.js +206 -0
- package/dist/utils/abort.js.map +1 -0
- package/dist/utils/abortPool.cjs +26 -0
- package/dist/utils/abortPool.d.ts +4 -0
- package/dist/utils/abortPool.d.ts.map +1 -0
- package/dist/utils/abortPool.js +22 -0
- package/dist/utils/abortPool.js.map +1 -0
- package/dist/utils/backoff.cjs +54 -0
- package/dist/utils/backoff.d.ts +14 -0
- package/dist/utils/backoff.d.ts.map +1 -0
- package/dist/utils/backoff.js +52 -0
- package/dist/utils/backoff.js.map +1 -0
- package/dist/utils/key.cjs +77 -0
- package/dist/utils/key.d.ts +29 -0
- package/dist/utils/key.d.ts.map +1 -0
- package/dist/utils/key.js +75 -0
- package/dist/utils/key.js.map +1 -0
- package/dist/utils/limits.cjs +40 -0
- package/dist/utils/limits.d.ts +39 -0
- package/dist/utils/limits.d.ts.map +1 -0
- package/dist/utils/limits.js +38 -0
- package/dist/utils/limits.js.map +1 -0
- package/dist/utils/sanitize.cjs +29 -0
- package/dist/utils/sanitize.d.ts +3 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +26 -0
- package/dist/utils/sanitize.js.map +1 -0
- package/dist/utils/validate.cjs +153 -0
- package/dist/utils/validate.d.ts +26 -0
- package/dist/utils/validate.d.ts.map +1 -0
- package/dist/utils/validate.js +141 -0
- package/dist/utils/validate.js.map +1 -0
- package/package.json +20 -37
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -1,377 +1,407 @@
|
|
|
1
1
|
# actly
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Retry. Timeout. Dedupe. Cache. Composable, typed, zero-throw.
|
|
3
|
+
A typed reliability kernel for async execution. Retry, timeout, dedupe, cache — composable, zero-throw, with `AbortSignal` cancellation, jittered backoff, observability hooks, and a typed error taxonomy.
|
|
5
4
|
|
|
6
5
|
```bash
|
|
7
6
|
npm install actly
|
|
8
7
|
```
|
|
9
8
|
|
|
10
|
-
> Requires Node
|
|
9
|
+
> Requires Node 20+. Ships ESM + CJS. Zero runtime dependencies. Tree-shakeable.
|
|
11
10
|
|
|
12
11
|
---
|
|
13
12
|
|
|
14
|
-
##
|
|
13
|
+
## What it does
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
These patterns are solved the same way every time. `actly` solves them once.
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
## Quick start
|
|
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.
|
|
23
16
|
|
|
24
17
|
```ts
|
|
25
18
|
import { act } from 'actly'
|
|
26
19
|
|
|
27
|
-
const result = await act(
|
|
20
|
+
const result = await act(
|
|
21
|
+
'user:42',
|
|
22
|
+
async (signal) => fetch(`/api/users/42`, { signal }),
|
|
23
|
+
{
|
|
24
|
+
retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
|
|
25
|
+
timeout: { ms: 5_000 },
|
|
26
|
+
totalTimeout: { ms: 12_000 },
|
|
27
|
+
dedupe: true,
|
|
28
|
+
cache: { ttl: 60_000 },
|
|
29
|
+
},
|
|
30
|
+
)
|
|
28
31
|
|
|
29
32
|
if (result.ok) {
|
|
30
|
-
console.log(result.value)
|
|
31
|
-
console.log(result.source)
|
|
32
|
-
console.log(result.attempts)
|
|
33
|
+
console.log(result.value) // T
|
|
34
|
+
console.log(result.source) // 'fresh' | 'cache'
|
|
35
|
+
console.log(result.attempts) // number (0 on cache hit)
|
|
36
|
+
console.log(result.durationMs) // number (wall-clock ms)
|
|
33
37
|
} else {
|
|
34
|
-
console.error(result.error)
|
|
38
|
+
console.error(result.error) // typed — see Error taxonomy
|
|
35
39
|
}
|
|
36
40
|
```
|
|
37
41
|
|
|
38
|
-
`act` always resolves. It never rejects. You check `.ok` and move on.
|
|
39
|
-
|
|
40
42
|
---
|
|
41
43
|
|
|
42
|
-
##
|
|
44
|
+
## Compared to Cockatiel
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
act<T>(key: string, fn: () => Promise<T>, options?: ActOptions): Promise<ActResult<T>>
|
|
46
|
-
```
|
|
46
|
+
Both are typed, zero-dependency resilience libraries for Node/TypeScript. The core difference is scope and composition model.
|
|
47
47
|
|
|
48
|
-
|
|
|
48
|
+
| | actly | Cockatiel |
|
|
49
49
|
|---|---|---|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
50
|
+
| Retry (backoff + jitter) | ✅ exponential/linear/constant, 4 jitter modes | ✅ exponential, `ExponentialBackoff` |
|
|
51
|
+
| Timeout (per-attempt) | ✅ | ✅ |
|
|
52
|
+
| Total/wall-clock timeout | ✅ `totalTimeout` spans the whole retry loop | — (compose manually) |
|
|
53
|
+
| Circuit breaker | ✅ half-open single-probe | ✅ |
|
|
54
|
+
| Bulkhead | ✅ with optional queue + queue timeout | ✅ |
|
|
55
|
+
| Rate limit | ✅ sliding window | — |
|
|
56
|
+
| Cache (with single-flight) | ✅ built in, stampede-proof | — |
|
|
57
|
+
| Request dedupe (single-flight) | ✅ generation-safe, joiner-isolated | — |
|
|
58
|
+
| Hedged requests | ✅ | — |
|
|
59
|
+
| Fallback | — (compose at call site) | ✅ |
|
|
60
|
+
| Composition model | one `act()` call, declarative options object | explicit `wrap()` chaining of policy objects |
|
|
61
|
+
| Cancellation | native `AbortSignal`, propagated through every policy | `CancellationToken` (custom, pre-dates broad `AbortSignal` adoption) |
|
|
62
|
+
| Observability | opt-in hooks, zero-cost when unused | events per policy object |
|
|
63
|
+
| State store | pluggable (`InMemoryStore` built in; bring your own for Redis etc.) | in-process only |
|
|
53
64
|
|
|
54
|
-
|
|
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`.
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
61
84
|
|
|
62
85
|
---
|
|
63
86
|
|
|
64
|
-
##
|
|
87
|
+
## API
|
|
65
88
|
|
|
66
|
-
|
|
89
|
+
### `act(key, fn, options?)`
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
totalTimeout → cache → dedupe → retry → timeout
|
|
70
|
-
```
|
|
91
|
+
Wraps `fn` with reliability policies. Returns `Promise<ActResult<T>>`.
|
|
71
92
|
|
|
72
|
-
|
|
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.
|
|
73
100
|
|
|
74
101
|
```ts
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
interface ActSuccess<T> {
|
|
103
|
+
ok: true
|
|
104
|
+
value: T
|
|
105
|
+
source: 'fresh' | 'cache'
|
|
106
|
+
attempts: number // 1-based; 0 on cache hit
|
|
107
|
+
traceId?: string // present when observability or traceId option set
|
|
108
|
+
durationMs?: number // wall-clock ms
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ActFailure {
|
|
112
|
+
ok: false
|
|
113
|
+
error: unknown // typed — see Error taxonomy
|
|
114
|
+
attempts: number
|
|
115
|
+
traceId?: string
|
|
116
|
+
durationMs?: number
|
|
117
|
+
}
|
|
82
118
|
```
|
|
83
119
|
|
|
84
|
-
`
|
|
120
|
+
### `invalidate(key)`
|
|
85
121
|
|
|
86
|
-
|
|
122
|
+
Removes the cached value for `key` from the default module-level store. Returns `true` if a cache entry was removed.
|
|
87
123
|
|
|
88
|
-
|
|
124
|
+
Does **not** cancel in-flight dedupe entries — those settle on their own.
|
|
89
125
|
|
|
90
126
|
```ts
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
delayMs: 150,
|
|
95
|
-
backoff: 'exponential',
|
|
96
|
-
shouldRetry: (err, attempt) => {
|
|
97
|
-
if (err instanceof HttpError && err.status < 500) return false
|
|
98
|
-
return true
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
})
|
|
127
|
+
await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
|
|
128
|
+
// ... user updates their profile ...
|
|
129
|
+
invalidate('user:42') // next call re-fetches
|
|
102
130
|
```
|
|
103
131
|
|
|
104
|
-
`
|
|
132
|
+
### `withStore(store)`
|
|
105
133
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
### Timeout
|
|
109
|
-
|
|
110
|
-
Per-attempt deadline. Each retry gets a fresh clock.
|
|
134
|
+
Creates a scoped `act` function bound to an explicit store. Use for SSR request isolation, multi-tenant scenarios, or test isolation.
|
|
111
135
|
|
|
112
136
|
```ts
|
|
113
|
-
import {
|
|
137
|
+
import { withStore, InMemoryStore } from 'actly'
|
|
114
138
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
})
|
|
139
|
+
const store = new InMemoryStore({ maxSize: 1000, autoCleanup: true })
|
|
140
|
+
const act = withStore(store)
|
|
118
141
|
|
|
119
|
-
|
|
120
|
-
|
|
142
|
+
try {
|
|
143
|
+
await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
|
|
144
|
+
act.invalidate('user:42')
|
|
145
|
+
} finally {
|
|
146
|
+
store.destroy()
|
|
121
147
|
}
|
|
122
148
|
```
|
|
123
149
|
|
|
124
|
-
|
|
150
|
+
For sync stores, `invalidate` returns `boolean`. For async stores, returns `Promise<boolean>`.
|
|
125
151
|
|
|
126
|
-
###
|
|
152
|
+
### `execute(input)`
|
|
127
153
|
|
|
128
|
-
|
|
154
|
+
Low-level execution engine for building custom policy chains. Accepts `{ key, fn, policies, store, meta, signal, observability }`. Most callers should use `act()` instead.
|
|
155
|
+
|
|
156
|
+
### `InMemoryStore`
|
|
157
|
+
|
|
158
|
+
Reference `SyncStateStore` implementation. Bounded LRU with TTL, background cleanup, O(1) operations.
|
|
129
159
|
|
|
130
160
|
```ts
|
|
131
|
-
import {
|
|
161
|
+
import { InMemoryStore } from 'actly'
|
|
132
162
|
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
163
|
+
const store = new InMemoryStore({
|
|
164
|
+
maxSize: 10_000, // default Infinity
|
|
165
|
+
autoCleanup: true, // default false
|
|
166
|
+
cleanupIntervalMs: 30_000 // default 30s
|
|
137
167
|
})
|
|
138
|
-
|
|
139
|
-
if (!result.ok) {
|
|
140
|
-
if (result.error instanceof TotalTimeoutError) {
|
|
141
|
-
console.log(`budget exhausted after ${result.error.ms}ms`)
|
|
142
|
-
} else if (result.error instanceof TimeoutError) {
|
|
143
|
-
console.log(`last attempt timed out after ${result.error.ms}ms`)
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
168
|
```
|
|
147
169
|
|
|
148
|
-
`TotalTimeoutError` and `TimeoutError` are separate classes — `instanceof` distinguishes which deadline fired.
|
|
149
|
-
|
|
150
170
|
---
|
|
151
171
|
|
|
152
|
-
|
|
172
|
+
## Options
|
|
173
|
+
|
|
174
|
+
### `retry`
|
|
153
175
|
|
|
154
|
-
|
|
176
|
+
Retry `fn` on failure.
|
|
155
177
|
|
|
156
178
|
```ts
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
179
|
+
{
|
|
180
|
+
retry: {
|
|
181
|
+
attempts: 3, // total attempts including first call (≥1, ≤100)
|
|
182
|
+
delayMs: 200, // base delay (≥0, ≤300_000)
|
|
183
|
+
backoff: 'exponential', // 'none' | 'linear' | 'exponential' (default 'none')
|
|
184
|
+
maxDelay: 30_000, // cap on computed delay (default Infinity)
|
|
185
|
+
jitter: 'full', // 'none' | 'full' | 'equal' | 'decorrelated' (default 'full')
|
|
186
|
+
shouldRetry: (error, attempt) => true // default: retry all except AbortError
|
|
187
|
+
}
|
|
188
|
+
}
|
|
160
189
|
```
|
|
161
190
|
|
|
162
|
-
|
|
191
|
+
**Delay computation order:** backoff grows → maxDelay caps → jitter randomizes.
|
|
163
192
|
|
|
164
|
-
|
|
193
|
+
**Default `shouldRetry`:** retries on every error except `AbortError` (caller/timeout cancellation). Override to skip non-recoverable errors (HTTP 4xx, auth failures).
|
|
165
194
|
|
|
166
|
-
|
|
195
|
+
**`attempts: 1` is a no-op** — the retry policy is skipped (pure overhead if included).
|
|
167
196
|
|
|
168
|
-
###
|
|
197
|
+
### `timeout`
|
|
169
198
|
|
|
170
|
-
|
|
199
|
+
Per-attempt deadline. Each retry gets a fresh clock.
|
|
171
200
|
|
|
172
201
|
```ts
|
|
173
|
-
|
|
174
|
-
cache: { ttl: 60_000 },
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
console.log(result.source) // 'fresh' on miss, 'cache' on hit
|
|
202
|
+
{ timeout: { ms: 5_000 } } // ms > 0, ≤100_000_000
|
|
178
203
|
```
|
|
179
204
|
|
|
180
|
-
|
|
205
|
+
If `fn` cooperates (passes `signal` to `fetch`, database drivers, etc.), the underlying work is cancelled. If `fn` ignores the signal, `act()` still returns promptly via a race — but the underlying work continues in the background (resource leak).
|
|
206
|
+
|
|
207
|
+
### `totalTimeout`
|
|
181
208
|
|
|
182
|
-
|
|
209
|
+
Hard wall-clock budget over the entire operation — including all retry attempts, delays, and per-attempt timeouts.
|
|
183
210
|
|
|
184
211
|
```ts
|
|
185
|
-
|
|
186
|
-
totalTimeout: { ms: 10_000 }, // outermost wall
|
|
187
|
-
cache: { ttl: 30_000 }, // short-circuits on hit
|
|
188
|
-
dedupe: true, // collapses concurrent calls
|
|
189
|
-
retry: { attempts: 3, delayMs: 100, backoff: 'linear' },
|
|
190
|
-
timeout: { ms: 3_000 }, // per attempt
|
|
191
|
-
})
|
|
212
|
+
{ totalTimeout: { ms: 12_000 } }
|
|
192
213
|
```
|
|
193
214
|
|
|
194
|
-
|
|
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
|
+
### `dedupe`
|
|
195
218
|
|
|
196
|
-
|
|
219
|
+
Collapse concurrent calls with the same key into one in-flight Promise.
|
|
197
220
|
|
|
198
221
|
```ts
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
dedupe?: boolean | DedupeOptions
|
|
204
|
-
cache?: CacheOptions
|
|
205
|
-
}
|
|
222
|
+
dedupe: true
|
|
223
|
+
// or
|
|
224
|
+
dedupe: { enabled: true, inflightTtl: 30_000 }
|
|
225
|
+
```
|
|
206
226
|
|
|
207
|
-
|
|
208
|
-
attempts: number
|
|
209
|
-
delayMs?: number
|
|
210
|
-
backoff?: 'none' | 'linear' | 'exponential'
|
|
211
|
-
shouldRetry?: (error: unknown, attempt: number) => boolean
|
|
212
|
-
}
|
|
227
|
+
`inflightTtl` is a safety-net TTL for the in-flight entry. If the originator's promise doesn't settle within this window, the entry is removed so subsequent callers can start fresh. Default: `Infinity` (no safety net). Pair with `timeout` or `totalTimeout` for proper cancellation.
|
|
213
228
|
|
|
214
|
-
|
|
215
|
-
interface DedupeOptions { enabled: boolean }
|
|
216
|
-
interface CacheOptions { ttl: number }
|
|
217
|
-
```
|
|
229
|
+
**Joiner isolation:** if the originator's caller aborts, joiners are NOT affected. Each joiner races the in-flight promise against their own signal.
|
|
218
230
|
|
|
219
|
-
|
|
231
|
+
**Requires sync store.** `execute()` throws at chain-build time if an async store is used with dedupe.
|
|
232
|
+
|
|
233
|
+
### `cache`
|
|
220
234
|
|
|
221
|
-
|
|
235
|
+
Store successful results for a TTL.
|
|
222
236
|
|
|
223
237
|
```ts
|
|
224
|
-
|
|
225
|
-
act, // primary function
|
|
226
|
-
InMemoryStore, // isolated store for SSR / tests
|
|
227
|
-
TimeoutError, // per-attempt deadline (.ms)
|
|
228
|
-
TotalTimeoutError, // total budget exhausted (.ms)
|
|
229
|
-
} from 'actly'
|
|
238
|
+
{ cache: { ttl: 60_000 } } // ttl > 0, ≤86_400_000
|
|
230
239
|
```
|
|
231
240
|
|
|
232
|
-
|
|
241
|
+
**Failures are never cached.** Only successful values are stored.
|
|
242
|
+
|
|
243
|
+
**Single-flight (cache stampede prevention):** on sync stores, concurrent cache misses join a single in-flight Promise. On async stores, stampedes are a known limitation.
|
|
244
|
+
|
|
245
|
+
**Fail-open writes:** if `store.set()` throws (e.g. Redis transient error), the error is swallowed and the value is returned to the caller. Caching is an optimization, not a correctness requirement.
|
|
246
|
+
|
|
247
|
+
### `signal`
|
|
233
248
|
|
|
234
|
-
|
|
249
|
+
Caller-provided `AbortSignal` for external cancellation.
|
|
235
250
|
|
|
236
251
|
```ts
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
ActOptions, ActFn,
|
|
240
|
-
RetryOptions, TimeoutOptions, DedupeOptions, CacheOptions,
|
|
241
|
-
// v1.1 — store interfaces
|
|
242
|
-
SyncStateStore,
|
|
243
|
-
AsyncStateStore,
|
|
244
|
-
InMemoryStoreOptions,
|
|
245
|
-
// v1.0 alias — still valid, zero migration needed
|
|
246
|
-
StateStore,
|
|
247
|
-
} from 'actly'
|
|
248
|
-
```
|
|
252
|
+
const controller = new AbortController()
|
|
253
|
+
setTimeout(() => controller.abort(new Error('user-cancelled')), 5_000)
|
|
249
254
|
|
|
250
|
-
|
|
255
|
+
await act('user:42', async (signal) => fetch(url, { signal }), {
|
|
256
|
+
signal: controller.signal,
|
|
257
|
+
})
|
|
258
|
+
```
|
|
251
259
|
|
|
252
|
-
|
|
260
|
+
### `observability`
|
|
253
261
|
|
|
254
|
-
|
|
262
|
+
Event hooks for metrics, logging, and tracing. Zero overhead when not registered.
|
|
255
263
|
|
|
256
264
|
```ts
|
|
257
|
-
|
|
265
|
+
{
|
|
266
|
+
observability: {
|
|
267
|
+
onAttempt: (e) => metrics.increment('act.attempt', { attempt: e.attempt }),
|
|
268
|
+
onRetry: (e) => metrics.increment('act.retry', { attempt: e.attempt }),
|
|
269
|
+
onCacheHit: (e) => metrics.increment('act.cache_hit', { ageMs: e.ageMs }),
|
|
270
|
+
onCacheMiss: (e) => metrics.increment('act.cache_miss'),
|
|
271
|
+
onDedupeJoin: (e) => metrics.increment('act.dedupe_join'),
|
|
272
|
+
onTimeout: (e) => metrics.increment('act.timeout', { kind: e.kind }),
|
|
273
|
+
onFinalSuccess: (e) => metrics.histogram('act.duration', e.durationMs),
|
|
274
|
+
onFinalFailure: (e) => logger.error({ traceId: e.traceId, failedBy: e.failedBy }, 'act failed'),
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
258
278
|
|
|
259
|
-
|
|
260
|
-
const store = new InMemoryStore()
|
|
279
|
+
**Zero-cost contract:** when `observability` is `undefined` or an empty hooks object, no event objects are allocated and no function calls are made. Verified by benchmark: empty observability adds ~0.24 µs over fast path.
|
|
261
280
|
|
|
262
|
-
|
|
263
|
-
const store = new InMemoryStore({
|
|
264
|
-
autoCleanup: true,
|
|
265
|
-
cleanupIntervalMs: 60_000, // sweep every 60s (default: 30s)
|
|
266
|
-
})
|
|
281
|
+
### `traceId`
|
|
267
282
|
|
|
268
|
-
|
|
269
|
-
store.destroy()
|
|
270
|
-
```
|
|
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`.
|
|
271
284
|
|
|
272
|
-
|
|
285
|
+
---
|
|
273
286
|
|
|
274
|
-
|
|
287
|
+
## Error taxonomy
|
|
275
288
|
|
|
276
|
-
|
|
289
|
+
Six error classes. All extend `ActlyError` which extends `Error`. Each carries a stable `.code` string for cross-realm telemetry (e.g. errors serialized over IPC).
|
|
277
290
|
|
|
278
291
|
```ts
|
|
279
|
-
import
|
|
292
|
+
import {
|
|
293
|
+
ActlyError, // abstract base — instanceof ActlyError
|
|
294
|
+
ActlyAbortError, // .code = 'ACTLY_ABORT' — caller/signal cancellation
|
|
295
|
+
TimeoutError, // .code = 'ACTLY_TIMEOUT' — per-attempt deadline
|
|
296
|
+
TotalTimeoutError, // .code = 'ACTLY_TOTAL_TIMEOUT' — operation-wide budget
|
|
297
|
+
RetryExhaustedError, // .code = 'ACTLY_RETRY_EXHAUSTED' — all attempts failed
|
|
298
|
+
ValidationError, // .code = 'ACTLY_VALIDATION' — programmer error (invalid options)
|
|
299
|
+
} from 'actly'
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**`ValidationError` throws synchronously** — it's not wrapped in `ActFailure` because the caller's code is broken, not the runtime.
|
|
280
303
|
|
|
281
|
-
|
|
282
|
-
readonly _sync = false as const
|
|
304
|
+
**`RetryExhaustedError`** carries `attempts`, `lastError`, and `errors[]` for debugging retry patterns.
|
|
283
305
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
async clear(): Promise<void> { /* ... */ }
|
|
289
|
-
async size(): Promise<number> { /* ... */ }
|
|
306
|
+
```ts
|
|
307
|
+
if (!result.ok && result.error instanceof RetryExhaustedError) {
|
|
308
|
+
console.log(`failed after ${result.error.attempts} attempts`)
|
|
309
|
+
console.log(`last error:`, result.error.lastError)
|
|
290
310
|
}
|
|
291
311
|
```
|
|
292
312
|
|
|
293
|
-
|
|
313
|
+
**Cross-realm safety:** prefer `err.code === 'ACTLY_TIMEOUT'` over `err instanceof TimeoutError` when errors cross process/worker boundaries.
|
|
294
314
|
|
|
295
315
|
---
|
|
296
316
|
|
|
297
|
-
##
|
|
317
|
+
## Stores
|
|
298
318
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
get<T>(key: string): T | undefined
|
|
303
|
-
set<T>(key: string, value: T, ttlMs?: number): void
|
|
304
|
-
delete(key: string): void
|
|
305
|
-
has(key: string): boolean
|
|
306
|
-
clear(): void
|
|
307
|
-
size(): number
|
|
308
|
-
}
|
|
319
|
+
### Sync vs Async
|
|
320
|
+
|
|
321
|
+
`SyncStateStore` — all operations complete synchronously. Required for `dedupePolicy` (the read-then-write that makes deduplication work must happen in a single synchronous frame).
|
|
309
322
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
323
|
+
`AsyncStateStore` — all operations return Promises. Compatible with `cachePolicy` only.
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
import type { SyncStateStore, AsyncStateStore } from 'actly'
|
|
327
|
+
|
|
328
|
+
// Sync: InMemoryStore is the reference implementation
|
|
329
|
+
const syncStore: SyncStateStore = new InMemoryStore()
|
|
330
|
+
|
|
331
|
+
// Async: implement the interface for Redis, DynamoDB, etc.
|
|
332
|
+
const asyncStore: AsyncStateStore = {
|
|
333
|
+
_sync: false as const,
|
|
334
|
+
async get<T>(key: string): Promise<T | undefined> { /* ... */ },
|
|
335
|
+
async set<T>(key: string, value: T, ttlMs?: number): Promise<void> { /* ... */ },
|
|
336
|
+
async delete(key: string): Promise<void> { /* ... */ },
|
|
337
|
+
async has(key: string): Promise<boolean> { /* ... */ },
|
|
338
|
+
async clear(): Promise<void> { /* ... */ },
|
|
339
|
+
async size(): Promise<number> { /* ... */ },
|
|
318
340
|
}
|
|
319
341
|
```
|
|
320
342
|
|
|
321
|
-
|
|
343
|
+
### Default store
|
|
322
344
|
|
|
323
|
-
|
|
345
|
+
`act()` without `withStore()` uses a module-level default `InMemoryStore` bounded at 10,000 entries with 60-second background cleanup.
|
|
324
346
|
|
|
325
|
-
|
|
347
|
+
**Multi-tenant warning:** the default store is shared across all callers in the process. For multi-tenant applications, use `withStore()` with per-tenant stores to prevent cross-tenant data leakage.
|
|
326
348
|
|
|
327
|
-
`
|
|
349
|
+
### `InMemoryStore` internals
|
|
350
|
+
|
|
351
|
+
- **LRU:** doubly-linked list for O(1) reordering (no `delete + set` Map churn).
|
|
352
|
+
- **TTL:** lazy on `get()`/`has()`, background sweep for unreferenced entries.
|
|
353
|
+
- **`size()`:** O(1) via Map size counter.
|
|
354
|
+
- **`destroy():** stops cleanup timer. Idempotent.
|
|
328
355
|
|
|
329
356
|
---
|
|
330
357
|
|
|
331
|
-
##
|
|
358
|
+
## Policy order
|
|
359
|
+
|
|
360
|
+
Policies are applied in a fixed order, outermost to innermost:
|
|
332
361
|
|
|
333
|
-
|
|
362
|
+
```
|
|
363
|
+
totalTimeout → cache → dedupe → retry → timeout → fn
|
|
364
|
+
```
|
|
334
365
|
|
|
335
|
-
|
|
366
|
+
- `totalTimeout` is outermost — if it fires, no inner policy can extend the deadline.
|
|
367
|
+
- `cache` short-circuits everything below it on a hit.
|
|
368
|
+
- `dedupe` collapses concurrent callers before retry fires.
|
|
369
|
+
- `retry` owns the attempt loop.
|
|
370
|
+
- `timeout` is innermost — each attempt gets a fresh clock.
|
|
336
371
|
|
|
337
|
-
Policies
|
|
372
|
+
Policies with no effect (e.g. `retry.attempts: 1`) are skipped.
|
|
338
373
|
|
|
339
374
|
---
|
|
340
375
|
|
|
341
|
-
##
|
|
376
|
+
## Limits
|
|
342
377
|
|
|
343
|
-
|
|
378
|
+
All numeric inputs are bounded to prevent memory/CPU/timer exhaustion:
|
|
344
379
|
|
|
345
|
-
|
|
380
|
+
| Limit | Value | Rationale |
|
|
381
|
+
|-------|-------|-----------|
|
|
382
|
+
| `MAX_KEY_LENGTH` | 1024 chars | Prevents multi-MB keys bloating stores |
|
|
383
|
+
| `MAX_RETRY_ATTEMPTS` | 100 | ~100 fn invocations |
|
|
384
|
+
| `MAX_TIMEOUT_MS` | 100,000,000 ms (~27h) | Prevents timer overflow |
|
|
385
|
+
| `MAX_CACHE_TTL` | 86,400,000 ms (24h) | Prevents pathological TTLs |
|
|
386
|
+
| `MAX_RETRY_DELAY_MS` | 300,000 ms (5 min) | Bounds worst-case latency per retry |
|
|
387
|
+
| `MAX_INFLIGHT_TTL` | 86,400,000 ms (24h) | Bounds dedupe inflight window |
|
|
388
|
+
| `DEFAULT_STORE_MAX_SIZE` | 10,000 entries | Bounds default store memory |
|
|
346
389
|
|
|
347
|
-
|
|
348
|
-
- **`SyncStateStore` interface** — the canonical public name for what was previously the internal store shape. `StateStore` is preserved as an alias with zero breakage for existing code.
|
|
349
|
-
- **`InMemoryStore` is now a public export** — construct isolated stores for SSR request isolation, per-test control, or multi-tenant scenarios. Accepts `InMemoryStoreOptions`.
|
|
350
|
-
- **`InMemoryStoreOptions`** — opt-in `autoCleanup` with configurable `cleanupIntervalMs`. Call `destroy()` to stop the background timer and prevent leaks.
|
|
351
|
-
- **`TotalTimeoutError`** — new error class thrown when `totalTimeout` fires. Distinct from `TimeoutError` (per-attempt) so `instanceof` tells you which deadline fired.
|
|
352
|
-
- **`totalTimeout` option** — hard wall-clock budget over the entire operation including all retry attempts and delays. Use alongside `timeout` to express both per-attempt and total constraints.
|
|
353
|
-
- **`dedupe: true` shorthand** — equivalent to `dedupe: { enabled: true }`. Object form still valid for forward compatibility.
|
|
354
|
-
- **`isSyncStore()` / `isAsyncStore()` type guards** — exported from `actly` for consumers building custom policy chains or store adapters.
|
|
390
|
+
Exceeding a limit throws `RangeError`. If you need more, you likely have a bug.
|
|
355
391
|
|
|
356
|
-
|
|
392
|
+
---
|
|
357
393
|
|
|
358
|
-
|
|
359
|
-
- `dedupePolicy` tagged with `REQUIRES_SYNC_STORE` symbol — allows executor to detect the constraint without importing the policy module (avoids circular deps).
|
|
360
|
-
- `cachePolicy` now branches sync/async paths — fast synchronous path for `InMemoryStore`, async-await path for external stores.
|
|
394
|
+
## Migration
|
|
361
395
|
|
|
362
|
-
|
|
396
|
+
### From v1.1.x to v1.2.0
|
|
363
397
|
|
|
364
|
-
|
|
365
|
-
- All new exports are purely additive.
|
|
398
|
+
**Node 20+ required** (was 18+). Node 18 reached EOL April 2025.
|
|
366
399
|
|
|
367
|
-
|
|
400
|
+
**`retryPolicy` now throws `RetryExhaustedError`** when all attempts fail AND at least one retry happened. Previously, the raw last error was surfaced. The raw error is available on `err.lastError` and `err.errors[]`. If `shouldRetry` returned `false` on the first attempt (no retries), the raw error is still surfaced — no wrapping.
|
|
368
401
|
|
|
369
|
-
|
|
402
|
+
**`TimeoutError` and `TotalTimeoutError` now extend `ActlyError`** (which extends `Error`). Existing `instanceof Error` and `instanceof TimeoutError` checks continue to work. Optional: switch to `err.code === 'ACTLY_TIMEOUT'` for cross-realm safety.
|
|
370
403
|
|
|
371
|
-
|
|
372
|
-
- `act()`, `retry`, `timeout`, `totalTimeout`, `dedupe`, `cache`.
|
|
373
|
-
- `TimeoutError` exported for `instanceof` checks.
|
|
374
|
-
- Zero dependencies. ESM + CJS. Node 18+.
|
|
404
|
+
**`ActResult` has new optional fields**: `traceId?`, `durationMs?`. Existing code that reads only `ok`, `value`, `error`, `source`, `attempts` is unaffected.
|
|
375
405
|
|
|
376
406
|
---
|
|
377
407
|
|