Skip to content
Buy me a coffee

Redis

In-memory state is per process. With ten instances behind a load balancer you get ten caches, ten “global” rate limits and ten circuit breakers that each have to learn on their own that a dependency is down. @nodelibraries/resilience/redis moves that state to Redis:

Class Plugs into Gives you
RedisCacheStore addCache({ store }) One cache for every instance.
RedisLockStore addLock({ store }) A mutex across processes, with fencing tokens.
RedisRateLimiter RateLimiterStrategy One rate limit shared by every instance.
redisPartitionLimiter addPartitionedRateLimiter({ limiter }) One shared limit per key (user, tenant, API key).
RedisCircuitStateStore sharedCircuitState({ store }) One instance opens the circuit for all of them.

The module imports no Redis library. It needs one method, ioredis’ eval(script, numKeys, ...keysAndArgs), typed as RedisClient. Every operation is a single Lua script, so each one is atomic on the server.

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!); // works as is

Requirements: Redis 5 or later (the scripts call TIME before writing). Redis Cluster is supported: every script touches keys of a single hash slot.

Every class takes { client, prefix? }. prefix defaults to 'rp:' and is prepended to every key. Don’t put { or } in it (see Cluster).

import {
class ResiliencePipelineBuilder<T = any, E = never, C extends string = never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
} from '@nodelibraries/resilience';
import {
class RedisCacheStore

CacheStore over Redis: entries are JSON (so values must be JSON-serializable) and expire natively after ttlMs.

RedisCacheStore
} from '@nodelibraries/resilience/redis';
const
const products: ResiliencePipeline<{
id: string;
price: number;
}, TimeoutRejectedError>
products
= new
new ResiliencePipelineBuilder<{
id: string;
price: number;
}, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<{
id: string;
price: number;
}, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
<{
id: string
id
: string;
price: number
price
: number }>({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'products' })
.
ResiliencePipelineBuilder<{ id: string; price: number; }, never, never>.addCache(options: CacheOptions<{
id: string;
price: number;
}>): ResiliencePipelineBuilder<{
id: string;
price: number;
}, never, never>

Cache successful results by key, coalesce concurrent identical calls, optionally serve stale values on failure.

addCache
({
CacheOptions<{ id: string; price: number; }>.ttl: Duration

How long a value is served without calling. 0 with coalesce gives request deduplication without caching.

ttl
: '5m',
CacheOptions<{ id: string; price: number; }>.staleTtl?: Duration | undefined

Stale-if-error window after ttl: when the call fails, a value younger than ttl + staleTtl is returned instead. Default 0.

staleTtl
: '1h',
CacheOptions<{ id: string; price: number; }>.store?: CacheStore | undefined

Default: a new MemoryCacheStore per strategy. Share one to share the cache between pipelines.

store
: new
new RedisCacheStore(options: RedisStoreOptions): RedisCacheStore

CacheStore over Redis: entries are JSON (so values must be JSON-serializable) and expire natively after ttlMs.

RedisCacheStore
({
RedisStoreOptions.client: RedisClient
client
:
const redis: RedisClient
redis
}) })
.
ResiliencePipelineBuilder<{ id: string; price: number; }, never, never>.addTimeout(options: Duration | TimeoutOptions): Next<{
id: string;
price: number;
}, never, never, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('2s')
.
ResiliencePipelineBuilder<{ id: string; price: number; }, TimeoutRejectedError, never>.build(): ResiliencePipeline<{
id: string;
price: number;
}, TimeoutRejectedError>
build
();
const
const product: {
id: string;
price: number;
}
product
= await
const products: ResiliencePipeline<{
id: string;
price: number;
}, TimeoutRejectedError>
products
.
ResiliencePipeline<{ id: string; price: number; }, TimeoutRejectedError>.execute<{
id: string;
price: number;
}>(callback: (context: ResilienceContext) => MaybePromise<{
id: string;
price: number;
}>, options?: ExecuteOptions): Promise<{
id: string;
price: number;
}>

Runs the callback through the pipeline; throws the final error.

execute
(({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function loadProduct(id: string, signal: AbortSignal): Promise<{
id: string;
price: number;
}>
loadProduct
('42',
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
), {
ExecuteOptions.operationKey?: string | undefined
operationKey
: 'product:42' });

Entries are stored as JSON with SET … PX, so they expire natively after ttl + staleTtl. Values must be JSON-serializable: a Date comes back as a string, a Map as {}. Concurrent misses are coalesced per process, not across instances.

import {
class ResiliencePipelineBuilder<T = any, E = never, C extends string = never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
,
function getLockToken(context?: ResilienceContext | undefined): number | undefined

Fencing token of the innermost lock the calling code runs under. Pass it to the protected resource so it can reject stale writers.

getLockToken
} from '@nodelibraries/resilience';
import {
class RedisLockStore

LockStore on a single Redis primary (no Redlock): lease = SET NX PX, fencing token = INCR of a per-key counter. The counter never expires (it must only grow), so one small key per lock key is kept forever.

Caveats: Redis replication is async, so after a failover a lease (or counter increments) can be lost: two holders, or a repeated fencing token. For tokens to survive restarts, run with AOF (appendfsync everysec or always). The protected resource should still reject writes whose token is below the highest it has seen.

RedisLockStore
} from '@nodelibraries/resilience/redis';
const
const nightly: ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
nightly
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'nightly-report' })
.
ResiliencePipelineBuilder<any, never, never>.addLock(options: LockOptions): Next<any, never, never, LockNotAcquiredError | LockLostError>

Run under a (distributed, with a custom store) lock: leases, renewal, fencing tokens.

addLock
({
LockOptions.key: (context: ResilienceContext) => string

Lock key for this execution, e.g. () => 'nightly-report' or (ctx) => \order:${ctx.operationKey}``.

key
: () => 'nightly-report',
LockOptions.store?: LockStore | undefined

Default: an in-memory store shared by every lock on the same clock (the pipeline's time provider). Use a distributed store to lock across processes.

store
: new
new RedisLockStore(options: RedisStoreOptions): RedisLockStore

LockStore on a single Redis primary (no Redlock): lease = SET NX PX, fencing token = INCR of a per-key counter. The counter never expires (it must only grow), so one small key per lock key is kept forever.

Caveats: Redis replication is async, so after a failover a lease (or counter increments) can be lost: two holders, or a repeated fencing token. For tokens to survive restarts, run with AOF (appendfsync everysec or always). The protected resource should still reject writes whose token is below the highest it has seen.

RedisLockStore
({
RedisStoreOptions.client: RedisClient
client
:
const redis: RedisClient
redis
}),
LockOptions.ttl?: Duration | undefined

Lease length. Renewed every ttl/3 while the callback runs. Default 30 s.

ttl
: '30s' })
.
ResiliencePipelineBuilder<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>, never>.build(): ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
build
();
// Runs on one instance at a time; the others get LockNotAcquiredError right away.
await
const nightly: ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
nightly
.
ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>.execute<void>(callback: (context: ResilienceContext) => MaybePromise<void>, options?: ExecuteOptions): Promise<void>

Runs the callback through the pipeline; throws the final error.

execute
(({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function buildReport(fencingToken: number | undefined, signal: AbortSignal): Promise<void>
buildReport
(
function getLockToken(context?: ResilienceContext | undefined): number | undefined

Fencing token of the innermost lock the calling code runs under. Pass it to the protected resource so it can reject stale writers.

getLockToken
(),
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
));
  • Lease: SET <prefix>{lock:<key>} <owner> NX PX <ttl>, renewed every ttl / 3 while the callback runs. Renewal and release check the owner inside the script, so an instance whose lease expired can’t extend or delete someone else’s.
  • Fencing token: INCR of a per-key counter, in the same script as the lease, so tokens follow lease order exactly. Pass it to the resource you protect and have it reject writes with a token lower than the highest it has seen. The counter key never expires (it must only grow): one small key per lock key.

RedisRateLimiter is a segmented sliding window (the same algorithm as the in-memory sliding window limiter) stored in a Redis hash. It runs on the Redis server’s clock (TIME), so instances with skewed clocks still agree on the window. Run it with RateLimiterStrategy, which adds waiting, queueing and the other rate limiter options:

import {
class RateLimiterStrategy<T>

Runs any RateLimiterAlgorithm as a strategy: weights, FIFO waiting up to queueTimeout, block, allow/deny, insurance, telemetry.

RateLimiterStrategy
,
class ResiliencePipelineBuilder<T = any, E = never, C extends string = never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
} from '@nodelibraries/resilience';
import {
class RedisRateLimiter

Sliding-window RateLimiterAlgorithm shared through Redis. Run it with RateLimiterStrategy:

builder.addStrategy((s) => new RateLimiterStrategy(new RedisRateLimiter({ client, key: 'api', permitLimit: 100, window: '1m' }), { queueTimeout: '2s' }, s));

The now passed to acquire is ignored: the window runs on the Redis server's clock.

RedisRateLimiter
,
function redisPartitionLimiter<T = any>(options: Omit<RedisRateLimiterOptions, "key"> & PermitRateLimiterOptions): (key: string, source: StrategySource) => RateLimiterStrategy<T>

limiter factory for PartitionedRateLimiterStrategy: one Redis-backed limit per partition key, shared by every instance. Evicting an idle partition only drops the local object; its count lives on in Redis until the window passes.

builder.addPartitionedRateLimiter({
partitionKey: (ctx) => ctx.properties.get('userId') as string,
limiter: redisPartitionLimiter({ client, permitLimit: 100, window: '1m', queueTimeout: '1s' }),
});
// same as: limiter: (key, s) => new RateLimiterStrategy(new RedisRateLimiter({ client, key, permitLimit, window }), opts, s)

redisPartitionLimiter
} from '@nodelibraries/resilience/redis';
// 100 calls per minute to a partner API, across all instances.
const
const partner: ResiliencePipeline<any, never>
partner
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'partner-api' })
.
PipelineBuilder<any, never, never>.addStrategy(create: StrategyFactory<any>, name?: string): ResiliencePipelineBuilder<any, never, never>

Plug in your own strategy.

addStrategy
((
s: StrategySource
s
) => new
new RateLimiterStrategy<any>(algorithm: RateLimiterAlgorithm, options: PermitRateLimiterOptions, source: StrategySource): RateLimiterStrategy<any>

Runs any RateLimiterAlgorithm as a strategy: weights, FIFO waiting up to queueTimeout, block, allow/deny, insurance, telemetry.

RateLimiterStrategy
(new
new RedisRateLimiter(options: RedisRateLimiterOptions): RedisRateLimiter

Sliding-window RateLimiterAlgorithm shared through Redis. Run it with RateLimiterStrategy:

builder.addStrategy((s) => new RateLimiterStrategy(new RedisRateLimiter({ client, key: 'api', permitLimit: 100, window: '1m' }), { queueTimeout: '2s' }, s));

The now passed to acquire is ignored: the window runs on the Redis server's clock.

RedisRateLimiter
({
RedisStoreOptions.client: RedisClient
client
:
const redis: RedisClient
redis
,
RedisRateLimiterOptions.key: string

Shared limit identity: every instance using the same key (and prefix) shares one budget.

key
: 'partner-api',
RedisRateLimiterOptions.permitLimit: number

Permits granted per window.

permitLimit
: 100,
RedisRateLimiterOptions.window: Duration
window
: '1m' }), {
PermitRateLimiterOptions.queueTimeout?: Duration | undefined

Longest an execution may wait for permits before it is rejected. Waiters are served first come, first served. Default 0: reject right away.

queueTimeout
: '2s' },
s: StrategySource
s
))
.
ResiliencePipelineBuilder<any, never, never>.build(): ResiliencePipeline<any, never>
build
();
// 1000 requests per hour per tenant, across all instances.
const
const perTenant: ResiliencePipeline<any, RateLimiterRejectedError>
perTenant
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'tenant-quota' })
.
ResiliencePipelineBuilder<any, never, never>.addPartitionedRateLimiter(options: PartitionedRateLimiterOptions<any>): Next<any, never, never, RateLimiterRejectedError>

One limiter per key (user, tenant, API key…), created on demand and evicted when idle.

addPartitionedRateLimiter
({
PartitionedRateLimiterOptions<any>.partitionKey: (context: ResilienceContext) => string

Which partition an execution counts against, e.g. (ctx) => ctx.properties.get('userId') as string.

partitionKey
: (
ctx: ResilienceContext
ctx
) =>
ctx: ResilienceContext
ctx
.
ResilienceContext.properties: Map<string, unknown>

Shared bag for passing data between strategies and the callback.

properties
.
Map<string, unknown>.get(key: string): unknown

Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.

@returns ― Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.

get
('tenant') as string,
PartitionedRateLimiterOptions<any>.limiter: (key: string, source: StrategySource) => ResilienceStrategy<any>

Creates the limiter of a new partition, e.g. (key, s) => new SlidingWindowRateLimiterStrategy({ permitLimit: 100, window: '1m' }, s).

limiter
:
redisPartitionLimiter<any>(options: Omit<RedisRateLimiterOptions, "key"> & PermitRateLimiterOptions): (key: string, source: StrategySource) => RateLimiterStrategy<any>

limiter factory for PartitionedRateLimiterStrategy: one Redis-backed limit per partition key, shared by every instance. Evicting an idle partition only drops the local object; its count lives on in Redis until the window passes.

builder.addPartitionedRateLimiter({
partitionKey: (ctx) => ctx.properties.get('userId') as string,
limiter: redisPartitionLimiter({ client, permitLimit: 100, window: '1m', queueTimeout: '1s' }),
});
// same as: limiter: (key, s) => new RateLimiterStrategy(new RedisRateLimiter({ client, key, permitLimit, window }), opts, s)

redisPartitionLimiter
({
client: RedisClient
client
:
const redis: RedisClient
redis
,
permitLimit: number

Permits granted per window.

permitLimit
: 1000,
window: Duration
window
: '1h' }),
})
.
ResiliencePipelineBuilder<any, RateLimiterRejectedError, never>.build(): ResiliencePipeline<any, RateLimiterRejectedError>
build
();
RedisRateLimiter option Type Default Description
client RedisClient required
key string required Limit identity: every instance with the same key and prefix shares one budget. Stored at <prefix>rl:<key>.
permitLimit number required Permits per window (integer ≥ 1).
window Duration required Window length.
segmentsPerWindow number 10 Buckets the window slides by. 1 is a fixed window aligned to the epoch.
prefix string 'rp:'

redisPartitionLimiter(options) takes the same options without key (the partition key is used), plus the RateLimiterStrategy options (queueTimeout, queueLimit, blockDuration, insurance…). When the partitioned limiter evicts an idle partition, only the local object goes; the count lives on in Redis until the window passes.

A rejection carries retryAfter: the time until enough old segments leave the window.

import {
class ResiliencePipelineBuilder<T = any, E = never, C extends string = never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
,
function sharedCircuitState(options: SharedCircuitStateOptions): CircuitBreakerExtension

Shares the breaker's state with other instances (servers, processes) through a store, e.g. RedisCircuitStateStore: one instance seeing the dependency fail opens the circuit for all of them. addCircuitBreaker({ extensions: [sharedCircuitState({ store, key: 'payments' })] })

sharedCircuitState
} from '@nodelibraries/resilience';
import {
class RedisCircuitStateStore

CircuitStateStore over Redis: a write only lands if its updatedAt is newer than the stored one. Keys don't expire.

RedisCircuitStateStore
} from '@nodelibraries/resilience/redis';
const
const payments: ResiliencePipeline<any, BrokenCircuitError>
payments
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'payments' })
.
ResiliencePipelineBuilder<any, never, never>.addCircuitBreaker(options?: CircuitBreakerOptions<any>): Next<any, never, never, BrokenCircuitError>
addCircuitBreaker
({
CircuitBreakerOptions<any>.failureRatio?: number | undefined

Failure ratio in the window that breaks the circuit. Default 0.1.

failureRatio
: 0.5,
CircuitBreakerOptions<any>.minimumThroughput?: number | undefined

Minimum recorded calls in the window before the ratio is considered. Default 100.

minimumThroughput
: 20,
CircuitBreakerOptions<any>.extensions?: CircuitBreakerExtension[] | undefined

Background behaviour plugged into the breaker, e.g. circuitHealthCheck({ check, interval }) or sharedCircuitState({ store, key }) from this package. Kept out of the core breaker so you only bundle what you use.

extensions
: [
function sharedCircuitState(options: SharedCircuitStateOptions): CircuitBreakerExtension

Shares the breaker's state with other instances (servers, processes) through a store, e.g. RedisCircuitStateStore: one instance seeing the dependency fail opens the circuit for all of them. addCircuitBreaker({ extensions: [sharedCircuitState({ store, key: 'payments' })] })

sharedCircuitState
({
SharedCircuitStateOptions.store: CircuitStateStore
store
: new
new RedisCircuitStateStore(options: RedisStoreOptions): RedisCircuitStateStore

CircuitStateStore over Redis: a write only lands if its updatedAt is newer than the stored one. Keys don't expire.

RedisCircuitStateStore
({
RedisStoreOptions.client: RedisClient
client
:
const redis: RedisClient
redis
}),
SharedCircuitStateOptions.key: string

One key per logical breaker (e.g. per downstream service).

key
: 'payments',
SharedCircuitStateOptions.syncInterval?: Duration | undefined

How often to pull the shared state. Default 5 s. Lower = faster agreement, more store traffic.

syncInterval
: '5s' })],
})
.
ResiliencePipelineBuilder<any, BrokenCircuitError, never>.build(): ResiliencePipeline<any, BrokenCircuitError>
build
();

Every state change is written to <prefix>cb:<key>, and every instance pulls the shared state every syncInterval (default 5 s). The write is a compare-and-set on the snapshot’s updatedAt, so a slow instance can’t overwrite a newer state with an older one. Each instance’s rolling window of call results stays local. Store errors are reported as OnCircuitStoreError telemetry and don’t fail calls. Keys don’t expire.

Each script uses keys of one hash slot only:

Store Keys Expiry
RedisCacheStore <prefix><key> ttl + staleTtl
RedisLockStore <prefix>{lock:<key>}, <prefix>{lock:<key>}:fence lease: ttl; fence: never
RedisRateLimiter <prefix>rl:<key> window after the last admitted call
RedisCircuitStateStore <prefix>cb:<key> never

The lock’s two keys share the hash tag {<key>}, so they land in the same slot. A {…} in prefix would become the hash tag instead and put every lock in one slot.

Each call sends the script text with EVAL (no EVALSHA caching). That is a few hundred bytes per call; it only matters at very high call rates.