lunibee 0.3.0 → 0.3.2
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/dist/builders/attachment.d.ts +1 -0
- package/dist/builders/clone.d.ts +10 -0
- package/dist/builders/commands.d.ts +1 -1
- package/dist/builders/components.d.ts +24 -5
- package/dist/builders/embed.d.ts +1 -0
- package/dist/builders/index.js +68 -17
- package/dist/builders/index.js.map +10 -9
- package/dist/collection/index.d.ts +1 -0
- package/dist/collection/index.js +297 -19
- package/dist/collection/index.js.map +5 -4
- package/dist/collection/redis.d.ts +98 -0
- package/dist/core/collector.d.ts +8 -0
- package/dist/core/events.d.ts +1 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +6252 -4704
- package/dist/core/index.js.map +47 -40
- package/dist/core/permissions.d.ts +3 -402
- package/dist/core/redis-cache.d.ts +85 -0
- package/dist/formatters/index.d.ts +5 -1
- package/dist/formatters/index.js +7 -3
- package/dist/formatters/index.js.map +3 -3
- package/dist/handlers/index.js +11 -2
- package/dist/handlers/index.js.map +3 -3
- package/dist/index.js +10481 -6148
- package/dist/index.js.map +70 -56
- package/dist/managers/application.d.ts +10 -6
- package/dist/managers/base.d.ts +4 -1
- package/dist/managers/guild.d.ts +3 -1
- package/dist/managers/index.d.ts +6 -1
- package/dist/managers/index.js +365 -103
- package/dist/managers/index.js.map +19 -22
- package/dist/managers/member.d.ts +3 -1
- package/dist/managers/message.d.ts +13 -4
- package/dist/managers/thread.d.ts +3 -3
- package/dist/rest/decoder.d.ts +2 -0
- package/dist/rest/errors.d.ts +13 -2
- package/dist/rest/headers.d.ts +31 -0
- package/dist/rest/identity.d.ts +31 -0
- package/dist/rest/index.d.ts +99 -6
- package/dist/rest/index.js +1412 -231
- package/dist/rest/index.js.map +16 -13
- package/dist/rest/limiter.d.ts +75 -10
- package/dist/rest/redis.d.ts +103 -22
- package/dist/rest/scheduler.d.ts +13 -0
- package/dist/rest/store.d.ts +135 -12
- package/dist/rest/telemetry.d.ts +151 -0
- package/dist/rest/transport.d.ts +9 -0
- package/dist/rest/webhook.d.ts +18 -6
- package/dist/sharding/bus.d.ts +12 -2
- package/dist/sharding/cluster.d.ts +225 -15
- package/dist/sharding/context.d.ts +41 -0
- package/dist/sharding/gateway-bot.d.ts +20 -0
- package/dist/sharding/health.d.ts +94 -0
- package/dist/sharding/host.d.ts +76 -0
- package/dist/sharding/identify.d.ts +64 -0
- package/dist/sharding/index.d.ts +62 -12
- package/dist/sharding/index.js +2459 -202
- package/dist/sharding/index.js.map +21 -13
- package/dist/sharding/protocol.d.ts +47 -0
- package/dist/sharding/remote-protocol.d.ts +100 -0
- package/dist/sharding/remote.d.ts +134 -0
- package/dist/sharding/requests.d.ts +54 -0
- package/dist/sharding/supervisor.d.ts +1 -1
- package/dist/sharding/transport.d.ts +6 -5
- package/dist/sharding/worker.d.ts +55 -0
- package/dist/structures/base.d.ts +16 -5
- package/dist/structures/index.d.ts +1 -1
- package/dist/structures/index.js +57 -91
- package/dist/structures/index.js.map +10 -18
- package/dist/structures/options.d.ts +3 -2
- package/dist/structures/permissions.d.ts +409 -0
- package/dist/structures/resources.d.ts +4 -1
- package/dist/types/gateway.d.ts +7 -0
- package/dist/types/index.d.ts +14 -4
- package/dist/types/index.js.map +3 -3
- package/dist/utils/args.d.ts +3 -1
- package/dist/utils/index.d.ts +6 -1
- package/dist/utils/index.js +17 -10
- package/dist/utils/index.js.map +4 -4
- package/dist/voice/index.d.ts +7 -1
- package/dist/voice/index.js +33 -10
- package/dist/voice/index.js.map +3 -3
- package/dist/ws/index.d.ts +3 -1
- package/dist/ws/index.js +33 -5
- package/dist/ws/index.js.map +6 -6
- package/dist/ws/send-budget.d.ts +1 -1
- package/package.json +22 -17
package/dist/rest/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// packages/rest/package.json
|
|
3
3
|
var package_default = {
|
|
4
4
|
name: "@lunibee/rest",
|
|
5
|
-
version: "0.3.
|
|
5
|
+
version: "0.3.2",
|
|
6
6
|
type: "module",
|
|
7
7
|
module: "./src/index.ts",
|
|
8
8
|
types: "./src/index.ts",
|
|
@@ -15,38 +15,179 @@ var package_default = {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
// packages/rest/src/store.ts
|
|
18
|
+
var GLOBAL_WINDOW_MS = 1050;
|
|
19
|
+
function takeGlobalSlot(log, now, limit, window = GLOBAL_WINDOW_MS) {
|
|
20
|
+
while (log.length > 0 && log[0] <= now - window)
|
|
21
|
+
log.shift();
|
|
22
|
+
if (log.length >= limit)
|
|
23
|
+
return Math.max(1, log[0] + window - now + 1);
|
|
24
|
+
log.push(now);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
var PROBE_POLL_MS = 50;
|
|
28
|
+
var DEFAULT_PROBE_MS = 16000;
|
|
29
|
+
var BUCKET_LINGER_MS = 60000;
|
|
30
|
+
var WINDOW_TOLERANCE_MS = 50;
|
|
31
|
+
function reserveState(state, now, probeMs) {
|
|
32
|
+
if (state && state.resetAt > now) {
|
|
33
|
+
if (state.remaining > 0)
|
|
34
|
+
return {
|
|
35
|
+
reservation: {
|
|
36
|
+
granted: true,
|
|
37
|
+
known: true,
|
|
38
|
+
resetAt: state.resetAt
|
|
39
|
+
},
|
|
40
|
+
next: { ...state, remaining: state.remaining - 1 }
|
|
41
|
+
};
|
|
42
|
+
const wait = state.resetAt - now;
|
|
43
|
+
return {
|
|
44
|
+
reservation: {
|
|
45
|
+
granted: false,
|
|
46
|
+
retryAfterMs: state.provisional ? Math.min(wait, PROBE_POLL_MS) : wait,
|
|
47
|
+
reason: state.probe ? "probe" : "bucket"
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const resetAt = now + probeMs;
|
|
52
|
+
if (state?.limit !== undefined && state.limit > 0)
|
|
53
|
+
return {
|
|
54
|
+
reservation: { granted: true, known: true, resetAt },
|
|
55
|
+
next: {
|
|
56
|
+
remaining: state.limit - 1,
|
|
57
|
+
resetAt,
|
|
58
|
+
limit: state.limit,
|
|
59
|
+
provisional: true
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
reservation: { granted: true, known: false, resetAt },
|
|
64
|
+
next: { remaining: 0, resetAt, provisional: true, probe: true }
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function reconcileState(state, update, now) {
|
|
68
|
+
const incoming = update.resetAfterMs === undefined ? undefined : Math.ceil(now + update.resetAfterMs);
|
|
69
|
+
const active = !!state && state.resetAt > now && !state.probe;
|
|
70
|
+
let next;
|
|
71
|
+
if (active) {
|
|
72
|
+
next = { ...state };
|
|
73
|
+
const stale = update.window !== undefined && next.window !== undefined && update.window < next.window - WINDOW_TOLERANCE_MS;
|
|
74
|
+
if (!stale) {
|
|
75
|
+
if (update.remaining !== undefined)
|
|
76
|
+
next.remaining = Math.min(next.remaining, update.remaining);
|
|
77
|
+
if (incoming !== undefined) {
|
|
78
|
+
const newer = update.window !== undefined && next.window !== undefined && update.window > next.window + WINDOW_TOLERANCE_MS;
|
|
79
|
+
next.resetAt = next.provisional || newer ? incoming : Math.max(next.resetAt, incoming);
|
|
80
|
+
delete next.provisional;
|
|
81
|
+
}
|
|
82
|
+
if (update.window !== undefined)
|
|
83
|
+
next.window = Math.max(next.window ?? 0, update.window);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
if (update.remaining === undefined && update.retryAfterMs === undefined) {
|
|
87
|
+
return state?.probe ? null : undefined;
|
|
88
|
+
}
|
|
89
|
+
next = {
|
|
90
|
+
remaining: update.remaining ?? 0,
|
|
91
|
+
resetAt: incoming ?? now
|
|
92
|
+
};
|
|
93
|
+
if (state?.limit !== undefined)
|
|
94
|
+
next.limit = state.limit;
|
|
95
|
+
if (update.window !== undefined)
|
|
96
|
+
next.window = update.window;
|
|
97
|
+
}
|
|
98
|
+
if (update.limit !== undefined && update.limit > 0)
|
|
99
|
+
next.limit = update.limit;
|
|
100
|
+
if (update.retryAfterMs !== undefined) {
|
|
101
|
+
next.remaining = 0;
|
|
102
|
+
next.resetAt = Math.max(next.resetAt, Math.ceil(now + update.retryAfterMs));
|
|
103
|
+
delete next.provisional;
|
|
104
|
+
}
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
|
|
18
108
|
class MemoryRateLimitStore {
|
|
19
109
|
#buckets = new Map;
|
|
20
110
|
#routeBuckets = new Map;
|
|
21
|
-
#
|
|
111
|
+
#globals = new Map;
|
|
112
|
+
#globalLogs = new Map;
|
|
22
113
|
static #PRUNE_INTERVAL = 60000;
|
|
23
114
|
#lastPruneAt = 0;
|
|
115
|
+
static #MAX_ENTRIES = 1e4;
|
|
116
|
+
reservesGlobal = true;
|
|
117
|
+
constructor() {}
|
|
118
|
+
static #evict(map) {
|
|
119
|
+
while (map.size > MemoryRateLimitStore.#MAX_ENTRIES)
|
|
120
|
+
map.delete(map.keys().next().value);
|
|
121
|
+
}
|
|
24
122
|
getBucketHash(route) {
|
|
25
123
|
return this.#routeBuckets.get(route);
|
|
26
124
|
}
|
|
27
125
|
setBucketHash(route, hash) {
|
|
126
|
+
this.#routeBuckets.delete(route);
|
|
28
127
|
this.#routeBuckets.set(route, hash);
|
|
128
|
+
MemoryRateLimitStore.#evict(this.#routeBuckets);
|
|
29
129
|
}
|
|
30
130
|
getBucket(key) {
|
|
31
131
|
return this.#buckets.get(key);
|
|
32
132
|
}
|
|
33
|
-
reserve(key) {
|
|
34
|
-
const state = this.#buckets.get(key);
|
|
133
|
+
reserve(key, options = {}) {
|
|
35
134
|
const now = Date.now();
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
135
|
+
if (options.global !== undefined) {
|
|
136
|
+
const globalWait = this.getGlobalReset(options.global) - now;
|
|
137
|
+
if (globalWait > 0)
|
|
138
|
+
return {
|
|
139
|
+
granted: false,
|
|
140
|
+
retryAfterMs: globalWait,
|
|
141
|
+
reason: "global"
|
|
142
|
+
};
|
|
41
143
|
}
|
|
42
|
-
|
|
144
|
+
const limit = options.global === undefined ? 0 : options.globalLimit ?? 0;
|
|
145
|
+
let log;
|
|
146
|
+
if (limit > 0) {
|
|
147
|
+
log = this.#globalLogs.get(options.global);
|
|
148
|
+
if (!log) {
|
|
149
|
+
this.#globalLogs.set(options.global, log = []);
|
|
150
|
+
MemoryRateLimitStore.#evict(this.#globalLogs);
|
|
151
|
+
}
|
|
152
|
+
const wait = takeGlobalSlot(log, now, limit);
|
|
153
|
+
if (wait > 0)
|
|
154
|
+
return { granted: false, retryAfterMs: wait, reason: "global" };
|
|
155
|
+
log.pop();
|
|
156
|
+
}
|
|
157
|
+
const { reservation, next } = reserveState(this.#buckets.get(key), now, options.probeMs ?? DEFAULT_PROBE_MS);
|
|
158
|
+
if (log && reservation.granted)
|
|
159
|
+
log.push(now);
|
|
160
|
+
if (next)
|
|
161
|
+
this.#write(key, next);
|
|
162
|
+
return reservation;
|
|
163
|
+
}
|
|
164
|
+
reconcile(key, update) {
|
|
165
|
+
const next = reconcileState(this.#buckets.get(key), update, Date.now());
|
|
166
|
+
if (next === null)
|
|
167
|
+
this.#buckets.delete(key);
|
|
168
|
+
else if (next)
|
|
169
|
+
this.#write(key, next);
|
|
170
|
+
}
|
|
171
|
+
release(key, grant, refund) {
|
|
172
|
+
const state = this.#buckets.get(key);
|
|
173
|
+
if (!state || state.resetAt !== grant.resetAt)
|
|
174
|
+
return;
|
|
175
|
+
if (state.probe)
|
|
176
|
+
this.#buckets.delete(key);
|
|
177
|
+
else if (refund)
|
|
178
|
+
state.remaining = Math.min(state.remaining + 1, state.limit ?? Number.POSITIVE_INFINITY);
|
|
43
179
|
}
|
|
44
180
|
updateBucket(key, state) {
|
|
181
|
+
this.#write(key, state);
|
|
182
|
+
}
|
|
183
|
+
#write(key, state) {
|
|
184
|
+
this.#buckets.delete(key);
|
|
45
185
|
this.#buckets.set(key, state);
|
|
186
|
+
MemoryRateLimitStore.#evict(this.#buckets);
|
|
46
187
|
const now = Date.now();
|
|
47
188
|
if (now - this.#lastPruneAt >= MemoryRateLimitStore.#PRUNE_INTERVAL) {
|
|
48
189
|
this.#lastPruneAt = now;
|
|
49
|
-
this.prune();
|
|
190
|
+
this.prune(BUCKET_LINGER_MS);
|
|
50
191
|
}
|
|
51
192
|
}
|
|
52
193
|
prune(maxAge = 0) {
|
|
@@ -55,12 +196,27 @@ class MemoryRateLimitStore {
|
|
|
55
196
|
if (state.resetAt <= cutoff)
|
|
56
197
|
this.#buckets.delete(key);
|
|
57
198
|
}
|
|
199
|
+
for (const [scope, resetAt] of this.#globals)
|
|
200
|
+
if (resetAt <= Date.now())
|
|
201
|
+
this.#globals.delete(scope);
|
|
202
|
+
for (const [scope, log] of this.#globalLogs)
|
|
203
|
+
if ((log.at(-1) ?? 0) <= Date.now() - GLOBAL_WINDOW_MS)
|
|
204
|
+
this.#globalLogs.delete(scope);
|
|
205
|
+
}
|
|
206
|
+
getGlobalReset(scope = "") {
|
|
207
|
+
return this.#globals.get(scope) ?? 0;
|
|
58
208
|
}
|
|
59
|
-
|
|
60
|
-
|
|
209
|
+
setGlobalReset(resetAt, scope = "") {
|
|
210
|
+
this.#globals.set(scope, resetAt);
|
|
211
|
+
MemoryRateLimitStore.#evict(this.#globals);
|
|
61
212
|
}
|
|
62
|
-
|
|
63
|
-
|
|
213
|
+
extendGlobalReset(scope, waitMs) {
|
|
214
|
+
const resetAt = Date.now() + Math.max(0, waitMs);
|
|
215
|
+
if (resetAt > this.getGlobalReset(scope))
|
|
216
|
+
this.setGlobalReset(resetAt, scope);
|
|
217
|
+
}
|
|
218
|
+
get size() {
|
|
219
|
+
return this.#buckets.size;
|
|
64
220
|
}
|
|
65
221
|
}
|
|
66
222
|
|
|
@@ -85,6 +241,24 @@ function kindFromStatus(status) {
|
|
|
85
241
|
function redactPath(path) {
|
|
86
242
|
return path.replace(/^(\/webhooks\/[^/?]+\/)[^/?]+/i, "$1:token").replace(/^(\/interactions\/[^/?]+\/)[^/?]+/i, "$1:token");
|
|
87
243
|
}
|
|
244
|
+
var TOKEN_IN_TEXT = /(\/(?:webhooks|interactions)\/[^/\s?"'`]+\/)[^/\s?"'`]+/gi;
|
|
245
|
+
function redactText(text) {
|
|
246
|
+
return text.replace(TOKEN_IN_TEXT, "$1:token");
|
|
247
|
+
}
|
|
248
|
+
function sanitizeCause(error) {
|
|
249
|
+
if (!(error instanceof Error))
|
|
250
|
+
return;
|
|
251
|
+
const copy = new Error(redactText(error.message));
|
|
252
|
+
copy.name = error.name;
|
|
253
|
+
if (error.stack)
|
|
254
|
+
copy.stack = redactText(error.stack);
|
|
255
|
+
for (const key of ["code", "errno", "syscall"]) {
|
|
256
|
+
const value = error[key];
|
|
257
|
+
if (typeof value === "string" || typeof value === "number")
|
|
258
|
+
copy[key] = value;
|
|
259
|
+
}
|
|
260
|
+
return copy;
|
|
261
|
+
}
|
|
88
262
|
var HINTS = {
|
|
89
263
|
50001: "The bot cannot see this resource: check that it is in the guild and has View Channel there.",
|
|
90
264
|
50013: "The bot lacks a permission for this action, or the target role or member is at or above the bot's highest role."
|
|
@@ -110,24 +284,31 @@ class RESTError extends Error {
|
|
|
110
284
|
this.path = options.path === undefined ? undefined : redactPath(options.path);
|
|
111
285
|
}
|
|
112
286
|
get retryable() {
|
|
113
|
-
return this.kind === "rateLimited" || this.kind === "server" || this.kind === "network" || this.kind === "timeout";
|
|
287
|
+
return this.kind === "rateLimited" || this.kind === "server" || this.kind === "network" || this.kind === "timeout" || this.kind === "coordination" || this.kind === "overloaded";
|
|
114
288
|
}
|
|
115
289
|
}
|
|
116
290
|
function abortError(path, reason) {
|
|
117
291
|
return new RESTError(reason instanceof Error ? reason.message : "Discord REST request was aborted", 0, undefined, undefined, { path, cause: reason, kind: "aborted" });
|
|
118
292
|
}
|
|
293
|
+
var MAX_TIMER_MS = 2147483647;
|
|
119
294
|
async function sleep(delay, signal, path) {
|
|
120
295
|
if (delay <= 0)
|
|
121
296
|
return;
|
|
297
|
+
delay = Math.min(delay, MAX_TIMER_MS);
|
|
122
298
|
await new Promise((resolve, reject) => {
|
|
123
|
-
const
|
|
124
|
-
const abort = () => {
|
|
299
|
+
const finish = (error) => {
|
|
125
300
|
clearTimeout(timer);
|
|
126
|
-
|
|
301
|
+
signal?.removeEventListener("abort", onAbort);
|
|
302
|
+
if (error)
|
|
303
|
+
reject(error);
|
|
304
|
+
else
|
|
305
|
+
resolve();
|
|
127
306
|
};
|
|
307
|
+
const onAbort = () => finish(abortError(path, signal?.reason));
|
|
308
|
+
const timer = setTimeout(finish, delay);
|
|
128
309
|
if (signal?.aborted)
|
|
129
|
-
return
|
|
130
|
-
signal?.addEventListener("abort",
|
|
310
|
+
return onAbort();
|
|
311
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
131
312
|
});
|
|
132
313
|
}
|
|
133
314
|
async function abortable(promise, signal, path) {
|
|
@@ -135,10 +316,17 @@ async function abortable(promise, signal, path) {
|
|
|
135
316
|
return promise;
|
|
136
317
|
if (signal.aborted)
|
|
137
318
|
throw abortError(path, signal.reason);
|
|
138
|
-
return Promise
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
319
|
+
return new Promise((resolve, reject) => {
|
|
320
|
+
const onAbort = () => reject(abortError(path, signal.reason));
|
|
321
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
322
|
+
promise.then(() => {
|
|
323
|
+
signal.removeEventListener("abort", onAbort);
|
|
324
|
+
resolve();
|
|
325
|
+
}, (error) => {
|
|
326
|
+
signal.removeEventListener("abort", onAbort);
|
|
327
|
+
reject(error);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
142
330
|
}
|
|
143
331
|
|
|
144
332
|
// packages/rest/src/route.ts
|
|
@@ -146,7 +334,7 @@ import { createHash } from "crypto";
|
|
|
146
334
|
function normalizeRoutePath(path) {
|
|
147
335
|
const queryIndex = path.indexOf("?");
|
|
148
336
|
const routePath = queryIndex === -1 ? path : path.slice(0, queryIndex);
|
|
149
|
-
return redactPath(routePath).replace(/\/\d+(?=\/|$)/g, "/:id");
|
|
337
|
+
return redactPath(routePath).replace(/\/\d+(?=\/|$)/g, "/:id").replace(/\/reactions\/[^/]+/i, "/reactions/:emoji");
|
|
150
338
|
}
|
|
151
339
|
function majorParameter(path) {
|
|
152
340
|
const match = /^\/(channels|guilds|webhooks)\/(\d+)(?:\/([^/?]+))?/i.exec(path);
|
|
@@ -185,9 +373,22 @@ function serializeQuery(query) {
|
|
|
185
373
|
class RequestScheduler {
|
|
186
374
|
static MAX_REMAPS = 2;
|
|
187
375
|
#queues = new Map;
|
|
376
|
+
#waiting = new Map;
|
|
377
|
+
#queued = 0;
|
|
378
|
+
#maxQueued;
|
|
379
|
+
#maxQueuedPerBucket;
|
|
380
|
+
constructor(options = {}) {
|
|
381
|
+
this.#maxQueued = options.maxQueued ?? Number.POSITIVE_INFINITY;
|
|
382
|
+
this.#maxQueuedPerBucket = options.maxQueuedPerBucket ?? Number.POSITIVE_INFINITY;
|
|
383
|
+
if (!(this.#maxQueued >= 0) || !(this.#maxQueuedPerBucket >= 0))
|
|
384
|
+
throw new RangeError("Queue limits must be non-negative numbers.");
|
|
385
|
+
}
|
|
188
386
|
get size() {
|
|
189
387
|
return this.#queues.size;
|
|
190
388
|
}
|
|
389
|
+
get queued() {
|
|
390
|
+
return this.#queued;
|
|
391
|
+
}
|
|
191
392
|
async runResolved(resolveKey, signal, path, task) {
|
|
192
393
|
let key = await resolveKey();
|
|
193
394
|
for (let hop = 0;hop < RequestScheduler.MAX_REMAPS; hop++) {
|
|
@@ -204,7 +405,10 @@ class RequestScheduler {
|
|
|
204
405
|
return this.run(key, signal, path, (release) => task(key, release));
|
|
205
406
|
}
|
|
206
407
|
async run(key, signal, path, task) {
|
|
207
|
-
const previous = this.#queues.get(key)
|
|
408
|
+
const previous = this.#queues.get(key);
|
|
409
|
+
const waiting = this.#waiting.get(key) ?? 0;
|
|
410
|
+
if (previous && (this.#queued >= this.#maxQueued || waiting >= this.#maxQueuedPerBucket))
|
|
411
|
+
throw new RESTError("The REST request queue is full; request not sent.", 0, undefined, undefined, { path, kind: "overloaded" });
|
|
208
412
|
let open;
|
|
209
413
|
const gate = new Promise((resolve) => {
|
|
210
414
|
open = resolve;
|
|
@@ -220,7 +424,21 @@ class RequestScheduler {
|
|
|
220
424
|
this.#queues.delete(key);
|
|
221
425
|
};
|
|
222
426
|
try {
|
|
223
|
-
|
|
427
|
+
if (previous) {
|
|
428
|
+
this.#queued++;
|
|
429
|
+
this.#waiting.set(key, waiting + 1);
|
|
430
|
+
try {
|
|
431
|
+
await abortable(previous, signal, path);
|
|
432
|
+
} finally {
|
|
433
|
+
this.#queued--;
|
|
434
|
+
const left = (this.#waiting.get(key) ?? 1) - 1;
|
|
435
|
+
if (left > 0)
|
|
436
|
+
this.#waiting.set(key, left);
|
|
437
|
+
else
|
|
438
|
+
this.#waiting.delete(key);
|
|
439
|
+
}
|
|
440
|
+
} else
|
|
441
|
+
await abortable(Promise.resolve(), signal, path);
|
|
224
442
|
return await task(release);
|
|
225
443
|
} finally {
|
|
226
444
|
release();
|
|
@@ -228,90 +446,520 @@ class RequestScheduler {
|
|
|
228
446
|
}
|
|
229
447
|
}
|
|
230
448
|
|
|
449
|
+
// packages/rest/src/headers.ts
|
|
450
|
+
function headerNumber(headers, name) {
|
|
451
|
+
const raw = headers.get(name);
|
|
452
|
+
if (raw === null || raw.trim() === "")
|
|
453
|
+
return;
|
|
454
|
+
const value = Number(raw);
|
|
455
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
456
|
+
}
|
|
457
|
+
var BUCKET_HASH = /^[A-Za-z0-9:_\-.]{1,64}$/;
|
|
458
|
+
var MAX_COUNT = 1e5;
|
|
459
|
+
function count(value) {
|
|
460
|
+
return value === undefined || value > MAX_COUNT ? undefined : Math.floor(value);
|
|
461
|
+
}
|
|
462
|
+
function parseRateLimitHeaders(headers) {
|
|
463
|
+
const remaining = headerNumber(headers, "X-RateLimit-Remaining");
|
|
464
|
+
const limit = headerNumber(headers, "X-RateLimit-Limit");
|
|
465
|
+
const resetAfter = headerNumber(headers, "X-RateLimit-Reset-After");
|
|
466
|
+
const reset = headerNumber(headers, "X-RateLimit-Reset");
|
|
467
|
+
const retryAfter = headerNumber(headers, "Retry-After");
|
|
468
|
+
const bucket = headers.get("X-RateLimit-Bucket")?.trim();
|
|
469
|
+
const scope = headers.get("X-RateLimit-Scope")?.trim().toLowerCase();
|
|
470
|
+
return {
|
|
471
|
+
limit: count(limit),
|
|
472
|
+
remaining: count(remaining),
|
|
473
|
+
resetAfterMs: resetAfter === undefined ? undefined : resetAfter * 1000,
|
|
474
|
+
resetAtMs: reset === undefined ? undefined : reset * 1000,
|
|
475
|
+
bucket: bucket && BUCKET_HASH.test(bucket) ? bucket : undefined,
|
|
476
|
+
scope: scope === "user" || scope === "global" || scope === "shared" ? scope : undefined,
|
|
477
|
+
global: headers.get("X-RateLimit-Global")?.trim().toLowerCase() === "true",
|
|
478
|
+
retryAfterMs: retryAfter === undefined ? undefined : retryAfter * 1000
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// packages/rest/src/telemetry.ts
|
|
483
|
+
function emitTelemetry(sink, event) {
|
|
484
|
+
if (!sink)
|
|
485
|
+
return;
|
|
486
|
+
try {
|
|
487
|
+
sink.emit(event);
|
|
488
|
+
} catch {}
|
|
489
|
+
}
|
|
490
|
+
var LATENCY_BOUNDS = [
|
|
491
|
+
1,
|
|
492
|
+
5,
|
|
493
|
+
10,
|
|
494
|
+
25,
|
|
495
|
+
50,
|
|
496
|
+
100,
|
|
497
|
+
250,
|
|
498
|
+
500,
|
|
499
|
+
1000,
|
|
500
|
+
2500,
|
|
501
|
+
5000,
|
|
502
|
+
1e4,
|
|
503
|
+
30000,
|
|
504
|
+
60000
|
|
505
|
+
];
|
|
506
|
+
|
|
507
|
+
class RESTMetrics {
|
|
508
|
+
#counters = new Map;
|
|
509
|
+
#gauges = new Map;
|
|
510
|
+
#histograms = new Map;
|
|
511
|
+
#routes = new Set;
|
|
512
|
+
#maxRoutes;
|
|
513
|
+
constructor(options = {}) {
|
|
514
|
+
this.#maxRoutes = options.maxRoutes ?? 500;
|
|
515
|
+
}
|
|
516
|
+
#route(route) {
|
|
517
|
+
if (this.#routes.has(route))
|
|
518
|
+
return route;
|
|
519
|
+
if (this.#routes.size >= this.#maxRoutes)
|
|
520
|
+
return "other";
|
|
521
|
+
this.#routes.add(route);
|
|
522
|
+
return route;
|
|
523
|
+
}
|
|
524
|
+
#inc(name, labels = {}, by = 1) {
|
|
525
|
+
const key = metricKey(name, labels);
|
|
526
|
+
this.#counters.set(key, (this.#counters.get(key) ?? 0) + by);
|
|
527
|
+
}
|
|
528
|
+
#observe(name, value, labels = {}) {
|
|
529
|
+
const key = metricKey(name, labels);
|
|
530
|
+
let h = this.#histograms.get(key);
|
|
531
|
+
if (!h) {
|
|
532
|
+
h = {
|
|
533
|
+
counts: new Array(LATENCY_BOUNDS.length + 1).fill(0),
|
|
534
|
+
sum: 0,
|
|
535
|
+
count: 0
|
|
536
|
+
};
|
|
537
|
+
this.#histograms.set(key, h);
|
|
538
|
+
}
|
|
539
|
+
let i = LATENCY_BOUNDS.findIndex((bound) => value <= bound);
|
|
540
|
+
if (i === -1)
|
|
541
|
+
i = LATENCY_BOUNDS.length;
|
|
542
|
+
h.counts[i]++;
|
|
543
|
+
h.sum += value;
|
|
544
|
+
h.count++;
|
|
545
|
+
}
|
|
546
|
+
emit(event) {
|
|
547
|
+
switch (event.type) {
|
|
548
|
+
case "response":
|
|
549
|
+
this.#inc("responses_total", {
|
|
550
|
+
method: event.method,
|
|
551
|
+
route: this.#route(event.route),
|
|
552
|
+
status: String(event.status)
|
|
553
|
+
});
|
|
554
|
+
this.#observe("response_latency_ms", event.durationMs, {
|
|
555
|
+
method: event.method
|
|
556
|
+
});
|
|
557
|
+
break;
|
|
558
|
+
case "request":
|
|
559
|
+
this.#inc("requests_total", {
|
|
560
|
+
method: event.method,
|
|
561
|
+
outcome: event.ok ? "ok" : "error"
|
|
562
|
+
});
|
|
563
|
+
this.#observe("request_latency_ms", event.durationMs, {
|
|
564
|
+
method: event.method
|
|
565
|
+
});
|
|
566
|
+
break;
|
|
567
|
+
case "rateLimited":
|
|
568
|
+
this.#inc("rate_limited_total", {
|
|
569
|
+
scope: event.scope,
|
|
570
|
+
route: this.#route(event.route)
|
|
571
|
+
});
|
|
572
|
+
break;
|
|
573
|
+
case "wait":
|
|
574
|
+
this.#inc("waits_total", { reason: event.reason });
|
|
575
|
+
this.#observe("wait_ms", event.durationMs, {
|
|
576
|
+
reason: event.reason
|
|
577
|
+
});
|
|
578
|
+
break;
|
|
579
|
+
case "reservation":
|
|
580
|
+
this.#inc("reservations_total", {
|
|
581
|
+
outcome: event.granted ? "granted" : "refused"
|
|
582
|
+
});
|
|
583
|
+
this.#observe("reservation_latency_ms", event.latencyMs);
|
|
584
|
+
break;
|
|
585
|
+
case "retry":
|
|
586
|
+
this.#inc("retries_total", { reason: event.reason });
|
|
587
|
+
break;
|
|
588
|
+
case "cancelled":
|
|
589
|
+
this.#inc("cancellations_total", { stage: event.stage });
|
|
590
|
+
break;
|
|
591
|
+
case "overloaded":
|
|
592
|
+
this.#inc("overloaded_total");
|
|
593
|
+
break;
|
|
594
|
+
case "tokenRejected":
|
|
595
|
+
this.#inc("token_rejected_total");
|
|
596
|
+
break;
|
|
597
|
+
case "networkError":
|
|
598
|
+
this.#inc("network_errors_total", {
|
|
599
|
+
timeout: String(event.timedOut)
|
|
600
|
+
});
|
|
601
|
+
break;
|
|
602
|
+
case "bucket":
|
|
603
|
+
this.#inc("bucket_changes_total", { change: event.change });
|
|
604
|
+
break;
|
|
605
|
+
case "store":
|
|
606
|
+
this.#inc("store_commands_total", {
|
|
607
|
+
operation: event.operation,
|
|
608
|
+
ok: String(event.ok)
|
|
609
|
+
});
|
|
610
|
+
this.#observe("store_latency_ms", event.latencyMs, {
|
|
611
|
+
operation: event.operation
|
|
612
|
+
});
|
|
613
|
+
break;
|
|
614
|
+
case "mode":
|
|
615
|
+
this.#inc("mode_changes_total", { mode: event.mode });
|
|
616
|
+
this.#gauges.set("degraded", event.mode === "degraded-local" || event.mode === "unavailable" ? 1 : 0);
|
|
617
|
+
break;
|
|
618
|
+
case "queue":
|
|
619
|
+
this.#gauges.set("queued", event.queued);
|
|
620
|
+
this.#gauges.set("in_flight", event.inFlight);
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
snapshot() {
|
|
625
|
+
const histograms = {};
|
|
626
|
+
for (const [key, h] of this.#histograms) {
|
|
627
|
+
const buckets = {};
|
|
628
|
+
let running = 0;
|
|
629
|
+
LATENCY_BOUNDS.forEach((bound, i) => {
|
|
630
|
+
running += h.counts[i];
|
|
631
|
+
buckets[String(bound)] = running;
|
|
632
|
+
});
|
|
633
|
+
buckets["+Inf"] = h.count;
|
|
634
|
+
histograms[key] = { count: h.count, sum: h.sum, buckets };
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
counters: Object.fromEntries(this.#counters),
|
|
638
|
+
gauges: Object.fromEntries(this.#gauges),
|
|
639
|
+
histograms
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
reset() {
|
|
643
|
+
this.#counters.clear();
|
|
644
|
+
this.#gauges.clear();
|
|
645
|
+
this.#histograms.clear();
|
|
646
|
+
this.#routes.clear();
|
|
647
|
+
}
|
|
648
|
+
toPrometheus(prefix = "lunibee_rest") {
|
|
649
|
+
const lines = [];
|
|
650
|
+
const typed = new Set;
|
|
651
|
+
const type = (name, kind) => {
|
|
652
|
+
if (typed.has(name))
|
|
653
|
+
return;
|
|
654
|
+
typed.add(name);
|
|
655
|
+
lines.push(`# TYPE ${name} ${kind}`);
|
|
656
|
+
};
|
|
657
|
+
for (const [key, value] of this.#counters) {
|
|
658
|
+
const name = `${prefix}_${key.split("{")[0]}`;
|
|
659
|
+
type(name, "counter");
|
|
660
|
+
lines.push(`${prefix}_${key} ${value}`);
|
|
661
|
+
}
|
|
662
|
+
for (const [key, value] of this.#gauges) {
|
|
663
|
+
type(`${prefix}_${key}`, "gauge");
|
|
664
|
+
lines.push(`${prefix}_${key} ${value}`);
|
|
665
|
+
}
|
|
666
|
+
for (const [key, h] of this.#histograms) {
|
|
667
|
+
const [base, rawLabels = ""] = key.split("{");
|
|
668
|
+
const labels = rawLabels.replace(/\}$/, "");
|
|
669
|
+
const name = `${prefix}_${base}`;
|
|
670
|
+
type(name, "histogram");
|
|
671
|
+
const join = (extra) => `{${[labels, extra].filter(Boolean).join(",")}}`;
|
|
672
|
+
let running = 0;
|
|
673
|
+
LATENCY_BOUNDS.forEach((bound, i) => {
|
|
674
|
+
running += h.counts[i];
|
|
675
|
+
lines.push(`${name}_bucket${join(`le="${bound}"`)} ${running}`);
|
|
676
|
+
});
|
|
677
|
+
lines.push(`${name}_bucket${join('le="+Inf"')} ${h.count}`);
|
|
678
|
+
lines.push(`${name}_sum${labels ? `{${labels}}` : ""} ${h.sum}`);
|
|
679
|
+
lines.push(`${name}_count${labels ? `{${labels}}` : ""} ${h.count}`);
|
|
680
|
+
}
|
|
681
|
+
return `${lines.join(`
|
|
682
|
+
`)}
|
|
683
|
+
`;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function metricKey(name, labels) {
|
|
687
|
+
const entries = Object.entries(labels);
|
|
688
|
+
if (entries.length === 0)
|
|
689
|
+
return name;
|
|
690
|
+
return `${name}{${entries.map(([k, v]) => `${k}="${v.replace(/["\\\n]/g, "_")}"`).join(",")}}`;
|
|
691
|
+
}
|
|
692
|
+
|
|
231
693
|
// packages/rest/src/limiter.ts
|
|
694
|
+
var DEFAULT_MAX_BACKOFF_MS = 600000;
|
|
695
|
+
var MAX_IDLE_REFUSALS = 16;
|
|
696
|
+
var MAX_CACHED_ROUTES = 1e4;
|
|
697
|
+
|
|
232
698
|
class RateLimiter {
|
|
233
699
|
#store;
|
|
234
|
-
#
|
|
700
|
+
#maxBackoff;
|
|
701
|
+
#telemetry;
|
|
702
|
+
#probeMs;
|
|
703
|
+
#routes = new Map;
|
|
704
|
+
#globalLimit;
|
|
705
|
+
#localGlobal = new Map;
|
|
235
706
|
constructor(store, options = {}) {
|
|
236
707
|
this.#store = store;
|
|
237
|
-
this.#
|
|
708
|
+
this.#maxBackoff = options.maxBackoff ?? DEFAULT_MAX_BACKOFF_MS;
|
|
709
|
+
if (!(this.#maxBackoff >= 0))
|
|
710
|
+
throw new RangeError("maxBackoff must be a non-negative number.");
|
|
711
|
+
this.#probeMs = options.probeMs ?? DEFAULT_PROBE_MS;
|
|
712
|
+
if (!(this.#probeMs > 0))
|
|
713
|
+
throw new RangeError("probeMs must be a positive number.");
|
|
714
|
+
this.#telemetry = options.telemetry;
|
|
715
|
+
this.#globalLimit = options.globalLimit ?? 0;
|
|
716
|
+
if (!(this.#globalLimit >= 0) || !Number.isFinite(this.#globalLimit))
|
|
717
|
+
throw new RangeError("globalLimit must be a non-negative number.");
|
|
718
|
+
}
|
|
719
|
+
get maxBackoff() {
|
|
720
|
+
return this.#maxBackoff;
|
|
721
|
+
}
|
|
722
|
+
#capWait(ms) {
|
|
723
|
+
return Math.min(Math.max(0, ms), this.#maxBackoff);
|
|
238
724
|
}
|
|
239
725
|
get store() {
|
|
240
726
|
return this.#store;
|
|
241
727
|
}
|
|
242
|
-
async resolveBucketKey(route, major) {
|
|
243
|
-
return scopeBucket(await this.#store.getBucketHash(route) ?? route, major);
|
|
244
|
-
}
|
|
245
728
|
get reserves() {
|
|
246
729
|
return typeof this.#store.reserve === "function";
|
|
247
730
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
731
|
+
#cacheRoute(route, hash) {
|
|
732
|
+
this.#routes.delete(route);
|
|
733
|
+
this.#routes.set(route, hash);
|
|
734
|
+
if (this.#routes.size > MAX_CACHED_ROUTES)
|
|
735
|
+
this.#routes.delete(this.#routes.keys().next().value);
|
|
736
|
+
}
|
|
737
|
+
async resolveBucketKey(route, major, identity) {
|
|
738
|
+
let hash = this.#routes.get(`${route}\x00${major}`) ?? this.#routes.get(route);
|
|
739
|
+
if (hash === undefined) {
|
|
740
|
+
hash = await this.#store.getBucketHash(route);
|
|
741
|
+
if (hash !== undefined)
|
|
742
|
+
this.#cacheRoute(route, hash);
|
|
743
|
+
}
|
|
744
|
+
const key = scopeBucket(hash ?? route, major);
|
|
745
|
+
return identity === undefined ? key : `${identity}/${key}`;
|
|
746
|
+
}
|
|
747
|
+
async#call(path, operation, call) {
|
|
748
|
+
try {
|
|
749
|
+
return await call();
|
|
750
|
+
} catch (error) {
|
|
751
|
+
if (error instanceof RESTError)
|
|
752
|
+
throw error;
|
|
753
|
+
throw new RESTError(`The rate-limit store failed (${operation}); request not sent.`, 0, undefined, undefined, { path, kind: "coordination", cause: sanitizeCause(error) });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
async acquire(bucketKey, signal, path, options = {}) {
|
|
757
|
+
const store = this.#store;
|
|
758
|
+
const global = "global" in options ? options.global : "";
|
|
759
|
+
if (!store.reserve) {
|
|
760
|
+
await this.#waitGlobal(global ?? "", signal, path);
|
|
761
|
+
if (global !== undefined)
|
|
762
|
+
await this.#localGlobalSlot(global, signal, path);
|
|
763
|
+
const bucket = await this.#call(path, "getBucket", () => store.getBucket(bucketKey));
|
|
252
764
|
if (bucket) {
|
|
253
765
|
const delay = bucket.resetAt - Date.now();
|
|
254
766
|
if (bucket.remaining <= 0 && delay > 0)
|
|
255
|
-
await
|
|
767
|
+
await this.#wait("bucket", delay, signal, path);
|
|
256
768
|
}
|
|
769
|
+
return {
|
|
770
|
+
reserved: false,
|
|
771
|
+
known: !!bucket && bucket.resetAt > Date.now()
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const started = performance.now();
|
|
775
|
+
let idle = 0;
|
|
776
|
+
for (;; ) {
|
|
777
|
+
if (global !== undefined && !store.reservesGlobal) {
|
|
778
|
+
await this.#waitGlobal(global, signal, path);
|
|
779
|
+
await this.#localGlobalSlot(global, signal, path);
|
|
780
|
+
}
|
|
781
|
+
const asked = performance.now();
|
|
782
|
+
const reservation = await this.#call(path, "reserve", () => store.reserve(bucketKey, {
|
|
783
|
+
global,
|
|
784
|
+
probeMs: this.#probeMs,
|
|
785
|
+
globalLimit: this.#globalLimit || undefined
|
|
786
|
+
}));
|
|
787
|
+
if (!reservation || typeof reservation !== "object" || typeof reservation.granted !== "boolean")
|
|
788
|
+
throw new RESTError("The rate-limit store returned an invalid reservation; request not sent.", 0, undefined, undefined, { path, kind: "coordination" });
|
|
789
|
+
if (reservation.granted) {
|
|
790
|
+
let known = reservation.known;
|
|
791
|
+
if (known === undefined) {
|
|
792
|
+
const bucket = await this.#call(path, "getBucket", () => store.getBucket(bucketKey));
|
|
793
|
+
known = !!bucket && bucket.resetAt > Date.now();
|
|
794
|
+
}
|
|
795
|
+
emitTelemetry(this.#telemetry, {
|
|
796
|
+
type: "reservation",
|
|
797
|
+
granted: true,
|
|
798
|
+
known,
|
|
799
|
+
latencyMs: performance.now() - asked
|
|
800
|
+
});
|
|
801
|
+
return { reserved: true, known, resetAt: reservation.resetAt };
|
|
802
|
+
}
|
|
803
|
+
emitTelemetry(this.#telemetry, {
|
|
804
|
+
type: "reservation",
|
|
805
|
+
granted: false,
|
|
806
|
+
known: true,
|
|
807
|
+
latencyMs: performance.now() - asked
|
|
808
|
+
});
|
|
809
|
+
let wait = Number(reservation.retryAfterMs);
|
|
810
|
+
if (!(wait > 0)) {
|
|
811
|
+
if (++idle > MAX_IDLE_REFUSALS)
|
|
812
|
+
throw new RESTError("The rate-limit store keeps refusing without a wait; request not sent.", 0, undefined, undefined, { path, kind: "coordination" });
|
|
813
|
+
wait = Math.min(100, 2 ** idle);
|
|
814
|
+
} else
|
|
815
|
+
idle = 0;
|
|
816
|
+
if (performance.now() - started + wait > 2 * this.#maxBackoff)
|
|
817
|
+
throw new RESTError(`Waited for the rate limit longer than twice maxBackoff (${Math.ceil(this.#maxBackoff / 1000)}s); request not sent.`, 429, undefined, undefined, { path });
|
|
818
|
+
await this.#wait(reservation.reason ?? "bucket", wait, signal, path);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
async#wait(reason, delay, signal, path) {
|
|
822
|
+
const ms = Math.min(delay, this.#maxBackoff);
|
|
823
|
+
await sleep(ms, signal, path);
|
|
824
|
+
emitTelemetry(this.#telemetry, {
|
|
825
|
+
type: "wait",
|
|
826
|
+
reason,
|
|
827
|
+
durationMs: ms
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
async#localGlobalSlot(scope, signal, path) {
|
|
831
|
+
if (!this.#globalLimit)
|
|
257
832
|
return;
|
|
833
|
+
let log = this.#localGlobal.get(scope);
|
|
834
|
+
if (!log) {
|
|
835
|
+
this.#localGlobal.set(scope, log = []);
|
|
836
|
+
if (this.#localGlobal.size > MAX_CACHED_ROUTES)
|
|
837
|
+
this.#localGlobal.delete(this.#localGlobal.keys().next().value);
|
|
258
838
|
}
|
|
259
|
-
for (
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
839
|
+
for (;; ) {
|
|
840
|
+
const wait = takeGlobalSlot(log, Date.now(), this.#globalLimit);
|
|
841
|
+
if (wait === 0)
|
|
262
842
|
return;
|
|
263
|
-
await
|
|
264
|
-
await this.#waitGlobal(signal, path);
|
|
843
|
+
await this.#wait("global", wait, signal, path);
|
|
265
844
|
}
|
|
266
845
|
}
|
|
267
|
-
async#waitGlobal(signal, path) {
|
|
268
|
-
const globalDelay = await this.#store.getGlobalReset() - Date.now();
|
|
846
|
+
async#waitGlobal(scope, signal, path) {
|
|
847
|
+
const globalDelay = await this.#call(path, "getGlobalReset", () => this.#store.getGlobalReset(scope)) - Date.now();
|
|
269
848
|
if (globalDelay > 0)
|
|
270
|
-
await
|
|
849
|
+
await this.#wait("global", globalDelay, signal, path);
|
|
271
850
|
}
|
|
272
|
-
async noteGlobalReset(resetAt) {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
851
|
+
async noteGlobalReset(resetAt, scope = "") {
|
|
852
|
+
const waitMs = this.#capWait(resetAt - Date.now());
|
|
853
|
+
if (this.#store.extendGlobalReset) {
|
|
854
|
+
await this.#store.extendGlobalReset(scope, waitMs);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const current = await this.#store.getGlobalReset(scope);
|
|
858
|
+
const target = Date.now() + waitMs;
|
|
859
|
+
if (target > current)
|
|
860
|
+
await this.#store.setGlobalReset(target, scope);
|
|
861
|
+
}
|
|
862
|
+
retryAfterMs(response, body = {}) {
|
|
863
|
+
const fromBody = body.retry_after;
|
|
864
|
+
if (typeof fromBody === "number" && Number.isFinite(fromBody) && fromBody >= 0)
|
|
865
|
+
return fromBody * 1000;
|
|
866
|
+
const headers = parseRateLimitHeaders(response.headers);
|
|
867
|
+
return headers.retryAfterMs ?? headers.resetAfterMs ?? 1000;
|
|
868
|
+
}
|
|
869
|
+
async applyResponse(response, bucketKey, route, major, options = {}) {
|
|
870
|
+
const headers = parseRateLimitHeaders(response.headers);
|
|
277
871
|
let currentKey = bucketKey;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
bucket.resetAt = Date.now() + Math.max(0, resetAfter) * 1000;
|
|
293
|
-
else if (Number.isFinite(reset))
|
|
294
|
-
bucket.resetAt = reset * 1000;
|
|
295
|
-
if (windowActive && stored) {
|
|
296
|
-
bucket.remaining = Math.min(bucket.remaining, stored.remaining);
|
|
297
|
-
bucket.resetAt = Math.max(bucket.resetAt, stored.resetAt);
|
|
298
|
-
}
|
|
299
|
-
if (response.status === 429) {
|
|
300
|
-
const retryAfter = Number(response.headers.get("Retry-After"));
|
|
301
|
-
if (Number.isFinite(retryAfter))
|
|
302
|
-
bucket.resetAt = Math.max(bucket.resetAt, Date.now() + retryAfter * 1000);
|
|
303
|
-
}
|
|
304
|
-
await this.#store.updateBucket(currentKey, bucket);
|
|
305
|
-
if (response.headers.get("X-RateLimit-Global") === "true") {
|
|
306
|
-
const retryAfter = Number(response.headers.get("Retry-After"));
|
|
307
|
-
if (Number.isFinite(retryAfter))
|
|
308
|
-
await this.noteGlobalReset(Date.now() + retryAfter * 1000);
|
|
872
|
+
if (headers.bucket) {
|
|
873
|
+
const previous = this.#routes.get(route);
|
|
874
|
+
this.#cacheRoute(`${route}\x00${major}`, headers.bucket);
|
|
875
|
+
if (previous !== headers.bucket) {
|
|
876
|
+
await this.#store.setBucketHash(route, headers.bucket);
|
|
877
|
+
this.#cacheRoute(route, headers.bucket);
|
|
878
|
+
emitTelemetry(this.#telemetry, {
|
|
879
|
+
type: "bucket",
|
|
880
|
+
change: previous === undefined ? "discovered" : "remapped",
|
|
881
|
+
route
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
const key = scopeBucket(headers.bucket, major);
|
|
885
|
+
currentKey = options.identity === undefined ? key : `${options.identity}/${key}`;
|
|
309
886
|
}
|
|
887
|
+
const limited = response.status === 429;
|
|
888
|
+
const retryAfterMs = limited ? this.#capWait(this.retryAfterMs(response, options.body)) : undefined;
|
|
889
|
+
const global = limited && (options.body?.global === true || headers.global || headers.scope === "global");
|
|
890
|
+
const onGlobal = global && options.global !== undefined;
|
|
891
|
+
let resetAfterMs = headers.resetAfterMs;
|
|
892
|
+
if (resetAfterMs === undefined && headers.resetAtMs !== undefined)
|
|
893
|
+
resetAfterMs = Math.max(0, headers.resetAtMs - Date.now());
|
|
894
|
+
const update = {
|
|
895
|
+
remaining: headers.remaining,
|
|
896
|
+
resetAfterMs: resetAfterMs === undefined ? undefined : this.#capWait(resetAfterMs),
|
|
897
|
+
limit: headers.limit,
|
|
898
|
+
window: headers.resetAtMs !== undefined && Math.abs(headers.resetAtMs - Date.now()) <= this.#maxBackoff ? headers.resetAtMs : undefined,
|
|
899
|
+
retryAfterMs: limited && !onGlobal ? retryAfterMs : undefined
|
|
900
|
+
};
|
|
901
|
+
if (this.#store.reconcile)
|
|
902
|
+
await this.#store.reconcile(currentKey, update);
|
|
903
|
+
else {
|
|
904
|
+
const next = reconcileState(await this.#store.getBucket(currentKey), update, Date.now());
|
|
905
|
+
if (next === null)
|
|
906
|
+
await this.#store.updateBucket(currentKey, {
|
|
907
|
+
remaining: 0,
|
|
908
|
+
resetAt: 0
|
|
909
|
+
});
|
|
910
|
+
else if (next)
|
|
911
|
+
await this.#store.updateBucket(currentKey, next);
|
|
912
|
+
}
|
|
913
|
+
if (currentKey !== bucketKey && options.grant?.reserved && this.#store.release)
|
|
914
|
+
await this.#store.release(bucketKey, options.grant, false);
|
|
915
|
+
if (onGlobal)
|
|
916
|
+
await this.noteGlobalReset(Date.now() + retryAfterMs, options.global);
|
|
310
917
|
return currentKey;
|
|
311
918
|
}
|
|
919
|
+
async releaseGrant(bucketKey, grant, notSent) {
|
|
920
|
+
if (!grant.reserved || !this.#store.release)
|
|
921
|
+
return;
|
|
922
|
+
try {
|
|
923
|
+
await this.#store.release(bucketKey, grant, notSent);
|
|
924
|
+
} catch {}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// packages/rest/src/identity.ts
|
|
929
|
+
import { createHash as createHash2 } from "crypto";
|
|
930
|
+
function tokenFingerprint(token) {
|
|
931
|
+
return createHash2("sha256").update(token).digest("hex").slice(0, 16);
|
|
932
|
+
}
|
|
933
|
+
function requestIdentity(path, fingerprint) {
|
|
934
|
+
if (/^\/interactions\//i.test(path))
|
|
935
|
+
return { bucket: "shared" };
|
|
936
|
+
if (/^\/webhooks\/[^/?]+\/[^/?]+/i.test(path))
|
|
937
|
+
return { bucket: "shared", global: fingerprint ?? "anon" };
|
|
938
|
+
if (!fingerprint)
|
|
939
|
+
return { bucket: "anon", global: "anon" };
|
|
940
|
+
return { bucket: fingerprint, global: fingerprint };
|
|
312
941
|
}
|
|
313
942
|
|
|
314
943
|
// packages/rest/src/transport.ts
|
|
944
|
+
var LOOPBACK = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/i;
|
|
945
|
+
function parseBaseURL(raw, allowInsecure = false) {
|
|
946
|
+
let url;
|
|
947
|
+
try {
|
|
948
|
+
url = new URL(raw);
|
|
949
|
+
} catch {
|
|
950
|
+
throw new TypeError("baseURL must be an absolute http(s) URL.");
|
|
951
|
+
}
|
|
952
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
953
|
+
throw new TypeError("baseURL must use https.");
|
|
954
|
+
if (url.username || url.password)
|
|
955
|
+
throw new TypeError("baseURL must not contain credentials.");
|
|
956
|
+
if (url.search || url.hash || /[?#]/.test(raw))
|
|
957
|
+
throw new TypeError("baseURL must not contain a query or fragment.");
|
|
958
|
+
if (url.protocol === "http:" && !allowInsecure && !LOOPBACK.test(url.hostname))
|
|
959
|
+
throw new TypeError("baseURL must use https: the bot token would travel in cleartext (allowInsecureBaseURL: true overrides this for a trusted network).");
|
|
960
|
+
return url.href.replace(/\/$/, "");
|
|
961
|
+
}
|
|
962
|
+
|
|
315
963
|
class TransportError extends Error {
|
|
316
964
|
timedOut;
|
|
317
965
|
constructor(message, timedOut, cause) {
|
|
@@ -326,7 +974,7 @@ class HttpTransport {
|
|
|
326
974
|
#timeout;
|
|
327
975
|
#fetch;
|
|
328
976
|
constructor(options = {}) {
|
|
329
|
-
this.#baseURL = (options.baseURL ?? "https://discord.com/api/v10"
|
|
977
|
+
this.#baseURL = parseBaseURL(options.baseURL ?? "https://discord.com/api/v10", options.allowInsecureBaseURL);
|
|
330
978
|
this.#timeout = Math.max(1, options.timeout ?? 15000);
|
|
331
979
|
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
332
980
|
}
|
|
@@ -348,13 +996,14 @@ class HttpTransport {
|
|
|
348
996
|
method: request.method,
|
|
349
997
|
headers: request.headers,
|
|
350
998
|
body: request.body,
|
|
351
|
-
signal: controller.signal
|
|
999
|
+
signal: controller.signal,
|
|
1000
|
+
redirect: "error"
|
|
352
1001
|
});
|
|
353
1002
|
} catch (error) {
|
|
354
1003
|
if (error instanceof RESTError)
|
|
355
1004
|
throw error;
|
|
356
1005
|
const timedOut = error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
357
|
-
throw new TransportError(timedOut ? `Discord REST request timed out after ${this.#timeout}ms` : "Discord REST request failed", timedOut, error);
|
|
1006
|
+
throw new TransportError(timedOut ? `Discord REST request timed out after ${this.#timeout}ms` : "Discord REST request failed", timedOut, sanitizeCause(error));
|
|
358
1007
|
} finally {
|
|
359
1008
|
clearTimeout(timer);
|
|
360
1009
|
request.signal?.removeEventListener("abort", onAbort);
|
|
@@ -364,6 +1013,7 @@ class HttpTransport {
|
|
|
364
1013
|
|
|
365
1014
|
// packages/rest/src/decoder.ts
|
|
366
1015
|
class ResponseDecoder {
|
|
1016
|
+
constructor() {}
|
|
367
1017
|
async read(response) {
|
|
368
1018
|
if (response.status === 204)
|
|
369
1019
|
return;
|
|
@@ -532,36 +1182,185 @@ var RESTErrorCode = {
|
|
|
532
1182
|
CannotReplyWithoutReadHistory: 160002
|
|
533
1183
|
};
|
|
534
1184
|
// packages/rest/src/redis.ts
|
|
535
|
-
var
|
|
536
|
-
local
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1185
|
+
var LUA_PRELUDE = `
|
|
1186
|
+
local function now_ms()
|
|
1187
|
+
local t = redis.call('TIME')
|
|
1188
|
+
return tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
|
|
1189
|
+
end
|
|
1190
|
+
local function load(key)
|
|
1191
|
+
local raw = redis.call('GET', key)
|
|
1192
|
+
if not raw then return nil end
|
|
1193
|
+
local ok, s = pcall(cjson.decode, raw)
|
|
1194
|
+
if ok and type(s) == 'table' and type(s.resetAt) == 'number'
|
|
1195
|
+
and type(s.remaining) == 'number' then return s end
|
|
1196
|
+
return nil
|
|
1197
|
+
end
|
|
1198
|
+
local function save(key, s, now, linger)
|
|
1199
|
+
local ttl = math.max(1, math.ceil(s.resetAt - now)) + linger
|
|
1200
|
+
redis.call('SET', key, cjson.encode(s), 'PX', ttl)
|
|
1201
|
+
end
|
|
1202
|
+
`;
|
|
1203
|
+
var RESERVE_SCRIPT = `${LUA_PRELUDE}
|
|
1204
|
+
local now = now_ms()
|
|
1205
|
+
local glimit, gwindow = tonumber(ARGV[4]) or 0, tonumber(ARGV[5]) or 1000
|
|
1206
|
+
if #KEYS > 1 then
|
|
1207
|
+
local g = tonumber(redis.call('GET', KEYS[2]) or '0') or 0
|
|
1208
|
+
if g > now then return {0, math.ceil(g - now), 'global'} end
|
|
1209
|
+
end
|
|
1210
|
+
if #KEYS < 3 then glimit = 0 end
|
|
1211
|
+
-- A sliding log of send times: at most glimit in any gwindow span.
|
|
1212
|
+
if glimit > 0 then
|
|
1213
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[3], '-inf', now - gwindow)
|
|
1214
|
+
if redis.call('ZCARD', KEYS[3]) >= glimit then
|
|
1215
|
+
local oldest = redis.call('ZRANGE', KEYS[3], 0, 0, 'WITHSCORES')
|
|
1216
|
+
return {0, math.max(1, tonumber(oldest[2]) + gwindow - now + 1), 'global'}
|
|
1217
|
+
end
|
|
1218
|
+
end
|
|
1219
|
+
local function count()
|
|
1220
|
+
if glimit > 0 then
|
|
1221
|
+
local seq = redis.call('ZCARD', KEYS[3])
|
|
1222
|
+
redis.call('ZADD', KEYS[3], now, now .. ':' .. seq)
|
|
1223
|
+
redis.call('PEXPIRE', KEYS[3], gwindow + 1000)
|
|
1224
|
+
end
|
|
541
1225
|
end
|
|
542
|
-
local
|
|
543
|
-
local
|
|
544
|
-
if
|
|
545
|
-
if
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1226
|
+
local probe, poll, linger = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
|
|
1227
|
+
local s = load(KEYS[1])
|
|
1228
|
+
if s and s.resetAt > now then
|
|
1229
|
+
if s.remaining > 0 then
|
|
1230
|
+
s.remaining = s.remaining - 1
|
|
1231
|
+
save(KEYS[1], s, now, linger)
|
|
1232
|
+
count()
|
|
1233
|
+
return {1, s.resetAt, 1}
|
|
1234
|
+
end
|
|
1235
|
+
local wait = s.resetAt - now
|
|
1236
|
+
if s.provisional then wait = math.min(wait, poll) end
|
|
1237
|
+
local reason = 'bucket'
|
|
1238
|
+
if s.probe then reason = 'probe' end
|
|
1239
|
+
return {0, math.ceil(wait), reason}
|
|
549
1240
|
end
|
|
550
|
-
|
|
1241
|
+
local resetAt = now + probe
|
|
1242
|
+
if s and type(s.limit) == 'number' and s.limit > 0 then
|
|
1243
|
+
save(KEYS[1], {remaining = s.limit - 1, resetAt = resetAt, limit = s.limit, provisional = true}, now, linger)
|
|
1244
|
+
count()
|
|
1245
|
+
return {1, resetAt, 1}
|
|
1246
|
+
end
|
|
1247
|
+
save(KEYS[1], {remaining = 0, resetAt = resetAt, provisional = true, probe = true}, now, 0)
|
|
1248
|
+
count()
|
|
1249
|
+
return {1, resetAt, 0}
|
|
1250
|
+
`;
|
|
1251
|
+
var RECONCILE_SCRIPT = `${LUA_PRELUDE}
|
|
1252
|
+
local now = now_ms()
|
|
1253
|
+
local rem, after, limit = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
|
|
1254
|
+
local win, retry, linger = tonumber(ARGV[4]), tonumber(ARGV[5]), tonumber(ARGV[6])
|
|
1255
|
+
local tol = 50
|
|
1256
|
+
local incoming = nil
|
|
1257
|
+
if after then incoming = math.ceil(now + after) end
|
|
1258
|
+
local s = load(KEYS[1])
|
|
1259
|
+
local n
|
|
1260
|
+
if s and s.resetAt > now and not s.probe then
|
|
1261
|
+
n = s
|
|
1262
|
+
local stale = win and n.window and win < n.window - tol
|
|
1263
|
+
if not stale then
|
|
1264
|
+
if rem then n.remaining = math.min(n.remaining, rem) end
|
|
1265
|
+
if incoming then
|
|
1266
|
+
local newer = win and n.window and win > n.window + tol
|
|
1267
|
+
if n.provisional or newer then n.resetAt = incoming
|
|
1268
|
+
else n.resetAt = math.max(n.resetAt, incoming) end
|
|
1269
|
+
n.provisional = nil
|
|
1270
|
+
end
|
|
1271
|
+
if win then n.window = math.max(n.window or 0, win) end
|
|
1272
|
+
end
|
|
1273
|
+
else
|
|
1274
|
+
if not rem and not retry then
|
|
1275
|
+
if s and s.probe then redis.call('DEL', KEYS[1]) end
|
|
1276
|
+
return 0
|
|
1277
|
+
end
|
|
1278
|
+
n = {remaining = rem or 0, resetAt = incoming or now}
|
|
1279
|
+
if s and type(s.limit) == 'number' then n.limit = s.limit end
|
|
1280
|
+
if win then n.window = win end
|
|
1281
|
+
end
|
|
1282
|
+
if limit and limit > 0 then n.limit = limit end
|
|
1283
|
+
if retry then
|
|
1284
|
+
n.remaining = 0
|
|
1285
|
+
n.resetAt = math.max(n.resetAt, math.ceil(now + retry))
|
|
1286
|
+
n.provisional = nil
|
|
1287
|
+
end
|
|
1288
|
+
save(KEYS[1], n, now, linger)
|
|
1289
|
+
return 1
|
|
551
1290
|
`;
|
|
1291
|
+
var EXTEND_GLOBAL_SCRIPT = `${LUA_PRELUDE}
|
|
1292
|
+
local now = now_ms()
|
|
1293
|
+
local target = math.ceil(now + tonumber(ARGV[1]))
|
|
1294
|
+
local cur = tonumber(redis.call('GET', KEYS[1]) or '0') or 0
|
|
1295
|
+
if target > cur then
|
|
1296
|
+
redis.call('SET', KEYS[1], tostring(target), 'PX', math.max(1, target - now))
|
|
1297
|
+
return target - now
|
|
1298
|
+
end
|
|
1299
|
+
return math.max(0, cur - now)
|
|
1300
|
+
`;
|
|
1301
|
+
var RELEASE_SCRIPT = `${LUA_PRELUDE}
|
|
1302
|
+
local s = load(KEYS[1])
|
|
1303
|
+
if not s or s.resetAt ~= tonumber(ARGV[1]) then return 0 end
|
|
1304
|
+
if s.probe then
|
|
1305
|
+
redis.call('DEL', KEYS[1])
|
|
1306
|
+
return 1
|
|
1307
|
+
end
|
|
1308
|
+
if ARGV[2] == '1' then
|
|
1309
|
+
local cap = s.remaining + 1
|
|
1310
|
+
if type(s.limit) == 'number' then cap = math.min(cap, s.limit) end
|
|
1311
|
+
s.remaining = cap
|
|
1312
|
+
redis.call('SET', KEYS[1], cjson.encode(s), 'KEEPTTL')
|
|
1313
|
+
return 1
|
|
1314
|
+
end
|
|
1315
|
+
return 0
|
|
1316
|
+
`;
|
|
1317
|
+
var VERIFY_SCRIPT = "return redis.call('TIME')[1]";
|
|
1318
|
+
function fromBunRedis(client) {
|
|
1319
|
+
return {
|
|
1320
|
+
get: (key) => client.send("GET", [key]),
|
|
1321
|
+
mget: (keys) => client.send("MGET", keys),
|
|
1322
|
+
set: (key, value, mode, duration) => client.send("SET", mode === undefined ? [key, String(value)] : [key, String(value), mode, String(duration)]),
|
|
1323
|
+
del: (...keys) => client.send("DEL", keys),
|
|
1324
|
+
eval: (script, numKeys, ...args) => client.send("EVAL", [script, String(numKeys), ...args.map(String)])
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
var SCHEMA = "v1";
|
|
552
1328
|
|
|
553
1329
|
class RedisRateLimitStore {
|
|
554
1330
|
#client;
|
|
555
1331
|
#prefix;
|
|
556
1332
|
#onErrorCallback;
|
|
1333
|
+
#policy;
|
|
1334
|
+
#timeout;
|
|
1335
|
+
#telemetry;
|
|
1336
|
+
#atomic;
|
|
557
1337
|
#local = new MemoryRateLimitStore;
|
|
558
1338
|
#lastError;
|
|
1339
|
+
#lastFailureAt;
|
|
1340
|
+
#failures = 0;
|
|
1341
|
+
#mode;
|
|
559
1342
|
constructor(options) {
|
|
560
1343
|
this.#client = options.client;
|
|
561
|
-
this.#prefix = options.prefix ?? "lunibee:rest:"
|
|
1344
|
+
this.#prefix = `${options.prefix ?? "lunibee:rest:"}${SCHEMA}:`;
|
|
562
1345
|
this.#onErrorCallback = options.onError;
|
|
563
|
-
|
|
564
|
-
|
|
1346
|
+
this.#policy = options.failurePolicy ?? "degraded-local";
|
|
1347
|
+
if (this.#policy !== "degraded-local" && this.#policy !== "fail-closed")
|
|
1348
|
+
throw new TypeError('failurePolicy must be "degraded-local" or "fail-closed".');
|
|
1349
|
+
this.#timeout = options.commandTimeout ?? 2000;
|
|
1350
|
+
if (!(this.#timeout > 0))
|
|
1351
|
+
throw new RangeError("commandTimeout must be a positive number.");
|
|
1352
|
+
this.#telemetry = options.telemetry;
|
|
1353
|
+
this.#atomic = typeof options.client.eval === "function";
|
|
1354
|
+
if (options.requireAtomic && !this.#atomic)
|
|
1355
|
+
throw new TypeError("requireAtomic: the Redis client has no eval method, so reservations cannot be atomic.");
|
|
1356
|
+
this.#mode = this.#atomic ? "distributed" : "observational";
|
|
1357
|
+
if (this.#atomic) {
|
|
1358
|
+
this.reserve = (key, opts) => this.#reserve(key, opts);
|
|
1359
|
+
this.reconcile = (key, update) => this.#reconcile(key, update);
|
|
1360
|
+
this.extendGlobalReset = (scope, waitMs) => this.#extendGlobal(scope, waitMs);
|
|
1361
|
+
this.release = (key, grant, refund) => this.#release(key, grant, refund);
|
|
1362
|
+
this.reservesGlobal = true;
|
|
1363
|
+
}
|
|
565
1364
|
}
|
|
566
1365
|
get lastError() {
|
|
567
1366
|
return this.#lastError;
|
|
@@ -569,107 +1368,204 @@ class RedisRateLimitStore {
|
|
|
569
1368
|
isHealthy() {
|
|
570
1369
|
return this.#lastError === undefined;
|
|
571
1370
|
}
|
|
572
|
-
|
|
573
|
-
this.#
|
|
574
|
-
console.warn(`[lunibee/rest] Redis rate-limit store ${operation} failed; ` + `falling back to local limiting for this call.`, error);
|
|
575
|
-
try {
|
|
576
|
-
this.#onErrorCallback?.(operation, error);
|
|
577
|
-
} catch {}
|
|
1371
|
+
get mode() {
|
|
1372
|
+
return this.#mode;
|
|
578
1373
|
}
|
|
579
|
-
|
|
580
|
-
this.#
|
|
1374
|
+
get supportsReservation() {
|
|
1375
|
+
return this.#atomic;
|
|
581
1376
|
}
|
|
582
|
-
|
|
1377
|
+
diagnostics() {
|
|
1378
|
+
const error = this.#lastError;
|
|
1379
|
+
return {
|
|
1380
|
+
mode: this.#mode,
|
|
1381
|
+
failurePolicy: this.#policy,
|
|
1382
|
+
atomic: this.#atomic,
|
|
1383
|
+
failures: this.#failures,
|
|
1384
|
+
lastFailureAt: this.#lastFailureAt,
|
|
1385
|
+
lastError: error === undefined ? undefined : String(sanitizeCause(error)?.message ?? error)
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
async verify() {
|
|
1389
|
+
if (!this.#atomic)
|
|
1390
|
+
throw new TypeError("The Redis client has no eval method: reservations cannot be atomic.");
|
|
583
1391
|
try {
|
|
584
|
-
|
|
585
|
-
this.#markHealthy();
|
|
586
|
-
return hash ?? undefined;
|
|
1392
|
+
await this.#withTimeout(this.#client.eval(VERIFY_SCRIPT, 0));
|
|
587
1393
|
} catch (error) {
|
|
588
|
-
|
|
589
|
-
return this.#local.getBucketHash(route);
|
|
1394
|
+
throw new RESTError("The Redis rate-limit store cannot run its scripts.", 0, undefined, undefined, { kind: "coordination", cause: sanitizeCause(error) });
|
|
590
1395
|
}
|
|
591
1396
|
}
|
|
592
|
-
|
|
593
|
-
this.#
|
|
1397
|
+
#setMode(mode) {
|
|
1398
|
+
if (mode === this.#mode)
|
|
1399
|
+
return;
|
|
1400
|
+
const previous = this.#mode;
|
|
1401
|
+
this.#mode = mode;
|
|
1402
|
+
emitTelemetry(this.#telemetry, { type: "mode", mode, previous });
|
|
1403
|
+
console.warn(mode === "degraded-local" ? "[lunibee/rest] Redis rate-limit store unreachable: limiting locally in this process until it answers." : mode === "unavailable" ? "[lunibee/rest] Redis rate-limit store unreachable: requests fail until it answers (fail-closed)." : "[lunibee/rest] Redis rate-limit store reachable again: limits are shared.");
|
|
1404
|
+
}
|
|
1405
|
+
#withTimeout(promise) {
|
|
1406
|
+
let timer;
|
|
1407
|
+
return Promise.race([
|
|
1408
|
+
promise,
|
|
1409
|
+
new Promise((_, reject) => {
|
|
1410
|
+
timer = setTimeout(() => reject(new Error(`Redis command timed out after ${this.#timeout}ms`)), this.#timeout);
|
|
1411
|
+
})
|
|
1412
|
+
]).finally(() => clearTimeout(timer));
|
|
1413
|
+
}
|
|
1414
|
+
async#run(operation, command, fallback, enforcing) {
|
|
1415
|
+
const started = performance.now();
|
|
594
1416
|
try {
|
|
595
|
-
await this.#
|
|
596
|
-
this.#
|
|
1417
|
+
const value = await this.#withTimeout(command());
|
|
1418
|
+
emitTelemetry(this.#telemetry, {
|
|
1419
|
+
type: "store",
|
|
1420
|
+
operation,
|
|
1421
|
+
latencyMs: performance.now() - started,
|
|
1422
|
+
ok: true
|
|
1423
|
+
});
|
|
1424
|
+
this.#lastError = undefined;
|
|
1425
|
+
this.#setMode(this.#atomic ? "distributed" : "observational");
|
|
1426
|
+
return value;
|
|
597
1427
|
} catch (error) {
|
|
598
|
-
this.#
|
|
1428
|
+
emitTelemetry(this.#telemetry, {
|
|
1429
|
+
type: "store",
|
|
1430
|
+
operation,
|
|
1431
|
+
latencyMs: performance.now() - started,
|
|
1432
|
+
ok: false
|
|
1433
|
+
});
|
|
1434
|
+
this.#lastError = error;
|
|
1435
|
+
this.#lastFailureAt = Date.now();
|
|
1436
|
+
this.#failures++;
|
|
1437
|
+
try {
|
|
1438
|
+
this.#onErrorCallback?.(operation, error);
|
|
1439
|
+
} catch {}
|
|
1440
|
+
if (this.#policy === "fail-closed") {
|
|
1441
|
+
this.#setMode("unavailable");
|
|
1442
|
+
if (enforcing)
|
|
1443
|
+
throw new RESTError(`The Redis rate-limit store is unavailable (${operation}); request not sent (failurePolicy: "fail-closed").`, 0, undefined, undefined, { kind: "coordination", cause: sanitizeCause(error) });
|
|
1444
|
+
} else
|
|
1445
|
+
this.#setMode("degraded-local");
|
|
1446
|
+
return fallback();
|
|
599
1447
|
}
|
|
600
1448
|
}
|
|
1449
|
+
#bucketKey(key) {
|
|
1450
|
+
return `${this.#prefix}bucket:${key}`;
|
|
1451
|
+
}
|
|
1452
|
+
#globalKey(scope) {
|
|
1453
|
+
return `${this.#prefix}global:${scope}`;
|
|
1454
|
+
}
|
|
1455
|
+
async getBucketHash(route) {
|
|
1456
|
+
return this.#run("getBucketHash", async () => await this.#client.get(`${this.#prefix}route:${route}`) ?? undefined, () => this.#local.getBucketHash(route), false);
|
|
1457
|
+
}
|
|
1458
|
+
async setBucketHash(route, hash) {
|
|
1459
|
+
this.#local.setBucketHash(route, hash);
|
|
1460
|
+
await this.#run("setBucketHash", async () => {
|
|
1461
|
+
await this.#client.set(`${this.#prefix}route:${route}`, hash, "EX", 604800);
|
|
1462
|
+
}, () => {
|
|
1463
|
+
return;
|
|
1464
|
+
}, false);
|
|
1465
|
+
}
|
|
601
1466
|
async getBucket(key) {
|
|
602
|
-
|
|
603
|
-
const data = await this.#client.get(
|
|
604
|
-
this.#markHealthy();
|
|
1467
|
+
return this.#run("getBucket", async () => {
|
|
1468
|
+
const data = await this.#client.get(this.#bucketKey(key));
|
|
605
1469
|
if (!data)
|
|
606
1470
|
return;
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
return this.#local.getBucket(key);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
async updateBucket(key, state) {
|
|
614
|
-
this.#local.updateBucket(key, state);
|
|
615
|
-
const bucketKey = `${this.#prefix}bucket:${key}`;
|
|
616
|
-
const ttl = Math.ceil((state.resetAt - Date.now()) / 1000);
|
|
617
|
-
try {
|
|
618
|
-
if (ttl <= 0) {
|
|
619
|
-
await this.#client.del(bucketKey);
|
|
620
|
-
this.#markHealthy();
|
|
1471
|
+
try {
|
|
1472
|
+
return JSON.parse(data);
|
|
1473
|
+
} catch {
|
|
621
1474
|
return;
|
|
622
1475
|
}
|
|
623
|
-
|
|
624
|
-
this.#markHealthy();
|
|
625
|
-
} catch (error) {
|
|
626
|
-
this.#onError("updateBucket", error);
|
|
627
|
-
}
|
|
1476
|
+
}, () => this.#local.getBucket(key), true);
|
|
628
1477
|
}
|
|
629
|
-
|
|
630
|
-
|
|
1478
|
+
async updateBucket(key, state) {
|
|
1479
|
+
this.#local.updateBucket(key, state);
|
|
1480
|
+
const ttl = Math.ceil(state.resetAt - Date.now());
|
|
1481
|
+
await this.#run("updateBucket", async () => {
|
|
1482
|
+
if (ttl <= 0)
|
|
1483
|
+
await this.#client.del(this.#bucketKey(key));
|
|
1484
|
+
else
|
|
1485
|
+
await this.#client.set(this.#bucketKey(key), JSON.stringify(state), "PX", ttl);
|
|
1486
|
+
}, () => {
|
|
1487
|
+
return;
|
|
1488
|
+
}, false);
|
|
631
1489
|
}
|
|
632
1490
|
reserve;
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
return this.#local.reserve(key);
|
|
1491
|
+
reconcile;
|
|
1492
|
+
extendGlobalReset;
|
|
1493
|
+
release;
|
|
1494
|
+
reservesGlobal;
|
|
1495
|
+
async#reserve(key, options = {}) {
|
|
1496
|
+
const keys = [this.#bucketKey(key)];
|
|
1497
|
+
if (options.global !== undefined) {
|
|
1498
|
+
keys.push(this.#globalKey(options.global));
|
|
1499
|
+
if (options.globalLimit)
|
|
1500
|
+
keys.push(`${this.#prefix}global-log:${options.global}`);
|
|
644
1501
|
}
|
|
1502
|
+
return this.#run("reserve", async () => {
|
|
1503
|
+
const result = await this.#client.eval(RESERVE_SCRIPT, keys.length, ...keys, options.probeMs ?? DEFAULT_PROBE_MS, PROBE_POLL_MS, BUCKET_LINGER_MS, options.globalLimit ?? 0, GLOBAL_WINDOW_MS);
|
|
1504
|
+
return parseReservation(result);
|
|
1505
|
+
}, () => this.#local.reserve(key, options), true);
|
|
1506
|
+
}
|
|
1507
|
+
async#reconcile(key, update) {
|
|
1508
|
+
this.#local.reconcile(key, update);
|
|
1509
|
+
const arg = (value) => value === undefined ? "" : String(value);
|
|
1510
|
+
await this.#run("reconcile", async () => {
|
|
1511
|
+
await this.#client.eval(RECONCILE_SCRIPT, 1, this.#bucketKey(key), arg(update.remaining), arg(update.resetAfterMs), arg(update.limit), arg(update.window), arg(update.retryAfterMs), BUCKET_LINGER_MS);
|
|
1512
|
+
}, () => {
|
|
1513
|
+
return;
|
|
1514
|
+
}, false);
|
|
645
1515
|
}
|
|
646
|
-
async
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
this.#
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
return this.#local.getGlobalReset();
|
|
654
|
-
}
|
|
1516
|
+
async#extendGlobal(scope, waitMs) {
|
|
1517
|
+
this.#local.extendGlobalReset(scope, waitMs);
|
|
1518
|
+
await this.#run("extendGlobalReset", async () => {
|
|
1519
|
+
await this.#client.eval(EXTEND_GLOBAL_SCRIPT, 1, this.#globalKey(scope), Math.max(0, Math.ceil(waitMs)));
|
|
1520
|
+
}, () => {
|
|
1521
|
+
return;
|
|
1522
|
+
}, false);
|
|
655
1523
|
}
|
|
656
|
-
async
|
|
657
|
-
this.#local.
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
}
|
|
1524
|
+
async#release(key, grant, refund) {
|
|
1525
|
+
this.#local.release(key, grant, refund);
|
|
1526
|
+
if (grant.resetAt === undefined)
|
|
1527
|
+
return;
|
|
1528
|
+
await this.#run("release", async () => {
|
|
1529
|
+
await this.#client.eval(RELEASE_SCRIPT, 1, this.#bucketKey(key), grant.resetAt, refund ? "1" : "0");
|
|
1530
|
+
}, () => {
|
|
1531
|
+
return;
|
|
1532
|
+
}, false);
|
|
1533
|
+
}
|
|
1534
|
+
async getGlobalReset(scope = "") {
|
|
1535
|
+
return this.#run("getGlobalReset", async () => {
|
|
1536
|
+
const resetAt = await this.#client.get(this.#globalKey(scope));
|
|
1537
|
+
return resetAt ? Number(resetAt) || 0 : 0;
|
|
1538
|
+
}, () => this.#local.getGlobalReset(scope), true);
|
|
1539
|
+
}
|
|
1540
|
+
async setGlobalReset(resetAt, scope = "") {
|
|
1541
|
+
this.#local.setGlobalReset(resetAt, scope);
|
|
1542
|
+
const ttl = Math.ceil(resetAt - Date.now());
|
|
1543
|
+
await this.#run("setGlobalReset", async () => {
|
|
1544
|
+
if (ttl <= 0)
|
|
1545
|
+
await this.#client.del(this.#globalKey(scope));
|
|
1546
|
+
else
|
|
1547
|
+
await this.#client.set(this.#globalKey(scope), String(resetAt), "PX", ttl);
|
|
1548
|
+
}, () => {
|
|
1549
|
+
return;
|
|
1550
|
+
}, false);
|
|
671
1551
|
}
|
|
672
1552
|
}
|
|
1553
|
+
function parseReservation(result) {
|
|
1554
|
+
if (!Array.isArray(result) || result.length < 3)
|
|
1555
|
+
throw new Error("Unexpected reply from the reservation script.");
|
|
1556
|
+
const [granted, value, flag] = result;
|
|
1557
|
+
const n = Number(value);
|
|
1558
|
+
if (!Number.isFinite(n))
|
|
1559
|
+
throw new Error("Unexpected reply from the reservation script.");
|
|
1560
|
+
if (Number(granted) === 1)
|
|
1561
|
+
return { granted: true, known: Number(flag) === 1, resetAt: n };
|
|
1562
|
+
const reason = String(flag);
|
|
1563
|
+
return {
|
|
1564
|
+
granted: false,
|
|
1565
|
+
retryAfterMs: Math.max(0, n),
|
|
1566
|
+
reason: reason === "global" || reason === "probe" ? reason : "bucket"
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
673
1569
|
// packages/rest/src/webhook.ts
|
|
674
1570
|
class WebhookClient {
|
|
675
1571
|
id;
|
|
@@ -690,29 +1586,49 @@ class WebhookClient {
|
|
|
690
1586
|
}
|
|
691
1587
|
this.#rest = new REST(restOptions);
|
|
692
1588
|
}
|
|
693
|
-
|
|
1589
|
+
#prepare(options, base = {}) {
|
|
694
1590
|
const payload = typeof options === "string" ? { content: options } : options;
|
|
695
|
-
const {
|
|
696
|
-
|
|
1591
|
+
const {
|
|
1592
|
+
thread_id: threadWire,
|
|
1593
|
+
threadId,
|
|
1594
|
+
avatarURL,
|
|
1595
|
+
allowedMentions,
|
|
1596
|
+
files,
|
|
1597
|
+
...body
|
|
1598
|
+
} = payload;
|
|
1599
|
+
const thread_id = threadWire ?? threadId;
|
|
1600
|
+
if (avatarURL !== undefined && body.avatar_url === undefined)
|
|
1601
|
+
body.avatar_url = avatarURL;
|
|
1602
|
+
if (allowedMentions !== undefined && body.allowed_mentions === undefined)
|
|
1603
|
+
body.allowed_mentions = allowedMentions;
|
|
1604
|
+
const params = new URLSearchParams(base);
|
|
697
1605
|
if (thread_id)
|
|
698
1606
|
params.set("thread_id", String(thread_id));
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
1607
|
+
const json = { ...body };
|
|
1608
|
+
if (body.embeds)
|
|
1609
|
+
json.embeds = body.embeds.map((e) => ("toJSON" in e) ? e.toJSON() : e);
|
|
1610
|
+
if (body.components)
|
|
1611
|
+
json.components = body.components.map((c) => ("toJSON" in c) ? c.toJSON() : c);
|
|
1612
|
+
const text = params.toString();
|
|
1613
|
+
return { query: text ? `?${text}` : "", json, files };
|
|
1614
|
+
}
|
|
1615
|
+
async send(options) {
|
|
1616
|
+
const { query, json, files } = this.#prepare(options, { wait: "true" });
|
|
702
1617
|
const path = `${Routes.webhook(this.id, this.token)}${query}`;
|
|
703
|
-
const json = {
|
|
704
|
-
...body,
|
|
705
|
-
embeds: formattedEmbeds,
|
|
706
|
-
components: formattedComponents
|
|
707
|
-
};
|
|
708
1618
|
return files?.length ? this.#rest.postWithFiles(path, json, files) : this.#rest.post(path, json);
|
|
709
1619
|
}
|
|
1620
|
+
async fetchMessage(messageId, options = {}) {
|
|
1621
|
+
const query = options.threadId ? `?${new URLSearchParams({ thread_id: options.threadId })}` : "";
|
|
1622
|
+
return this.#rest.get(`${Routes.webhookMessage(this.id, this.token, messageId)}${query}`);
|
|
1623
|
+
}
|
|
710
1624
|
async editMessage(messageId, options) {
|
|
711
|
-
const
|
|
712
|
-
|
|
1625
|
+
const { query, json, files } = this.#prepare(options);
|
|
1626
|
+
const path = `${Routes.webhookMessage(this.id, this.token, messageId)}${query}`;
|
|
1627
|
+
return files?.length ? this.#rest.patchWithFiles(path, json, files) : this.#rest.patch(path, json);
|
|
713
1628
|
}
|
|
714
|
-
async deleteMessage(messageId) {
|
|
715
|
-
|
|
1629
|
+
async deleteMessage(messageId, options = {}) {
|
|
1630
|
+
const query = options.threadId ? `?${new URLSearchParams({ thread_id: options.threadId })}` : "";
|
|
1631
|
+
await this.#rest.delete(`${Routes.webhookMessage(this.id, this.token, messageId)}${query}`);
|
|
716
1632
|
}
|
|
717
1633
|
}
|
|
718
1634
|
|
|
@@ -721,7 +1637,9 @@ var LIBRARY_VERSION = package_default.version;
|
|
|
721
1637
|
var LIBRARY_URL = "https://github.com/Ekretos/lunibee";
|
|
722
1638
|
var USER_AGENT = `DiscordBot (${LIBRARY_URL}, ${LIBRARY_VERSION})`;
|
|
723
1639
|
function assertSafePath(path) {
|
|
724
|
-
|
|
1640
|
+
if (/[\u0000-\u0020\u007f#]/.test(path))
|
|
1641
|
+
throw new TypeError("REST paths must not contain control characters, spaces or '#'.");
|
|
1642
|
+
const end = path.indexOf("?");
|
|
725
1643
|
const pathname = end === -1 ? path : path.slice(0, end);
|
|
726
1644
|
if (pathname.includes("\\"))
|
|
727
1645
|
throw new TypeError("REST paths must not contain backslashes.");
|
|
@@ -732,7 +1650,11 @@ function assertSafePath(path) {
|
|
|
732
1650
|
} catch {
|
|
733
1651
|
throw new TypeError("REST path has malformed percent-encoding.");
|
|
734
1652
|
}
|
|
735
|
-
|
|
1653
|
+
const bare = decoded.replace(/[\u0000-\u0020\u007f]/g, "");
|
|
1654
|
+
if (bare === "." || bare === ".." || /[\\/]/.test(decoded) && decoded.split(/[\\/]/).some((s) => {
|
|
1655
|
+
const part = s.replace(/[\u0000-\u0020\u007f]/g, "");
|
|
1656
|
+
return part === "." || part === "..";
|
|
1657
|
+
}))
|
|
736
1658
|
throw new TypeError("REST paths must not contain '.' or '..' segments.");
|
|
737
1659
|
}
|
|
738
1660
|
}
|
|
@@ -743,7 +1665,8 @@ var REQUEST_DATA_KEYS = new Set([
|
|
|
743
1665
|
"headers",
|
|
744
1666
|
"reason",
|
|
745
1667
|
"auth",
|
|
746
|
-
"signal"
|
|
1668
|
+
"signal",
|
|
1669
|
+
"maxQueueWait"
|
|
747
1670
|
]);
|
|
748
1671
|
function isRequestData(value) {
|
|
749
1672
|
if (value === null || typeof value !== "object")
|
|
@@ -779,27 +1702,66 @@ function createRetryPolicy(maxRetries = 2) {
|
|
|
779
1702
|
};
|
|
780
1703
|
}
|
|
781
1704
|
|
|
1705
|
+
class QueueWaitExceeded extends Error {
|
|
1706
|
+
ms;
|
|
1707
|
+
constructor(ms) {
|
|
1708
|
+
super("maxQueueWait exceeded");
|
|
1709
|
+
this.ms = ms;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
function checkWait(ms) {
|
|
1713
|
+
if (ms === undefined)
|
|
1714
|
+
return;
|
|
1715
|
+
if (!(ms >= 0) || !Number.isFinite(ms))
|
|
1716
|
+
throw new RangeError("maxQueueWait must be a non-negative number.");
|
|
1717
|
+
return ms;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
782
1720
|
class REST {
|
|
783
1721
|
#retryPolicy;
|
|
784
1722
|
#token;
|
|
1723
|
+
#fingerprint;
|
|
1724
|
+
#telemetry;
|
|
1725
|
+
#closing = new AbortController;
|
|
1726
|
+
#inFlight = 0;
|
|
1727
|
+
#idle;
|
|
1728
|
+
#rejected;
|
|
1729
|
+
#stopOnInvalidToken;
|
|
1730
|
+
#maxQueueWait;
|
|
1731
|
+
#invalid = new Array(10).fill(0);
|
|
1732
|
+
#invalidMinute = 0;
|
|
1733
|
+
#drained;
|
|
785
1734
|
#limiter;
|
|
786
|
-
#scheduler
|
|
1735
|
+
#scheduler;
|
|
787
1736
|
#transport;
|
|
788
1737
|
#decoder = new ResponseDecoder;
|
|
789
1738
|
#hooks = {};
|
|
790
1739
|
#concurrentBuckets;
|
|
791
1740
|
constructor(options = {}) {
|
|
792
1741
|
this.#token = options.token;
|
|
1742
|
+
this.#fingerprint = options.token ? tokenFingerprint(options.token) : undefined;
|
|
1743
|
+
this.#telemetry = options.telemetry;
|
|
1744
|
+
this.#scheduler = new RequestScheduler({
|
|
1745
|
+
maxQueued: options.maxQueued,
|
|
1746
|
+
maxQueuedPerBucket: options.maxQueuedPerBucket
|
|
1747
|
+
});
|
|
793
1748
|
this.#retryPolicy = options.retryPolicy ?? createRetryPolicy(options.retries ?? 2);
|
|
794
1749
|
this.#transport = options.transport ?? new HttpTransport({
|
|
795
1750
|
baseURL: options.baseURL,
|
|
1751
|
+
allowInsecureBaseURL: options.allowInsecureBaseURL,
|
|
796
1752
|
timeout: options.timeout
|
|
797
1753
|
});
|
|
798
1754
|
const store = options.store ?? new MemoryRateLimitStore;
|
|
799
1755
|
this.#concurrentBuckets = (options.concurrentBuckets ?? true) && typeof store.reserve === "function";
|
|
800
1756
|
this.#limiter = new RateLimiter(store, {
|
|
801
|
-
concurrent: this.#concurrentBuckets
|
|
1757
|
+
concurrent: this.#concurrentBuckets,
|
|
1758
|
+
maxBackoff: options.maxBackoff,
|
|
1759
|
+
probeMs: this.#transport.timeout > 0 ? this.#transport.timeout + 1000 : undefined,
|
|
1760
|
+
telemetry: options.telemetry,
|
|
1761
|
+
globalLimit: options.globalLimit === false ? undefined : options.globalLimit ?? 50
|
|
802
1762
|
});
|
|
1763
|
+
this.#stopOnInvalidToken = options.stopOnInvalidToken ?? true;
|
|
1764
|
+
this.#maxQueueWait = checkWait(options.maxQueueWait);
|
|
803
1765
|
if (options.hooks)
|
|
804
1766
|
this.#hooks = options.hooks;
|
|
805
1767
|
}
|
|
@@ -814,9 +1776,64 @@ class REST {
|
|
|
814
1776
|
if (!token.trim())
|
|
815
1777
|
throw new TypeError("A Discord bot token is required.");
|
|
816
1778
|
this.#token = token;
|
|
1779
|
+
this.#fingerprint = tokenFingerprint(token);
|
|
1780
|
+
this.#rejected = undefined;
|
|
817
1781
|
}
|
|
818
|
-
|
|
819
|
-
|
|
1782
|
+
diagnostics() {
|
|
1783
|
+
const store = this.#limiter.store;
|
|
1784
|
+
return {
|
|
1785
|
+
mode: store.mode ?? (store instanceof MemoryRateLimitStore ? "local" : "distributed"),
|
|
1786
|
+
queued: this.#scheduler.queued,
|
|
1787
|
+
inFlight: this.#inFlight,
|
|
1788
|
+
buckets: this.#scheduler.size,
|
|
1789
|
+
closed: this.#closing.signal.aborted,
|
|
1790
|
+
invalidRequests: this.#invalidCount(0),
|
|
1791
|
+
tokenRejected: this.#rejected !== undefined && this.#rejected === this.#fingerprint
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
async close() {
|
|
1795
|
+
if (!this.#closing.signal.aborted)
|
|
1796
|
+
this.#closing.abort(new Error("The REST client was closed."));
|
|
1797
|
+
if (this.#inFlight === 0)
|
|
1798
|
+
return;
|
|
1799
|
+
this.#drained ??= new Promise((resolve) => {
|
|
1800
|
+
this.#idle = resolve;
|
|
1801
|
+
});
|
|
1802
|
+
await this.#drained;
|
|
1803
|
+
}
|
|
1804
|
+
#invalidCount(add) {
|
|
1805
|
+
const minute = Math.floor(Date.now() / 60000);
|
|
1806
|
+
const elapsed = Math.min(10, minute - this.#invalidMinute);
|
|
1807
|
+
for (let i = 1;i <= elapsed; i++)
|
|
1808
|
+
this.#invalid[(this.#invalidMinute + i) % 10] = 0;
|
|
1809
|
+
if (elapsed > 0)
|
|
1810
|
+
this.#invalidMinute = minute;
|
|
1811
|
+
this.#invalid[minute % 10] += add;
|
|
1812
|
+
return this.#invalid.reduce((a, b) => a + b, 0);
|
|
1813
|
+
}
|
|
1814
|
+
#queueChanged() {
|
|
1815
|
+
emitTelemetry(this.#telemetry, {
|
|
1816
|
+
type: "queue",
|
|
1817
|
+
queued: this.#scheduler.queued,
|
|
1818
|
+
inFlight: this.#inFlight
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
async request(methodOrOptions, pathArg, bodyArg, optionsArg = {}) {
|
|
1822
|
+
let method;
|
|
1823
|
+
let path;
|
|
1824
|
+
let body = bodyArg;
|
|
1825
|
+
let options = optionsArg;
|
|
1826
|
+
if (typeof methodOrOptions === "object" && methodOrOptions !== null) {
|
|
1827
|
+
const { method: m, path: p, fullRoute, ...rest } = methodOrOptions;
|
|
1828
|
+
method = m;
|
|
1829
|
+
path = p ?? fullRoute ?? "";
|
|
1830
|
+
body = rest.body;
|
|
1831
|
+
options = rest;
|
|
1832
|
+
} else {
|
|
1833
|
+
method = methodOrOptions;
|
|
1834
|
+
path = pathArg;
|
|
1835
|
+
}
|
|
1836
|
+
if (typeof method !== "string" || !method.toUpperCase())
|
|
820
1837
|
throw new TypeError("REST method is required.");
|
|
821
1838
|
if (!path.startsWith("/"))
|
|
822
1839
|
throw new TypeError("REST paths must start with '/'.");
|
|
@@ -824,22 +1841,101 @@ class REST {
|
|
|
824
1841
|
const route = createRouteKey(method, path);
|
|
825
1842
|
const files = options.files;
|
|
826
1843
|
if (files && files.length > 0 && !(body instanceof FormData))
|
|
827
|
-
body = this.#fileForm(body, files)
|
|
1844
|
+
body = this.#fileForm(body, await Promise.all(files.map(async (file) => ({
|
|
1845
|
+
...file,
|
|
1846
|
+
data: await file.data
|
|
1847
|
+
}))));
|
|
828
1848
|
const query = serializeQuery(options.query);
|
|
829
1849
|
const requestPath = query ? `${path}${query}` : path;
|
|
830
|
-
|
|
1850
|
+
if (this.#closing.signal.aborted)
|
|
1851
|
+
throw abortError(path, this.#closing.signal.reason);
|
|
1852
|
+
const identity = requestIdentity(path, options.auth !== false && this.#token ? this.#fingerprint : undefined);
|
|
1853
|
+
if (identity.bucket === this.#fingerprint && this.#rejected === this.#fingerprint)
|
|
1854
|
+
throw new RESTError("Discord rejected this bot token (401); request not sent. Call setToken() with a valid token.", 401, undefined, undefined, { method: route.method, path, kind: "authentication" });
|
|
1855
|
+
const queueWait = checkWait(options.maxQueueWait) ?? this.#maxQueueWait;
|
|
1856
|
+
let deadline;
|
|
1857
|
+
const sources = [this.#closing.signal];
|
|
1858
|
+
if (options.signal)
|
|
1859
|
+
sources.push(options.signal);
|
|
1860
|
+
if (queueWait !== undefined) {
|
|
1861
|
+
const expired = new AbortController;
|
|
1862
|
+
deadline = setTimeout(() => expired.abort(new QueueWaitExceeded(queueWait)), queueWait);
|
|
1863
|
+
sources.push(expired.signal);
|
|
1864
|
+
}
|
|
1865
|
+
const waitSignal = sources.length === 1 ? sources[0] : AbortSignal.any(sources);
|
|
1866
|
+
const state = { attempts: 0, status: 0 };
|
|
1867
|
+
const started = performance.now();
|
|
1868
|
+
this.#inFlight++;
|
|
1869
|
+
this.#queueChanged();
|
|
1870
|
+
try {
|
|
1871
|
+
return await this.#scheduler.runResolved(() => this.#limiter.resolveBucketKey(route.route, route.major, identity.bucket), waitSignal, path, (bucketKey, release) => {
|
|
1872
|
+
this.#queueChanged();
|
|
1873
|
+
return this.#attempts(route, requestPath, body, options, { identity, waitSignal, state }, bucketKey, release);
|
|
1874
|
+
});
|
|
1875
|
+
} catch (caught) {
|
|
1876
|
+
let error = caught;
|
|
1877
|
+
if (error instanceof RESTError && error.kind === "aborted" && error.cause instanceof QueueWaitExceeded)
|
|
1878
|
+
error = new RESTError(`The request waited longer than maxQueueWait (${error.cause.ms}ms) before it could be sent.`, 0, undefined, undefined, { method: route.method, path, kind: "overloaded" });
|
|
1879
|
+
if (error instanceof RESTError) {
|
|
1880
|
+
if (error.kind === "aborted" && state.attempts === 0)
|
|
1881
|
+
emitTelemetry(this.#telemetry, {
|
|
1882
|
+
type: "cancelled",
|
|
1883
|
+
stage: "queued"
|
|
1884
|
+
});
|
|
1885
|
+
else if (error.kind === "overloaded")
|
|
1886
|
+
emitTelemetry(this.#telemetry, { type: "overloaded" });
|
|
1887
|
+
if (state.status === 0)
|
|
1888
|
+
state.status = error.status;
|
|
1889
|
+
}
|
|
1890
|
+
throw error;
|
|
1891
|
+
} finally {
|
|
1892
|
+
clearTimeout(deadline);
|
|
1893
|
+
emitTelemetry(this.#telemetry, {
|
|
1894
|
+
type: "request",
|
|
1895
|
+
method: route.method,
|
|
1896
|
+
route: route.route,
|
|
1897
|
+
status: state.status,
|
|
1898
|
+
attempts: state.attempts,
|
|
1899
|
+
durationMs: performance.now() - started,
|
|
1900
|
+
ok: state.status >= 200 && state.status < 300
|
|
1901
|
+
});
|
|
1902
|
+
if (--this.#inFlight === 0 && this.#idle) {
|
|
1903
|
+
this.#idle();
|
|
1904
|
+
this.#idle = undefined;
|
|
1905
|
+
this.#drained = undefined;
|
|
1906
|
+
}
|
|
1907
|
+
this.#queueChanged();
|
|
1908
|
+
}
|
|
831
1909
|
}
|
|
832
|
-
async#attempts(route, requestPath, body, options, initialBucketKey, release) {
|
|
1910
|
+
async#attempts(route, requestPath, body, options, context, initialBucketKey, release) {
|
|
833
1911
|
const { method, path } = route;
|
|
1912
|
+
const { identity, waitSignal, state } = context;
|
|
1913
|
+
const label = route.route;
|
|
834
1914
|
let bucketKey = initialBucketKey;
|
|
835
1915
|
for (let attempt = 0;attempt <= this.#retryPolicy.maxRetries; attempt++) {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1916
|
+
let grant;
|
|
1917
|
+
try {
|
|
1918
|
+
grant = await this.#limiter.acquire(bucketKey, waitSignal, path, { global: identity.global });
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
if (error instanceof RESTError && error.kind === "aborted")
|
|
1921
|
+
emitTelemetry(this.#telemetry, {
|
|
1922
|
+
type: "cancelled",
|
|
1923
|
+
stage: "waiting"
|
|
1924
|
+
});
|
|
1925
|
+
throw error;
|
|
1926
|
+
}
|
|
1927
|
+
if (this.#concurrentBuckets && grant.known)
|
|
1928
|
+
release();
|
|
1929
|
+
if (waitSignal.aborted) {
|
|
1930
|
+
await this.#limiter.releaseGrant(bucketKey, grant, true);
|
|
1931
|
+
emitTelemetry(this.#telemetry, {
|
|
1932
|
+
type: "cancelled",
|
|
1933
|
+
stage: "waiting"
|
|
1934
|
+
});
|
|
1935
|
+
throw abortError(path, waitSignal.reason);
|
|
841
1936
|
}
|
|
842
1937
|
this.#emit(this.#hooks.onRequest, { method, path, attempt });
|
|
1938
|
+
state.attempts = attempt + 1;
|
|
843
1939
|
const requestStart = Date.now();
|
|
844
1940
|
let response;
|
|
845
1941
|
try {
|
|
@@ -851,13 +1947,44 @@ class REST {
|
|
|
851
1947
|
signal: options.signal
|
|
852
1948
|
});
|
|
853
1949
|
} catch (error) {
|
|
854
|
-
|
|
1950
|
+
const notSent = error instanceof RESTError && error.kind === "aborted";
|
|
1951
|
+
await this.#limiter.releaseGrant(bucketKey, grant, notSent);
|
|
1952
|
+
if (error instanceof RESTError) {
|
|
1953
|
+
if (error.kind === "aborted")
|
|
1954
|
+
emitTelemetry(this.#telemetry, {
|
|
1955
|
+
type: "cancelled",
|
|
1956
|
+
stage: "transport"
|
|
1957
|
+
});
|
|
855
1958
|
throw error;
|
|
856
|
-
|
|
1959
|
+
}
|
|
1960
|
+
if (options.signal?.aborted) {
|
|
1961
|
+
emitTelemetry(this.#telemetry, {
|
|
1962
|
+
type: "cancelled",
|
|
1963
|
+
stage: "transport"
|
|
1964
|
+
});
|
|
857
1965
|
throw abortError(path, options.signal.reason);
|
|
858
|
-
|
|
1966
|
+
}
|
|
1967
|
+
const failure = error instanceof TransportError ? error : new TransportError("Discord REST request failed", false, sanitizeCause(error));
|
|
1968
|
+
emitTelemetry(this.#telemetry, {
|
|
1969
|
+
type: "networkError",
|
|
1970
|
+
timedOut: failure.timedOut
|
|
1971
|
+
});
|
|
859
1972
|
if (!failure.timedOut && attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, 0)) {
|
|
860
|
-
|
|
1973
|
+
const delay = this.#retryPolicy.getDelay(attempt);
|
|
1974
|
+
this.#emit(this.#hooks.onRetry, {
|
|
1975
|
+
method,
|
|
1976
|
+
path,
|
|
1977
|
+
attempt,
|
|
1978
|
+
status: 0,
|
|
1979
|
+
delayMs: delay
|
|
1980
|
+
});
|
|
1981
|
+
emitTelemetry(this.#telemetry, {
|
|
1982
|
+
type: "retry",
|
|
1983
|
+
route: label,
|
|
1984
|
+
reason: "network",
|
|
1985
|
+
attempt
|
|
1986
|
+
});
|
|
1987
|
+
await this.#retrySleep(delay, waitSignal, path);
|
|
861
1988
|
continue;
|
|
862
1989
|
}
|
|
863
1990
|
throw new RESTError(failure.message, 0, undefined, undefined, {
|
|
@@ -867,30 +1994,60 @@ class REST {
|
|
|
867
1994
|
kind: failure.timedOut ? "timeout" : "network"
|
|
868
1995
|
});
|
|
869
1996
|
}
|
|
1997
|
+
state.status = response.status;
|
|
1998
|
+
if (response.status === 401 || response.status === 403 || response.status === 429 && response.headers.get("X-RateLimit-Scope") !== "shared")
|
|
1999
|
+
this.#invalidCount(1);
|
|
2000
|
+
if (response.status === 401 && this.#stopOnInvalidToken && identity.bucket === this.#fingerprint) {
|
|
2001
|
+
this.#rejected = this.#fingerprint;
|
|
2002
|
+
emitTelemetry(this.#telemetry, { type: "tokenRejected" });
|
|
2003
|
+
}
|
|
2004
|
+
const durationMs = Date.now() - requestStart;
|
|
870
2005
|
this.#emit(this.#hooks.onResponse, {
|
|
871
2006
|
method,
|
|
872
2007
|
path,
|
|
873
2008
|
status: response.status,
|
|
874
|
-
durationMs
|
|
2009
|
+
durationMs
|
|
2010
|
+
});
|
|
2011
|
+
emitTelemetry(this.#telemetry, {
|
|
2012
|
+
type: "response",
|
|
2013
|
+
method,
|
|
2014
|
+
route: label,
|
|
2015
|
+
status: response.status,
|
|
2016
|
+
durationMs
|
|
875
2017
|
});
|
|
876
|
-
bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major);
|
|
877
2018
|
const payload = await this.#decoder.read(response);
|
|
2019
|
+
const data = response.ok ? {} : this.#decoder.errorData(payload);
|
|
2020
|
+
bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major, {
|
|
2021
|
+
identity: identity.bucket,
|
|
2022
|
+
global: identity.global,
|
|
2023
|
+
body: data,
|
|
2024
|
+
grant
|
|
2025
|
+
});
|
|
878
2026
|
if (response.ok)
|
|
879
2027
|
return response.status === 204 ? undefined : payload;
|
|
880
|
-
const
|
|
881
|
-
const
|
|
882
|
-
if (
|
|
883
|
-
|
|
884
|
-
|
|
2028
|
+
const limited = response.status === 429;
|
|
2029
|
+
const retryAfterMs = limited ? this.#limiter.retryAfterMs(response, data) : undefined;
|
|
2030
|
+
if (limited) {
|
|
2031
|
+
const global = data.global === true || response.headers.get("X-RateLimit-Global") === "true";
|
|
2032
|
+
const scope = response.headers.get("X-RateLimit-Scope")?.toLowerCase();
|
|
2033
|
+
emitTelemetry(this.#telemetry, {
|
|
2034
|
+
type: "rateLimited",
|
|
2035
|
+
route: label,
|
|
2036
|
+
scope: global ? "global" : scope === "user" || scope === "shared" ? scope : "unknown",
|
|
2037
|
+
retryAfterMs
|
|
2038
|
+
});
|
|
885
2039
|
this.#emit(this.#hooks.onRateLimit, {
|
|
886
2040
|
method,
|
|
887
2041
|
path,
|
|
888
|
-
retryAfterMs
|
|
889
|
-
global
|
|
890
|
-
bucket: await this.#limiter.store.getBucketHash(route.route)
|
|
2042
|
+
retryAfterMs,
|
|
2043
|
+
global,
|
|
2044
|
+
bucket: response.headers.get("X-RateLimit-Bucket") ?? await this.#limiter.store.getBucketHash(route.route)
|
|
891
2045
|
});
|
|
2046
|
+
}
|
|
892
2047
|
if (attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, response.status)) {
|
|
893
|
-
const delay = this.#retryPolicy.getDelay(attempt,
|
|
2048
|
+
const delay = this.#retryPolicy.getDelay(attempt, limited ? retryAfterMs / 1000 : undefined);
|
|
2049
|
+
if (delay > this.#limiter.maxBackoff)
|
|
2050
|
+
throw new RESTError(`Discord asked to wait ${Math.ceil(delay / 1000)}s before retrying, longer than maxBackoff (${Math.ceil(this.#limiter.maxBackoff / 1000)}s).`, response.status, data.code, payload, { method, path });
|
|
894
2051
|
this.#emit(this.#hooks.onRetry, {
|
|
895
2052
|
method,
|
|
896
2053
|
path,
|
|
@@ -898,13 +2055,30 @@ class REST {
|
|
|
898
2055
|
status: response.status,
|
|
899
2056
|
delayMs: delay
|
|
900
2057
|
});
|
|
901
|
-
|
|
2058
|
+
emitTelemetry(this.#telemetry, {
|
|
2059
|
+
type: "retry",
|
|
2060
|
+
route: label,
|
|
2061
|
+
reason: limited ? "rateLimited" : "server",
|
|
2062
|
+
attempt
|
|
2063
|
+
});
|
|
2064
|
+
await this.#retrySleep(limited ? delay - retryAfterMs : delay, waitSignal, path);
|
|
902
2065
|
continue;
|
|
903
2066
|
}
|
|
904
2067
|
throw new RESTError(data.message ?? response.statusText ?? `Discord REST request failed with status ${response.status}`, response.status, data.code, payload, { method, path });
|
|
905
2068
|
}
|
|
906
2069
|
throw new RESTError("Discord REST request exhausted its retry attempts", 0, undefined, undefined, { method, path });
|
|
907
2070
|
}
|
|
2071
|
+
async#retrySleep(delay, signal, path) {
|
|
2072
|
+
try {
|
|
2073
|
+
await sleep(delay, signal, path);
|
|
2074
|
+
} catch (error) {
|
|
2075
|
+
emitTelemetry(this.#telemetry, {
|
|
2076
|
+
type: "cancelled",
|
|
2077
|
+
stage: "retry"
|
|
2078
|
+
});
|
|
2079
|
+
throw error;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
908
2082
|
#headers(body, options) {
|
|
909
2083
|
const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
|
|
910
2084
|
const headers = { ...options.headers };
|
|
@@ -956,15 +2130,15 @@ class REST {
|
|
|
956
2130
|
const blob = file.data instanceof Blob ? file.data : new Blob([file.data], {
|
|
957
2131
|
type: file.contentType ?? "application/octet-stream"
|
|
958
2132
|
});
|
|
959
|
-
form.append(`files[${i}]`, blob, file.name);
|
|
2133
|
+
form.append(`files[${i}]`, blob, file.name.replace(/[\r\n"\\]/g, "_"));
|
|
960
2134
|
}
|
|
961
2135
|
return form;
|
|
962
2136
|
}
|
|
963
2137
|
postWithFiles(path, payload, files, options) {
|
|
964
|
-
return this.request("POST", path,
|
|
2138
|
+
return this.request("POST", path, payload, { ...options, files });
|
|
965
2139
|
}
|
|
966
2140
|
patchWithFiles(path, payload, files, options) {
|
|
967
|
-
return this.request("PATCH", path,
|
|
2141
|
+
return this.request("PATCH", path, payload, { ...options, files });
|
|
968
2142
|
}
|
|
969
2143
|
#emit(hook, ctx) {
|
|
970
2144
|
if (!hook)
|
|
@@ -976,12 +2150,14 @@ class REST {
|
|
|
976
2150
|
}
|
|
977
2151
|
}
|
|
978
2152
|
export {
|
|
2153
|
+
DEFAULT_MAX_BACKOFF_MS,
|
|
979
2154
|
RESTError as DiscordAPIError,
|
|
980
2155
|
HttpTransport,
|
|
981
2156
|
MemoryRateLimitStore,
|
|
982
2157
|
REST,
|
|
983
2158
|
RESTError,
|
|
984
2159
|
RESTErrorCode,
|
|
2160
|
+
RESTMetrics,
|
|
985
2161
|
RateLimiter,
|
|
986
2162
|
RedisRateLimitStore,
|
|
987
2163
|
RequestScheduler,
|
|
@@ -992,11 +2168,16 @@ export {
|
|
|
992
2168
|
abortError,
|
|
993
2169
|
createRetryPolicy,
|
|
994
2170
|
createRouteKey,
|
|
2171
|
+
fromBunRedis,
|
|
2172
|
+
headerNumber,
|
|
995
2173
|
majorParameter,
|
|
996
2174
|
normalizeRoutePath,
|
|
2175
|
+
parseRateLimitHeaders,
|
|
997
2176
|
redactPath,
|
|
2177
|
+
requestIdentity,
|
|
998
2178
|
scopeBucket,
|
|
999
|
-
serializeQuery
|
|
2179
|
+
serializeQuery,
|
|
2180
|
+
tokenFingerprint
|
|
1000
2181
|
};
|
|
1001
2182
|
|
|
1002
|
-
//# debugId=
|
|
2183
|
+
//# debugId=8464D7D05D07B1F864756E2164756E21
|