Many workers. One rate limit.
A TypeScript library for outbound API calls. Share one rate budget and provider pauses across every worker.
Beta until 1.0. APIs may change between releases.
README.md
Start local. Share when needed.
Start with an in-memory store for a script. Add Redis or Valkey when calls span processes. Same API, same guarantees: rate bounds apply at atomic store admission.
Installation guide$ pnpm add valvejs
import { createValve } from 'valvejs';
const api = createValve({
id: 'partner:credential-42',
rate: { limit: 100, interval: '1s' },
});
// GET retries after the provider's Retry-After; POST needs retryUnsafeMethods.
const limitedFetch = api.wrapFetch();
await limitedFetch('https://api.example.com/contacts');
await api.close({ drain: true });The Redis example expects your connected client. valve does not open or close it. Share a quota
Control the
shared budget.
Space starts, share quotas, and respect the provider’s backoff. Keep the scheduling rules in one place.
Read the guaranteesGCRA spaces starts; fixed windows cap aligned intervals. Several rules are checked atomically.
rate: [{ limit: 10, interval: '1s' }, { limit: 5000, interval: '1h', algorithm: 'fixed-window' }]Read the guide Spend business and per-recipient or per-tenant budgets atomically. Eligible identities proceed while others wait.
api.run(send, { scopes: { recipient: 'contact-42' } })Read the guide A Redis or Valkey store shares one quota across processes using the same credential.
store: redisStore({ client: redis })Read the guide Reads Retry-After and rate limit headers, then pauses every process sharing the valve.
api.observe({ status: 429, headers })Read the guide Retries 429s and 503s with a valid Retry-After for GET, HEAD, OPTIONS, PUT, and DELETE. POST and PATCH opt in.
api.wrapFetch(fetch, { retryUnsafeMethods: true })Read the guide Cap running work per process, or share one limit across workers through renewable leases.
concurrency: { limit: 4, scope: 'global' }Read the guide Job costs, queue bounds, wait deadlines, and priorities for interactive work.
api.run(() => sync(id), { priority: 10 })Read the guide Typed events for pauses, rejections, and retries, plus a stats() snapshot per valve.
api.on('pause', ({ durationMs }) => log(durationMs))Read the guide TypeScript types, ESM and CommonJS builds. It uses the Redis client you pass in.
dependencies: {}Read the guide Changes, in the open.
All changes- v0.5.1
latest
Patch Changes
maxWait: 0jobs queued behind an in-flight store call now wait for the valve to send another batch instead of being rejected right away. Previously, withredisStoreor another asynchronous store, only the first batch reached the store and every other zero-wait job was rejected withMAX_WAIT_EXCEEDEDwithout an attempt. Zero-wait jobs still reject withMAX_WAIT_EXCEEDEDwhen the store denies them. They reject without an attempt when the valve sends no further batch (after a rate denial, a pause, or a store outage, or with local concurrency full) or when newer higher-priority work keeps them out of every batch.
- v0.5.0
published
Minor Changes
memoryStore()now rejects valves that share an id with different rate rules, global concurrency limit, or lease TTL withINVALID_OPTIONS, as the Redis store does. Mismatched configurations that previously ran with mixed limits now fail. The memory store also forgets an id's static policy once its rate, lease, and pause state expire, so short-lived ids no longer grow memory without bound.ScopedAcquireResultgains an optionalpausedMsfield. Scoped valves now updatestats().pausedUntiland emitpausewhen the store reports a shared pause, including Redis stores without a subscriber. A directacquirethat asks for more jobs than free lease slots on an empty slot set now returns the lease TTL as its retry hint. The Redis script previously failed with a Lua error, and the memory store returnedInfinity.- Waiting global valves call the store far less often.
AcquireResultand scopedblockedentries gain an optionalleaseBlockedfield, which the memory and Redis stores set on lease requests, so a valve waits out rate denials for the full retry hint instead of polling every 50ms. Lease waits poll every 50ms only without a subscriber, and at least once a second with one. Release notices are coalesced and answered after a short random delay that widens for valves that keep losing slots, so one release no longer sends every waiting valve to the store at once. A valve no longer polls right after its own job settles, before its lease is released. Aborts andmaxWaitexpiries no longer cut short the store outage backoff underonStoreError: 'wait'.
Patch Changes
ietfRateLimit()anddefaultFeedbacknow splitRateLimitfields in linear time and ignore fields longer than 8192 characters, so a malformed header can no longer stall the event loop. Quoted strings may contain escaped quotes and commas. Valves also register their disposer underSymbol.for('nodejs.asyncDispose')on Node 20 releases that lackSymbol.asyncDispose, soawait usingworks across the supported Node 20 range.maxWait: 0now runs a job when its first admission attempt is granted and rejects it withMAX_WAIT_EXCEEDEDotherwise. Previously every such job was rejected, even with free capacity.redisStorenow rejects atimeoutbelow 1ms withINVALID_OPTIONS, since a zero timeout failed every store call.
- v0.4.1
published
Patch Changes
- With global concurrency,
close()now waits up toleaseTtlfor an in-flight store admission and releases any leases it grants, so a shutdown no longer strands slots until their TTL.close({ drain: true })lets acceptedwrapFetchrequests finish their retries. When a retry cannot be queued, because the valve closed without draining ormaxQueueis full,wrapFetchreturns the rate limit response unread instead of rejecting. Errors fromwrapFetchcost,priority, andscopesresolvers now count as rejections and emitreject. - Keep equal-priority jobs in call order when the store denies part of a batch, and keep
wrapFetchretries at their original queue position. Denied jobs and retries were previously appended behind later submissions.
- With global concurrency,
Built in the open.
Code, docs, benchmarks, and careful bug reports. Every contribution counts.