Skip to content
ESM Zero dependencies · Node.js, Bun, Workers

Resilience policies
with typed errors.

Timeout, retry, circuit breaker, bulkhead, rate limit, lock, dedupe, cache, fallback, and chaos testing for TypeScript. Each policy knows every error it can return.

Get startedGitHub
users.ts
import { Tenace } from '@julr/tenace'
const policy = Tenace.policy({ name: 'users.get' })
.withTimeout('1s')
.withRetry({ retries: 3 })
.withCircuitBreaker()
const user = await policy.execute(({ signal }) => fetchUser(id, { signal }))

What it does

Five jobs, one chain.

Failure

Stack strategies in the order they run.

Each call wraps the steps added before it. The timeout bounds one attempt, the retry repeats it, the breaker stops calling a dead service, and the fallback answers.

Read more
const policy = Tenace.policy({ name: 'users.get' })
.withTimeout('1s')
.withRetry({ retries: 3, retryOn: ['TimeoutError'] })
.withCircuitBreaker({ trip: { consecutive: 5 }, openFor: '30s' })
.withFallbackOn(['TimeoutError', 'CircuitOpenError'], () => null)
const user = await policy.execute(({ signal }) => fetchUser(id, { signal }))
// user: User | null

Concurrency

Control how many calls run, and how often.

Bulkheads cap parallel work, rate limits cap throughput per key, Tenace.map runs batches with a limit.

Read more
// 10 reports at once, 100 more waiting
const reports = Tenace.policy()
.withBulkhead({ concurrency: 10, queue: 100 })
// 10 searches per second and per user
const search = Tenace.policy()
.withRateLimit({ limit: 10, per: '1s' })
await search.execute(() => searchUsers(query), {
key: `user:${userId}`,
})
// 1,000 ids, at most 5 requests in flight
const users = await Tenace.map(ids, fetchUser, {
policy,
concurrency: 5,
}).execute()

Typed errors

The compiler knows every error.

match() needs one handler per error, so a new strategy fails the build until you handle it.

Read more
const policy = Tenace.policy()
.withTimeout('1s')
.withCircuitBreaker()
type Failure = ErrorOf<typeof policy>
// UnexpectedError | TimeoutError | CircuitOpenError
const result = await policy.settle(() => fetchUser(id))
const message = match(result, {
ok: user => `Hello ${user.name}`,
TimeoutError: () => 'The user service is slow',
CircuitOpenError: () => 'The user service is down',
UnexpectedError: () => 'Something went wrong',
})

Chaos

Break it before production does.

Inject latency, throws, or typed errors into a share of calls, in one policy or all of them.

Read more
// 1 call in 5 takes 2s longer: the timeout fires
const policy = Tenace.policy()
.withChaos({ rate: 0.2, latency: '2s' })
.withTimeout('1s')
.withFallbackOn(['TimeoutError'], () => cachedUser)
// in tests: every policy throws until restore()
const restore = Tenace.chaos({
throws: () => new Error('users API down'),
})

Testing

Test retries without waiting.

TestClock moves time only when the test says so. Three timeouts and two delays run instantly.

Read more
import { TestClock } from '@julr/tenace/testing'
const clock = new TestClock()
const policy = Tenace.policy()
.withTimeout('1s')
.withRetry({ retries: 2, delay: '100ms' })
const hang = () => new Promise<never>(() => {})
const done = policy.settle(hang, { clock })
await clock.runAll() // 3 timeouts, 2 delays, 0ms
const result = await done // a TimeoutError

The toolbox

Everything a call to a flaky dependency needs.

In code

Small API, read in one sitting.

Stack strategies in the order they run.

Each call wraps the steps added before it. The timeout bounds one attempt, the retry repeats it, the breaker stops the bleeding, the fallback answers.

Read more
const policy = Tenace.policy({ name: 'users.get' })
.withTimeout('1s')
.withRetry({ retries: 3, retryOn: ['TimeoutError'] })
.withCircuitBreaker({ trip: { consecutive: 5 }, openFor: '30s' })
.withFallbackOn(['TimeoutError', 'CircuitOpenError'], () => null)
const user = await policy.execute(({ signal }) => fetchUser(id, { signal }))
// user: User | null

Get started

Start with one policy.

Add a timeout and a retry to one call. Grow from there.

Get startedGitHub