@erox/rate-limiter 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Erox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # @discord-toolkit/rate-limiter
2
+
3
+ Handles Discord's rate limits automatically. Wraps your requests, tracks
4
+ the buckets, queues stuff when it needs to, retries on 429s. You don't
5
+ have to think about any of it.
6
+
7
+ Current with Discord API v10 rate limit behavior as of mid-2026,
8
+ including `X-RateLimit-Scope`, the shared-bucket 429 exemption, and the
9
+ invalid-request ban threshold.
10
+
11
+ ## Why
12
+
13
+ Discord splits rate limits per-route (via the `X-RateLimit-Bucket`
14
+ header) and also has a global cap on top of that (50 req/sec by
15
+ default). Most people either ignore this until they get hit with 429s
16
+ in production, or they write some half-working delay logic that breaks
17
+ the moment they add a second route. This handles both layers properly.
18
+
19
+ ## Install
20
+
21
+ ```
22
+ npm install @discord-toolkit/rate-limiter
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```js
28
+ const { RateLimitManager } = require('@discord-toolkit/rate-limiter');
29
+
30
+ const limiter = new RateLimitManager();
31
+
32
+ async function sendMessage(channelId, content) {
33
+ const routeKey = `POST /channels/${channelId}/messages`;
34
+
35
+ return limiter.schedule(routeKey, () =>
36
+ fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
37
+ method: 'POST',
38
+ headers: {
39
+ Authorization: `Bot ${TOKEN}`,
40
+ 'Content-Type': 'application/json',
41
+ },
42
+ body: JSON.stringify({ content }),
43
+ })
44
+ );
45
+ }
46
+ ```
47
+
48
+ Call `sendMessage` as many times as you want, from wherever — it queues
49
+ and paces itself against whatever Discord tells it in the response
50
+ headers. That's the whole pitch.
51
+
52
+ ---
53
+
54
+ ## Beginner guide: what's actually going on
55
+
56
+ If you're new to rate limiting on Discord, here's the short version:
57
+
58
+ 1. Every time you hit an endpoint (send a message, edit a role, whatever),
59
+ Discord's response includes headers telling you how many more requests
60
+ you can make before you get cut off, and when that count resets.
61
+ 2. If you ignore those headers and just fire requests as fast as your
62
+ code can go, eventually you'll get a `429 Too Many Requests` back.
63
+ Do that too often and Discord can temporarily ban your bot from the
64
+ API entirely (a "Cloudflare ban"), which is much worse than a slow bot.
65
+ 3. There isn't just one limit — there's a limit *per route* (e.g.
66
+ sending messages in one channel doesn't affect your ability to edit
67
+ roles) and a *global* limit across everything combined.
68
+
69
+ This library reads those headers for you after every request and holds
70
+ back future requests to the same bucket until it's safe, instead of you
71
+ having to write `setTimeout` guesses everywhere.
72
+
73
+ You don't need to understand buckets deeply to use this — just wrap your
74
+ requests in `limiter.schedule(...)` like the example above and it's handled.
75
+ Read on if you want the details.
76
+
77
+ ---
78
+
79
+ ## API reference
80
+
81
+ ### `new RateLimitManager(options)`
82
+
83
+ Creates a manager. You'll usually want exactly one of these per bot,
84
+ shared across all your commands/handlers.
85
+
86
+ | option | default | what it does |
87
+ |---|---|---|
88
+ | `globalLimit` | `50` | requests/sec allowed across every route combined |
89
+ | `maxRetries` | `3` | how many times a 429 gets retried before the call throws |
90
+ | `onRateLimit` | `undefined` | `(info) => void`, called every time a 429 is hit |
91
+ | `onInvalidRequestWarning` | `undefined` | `(count) => void`, called once the invalid-request count crosses `invalidRequestWarningThreshold` in a 10-minute window |
92
+ | `invalidRequestWarningThreshold` | `8000` | how many 401/403/non-shared-429 responses in 10 minutes before `onInvalidRequestWarning` fires (Discord's own cutoff is 10,000) |
93
+
94
+ ```js
95
+ const limiter = new RateLimitManager({
96
+ globalLimit: 50,
97
+ maxRetries: 5,
98
+ onRateLimit: (info) => {
99
+ console.warn(`rate limited on ${info.routeKey}, retrying in ${info.retryAfterMs}ms`);
100
+ },
101
+ onInvalidRequestWarning: (count) => {
102
+ console.error(`hit ${count} invalid requests in the last 10 minutes - approaching an IP ban`);
103
+ },
104
+ });
105
+ ```
106
+
107
+ ### `onRateLimit` payload
108
+
109
+ ```js
110
+ {
111
+ routeKey: 'POST /channels/123/messages',
112
+ global: false, // whether this was the global limit or a per-route one
113
+ scope: 'user', // 'user' | 'shared' | null, from X-RateLimit-Scope
114
+ retryAfterMs: 1200,
115
+ attempt: 1, // which retry attempt this is
116
+ }
117
+ ```
118
+
119
+ `scope: 'shared'` means the 429 came from contention on a resource other
120
+ apps also hit (common on things like reaction endpoints), not from your
121
+ own bot overusing it - these don't count toward the invalid-request
122
+ tracker since they're not really "your" mistake.
123
+
124
+ ### `limiter.schedule(routeKey, requestFn)`
125
+
126
+ The main method. Queues `requestFn` behind whatever rate limit state
127
+ exists for `routeKey`, waits if needed, runs it, reads the response
128
+ headers, and retries automatically on 429.
129
+
130
+ - **`routeKey`** — a string identifying the endpoint. Use `METHOD path`
131
+ with actual IDs, e.g. `"POST /channels/123/messages"`. Different IDs
132
+ for the same route type should get different keys so they don't block
133
+ each other unnecessarily (they'll get merged automatically if Discord
134
+ says they share a bucket anyway).
135
+ - **`requestFn`** — a function `() => Promise<Response>` that actually
136
+ performs the request. Must return something fetch-shaped: a `Response`
137
+ object with `.headers.get(name)`, `.json()`, and `.clone()`. If you're
138
+ using `node-fetch`, undici, or the built-in global `fetch`, you're fine.
139
+ Axios users: wrap the response so it matches this shape, or use `fetch`
140
+ instead for the parts that go through the limiter.
141
+
142
+ Returns whatever `requestFn` resolves to (the `Response`), after any
143
+ needed retries.
144
+
145
+ Throws if `maxRetries` is exceeded on repeated 429s.
146
+
147
+ ### `limiter.buckets`
148
+
149
+ A `Map` of bucket id → `Bucket` instance, in case you want to inspect
150
+ state directly (mostly useful for debugging/logging).
151
+
152
+ ### `limiter.global`
153
+
154
+ The shared `GlobalBucket` instance tracking the overall request cap.
155
+
156
+ ---
157
+
158
+ ## Which action maps to which route key
159
+
160
+ This library doesn't hardcode Discord's routes for you — you pass the
161
+ route key yourself, since it's transport-agnostic (works with any HTTP
162
+ client). Here's a cheat sheet for common actions so you're not guessing
163
+ the format:
164
+
165
+ | action | route key example |
166
+ |---|---|
167
+ | send message | `POST /channels/{channel.id}/messages` |
168
+ | edit message | `PATCH /channels/{channel.id}/messages/{message.id}` |
169
+ | delete message | `DELETE /channels/{channel.id}/messages/{message.id}` |
170
+ | add reaction | `PUT /channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me` |
171
+ | create channel | `POST /guilds/{guild.id}/channels` |
172
+ | edit channel | `PATCH /channels/{channel.id}` |
173
+ | ban member | `PUT /guilds/{guild.id}/bans/{user.id}` |
174
+ | kick member | `DELETE /guilds/{guild.id}/members/{user.id}` |
175
+ | edit role | `PATCH /guilds/{guild.id}/roles/{role.id}` |
176
+ | respond to interaction | `POST /interactions/{interaction.id}/{interaction.token}/callback` |
177
+ | edit interaction reply | `PATCH /webhooks/{application.id}/{interaction.token}/messages/@original` |
178
+
179
+ The exact string doesn't have to match Discord's docs word for word —
180
+ what matters is that requests to the *same actual endpoint with the same
181
+ major params* use the *same route key*, so they queue together correctly.
182
+ Discord's own bucket id (returned in headers) gets merged in automatically,
183
+ so even if your key naming is a little off, correctness won't break —
184
+ you just might get slightly less optimal queuing until the real bucket
185
+ id kicks in after the first request.
186
+
187
+ ---
188
+
189
+ ## How retries work
190
+
191
+ When a request comes back `429`:
192
+
193
+ 1. The response body is read for `retry_after` (seconds) and whether
194
+ it's a `global` limit or scoped to this bucket.
195
+ 2. If global, the shared `GlobalBucket` gets locked for that duration —
196
+ every route pauses.
197
+ 3. If scoped, only that specific bucket gets locked.
198
+ 4. The request is automatically re-queued and retried.
199
+ 5. If it 429s again more than `maxRetries` times in a row, it throws
200
+ instead of retrying forever.
201
+
202
+ You don't need to catch 429s yourself in normal use — just be ready to
203
+ catch the eventual error if `maxRetries` is exceeded (usually means
204
+ something's wrong, like clock drift or way too much concurrent traffic).
205
+
206
+ ---
207
+
208
+ ## Common mistakes
209
+
210
+ - **Using a new route key with the ID baked in wrong.** `POST /channels/123/messages`
211
+ and `POST /channels/456/messages` are different buckets — that's correct,
212
+ don't try to collapse them into one key.
213
+ - **Not sharing one `RateLimitManager` instance.** If you create a new
214
+ one per command, none of them know about each other's rate limit state.
215
+ Create one at startup and pass it around (or use a module-level singleton).
216
+ - **Wrapping something that isn't fetch-shaped.** If `requestFn` doesn't
217
+ return a real `Response`-like object, header reading will throw. Check
218
+ your HTTP client's return shape first.
219
+
220
+ ## The invalid-request ban (and how this protects you)
221
+
222
+ Separately from per-route and global rate limits, Discord tracks
223
+ 401/403/429 responses per IP over a rolling 10-minute window and
224
+ temporarily bans the IP once that count crosses roughly 10,000. This is
225
+ easy to trip accidentally: a bug that keeps hitting a 403 in a loop, or
226
+ overly aggressive retries, will get you there fast.
227
+
228
+ This library tracks that count internally (excluding `scope: 'shared'`
229
+ 429s, which aren't your bot's fault) and calls `onInvalidRequestWarning`
230
+ once you cross `invalidRequestWarningThreshold` (default 8,000), giving
231
+ you headroom to fix whatever's looping before you actually get banned.
232
+
233
+ ## Notes
234
+
235
+ - This doesn't make the actual HTTP calls for you — it just paces them.
236
+ Bring your own client.
237
+ - Works with any Discord API wrapper, or raw `fetch`, since it doesn't
238
+ care what's inside `requestFn` beyond the response shape.
239
+
240
+ ## Changelog
241
+
242
+ **0.2.0**
243
+ - Reads `X-RateLimit-Scope` and skips counting `shared`-scoped 429s
244
+ against the invalid-request tracker.
245
+ - Falls back to the `Retry-After` header when a 429 response has no
246
+ usable JSON body.
247
+ - Added `onRateLimit` and `onInvalidRequestWarning` hooks.
248
+ - Fixed a deadlock where a retried request could get stuck forever if
249
+ the retry was scheduled while the same bucket's queue was still
250
+ awaiting the original attempt.
251
+
252
+ **0.1.0**
253
+ - Initial release: per-route bucket tracking, global bucket, automatic
254
+ 429 retry.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@erox/rate-limiter",
3
+ "version": "0.2.0",
4
+ "description": "Handles Discord API rate limits so you don't have to think about it",
5
+ "main": "src/index.js",
6
+ "files": [
7
+ "src",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
11
+ "scripts": {
12
+ "test": "node --test test/*.test.js"
13
+ },
14
+ "keywords": ["discord", "rate-limit", "bot"],
15
+ "author": "Erox (https://www.npmjs.com/~erox_the._exotic)",
16
+ "homepage": "https://www.npmjs.com/~erox_the._exotic",
17
+ "license": "MIT"
18
+ }
package/src/bucket.js ADDED
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ // Tracks the rate limit state for a single Discord API bucket.
4
+ // Discord groups routes into buckets via the X-RateLimit-Bucket header,
5
+ // so multiple routes can share the same bucket depending on major params.
6
+
7
+ class Bucket {
8
+ constructor(id) {
9
+ this.id = id;
10
+ this.limit = Infinity;
11
+ this.remaining = Infinity;
12
+ this.resetAt = 0; // epoch ms
13
+ this.scope = null; // 'user' | 'shared' | 'global', from X-RateLimit-Scope
14
+ this.queue = [];
15
+ this.processing = false;
16
+ }
17
+
18
+ updateFromHeaders(headers) {
19
+ const limit = headers.get('x-ratelimit-limit');
20
+ const remaining = headers.get('x-ratelimit-remaining');
21
+ const resetAfter = headers.get('x-ratelimit-reset-after');
22
+
23
+ if (limit !== null) this.limit = Number(limit);
24
+ if (remaining !== null) this.remaining = Number(remaining);
25
+ if (resetAfter !== null) {
26
+ this.resetAt = Date.now() + Number(resetAfter) * 1000;
27
+ }
28
+ }
29
+
30
+ get isLimited() {
31
+ return this.remaining <= 0 && Date.now() < this.resetAt;
32
+ }
33
+
34
+ get waitTime() {
35
+ return this.isLimited ? this.resetAt - Date.now() : 0;
36
+ }
37
+
38
+ async enqueue(task) {
39
+ return new Promise((resolve, reject) => {
40
+ this.queue.push({ task, resolve, reject });
41
+ this._process();
42
+ });
43
+ }
44
+
45
+ async _process() {
46
+ if (this.processing) return;
47
+ this.processing = true;
48
+
49
+ while (this.queue.length) {
50
+ if (this.isLimited) {
51
+ await sleep(this.waitTime);
52
+ }
53
+
54
+ const { task, resolve, reject } = this.queue.shift();
55
+ try {
56
+ const result = await task();
57
+ resolve(result);
58
+ } catch (err) {
59
+ reject(err);
60
+ }
61
+ }
62
+
63
+ this.processing = false;
64
+ }
65
+ }
66
+
67
+ function sleep(ms) {
68
+ return new Promise((res) => setTimeout(res, ms));
69
+ }
70
+
71
+ module.exports = { Bucket };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // Discord enforces a global cap (default 50 requests/sec per bot) on top
4
+ // of per-route buckets. This tracks that separately since it applies
5
+ // across every route regardless of which bucket a request belongs to.
6
+
7
+ class GlobalBucket {
8
+ constructor(limit = 50) {
9
+ this.limit = limit;
10
+ this.remaining = limit;
11
+ this.windowStart = Date.now();
12
+ this.lockedUntil = 0;
13
+ }
14
+
15
+ _refillIfNeeded() {
16
+ const now = Date.now();
17
+ if (now - this.windowStart >= 1000) {
18
+ this.windowStart = now;
19
+ this.remaining = this.limit;
20
+ }
21
+ }
22
+
23
+ get isLocked() {
24
+ return Date.now() < this.lockedUntil;
25
+ }
26
+
27
+ lock(retryAfterMs) {
28
+ this.lockedUntil = Date.now() + retryAfterMs;
29
+ }
30
+
31
+ async take() {
32
+ this._refillIfNeeded();
33
+
34
+ if (this.isLocked) {
35
+ await sleep(this.lockedUntil - Date.now());
36
+ return this.take();
37
+ }
38
+
39
+ if (this.remaining <= 0) {
40
+ const waitMs = 1000 - (Date.now() - this.windowStart);
41
+ await sleep(Math.max(waitMs, 0));
42
+ return this.take();
43
+ }
44
+
45
+ this.remaining -= 1;
46
+ }
47
+ }
48
+
49
+ function sleep(ms) {
50
+ return new Promise((res) => setTimeout(res, ms));
51
+ }
52
+
53
+ module.exports = { GlobalBucket };
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ const { RateLimitManager } = require('./queue');
4
+ const { Bucket } = require('./bucket');
5
+ const { GlobalBucket } = require('./globalBucket');
6
+
7
+ module.exports = {
8
+ RateLimitManager,
9
+ Bucket,
10
+ GlobalBucket,
11
+ };
package/src/queue.js ADDED
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const { Bucket } = require('./bucket');
4
+ const { GlobalBucket } = require('./globalBucket');
5
+
6
+ // Ties per-route buckets and the global bucket together. This is the
7
+ // piece that actually wraps a request function and decides when it's
8
+ // safe to fire it off.
9
+
10
+ // Discord temporarily bans IPs that rack up too many invalid responses
11
+ // (401/403/429) within a 10 minute window (currently 10,000). This is
12
+ // tracked separately from per-route/global limits since it's a distinct
13
+ // failure mode that's easy to trip during retries or bad-permission bugs.
14
+ const INVALID_REQUEST_WINDOW_MS = 10 * 60 * 1000;
15
+
16
+ class RateLimitManager {
17
+ constructor(opts = {}) {
18
+ this.buckets = new Map(); // route key -> Bucket
19
+ this.routeToBucketId = new Map(); // route key -> discord bucket id
20
+ this.global = new GlobalBucket(opts.globalLimit || 50);
21
+ this.maxRetries = opts.maxRetries ?? 3;
22
+
23
+ this.onRateLimit = opts.onRateLimit; // (info) => void, fired whenever a 429 is hit
24
+ this.onInvalidRequestWarning = opts.onInvalidRequestWarning; // (count) => void
25
+
26
+ this._invalidTimestamps = [];
27
+ this.invalidRequestWarningThreshold = opts.invalidRequestWarningThreshold ?? 8000;
28
+ }
29
+
30
+ _bucketFor(routeKey) {
31
+ const bucketId = this.routeToBucketId.get(routeKey) || routeKey;
32
+ if (!this.buckets.has(bucketId)) {
33
+ this.buckets.set(bucketId, new Bucket(bucketId));
34
+ }
35
+ return this.buckets.get(bucketId);
36
+ }
37
+
38
+ _trackInvalidRequest() {
39
+ const now = Date.now();
40
+ this._invalidTimestamps.push(now);
41
+ this._invalidTimestamps = this._invalidTimestamps.filter(
42
+ (t) => now - t < INVALID_REQUEST_WINDOW_MS
43
+ );
44
+
45
+ if (
46
+ this.onInvalidRequestWarning &&
47
+ this._invalidTimestamps.length === this.invalidRequestWarningThreshold
48
+ ) {
49
+ this.onInvalidRequestWarning(this._invalidTimestamps.length);
50
+ }
51
+ }
52
+
53
+ // routeKey: a string identifying the route + major params, e.g. "POST /channels/123/messages"
54
+ // requestFn: () => Promise<Response> (fetch-like, must return a Response object)
55
+ //
56
+ // Retries are handled with a loop inside the single enqueued task below,
57
+ // rather than by recursively calling schedule()/enqueue() again. Recursing
58
+ // back into bucket.enqueue() from within a task that bucket's own queue is
59
+ // currently awaiting would deadlock - the queue can't advance to the newly
60
+ // pushed retry because it's still blocked on the task that pushed it.
61
+ async schedule(routeKey, requestFn) {
62
+ const bucket = this._bucketFor(routeKey);
63
+
64
+ return bucket.enqueue(async () => {
65
+ let attempt = 0;
66
+
67
+ while (true) {
68
+ await this.global.take();
69
+
70
+ const res = await requestFn();
71
+
72
+ const discordBucketId = res.headers.get('x-ratelimit-bucket');
73
+ if (discordBucketId && this.routeToBucketId.get(routeKey) !== discordBucketId) {
74
+ this.routeToBucketId.set(routeKey, discordBucketId);
75
+ }
76
+ bucket.updateFromHeaders(res.headers);
77
+ bucket.scope = res.headers.get('x-ratelimit-scope') || bucket.scope;
78
+
79
+ if (res.status === 401 || res.status === 403) {
80
+ this._trackInvalidRequest();
81
+ return res;
82
+ }
83
+
84
+ if (res.status !== 429) {
85
+ return res;
86
+ }
87
+
88
+ // 429s scoped as "shared" come from contention on a resource shared
89
+ // with other apps/users, not our own overuse - they don't count
90
+ // toward the invalid request ban threshold.
91
+ if (bucket.scope !== 'shared') {
92
+ this._trackInvalidRequest();
93
+ }
94
+
95
+ if (attempt >= this.maxRetries) {
96
+ throw new Error(`Rate limited after ${this.maxRetries} retries on ${routeKey}`);
97
+ }
98
+
99
+ const body = await res.clone().json().catch(() => null);
100
+ const headerRetryAfter = res.headers.get('retry-after');
101
+ const retryAfterSeconds = body?.retry_after ?? (headerRetryAfter ? Number(headerRetryAfter) : 1);
102
+ const retryAfterMs = retryAfterSeconds * 1000;
103
+ const isGlobal = body?.global ?? res.headers.get('x-ratelimit-global') === 'true';
104
+
105
+ if (isGlobal) {
106
+ this.global.lock(retryAfterMs);
107
+ } else {
108
+ bucket.remaining = 0;
109
+ bucket.resetAt = Date.now() + retryAfterMs;
110
+ }
111
+
112
+ attempt += 1;
113
+
114
+ if (this.onRateLimit) {
115
+ this.onRateLimit({
116
+ routeKey,
117
+ global: isGlobal,
118
+ scope: bucket.scope,
119
+ retryAfterMs,
120
+ attempt,
121
+ });
122
+ }
123
+
124
+ // loop and retry - global.take() and the bucket's own reset check
125
+ // (inside enqueue's caller) will naturally hold this back until
126
+ // whichever lock we just set has expired
127
+ if (bucket.isLimited) {
128
+ await sleep(bucket.waitTime);
129
+ }
130
+ }
131
+ });
132
+ }
133
+ }
134
+
135
+ function sleep(ms) {
136
+ return new Promise((res) => setTimeout(res, ms));
137
+ }
138
+
139
+ module.exports = { RateLimitManager };