@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 +21 -0
- package/README.md +254 -0
- package/package.json +18 -0
- package/src/bucket.js +71 -0
- package/src/globalBucket.js +53 -0
- package/src/index.js +11 -0
- package/src/queue.js +139 -0
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
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 };
|