From 90d02ee895b029d24f9eabf2465eda833e27e4a0 Mon Sep 17 00:00:00 2001 From: John Hurliman Date: Thu, 10 Sep 2026 12:32:27 -0700 Subject: [PATCH] Add cooldown estimates and complete limiter issue documentation --- .github/workflows/ci.yml | 10 +- CHANGELOG.md | 7 ++ README.md | 253 ++++++++++++++++++++------------------- package.json | 21 ++-- src/RateLimiter.ts | 30 ++++- src/wait-time.test.ts | 69 +++++++++++ 6 files changed, 247 insertions(+), 143 deletions(-) create mode 100644 src/wait-time.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a8b476..cb8c4f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,16 +27,8 @@ jobs: node-version: ${{ matrix.node-version }} cache: "yarn" - - name: Cache node_modules - uses: actions/cache@v4 - with: - path: node_modules - key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-node_modules- - - name: Install dependencies - run: yarn install --immutable + run: yarn install --frozen-lockfile - name: Lint projects run: yarn lint:ci diff --git a/CHANGELOG.md b/CHANGELOG.md index 9407949..2371772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [4.1.0] - Unreleased + +- Add `RateLimiter.getWaitTime(count)` to estimate cooldowns across both the bucket and interval allowance (#88). +- Add regressions for positive bucket balances during interval exhaustion and the 5,000-request backlog report. +- Replace placeholder README snippets with complete examples and clarify sequential limiter composition. +- Allow npm to build distribution archives without requiring Yarn globally. + ## [4.0.0] - 2026-09-08 This is a major release because input validation and asynchronous request ordering diff --git a/README.md b/README.md index 6f9c9f6..35b12c7 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,119 @@ # limiter -![Build Status](https://github.com/jhurliman/node-rate-limiter/actions/workflows/ci.yml/badge.svg) -[![NPM Downloads](https://img.shields.io/npm/dm/limiter.svg?style=flat)](https://www.npmjs.com/package/limiter) +[![CI](https://github.com/jhurliman/node-rate-limiter/actions/workflows/ci.yml/badge.svg)](https://github.com/jhurliman/node-rate-limiter/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/limiter.svg)](https://www.npmjs.com/package/limiter) -Provides a generic rate limiter for the web and node.js. Useful for API clients, -web crawling, or other tasks that need to be throttled. Two classes are exposed, -RateLimiter and TokenBucket. TokenBucket provides a lower level interface to -rate limiting with a configurable burst rate and drip rate. RateLimiter sits on -top of the token bucket and adds a restriction on the maximum number of tokens -that can be removed each interval to comply with common API restrictions such as -"150 requests per hour maximum". +Control when work starts in Node.js and browsers. **limiter** provides an +interval rate limiter and a hierarchical token bucket, with Promise-based +waiting, synchronous admission checks, and no runtime dependencies. -## Installation +Use it to pace API calls, throttle messages, or budget bytes. Waiting requests +run in FIFO order on each instance with one active timer, even with thousands +of callers. CommonJS, ES modules, and TypeScript declarations are included. - yarn add limiter +| Choose | When you need | +| --- | --- | +| `RateLimiter` | A maximum token count per interval, with continuous refill underneath | +| `TokenBucket` | Separate burst capacity and refill rate, optionally shared through parent buckets | -## Upgrading from 3.x - -Version 4 fixes concurrent accounting and waiting behavior. It keeps the existing -constructors, methods, CommonJS/ESM imports, and fractional-token support, but is -a major release because these observable behaviors change: +## Install -- Token counts and capacities must be finite, non-negative numbers no greater - than `Number.MAX_SAFE_INTEGER`. Numeric intervals must be finite and positive. - Invalid values now throw `RangeError`, or reject an asynchronous call. Validate - configuration and handle rejected `removeTokens()` promises; do not use - negative values, `NaN`, or `Infinity` as sentinels. -- Waiting `removeTokens()` calls on the same instance run in FIFO order. A large - request at the front can delay smaller requests behind it. Synchronous calls - and `fireImmediately` requests can still consume capacity ahead of the queue; - children sharing a parent do not have a global FIFO order. -- Concurrent requests now respect the interval allowance and charge a hierarchy - only once per successful removal. Workloads that previously exceeded their - configured limits may wait longer or receive an immediate rejection signal. -- Remaining balances retain fractional millisecond timing precision. Avoid exact - equality checks on fractional balances; use `tryRemoveTokens()` to check - whether a request can proceed immediately. - -Zero-value conventions are unchanged: a standalone `TokenBucket` with -`bucketSize: 0` is unlimited and bypasses parents; `tokensPerInterval: 0` refills -a finite bucket on every attempt. A `RateLimiter` with `tokensPerInterval: 0` -accepts only zero-token requests. These settings are not a general off switch. - -See the [changelog](CHANGELOG.md) for the release history and -[Additional Notes](#additional-notes) for the full timing and queue semantics. - -## Usage - -A simple example allowing 150 requests per hour: - -```javascript -import { RateLimiter } from "limiter"; - -// Allow 150 requests per hour. Also understands -// 'second', 'minute', 'day', or a number of milliseconds -const limiter = new RateLimiter({ tokensPerInterval: 150, interval: "hour" }); - -async function sendRequest() { - // This call will throw if we request more than the maximum number of requests - // that were set in the constructor - // remainingRequests is the underlying bucket balance; the interval counter - // may impose a lower allowance. - const remainingRequests = await limiter.removeTokens(1); - callMyRequestSendingFunction(...); -} +```sh +npm install limiter ``` -Another example allowing one message to be sent every 250ms: +## Pace work -```javascript +Save as `example.mjs` and run `node example.mjs`. The first message starts +immediately; subsequent messages start at least 250 ms apart. + +```js import { RateLimiter } from "limiter"; const limiter = new RateLimiter({ tokensPerInterval: 1, interval: 250 }); -async function sendMessage() { - const remainingMessages = await limiter.removeTokens(1); - callMyMessageSendingFunction(...); -} +await Promise.all(["first", "second", "third"].map(async (message) => { + await limiter.removeTokens(1); + console.log(message); +})); ``` -The default behaviour is to wait for the duration of the rate limiting that's -currently in effect before the promise is resolved, but if you pass in -`"fireImmediately": true`, the promise will be resolved immediately with -`remainingRequests` set to -1: +For CommonJS, use `const { RateLimiter, TokenBucket } = require("limiter")`. +Create and reuse a limiter for each resource whose budget should be shared. +Each token represents an application-defined unit: a request, a message, a byte, +or a fractional cost. Work starts after `removeTokens()` resolves. -```javascript -import { RateLimiter } from "limiter"; +## Reject instead of waiting -const limiter = new RateLimiter({ - tokensPerInterval: 150, - interval: "hour", - fireImmediately: true -}); +`tryRemoveTokens()` atomically checks capacity and consumes tokens on success. +This complete HTTP example applies one shared budget to all incoming requests: -async function requestHandler(request, response) { - // Immediately send 429 header to client when rate limiting is in effect - const remainingRequests = await limiter.removeTokens(1); - if (remainingRequests < 0) { - response.writeHead(429, {'Content-Type': 'text/plain;charset=UTF-8'}); - response.end('429 Too Many Requests - your IP is being rate limited'); - } else { - callMyMessageSendingFunction(...); - } -} -``` - -A synchronous method, tryRemoveTokens(), is available in both RateLimiter and -TokenBucket. This will return immediately with a boolean value indicating if the -token removal was successful. - -```javascript +```js +import { createServer } from "node:http"; import { RateLimiter } from "limiter"; const limiter = new RateLimiter({ tokensPerInterval: 10, interval: "second" }); -if (limiter.tryRemoveTokens(5)) - console.log('Tokens removed'); -else - console.log('No tokens removed'); +createServer((request, response) => { + if (!limiter.tryRemoveTokens(1)) { + const seconds = Math.max(1, Math.ceil(limiter.getWaitTime(1) / 1000)); + response.writeHead(429, { "Retry-After": String(seconds) }); + response.end("Too many requests\n"); + return; + } + response.end("Accepted\n"); +}).listen(3000); ``` -To get the number of remaining tokens **outside** the `removeTokens` promise, -simply use the `getTokensRemaining` method. +`getWaitTime(count)` returns an estimated delay in **milliseconds** without +consuming tokens. On `RateLimiter` it accounts for both refill and the interval +allowance. It returns zero when capacity is currently available. This +`RateLimiter` helper is new in 4.1; `TokenBucket.getWaitTime()` is available in 4.0. -```javascript -import { RateLimiter } from "limiter"; +For the Promise API with immediate rejection signaling, construct a limiter with +`fireImmediately: true`: `removeTokens()` resolves to `-1` when denied, otherwise +to the remaining bucket balance. Invalid inputs still reject the promise. -const limiter = new RateLimiter({ tokensPerInterval: 1, interval: 250 }); - -// Prints 1 since we did not remove a token and our number of tokens per -// interval is 1 -console.log(limiter.getTokensRemaining()); -``` +## Budget bytes and bursts -Using the token bucket directly to throttle at the byte level: +A standalone bucket starts empty and refills as time passes. This example +allows a 150 KiB burst after enough idle time and refills at 50 KiB per second: -```javascript +```js import { TokenBucket } from "limiter"; -const BURST_CAPACITY = 1024 * 150; // 150 KiB maximum burst -const FILL_RATE = 1024 * 50; // 50 KiB per second sustained rate - -// We could also pass a parent token bucket in to create a hierarchical token -// bucket -// bucketSize, tokensPerInterval, interval const bucket = new TokenBucket({ - bucketSize: BURST_CAPACITY, - tokensPerInterval: FILL_RATE, + bucketSize: 150 * 1024, + tokensPerInterval: 50 * 1024, interval: "second" }); -async function handleData(myData) { - await bucket.removeTokens(myData.byteLength); - sendMyData(myData); +const chunks = [new Uint8Array(1024), new Uint8Array(2048)]; +for (const chunk of chunks) { + await bucket.removeTokens(chunk.byteLength); + console.log(`Ready to send ${chunk.byteLength} bytes`); } ``` +Split any chunk larger than `bucketSize` before requesting tokens. To share a +budget, pass another `TokenBucket` as `parentBucket`. A successful removal charges +the child and every finite ancestor together; a failed attempt charges none. + +## API at a glance + +| API | Result | +| --- | --- | +| `new RateLimiter({ tokensPerInterval, interval, fireImmediately? })` | A full bucket plus an interval allowance | +| `new TokenBucket({ bucketSize, tokensPerInterval, interval, parentBucket? })` | An empty bucket with independent burst and refill settings | +| `removeTokens(count)` | `Promise`: wait, consume, and return remaining balance | +| `tryRemoveTokens(count)` | `boolean`: consume immediately if possible | +| `getWaitTime(count)` | Estimated milliseconds until capacity is available; never consumes tokens | +| `RateLimiter.getTokensRemaining()` | Underlying bucket balance, which can exceed the current interval allowance | + +Intervals accept positive milliseconds or `"second"`, `"minute"`, `"hour"`, and +`"day"` (also `"sec"`, `"min"`, and `"hr"`). Token amounts may be fractional. +Always handle rejected promises for invalid or oversized requests. + ## Additional Notes Waiting calls to `removeTokens()` are processed in FIFO order on each instance, @@ -175,7 +133,14 @@ rolling-window algorithm separately when those are your requirements. `getTokensRemaining()` reports the underlying bucket's balance (possibly fractional), not the interval counter's remaining allowance. Use -`tryRemoveTokens()` to test whether a request can proceed immediately. +`getWaitTime(count)` for a non-consuming cooldown estimate, or +`tryRemoveTokens(count)` to check and consume in one step. Estimates ignore +queued requests and are not reservations; other calls can change availability. + +Awaiting two independent rate limiters in sequence does not atomically enforce +both limits at the eventual work start: work can collect behind the second +limiter. For atomic burst and sustained-rate budgets, use parent/child +`TokenBucket` instances. Those implement token-bucket limits, not rolling windows. Token counts and capacities must be finite, non-negative numbers at most `Number.MAX_SAFE_INTEGER`; fractional tokens are supported. Numeric intervals @@ -194,6 +159,50 @@ the instance and is not shared across processes, workers, or machines. Configure instances at the scope of the resource you want to limit; creating a new limiter for every request defeats a shared rate limit. + +## Upgrading from 3.x + +Version 4 fixes concurrent accounting and waiting behavior. It keeps the existing +constructors, methods, CommonJS/ESM imports, and fractional-token support, but is +a major release because these observable behaviors change: + +- Token counts and capacities must be finite, non-negative numbers no greater + than `Number.MAX_SAFE_INTEGER`. Numeric intervals must be finite and positive. + Invalid values now throw `RangeError`, or reject an asynchronous call. Validate + configuration and handle rejected `removeTokens()` promises; do not use + negative values, `NaN`, or `Infinity` as sentinels. +- Waiting `removeTokens()` calls on the same instance run in FIFO order. A large + request at the front can delay smaller requests behind it. Synchronous calls + and `fireImmediately` requests can still consume capacity ahead of the queue; + children sharing a parent do not have a global FIFO order. +- Concurrent requests now respect the interval allowance and charge a hierarchy + only once per successful removal. Workloads that previously exceeded their + configured limits may wait longer or receive an immediate rejection signal. +- Remaining balances retain fractional millisecond timing precision. Avoid exact + equality checks on fractional balances; use `tryRemoveTokens()` to check + whether a request can proceed immediately. + +Zero-value conventions are unchanged: a standalone `TokenBucket` with +`bucketSize: 0` is unlimited and bypasses parents; `tokensPerInterval: 0` refills +a finite bucket on every attempt. A `RateLimiter` with `tokensPerInterval: 0` +accepts only zero-token requests. These settings are not a general off switch. + +See the [changelog](CHANGELOG.md) for the release history and +[Additional Notes](#additional-notes) for the full timing and queue semantics. + +## Development + +```sh +yarn install --frozen-lockfile +yarn lint:ci +yarn test +yarn prepack +``` + +CI checks the source and both module distributions. Tests cover concurrent +accounting, hierarchical buckets, FIFO backlogs, timing boundaries, and invalid +inputs using deterministic clocks. + ## License -MIT License +[MIT](LICENSE.txt). diff --git a/package.json b/package.json index 3fad96b..520f0fc 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,24 @@ { "name": "limiter", "description": "A generic rate limiter for the web and node.js. Useful for API clients, web crawling, or other tasks that need to be throttled", - "version": "4.0.0", + "version": "4.1.0", "author": "John Hurliman ", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "browser": "./dist/esm/index.js", "exports": { - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js" + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } }, - "repository": "git://github.com/jhurliman/node-rate-limiter", + "repository": "https://github.com/jhurliman/node-rate-limiter.git", "bugs": { - "url": "http://github.com/jhurliman/node-rate-limiter/issues" + "url": "https://github.com/jhurliman/node-rate-limiter/issues" }, "license": "MIT", "files": [ @@ -30,7 +36,7 @@ "scripts": { "lint": "eslint --fix src", "lint:ci": "eslint src", - "prepack": "yarn tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node ./create-package-json.js", + "prepack": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node ./create-package-json.js", "test": "jest src" }, "devDependencies": { @@ -54,5 +60,6 @@ "limiting", "throttling" ], - "packageManager": "yarn@1.22.19+sha512.ff4579ab459bb25aa7c0ff75b62acebe576f6084b36aa842971cf250a5d8c6cd3bc9420b22ce63c7f93a0857bc6ef29291db39c3e7a23aab5adfd5a4dd6c5d71" + "packageManager": "yarn@1.22.19+sha512.ff4579ab459bb25aa7c0ff75b62acebe576f6084b36aa842971cf250a5d8c6cd3bc9420b22ce63c7f93a0857bc6ef29291db39c3e7a23aab5adfd5a4dd6c5d71", + "types": "./dist/cjs/index.d.ts" } diff --git a/src/RateLimiter.ts b/src/RateLimiter.ts index 1b27dd1..1ad9871 100644 --- a/src/RateLimiter.ts +++ b/src/RateLimiter.ts @@ -63,11 +63,7 @@ export class RateLimiter { return this.requests.run(async () => { while (true) { if (this.tryRemoveTokens(count)) return this.tokenBucket.content; - const intervalDelay = - count > this.tokenBucket.tokensPerInterval - this.tokensThisInterval - ? this.curIntervalStart + this.tokenBucket.interval - getMilliseconds() - : 0; - await wait(Math.max(1, Math.ceil(intervalDelay), this.tokenBucket.getWaitTime(count))); + await wait(this.getWaitTime(count)); } }); } @@ -106,6 +102,30 @@ export class RateLimiter { return removed; } + /** + * Estimate milliseconds until count tokens can be removed, accounting for + * both bucket refill and the interval allowance. Does not reserve tokens or + * account for queued requests; competing removals can change the estimate. + * Throws RangeError for invalid counts or requests exceeding capacity. + */ + getWaitTime(count: number): number { + validateTokens(count, "count"); + if (count > this.tokenBucket.bucketSize) { + throw new RangeError( + `Requested tokens ${count} exceeds maximum tokens per interval ${this.tokenBucket.bucketSize}`, + ); + } + const bucketDelay = this.tokenBucket.getWaitTime(count); + const now = getMilliseconds(); + const intervalActive = + now >= this.curIntervalStart && now - this.curIntervalStart < this.tokenBucket.interval; + const intervalDelay = + intervalActive && count > this.tokenBucket.tokensPerInterval - this.tokensThisInterval + ? this.curIntervalStart + this.tokenBucket.interval - now + : 0; + return Math.max(0, Math.ceil(intervalDelay), bucketDelay); + } + /** * Returns the number of tokens remaining in the TokenBucket. * @returns {Number} The number of tokens remaining. diff --git a/src/wait-time.test.ts b/src/wait-time.test.ts new file mode 100644 index 0000000..3b5bccc --- /dev/null +++ b/src/wait-time.test.ts @@ -0,0 +1,69 @@ +import { RateLimiter } from "./RateLimiter.js"; + +describe("RateLimiter cooldown estimates", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("does not consume or reserve available tokens", () => { + const limiter = new RateLimiter({ tokensPerInterval: 3, interval: 60000 }); + expect(limiter.getWaitTime(3)).toBe(0); + expect(limiter.getWaitTime(3)).toBe(0); + expect(limiter.tryRemoveTokens(3)).toBe(true); + expect(limiter.getWaitTime(1)).toBe(60000); + }); + + it("waits for the interval even when the underlying bucket has refilled (#25, #61)", () => { + const limiter = new RateLimiter({ tokensPerInterval: 3, interval: 60000 }); + expect(limiter.tryRemoveTokens(3)).toBe(true); + jest.advanceTimersByTime(20000); + expect(limiter.getTokensRemaining()).toBe(1); + expect(limiter.tryRemoveTokens(1)).toBe(false); + expect(limiter.getWaitTime(1)).toBe(40000); + jest.advanceTimersByTime(40000); + expect(limiter.getWaitTime(3)).toBe(0); + expect(limiter.tryRemoveTokens(3)).toBe(true); + }); + + it("includes refill time when an interval has expired", () => { + const limiter = new RateLimiter({ tokensPerInterval: 10, interval: 1000 }); + jest.advanceTimersByTime(900); + expect(limiter.tryRemoveTokens(10)).toBe(true); + jest.advanceTimersByTime(100); + expect(limiter.getWaitTime(10)).toBe(900); + jest.advanceTimersByTime(900); + expect(limiter.tryRemoveTokens(10)).toBe(true); + }); + + it("rounds fractional waits up and validates impossible requests", () => { + const limiter = new RateLimiter({ tokensPerInterval: 1, interval: 2.5 }); + expect(limiter.tryRemoveTokens(1)).toBe(true); + expect(limiter.getWaitTime(0.5)).toBe(3); + expect(limiter.getWaitTime(0)).toBe(0); + for (const count of [-1, NaN, Infinity, 2]) { + expect(() => limiter.getWaitTime(count)).toThrow(RangeError); + } + const disabled = new RateLimiter({ tokensPerInterval: 0, interval: 1000 }); + expect(disabled.getWaitTime(0)).toBe(0); + expect(() => disabled.getWaitTime(1)).toThrow(RangeError); + }); + + it("drains the reported 5,000-request backlog in FIFO order with one timer (#85)", async () => { + const limiter = new RateLimiter({ tokensPerInterval: 1, interval: 100 }); + const order: number[] = []; + const requests = Array.from({ length: 5000 }, (_, index) => + limiter.removeTokens(1).then(() => { + order.push(index); + }), + ); + await jest.advanceTimersByTimeAsync(0); + expect(order).toEqual([0]); + expect(jest.getTimerCount()).toBe(1); + await jest.advanceTimersByTimeAsync(499800); + expect(order).toHaveLength(4999); + expect(jest.getTimerCount()).toBe(1); + await jest.advanceTimersByTimeAsync(100); + await Promise.all(requests); + expect(order).toEqual(Array.from({ length: 5000 }, (_, index) => index)); + expect(jest.getTimerCount()).toBe(0); + }); +});