Share a quota across workers
Store the budget in Redis so every process draws from one provider limit.
This guide moves a valve's budget into Redis or Valkey, so every worker that calls a provider with the same credential shares one limit.
Before you begin
- A Redis 6.2+ or Valkey 7.2+ server reachable from every worker.
- An
ioredisornode-redisclient. See Installation. - The provider's documented quota for your credential.
Configure the valve
Create one store per process
Pass your connected client to redisStore. Reuse the store for every valve in
the process.
import { Redis } from 'ioredis';
import { redisStore } from 'valvejs/redis';
export const redis = new Redis(process.env.REDIS_URL!);
export const store = redisStore({ client: redis });Create the valve with the provider's quota
For a credential-wide quota, use one id per credential. Every worker must use
the same id, the same rules, and the same Redis server, database, and prefix.
If the provider has independent endpoint, resource, or model quotas, choose
separate IDs for those scopes. See Choose provider quota scopes.
import { createValve } from 'valvejs';
export const hubspot = createValve({
id: 'hubspot:portal-123',
rate: { limit: 190, interval: '10s' },
concurrency: 10,
store,
});The rule above is illustrative. Set it from your provider's current quota for
your account. concurrency: 10 applies to this process only. For a
fleet-wide limit, see Share a concurrency limit.
Route calls through it
import { hubspot } from './valves';
const hubspotFetch = hubspot.wrapFetch();
const res = await hubspotFetch(
'https://api.hubapi.com/crm/v3/objects/contacts',
);Shut down in order
Close valves first, then your Redis client. valve never closes a client you supplied.
process.on('SIGTERM', async () => {
await hubspot.close({ drain: true });
await redis.quit();
});Redis key layout
Keys look like PREFIX:{ID}:.... The {ID} hash tag places every rule, the
pause, and the leases for one valve in the same Redis Cluster slot, so one Lua
script can update them atomically. For this reason, ids and prefixes must not
contain braces.
Use a different prefix to separate environments that share a Redis server:
redisStore({ client: redis, prefix: `valve:${process.env.NODE_ENV}` });Configuration drift
The first store admission pins a versioned configuration fingerprint at
PREFIX:{ID}:config. Every later admission checks it atomically before spending
rate budget or creating leases, including while the quota is paused.
The fingerprint covers names and scope dimensions when configured, plus each
normalized rule's algorithm, interval, limit, and
burst, plus global concurrency scope, limit, and lease TTL. Rule order and
equivalent durations such as '1s' and 1000 match. Local concurrency, queue
settings, and lease owners can differ between workers.
When Redis reports a mismatch, the incompatible worker's jobs reject with
INVALID_OPTIONS; its callbacks do not run. This applies to all onStoreError
policies, including 'open' and 'wait'. Restore the pinned configuration to
resume that worker. Compatible workers keep using the same budget during a
rolling deployment.
An outage or timeout can hide Redis's mismatch response. With 'open', jobs then
run without shared admission checks, so configuration compatibility and rate
bounds are not guaranteed. Use 'wait' or 'throw' when these checks must hold.
The config key has no TTL. Expired rate state, released leases, resume(), and
close() do not clear it. Valve leaves one config key per quota id. Keep it while
that quota is in use, including during idle periods.
Reliable shared quota enforcement and mismatch detection require preserving
Redis state. Use a non-evicting deployment, such as maxmemory-policy noeviction.
An allkeys-* eviction policy
can remove the config key despite its lack of a TTL. The next admission then pins
its caller's fingerprint, allowing a worker with different settings to admit
jobs without a mismatch error. Eviction can also remove active rate and lease
state; volatile-* policies protect the config key but can evict those TTL keys.
Changing a shared quota
Intentional changes to rate rules or global concurrency require a coordinated migration. Before resetting the fingerprint:
-
Stop producers and drain every worker using this Redis database, prefix, and id with
await valve.close({ drain: true }). Disable restarts of old workers and ensure all outbound jobs have actually finished, including jobs whose leases expired. A store pause alone does not stop already-issued grants. -
Let outstanding Redis commands settle and close the old clients. Account for timed-out commands and offline queues as described in Timeouts and reconnection.
-
Wait until all old shared
PREFIX:{ID}:r:*and scopedPREFIX:{ID}:s:*rate keys have expired and the provider's quota has fully recovered. GCRA debt can exceed one interval whenburst > limit. Confirm thatPREFIX:{ID}:leasesis empty or expired and that no old jobs can start or continue. Leave any provider cooldown in place. -
Delete only
PREFIX:{ID}:config, using an administrative Redis connection. For example, with the default prefix and the id above:redis-cli -u "$REDIS_URL" DEL 'valve:{hubspot:portal-123}:config' -
Start all workers with the same new settings. The first admission pins the new fingerprint. Verify it by running a job and checking for
INVALID_OPTIONS.
Do not delete the config key while workers are active. Changing the prefix or id creates an independent budget: old and new workers can exceed a provider quota when they overlap. A namespace change requires the same drain and recovery steps, or an external mechanism that enforces a common budget during migration.
Adding, renaming, or removing rules
Prepare the complete new policy before the coordinated migration above:
- Add a scoped rule: update every producer to supply its required identity at
the cutover. For example, adding
{ name: 'recipientPacing', scope: 'recipient', limit: 1, interval: '6s' }requiresscopes: { recipient: stableId }on every job. Stage the code behind a deployment gate, then switch policy and callers together after draining. Old policies reject extra dimensions; new policies reject missing ones. - Rename a rule or dimension: change all worker configurations and, for a
dimension rename, every caller's
scopesproperty. A name selects state keys; a rename is not a cosmetic edit and does not transfer the old counter. - Remove a rule: remove its identity from callers only if no remaining rule uses that dimension. Wait for the old quota to recover before starting the reduced policy; removing a rule does not cancel provider restrictions.
- Change an algorithm, interval, limit, or burst: use the same migration, even for a limit increase. Valve cannot infer whether the provider permits reinterpreting existing debt or counts under the new settings.
Routine rolling deployments with an unchanged normalized policy retain their budgets. Mixed-policy rolling deployments are unsupported: new workers receive fingerprint conflicts until the coordinated cutover. If uninterrupted processing is required during a policy change, use external coordination that enforces the common provider budget. Do not delete the fingerprint or switch IDs while old workers are still admitting jobs. Normal inactive-scope expiry needs no migration or administrative deletion.
Upgrading older Valve workers
Older versions do not check the fingerprint. A rolling library upgrade with unchanged quota settings can retain existing counters and leases, but detection is complete only after all workers run a version with this guard. The first guarded admission pins its caller's configuration; it cannot infer the settings that older workers used. Keep settings identical throughout that upgrade. Use the coordinated migration above before changing them.
Redis Cluster
Pass an ioredis Cluster instance, or a node-redis cluster client, as
client. No other configuration is needed. The integration suite runs both
client families against a three-primary cluster.
Verify it locally
Start Redis, build the workspace, and run the shared example. It creates six valve instances against one Redis budget.
docker compose -f compose.test.yml up -d
pnpm build
REDIS_URL=redis://127.0.0.1:6379 pnpm --filter @valve/example-shared start