Skip to content

Repository files navigation

@superbuilders/limiter

Two throttling primitives for TypeScript, one per failure mode:

  • limiter.rate — a leaky-bucket rate limiter: operations are spaced in time, on average per / rate apart. A faithful port of go.uber.org/ratelimit.
  • limiter.concurrency — a concurrency limiter: at most N operations in flight, the rest queue FIFO.

Rate protects a target from your call frequency; concurrency protects it (and you) from your call volume in flight. They compose — take a rate slot, then run under a concurrency cap.

import*aslimiterfrom"@superbuilders/limiter"constapiRate=limiter.rate(100)// 100 per second, uber semanticsconstapiConcurrency=limiter.concurrency(8)// at most 8 in flightasyncfunctioncallApi<T>(signal: AbortSignal,work: ()=>Promise<T>): Promise<T>{awaitapiRate.take(signal)returnapiConcurrency.run({ signal, work })}

Install

pnpm add @superbuilders/limiter

ESM only. No runtime dependencies beyond @superbuilders/errors; uses only the global setTimeout, so it runs anywhere (Node, browsers, Bun, Deno) and fake-timer test harnesses intercept it naturally.

limiter.rate(rate, options?)

constrl=limiter.rate(10)// 10 per secondconststrict=limiter.rate(10,{slack: 0})// strict spacing, no burstsconstperMinute=limiter.rate(2,{per: 60_000})// 2 per minuteawaitrl.take(signal)// resolves when your slot arrivesdoTheThing()

take(signal) resolves when the operation may proceed, sleeping if necessary so that calls are spaced per / rate milliseconds apart on average. Call it before every iteration — the direct translation of the Go library's "the process is expected to call Take() before every iteration".

Slack is the burst allowance: idle time accumulates up to slack unspent permissions (default 10, exactly Uber's default), so a limiter that sat quiet can absorb a small burst at full speed before throttling resumes. slack: 0 is Uber's WithoutSlack — strict spacing regardless of idle time.

Abort: a pre-aborted signal rejects immediately; aborting mid-sleep rejects the waiting take. Either way the rejection is the signal's own reason when it's an Error (so errors.is(err, myReason) works), and an aborted take's time slot stays consumed — the state can't roll back once later takes have built on it.

Fidelity to the Go original

The algorithm is line-for-line uber-go/ratelimit's atomicInt64Limiter.Take: state is a single number — the time the next permission issues — advanced through the same three cases (first call / strict-idle reset, slack-capped idle reset, normal += perRequest). What's deliberately absent:

uber-go/ratelimitherewhy
CAS loop over atomic int64straight-line updatethe loop exists for goroutine contention; JavaScript is single-threaded
cache-line paddingsame
WithClock(clock)fake-timer harnesses mock the global setTimeout/Date directly
Per(duration) / WithSlack(n) / WithoutSlack{ per, slack } optionsoptions object instead of functional options
Take() time.Time returns the permission timetake(signal): Promise<void>JS callers need abortability more than the timestamp
NewUnlimited()pass no limiter

limiter.concurrency(max)

constcl=limiter.concurrency(12)constresult=awaitcl.run({signal: opts.signal,work: ()=>fetchRoster(classId),onStart(start){if(start.waitMs>1_000){logger.info({waitMs: start.waitMs,active: start.active,pending: start.pending},"roster call delayed by limiter")}}})

At most max works run at once; excess run calls queue FIFO. The returned promise settles with the work's own result or rejection.

  • onStart fires as the work leaves the queue, with { waitMs, active, pending, max } — the hook for backpressure telemetry. A throwing onStart rejects that run and releases the slot; the queue keeps moving.
  • Abort while queued rejects with the signal's reason and the work never starts. Abort after start does nothing — a running work settles its own promise (this library bounds admission, it does not cancel work; pass the same signal to the work for that).
  • Counters: cl.active, cl.pending, cl.max are live reads.

Migrating from 0.x

0.x exported one primitive, create({ concurrency, abortMessage }). In 1.0:

  • limiter.create({ concurrency: n, abortMessage })limiter.concurrency(n) — aborts now reject with the signal's reason instead of a configured message.
  • Start fields renamed: activeCount/pendingCount/concurrencyactive/pending/max.
  • New: limiter.rate (the Uber port that gave the package its name).

License

0BSD © Bjorn Pagen

About

Concurrency limiter primitive for Superbuilders applications

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages