Skip to content

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.

worker-01worker-02worker-03ingressqueued 00/7 admitted 2 req/s
Illustrative flow

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 guarantees

GCRA 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

Changes, in the open.

All changes
  1. v0.5.1

    latest

    Patch Changes

    • maxWait: 0 jobs queued behind an in-flight store call now wait for the valve to send another batch instead of being rejected right away. Previously, with redisStore or another asynchronous store, only the first batch reached the store and every other zero-wait job was rejected with MAX_WAIT_EXCEEDED without an attempt. Zero-wait jobs still reject with MAX_WAIT_EXCEEDED when 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.
  2. v0.5.0

    published

    Minor Changes

    • memoryStore() now rejects valves that share an id with different rate rules, global concurrency limit, or lease TTL with INVALID_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. ScopedAcquireResult gains an optional pausedMs field. Scoped valves now update stats().pausedUntil and emit pause when the store reports a shared pause, including Redis stores without a subscriber. A direct acquire that 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 returned Infinity.
    • Waiting global valves call the store far less often. AcquireResult and scoped blocked entries gain an optional leaseBlocked field, 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 and maxWait expiries no longer cut short the store outage backoff under onStoreError: 'wait'.

    Patch Changes

    • ietfRateLimit() and defaultFeedback now split RateLimit fields 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 under Symbol.for('nodejs.asyncDispose') on Node 20 releases that lack Symbol.asyncDispose, so await using works across the supported Node 20 range.
    • maxWait: 0 now runs a job when its first admission attempt is granted and rejects it with MAX_WAIT_EXCEEDED otherwise. Previously every such job was rejected, even with free capacity. redisStore now rejects a timeout below 1ms with INVALID_OPTIONS, since a zero timeout failed every store call.
  3. v0.4.1

    published

    Patch Changes

    • With global concurrency, close() now waits up to leaseTtl for 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 accepted wrapFetch requests finish their retries. When a retry cannot be queued, because the valve closed without draining or maxQueue is full, wrapFetch returns the rate limit response unread instead of rejecting. Errors from wrapFetch cost, priority, and scopes resolvers now count as rejections and emit reject.
    • Keep equal-priority jobs in call order when the store denies part of a batch, and keep wrapFetch retries at their original queue position. Denied jobs and retries were previously appended behind later submissions.

Built in the open.

Code, docs, benchmarks, and careful bug reports. Every contribution counts.