Apply scoped rate limits
Admit each job against shared and per-recipient budgets in one atomic operation.
Use scoped rate limits when each request spends both a shared budget and a budget tied to a recipient, tenant, model, or resource. Define the rules once, then supply the identities with each job. Valve spends every applicable budget together, or spends none when admission is denied.
This API is included in the 0.4 series.
It requires the matching memory or Redis store, or a
custom store implementing acquireScoped.
Define shared and recipient budgets
import { createValve } from 'valvejs';
const messages = createValve({
id: 'messaging:business-123',
rate: [
{
name: 'throughput',
limit: 80,
interval: '1s',
burst: 8, // GCRA is the default: pace at 80/s, allow 8 immediately.
},
{ name: 'spacing', scope: 'recipient', limit: 1, interval: '6s', burst: 1 },
{
name: 'daily',
scope: 'recipient',
limit: 100,
interval: '24h',
algorithm: 'fixed-window',
},
],
maxQueue: 1000,
maxWait: '30s',
});
await messages.run(
() =>
fetch('https://example.com/messages', {
method: 'POST',
body: JSON.stringify({ recipientId: 'contact-42', text: 'Hello' }),
}),
{ scopes: { recipient: 'contact-42' } },
);
await messages.close();The quotas are illustrative. A rule without scope applies to the whole
Valve ID. Both recipient rules use the same identity but maintain distinct
counters through their names. A different recipient has independent spacing
and daily allowance while sharing throughput.
The daily rule resets at UTC midnight. It is a fixed 24-hour window, not an
exact rolling 24-hour limit. Exact rolling windows are unsupported. Each rule
chooses its own algorithm, interval, and burst: throughput uses GCRA with a burst
of eight, spacing uses GCRA with a burst of one, and daily uses an explicit
fixed window. Fixed windows ignore burst. See
Choosing an algorithm for boundary semantics.
Without store, this example has a private in-process budget. To share across
workers, pass a Redis store with the same
prefix, Valve ID, and policy on every worker.
Supply stable identities
name and scope must start with an ASCII letter and contain only letters,
digits, underscores, or hyphens, up to 64 characters. Every scoped rule needs
a unique name. Names are optional for static rules; named rules can share
an algorithm and interval.
Each job must supply exactly the configured scope dimensions as own properties
of scopes. Values must be nonempty strings of at most 256 UTF-16 code units,
with valid Unicode. Missing identities, extra dimensions, and invalid values
reject with INVALID_OPTIONS before enqueueing. Valve copies identities, so
later changes to your object do not reroute queued work. Missing-identity errors
name the required dimension. Identities are mandatory because omitting one would
otherwise skip a quota or combine unrelated callers into a fallback budget.
Rules cannot be optional or conditional in this release; do not use placeholder
identities to bypass them.
Use stable opaque application IDs. Redis keys encode identity values to avoid separator collisions, but encoding does not anonymize them. Never pass raw API keys, access tokens, or phone numbers. If you cannot use an opaque ID, derive a keyed hash in your application and use the same key on every worker. Changing that mapping creates fresh budgets.
cost spends the same number of units from every applicable rule. A cost
above any rule's capacity rejects with COST_EXCEEDS_LIMIT.
Combine multiple scope dimensions
A job can spend tenant and recipient budgets together:
const api = createValve({
id: 'notifications',
rate: [
{ name: 'service', limit: 100, interval: '1s', burst: 10 },
{
name: 'tenantPacing',
scope: 'tenant',
limit: 20,
interval: '1s',
burst: 4,
},
{
name: 'recipientPacing',
scope: 'recipient',
limit: 1,
interval: '6s',
burst: 1,
},
{
name: 'tenantDaily',
scope: 'tenant',
limit: 10000,
interval: '24h',
algorithm: 'fixed-window',
},
],
});
await api.run(() => sendNotification(), {
scopes: { tenant: 'tenant-7', recipient: 'contact-42' },
});
await api.close();Each dimension is independent: contact-42 shares its recipient budget across
all tenants. If recipients are tenant-local, supply a stable opaque ID for the
tenant-recipient pair instead. Valve does not automatically combine scope
values. Every identity within a rule has the same configured limit and burst;
dynamically assigned limits per identity are unsupported. For different policy
tiers, use separately configured Valve IDs only when their budgets are genuinely
independent, or enforce the shared parent quota outside those instances.
Wrap clients and retries
Use fixed identities with wrap:
const getContact = messages.wrap((url: string) => fetch(url), {
scopes: { recipient: 'contact-42' },
});For fetch arguments that carry a stable application identity, use a resolver.
This example requires absolute URLs. In a browser, resolve relative strings
against document.baseURI by passing it as the second argument to new URL:
const limitedFetch = messages.wrapFetch(fetch, {
scopes: (input) => {
const url = new URL(input instanceof Request ? input.url : String(input));
const recipient = url.searchParams.get('contactId');
if (!recipient) throw new Error('contactId is required');
return { recipient };
},
});The synchronous resolver runs once per logical request. Valve copies its result and reuses it across retries. Each retry reacquires and spends every budget. Existing method safety and request-body replay restrictions still apply. Resolver errors reject the request before sending it.
Understand queue ordering and feedback
For submissions A1, A2, B1, C1, recipient A's spacing may block A2 while A1, B1, and C1 start. Higher priority runs first among ready jobs; equal priorities use submission order. There is no strict FIFO across blocked identities and no global scheduling fairness across workers. A high-cost denial does not prevent a lower-cost job from being considered.
Denied jobs wait in a delayed queue. Retry times are hints: every start checks
the authoritative store again. Valve uses one eligibility timer, plus existing
per-job deadline timers. maxQueue, maxWait, cancellation, shutdown, and
global concurrency leases still apply.
Already reserved quota is not refunded if a job is cancelled after admission.
pause, resume, observe, and wrapFetch feedback apply to the entire Valve
ID, including all recipients. stats().pausedUntil and pause events describe
that shared pause; a scoped rate denial does not emit a pause event. Targeted
pause/resume is not included in this release. Valve does not infer which quota
a generic 429 refers to. A 429 or Retry-After response observed for one
recipient can therefore pause work for every recipient under that Valve ID.
Operate and upgrade safely
Scoped configurations support at most 16 rate rules and a batch of at most 128 jobs; the default batch is 32. One operation touches at most 2,048 unique rate keys. Smaller batches reduce individual script work; larger batches can reduce round trips. The maximum 128 jobs x 16 rules can make Redis Lua expensive and block other clients on that shard; it is a safety bound, not a recommended batch size.
Start with 32. In the checked-in scoped benchmark report, 32 jobs with two rules and cold identities took 0.298 ms median / 0.837 ms p99 per Redis admission. At 128 jobs and 16 rules (1,921 unique keys), this grew to 12.46 ms / 20.83 ms. These are local measurements, not production latency promises. Benchmark your rule count, identity distribution, payload costs, and worker contention. Try batches of 8 or 16 when shard latency matters; increase only when measured round-trip savings justify longer script execution.
All Redis keys for one Valve share its hash tag. This allows one atomic Lua operation in Redis Cluster, but places that quota domain on one shard. A hot business remains a hot slot. Key cardinality grows with active identities and scoped rules. Inactive scopes expire automatically; do not manually delete their Redis keys. GCRA keys expire when their stored theoretical arrival time is reached and the burst capacity is fully restored. Scoped fixed-window keys expire at the next window boundary. Active jobs recreate expired keys as needed. Deleting active keys restores spent quota prematurely. The policy fingerprint has no TTL and remains until an explicit migration: it is one key per Valve ID, not per identity. There are no per-recipient subscriptions.
Changing an identity value selects a different budget immediately; queued jobs keep their original copied identity. It does not change the configuration fingerprint. Renaming a rule or scope dimension, adding/removing a rule, or changing its algorithm, interval, limit, or burst changes the policy and requires the coordinated migration below. Keep identity mappings stable during deploys.
Redis checks an order-independent fingerprint of rule identities, dimensions,
algorithms, limits, intervals, bursts, and global lease settings. Different
policies under one ID reject with INVALID_OPTIONS, even with fail-open enabled.
Reordering identical rules is safe. Adding scopes or changing names is a policy
change: drain old workers and follow the
shared quota migration procedure.
Do not reset the configuration key during a rolling deployment or change the
namespace to bypass exhausted budgets.
Admission is atomic across the configured rules; provider delivery is not
guaranteed. Network errors, crashes, and provider rejection can occur after
admission. Use application-level idempotency and durable job storage when needed.
Admission governs when work may start, not when a provider receives it. Queues
are local and non-durable. A process crash loses queued jobs; a timeout can leave
a committed reservation whose response never reached the caller. Valve does
not start that timed-out attempt under the default wait policy, but a later
attempt may spend quota again. Fail-open permits work without shared rate
checks. Redis restarts, eviction, or failover that lose state can restore spent
capacity; atomic Lua does not provide durable or exactly-once reservations.
See Guarantees and limits.