Reliability primitive for async functions.
Retry. Timeout. Dedupe. Cache. Composable, typed, zero-throw.
npm install actlyRequires Node 18+. Ships ESM + CJS. Zero dependencies.
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.
These patterns are solved the same way every time. actly solves them once.
import { act } from 'actly'
const result = await act('user:42', () => fetchUser(42))
if (result.ok) {
console.log(result.value) // T
console.log(result.source) // 'fresh' | 'cache'
console.log(result.attempts) // number
} else {
console.error(result.error) // unknown — never throws
}act always resolves. It never rejects. You check .ok and move on.
act<T>(key: string, fn: () => Promise<T>, options?: ActOptions): Promise<ActResult<T>>| Argument | Type | Description |
|---|---|---|
key |
string |
Stable, unique identifier. Scopes dedupe and cache state. |
fn |
() => Promise<T> |
The async function to execute. |
options |
ActOptions |
All optional. See policies below. |
type ActResult<T> =
| { ok: true; value: T; source: 'fresh' | 'cache'; attempts: number }
| { ok: false; error: unknown; attempts: number }All policies are optional and compose freely. The execution order is fixed:
totalTimeout → cache → dedupe → retry → timeout
const result = await act('payments:charge', () => chargeCard(payload), {
retry: {
attempts: 3, // total attempts (including first)
delayMs: 200, // base delay between attempts (ms)
backoff: 'exponential' // 'none' | 'linear' | 'exponential'
},
})result.attempts tells you how many tries it took.
By default, every error triggers a retry. Use shouldRetry to skip retrying permanent errors — a 404 will never recover, a 503 might.
const result = await act('api:resource', () => fetchResource(id), {
retry: {
attempts: 4,
delayMs: 150,
backoff: 'exponential',
shouldRetry: (err, attempt) => {
if (err instanceof HttpError && err.status < 500) return false
return true
},
},
})shouldRetry(error, attempt) receives the error and the 1-based attempt number. Return false to surface the error immediately. Not called on the final attempt — that always surfaces.
Per-attempt deadline. Each retry gets a fresh clock.
import { act, TimeoutError } from 'actly'
const result = await act('geo:lookup', () => lookupCoords(ip), {
timeout: { ms: 3_000 },
})
if (!result.ok && result.error instanceof TimeoutError) {
console.log(`timed out after ${result.error.ms}ms`)
}Hard budget over the entire operation — all attempts, delays, and per-attempt timeouts combined. The per-attempt timeout resets on retry; totalTimeout does not.
import { act, TimeoutError, TotalTimeoutError } from 'actly'
const result = await act('search:query', () => runQuery(q), {
retry: { attempts: 5, delayMs: 100, backoff: 'linear' },
timeout: { ms: 2_000 }, // per attempt
totalTimeout: { ms: 8_000 }, // whole operation
})
if (!result.ok) {
if (result.error instanceof TotalTimeoutError) {
console.log(`budget exhausted after ${result.error.ms}ms`)
} else if (result.error instanceof TimeoutError) {
console.log(`last attempt timed out after ${result.error.ms}ms`)
}
}TotalTimeoutError and TimeoutError are separate classes — instanceof distinguishes which deadline fired.
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.
const result = await act('config:load', () => loadRemoteConfig(), {
dedupe: true,
})dedupe: { enabled: true } is also valid — object form for forward compatibility.
Note: Deduped callers (those that joined an in-flight Promise) always receive
attempts: 1in their result, because the retry counter belongs to the originating call. This is a known trade-off.
Stores successful results for ttl milliseconds. Failures are never cached. A cache hit short-circuits everything — dedupe, retry, and timeout are all skipped.
const result = await act('flags:all', () => fetchFeatureFlags(), {
cache: { ttl: 60_000 },
})
console.log(result.source) // 'fresh' on miss, 'cache' on hitconst result = await act('product:detail', () => fetchProduct(id), {
totalTimeout: { ms: 10_000 }, // outermost wall
cache: { ttl: 30_000 }, // short-circuits on hit
dedupe: true, // collapses concurrent calls
retry: { attempts: 3, delayMs: 100, backoff: 'linear' },
timeout: { ms: 3_000 }, // per attempt
})interface ActOptions {
retry?: RetryOptions
timeout?: TimeoutOptions // per-attempt
totalTimeout?: TimeoutOptions // entire operation
dedupe?: boolean | DedupeOptions
cache?: CacheOptions
}
interface RetryOptions {
attempts: number
delayMs?: number
backoff?: 'none' | 'linear' | 'exponential'
shouldRetry?: (error: unknown, attempt: number) => boolean
}
interface TimeoutOptions { ms: number }
interface DedupeOptions { enabled: boolean }
interface CacheOptions { ttl: number }import {
act, // primary function
InMemoryStore, // isolated store for SSR / tests
TimeoutError, // per-attempt deadline (.ms)
TotalTimeoutError, // total budget exhausted (.ms)
} from 'actly'import type {
ActResult, ActSuccess, ActFailure, ActSource,
ActOptions, ActFn,
RetryOptions, TimeoutOptions, DedupeOptions, CacheOptions,
// v1.1 — store interfaces
SyncStateStore,
AsyncStateStore,
InMemoryStoreOptions,
// v1.0 alias — still valid, zero migration needed
StateStore,
} from 'actly'By default act uses a module-level InMemoryStore. For SSR request isolation or test control, instantiate your own:
import { InMemoryStore } from 'actly'
// Basic
const store = new InMemoryStore()
// With background cleanup (useful for long-lived server-side stores)
const store = new InMemoryStore({
autoCleanup: true,
cleanupIntervalMs: 60_000, // sweep every 60s (default: 30s)
})
// Always call destroy() when done to prevent timer leaks
store.destroy()InMemoryStore satisfies SyncStateStore. Use it with execute() directly for full control.
For external cache backends (Redis, Upstash, etc.), implement AsyncStateStore:
import type { AsyncStateStore } from 'actly'
class RedisStore implements AsyncStateStore {
readonly _sync = false as const
async get<T>(key: string): Promise<T | undefined> { /* ... */ }
async set<T>(key: string, value: T, ttlMs?: number): Promise<void> { /* ... */ }
async delete(key: string): Promise<void> { /* ... */ }
async has(key: string): Promise<boolean> { /* ... */ }
async clear(): Promise<void> { /* ... */ }
async size(): Promise<number> { /* ... */ }
}
AsyncStateStoreis compatible withcacheonly. Using it withdedupeis a TypeScript error and a runtime error — dedupe requires synchronous store access. SeeSyncStateStoredocs for the reason.
interface SyncStateStore {
readonly _sync: true
get<T>(key: string): T | undefined
set<T>(key: string, value: T, ttlMs?: number): void
delete(key: string): void
has(key: string): boolean
clear(): void
size(): number
}
interface AsyncStateStore {
readonly _sync: false
get<T>(key: string): Promise<T | undefined>
set<T>(key: string, value: T, ttlMs?: number): Promise<void>
delete(key: string): Promise<void>
has(key: string): Promise<boolean>
clear(): Promise<void>
size(): Promise<number>
}The _sync discriminant is read at runtime by the executor to enforce the dedupe constraint. Set it as a readonly literal — true as const or false as const.
act() always resolves. Under any condition — network error, thrown exception, timeout, total budget exhaustion — the result is an ActFailure, not a rejected promise. This contract holds across all versions.
One function. One return type. No exceptions in userland.
The key is your responsibility. Make it stable and specific. user:42 is good. fetch is not.
Policies are composable but intentionally constrained. There is no builder API, no middleware system, no hooks. If you need something act doesn't do, write a wrapper around it — that's the right boundary.
New features
AsyncStateStoreinterface — plug in any async key-value backend (Redis, Upstash, Cloudflare KV). Compatible withcachepolicy. Using withdedupeis a compile-time and runtime error by design.SyncStateStoreinterface — the canonical public name for what was previously the internal store shape.StateStoreis preserved as an alias with zero breakage for existing code.InMemoryStoreis now a public export — construct isolated stores for SSR request isolation, per-test control, or multi-tenant scenarios. AcceptsInMemoryStoreOptions.InMemoryStoreOptions— opt-inautoCleanupwith configurablecleanupIntervalMs. Calldestroy()to stop the background timer and prevent leaks.TotalTimeoutError— new error class thrown whentotalTimeoutfires. Distinct fromTimeoutError(per-attempt) soinstanceoftells you which deadline fired.totalTimeoutoption — hard wall-clock budget over the entire operation including all retry attempts and delays. Use alongsidetimeoutto express both per-attempt and total constraints.dedupe: trueshorthand — equivalent todedupe: { enabled: true }. Object form still valid for forward compatibility.isSyncStore()/isAsyncStore()type guards — exported fromactlyfor consumers building custom policy chains or store adapters.
Internal improvements
- Executor validates sync-store requirement at chain-build time — async store +
dedupePolicythrows immediately with a clear message instead of producing silent correctness failures. dedupePolicytagged withREQUIRES_SYNC_STOREsymbol — allows executor to detect the constraint without importing the policy module (avoids circular deps).cachePolicynow branches sync/async paths — fast synchronous path forInMemoryStore, async-await path for external stores.
Non-breaking changes
StateStorepreserved as type alias forSyncStateStore— all v1.0 code compiles without changes.- All new exports are purely additive.
- Initial stable release.
act(),retry,timeout,totalTimeout,dedupe,cache.TimeoutErrorexported forinstanceofchecks.- Zero dependencies. ESM + CJS. Node 18+.
MIT