Skip to content
Buy me a coffee

Running several instances

The problem. Every strategy keeps its state in the process that runs it. Scale a service to ten instances behind a load balancer and you get:

  • Ten circuit breakers. A dependency goes down. Each instance needs its own run of failures before its circuit opens, so the dead dependency takes ten times the failed calls, and users hit ten times the errors.
  • Ten rate limits. A partner API allows 100 calls a minute. Each instance enforces 100 a minute, so the fleet sends up to 1 000 and gets 429s.
  • Ten caches, ten locks. A “run once” job runs once per instance. A cache miss is fetched ten times.

@ylcnfrht/resilience/redis moves that state to Redis. This guide shows which state to share, how to wire it, and what stays local on purpose. The class-by-class reference is on the Redis integration page.

State Shareable How Why (or why not)
Circuit state (open / closed / isolated) yes sharedCircuitState + RedisCircuitStateStore One instance that sees the dependency fail opens the circuit for all.
Circuit rolling window (call results) no stays local Each instance judges from its own traffic; only the decision is shared.
Rate limits toward a quota yes RedisRateLimiter, redisPartitionLimiter The quota belongs to the API key, not to the instance.
Locks (“run once”) yes RedisLockStore A lock that only one process sees isn’t a lock.
Cache entries yes RedisCacheStore One fetch fills the cache for every instance.
Cache miss coalescing no per process Concurrent misses on different instances each call the loader.
Concurrency limiter, adaptive limiter no per process They protect this process (its event loop, its sockets). Size them per instance.
Retry budget no per process Ratios hold per instance as well as fleet-wide, so local is enough.
Token bucket no per process No Redis token bucket. For a shared limit use RedisRateLimiter (sliding window).

Create one Redis client per process and build pipelines once, at module scope. A pipeline built per request starts from an empty state each time, and with sharedCircuitState it would also start a new sync timer per request.

redis.ts
import Redis from 'ioredis';
// Short timeouts: a rate limit check that waits 10 s for Redis is worse than no rate limit.
export const redis = new Redis(process.env.REDIS_URL!, { commandTimeout: 200, maxRetriesPerRequest: 1 });
payments.ts
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 '@ylcnfrht/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 '@ylcnfrht/resilience/redis';
const
const circuitStore: RedisCircuitStateStore
circuitStore
= 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
});
export const
const payments: ResiliencePipeline<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>>
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>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
({
RetryOptions<any>.maxRetryAttempts?: number | undefined

Retries in addition to the original call. Default 3. Infinity retries until aborted.

maxRetryAttempts
: 2,
RetryOptions<any>.delay?: Duration | undefined

Base delay. Default 2000 ms.

delay
: '200ms',
RetryOptions<any>.backoffType?: BackoffType | undefined

Default 'constant'.

backoffType
: 'exponential',
RetryOptions<any>.useJitter?: Jitter | undefined
useJitter
: true })
.
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>.breakDuration?: Duration | undefined

How long the circuit stays open. Default 5 s.

breakDuration
: '30s',
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
:
const circuitStore: RedisCircuitStateStore
circuitStore
,
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
: '2s' })],
})
.
ResiliencePipelineBuilder<any, BrokenCircuitError, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, BrokenCircuitError, never, TimeoutRejectedError>

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

addTimeout
('2s')
.
ResiliencePipelineBuilder<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>, never>.build(): ResiliencePipeline<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>>
build
();

What happens when the payments API goes down:

  1. Instance A’s own window crosses 50% failures. Its circuit opens and it writes { state: 'open', openUntil, … } to rp:cb:payments.
  2. Every other instance reads the key within syncInterval (2 s here) and opens too, without having seen a single failure.
  3. After breakDuration, each instance lets its own probe through. The first one whose probe succeeds closes its circuit and writes closed; the others adopt it on their next sync.

The key is one logical breaker. Use the same key on every instance that calls the same dependency, and different keys for different dependencies (payments, payments-eu). The rolling window is never written to Redis.

The shared state also gives you a fleet-wide kill switch: isolate() through a CircuitBreakerManualControl on one instance writes isolated, and every instance rejects calls until one of them calls close().

A quota is per API key. Enforce it in Redis so the fleet as a whole stays under it. When Redis is unreachable, insurance answers from an in-memory limiter instead of failing every call, sized to this instance’s share of the quota:

partner.ts
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
,
class WindowCounter

Segmented sliding window: the window is split into segments buckets and slides one bucket at a time. With 1 segment it is a fixed window. Windows are aligned to origin; with 'first-request' alignment a new window starts at the first request after the window empties instead.

WindowCounter
} from '@ylcnfrht/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
} from '@ylcnfrht/resilience/redis';
const
const QUOTA: 100
QUOTA
= 100; // calls per minute, for the whole fleet
const
const INSTANCES: 4
INSTANCES
= 4;
export const
const partner: ResiliencePipeline<any, TimeoutRejectedError>
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
:
const QUOTA: 100
QUOTA
,
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', // wait a little for a permit instead of failing at once
// Redis down or slower than 50 ms: this instance's share of the quota, counted locally.
PermitRateLimiterOptions.insurance?: {
algorithm: RateLimiterAlgorithm;
timeout?: Duration;
} | undefined

In-memory fallback for a remote algorithm: when acquire throws or exceeds timeout, algorithm answers instead and OnRateLimiterInsuranceUsed is reported. Size it per process (e.g. the shared limit / instances).

insurance
: {
algorithm: RateLimiterAlgorithm
algorithm
: new
new WindowCounter(permitLimit: number, windowMs: number, segments: number, origin: number, alignment?: "fixed" | "first-request"): WindowCounter

Segmented sliding window: the window is split into segments buckets and slides one bucket at a time. With 1 segment it is a fixed window. Windows are aligned to origin; with 'first-request' alignment a new window starts at the first request after the window empties instead.

WindowCounter
(
var Math: Math

An intrinsic object that provides basic mathematics functionality and constants.

Math
.
Math.floor(x: number): number

Returns the greatest integer less than or equal to its numeric argument.

@param ― x A numeric expression.

floor
(
const QUOTA: 100
QUOTA
/
const INSTANCES: 4
INSTANCES
), 60_000, 10,
s: StrategySource
s
.
StrategySource.timeProvider: TimeProvider
timeProvider
.
TimeProvider.now(): number

Epoch milliseconds.

now
()),
timeout?: Duration | undefined
timeout
: '50ms' },
},
s: StrategySource
s
,
),
'PartnerQuota',
)
.
ResiliencePipelineBuilder<any, never, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, never, never, TimeoutRejectedError>

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

addTimeout
('5s')
.
ResiliencePipelineBuilder<any, TimeoutRejectedError, never>.build(): ResiliencePipeline<any, TimeoutRejectedError>
build
();

WindowCounter(permitLimit, windowMs, segments, origin) is the same sliding window the in-memory limiters use. Every call answered by it reports OnRateLimiterInsuranceUsed, so you can see when the fleet runs on local limits.

For a limit per tenant or user across instances, use redisPartitionLimiter with addPartitionedRateLimiter; the rate-limited APIs guide has the full pattern.

nightly.ts
import {
class LockNotAcquiredError

The lock was held by someone else for longer than wait.

LockNotAcquiredError
,
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 '@ylcnfrht/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 '@ylcnfrht/resilience/redis';
const
const reindex: ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
reindex
= 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
: 'reindex' })
.
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
: () => 'reindex',
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
();
// Every instance runs this on a timer; one wins, the rest skip.
try {
await
const reindex: ResiliencePipeline<any, WithErrors<never, never, LockNotAcquiredError | LockLostError>>
reindex
.
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 rebuildSearchIndex(fencingToken: number | undefined, signal: AbortSignal): Promise<void>
rebuildSearchIndex
(
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
));
} catch (
var e: unknown
e
) {
if (!(
var e: unknown
e
instanceof
class LockNotAcquiredError

The lock was held by someone else for longer than wait.

LockNotAcquiredError
)) throw
var e: unknown
e
;
}

The lease is renewed every ttl / 3 while the job runs. If a renewal fails (the instance was paused by GC, or lost Redis), the job’s signal is aborted with LockLostError, because another instance may take the lease from then on. Stop writing when the signal aborts, and have the protected resource reject writes with a fencing token lower than the highest it has seen: that check, not the lease, is what keeps two holders from both writing.

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 '@ylcnfrht/resilience';
import {
class RedisCacheStore

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

RedisCacheStore
} from '@ylcnfrht/resilience/redis';
const
const rates: ResiliencePipeline<Rates, TimeoutRejectedError>
rates
= new
new ResiliencePipelineBuilder<Rates, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<Rates, 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
<
type Rates = {
[x: string]: number;
}
Rates
>({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'fx-rates' })
.
ResiliencePipelineBuilder<Rates, never, never>.addCache(options: CacheOptions<Rates>): ResiliencePipelineBuilder<Rates, never, never>

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

addCache
({
CacheOptions<Rates>.ttl: Duration

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

ttl
: '1m',
CacheOptions<Rates>.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<Rates>.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
,
RedisStoreOptions.prefix?: string | undefined

Prepended to every key. Default 'rp:'.

prefix
: 'fx:' }) })
.
ResiliencePipelineBuilder<Rates, never, never>.addTimeout(options: Duration | TimeoutOptions): Next<Rates, never, never, TimeoutRejectedError>

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

addTimeout
('2s')
.
ResiliencePipelineBuilder<Rates, TimeoutRejectedError, never>.build(): ResiliencePipeline<Rates, TimeoutRejectedError>
build
();
const
const today: Rates
today
= await
const rates: ResiliencePipeline<Rates, TimeoutRejectedError>
rates
.
ResiliencePipeline<Rates, TimeoutRejectedError>.execute<Rates>(callback: (context: ResilienceContext) => MaybePromise<Rates>, options?: ExecuteOptions): Promise<Rates>

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 fetchRates(signal: AbortSignal): Promise<Rates>
fetchRates
(
signal: AbortSignal

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

signal
), {
ExecuteOptions.operationKey?: string | undefined
operationKey
: 'rates:EUR' });

staleTtl makes the shared cache a fleet-wide fallback: while the rates API is down, every instance serves the last value any of them fetched. Values go through JSON.stringify, so store plain data (a Date comes back as a string). Concurrent misses are coalesced per process only: right after the entry expires, each instance may fetch once.

Limits that stay local are per instance. Size them from the dependency’s capacity divided by the instance count, and remember that autoscaling changes the divisor:

  • Concurrency limiter toward a database with 100 connections and 5 instances: permitLimit: 20 each, or less if other services share the database.
  • Inbound load shedding: per instance by nature, since it protects that process; see load shedding.
  • Insurance limits: the shared quota divided by the instance count, as above. With autoscaling, divide by the maximum instance count so a scaled-out fleet on insurance still stays under the quota.
  • One Redis client per process with short command timeouts; pipelines built once at module scope.
  • sharedCircuitState with one key per dependency; alert on OnCircuitStoreError; clocks synced.
  • Shared quotas in RedisRateLimiter with insurance sized per instance.
  • Locks used with fencing tokens checked by the protected resource.
  • Per-process limits (concurrency, adaptive, retry budget) sized for one instance.