lunibee 0.3.1 → 0.3.3
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/index.js +64 -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 +6242 -4733
- 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 +8688 -6136
- package/dist/index.js.map +67 -59
- package/dist/managers/application.d.ts +10 -6
- package/dist/managers/base.d.ts +11 -1
- package/dist/managers/guild.d.ts +3 -1
- package/dist/managers/index.d.ts +6 -1
- package/dist/managers/index.js +383 -109
- package/dist/managers/index.js.map +20 -23
- 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 +5 -2
- package/dist/rest/headers.d.ts +31 -0
- package/dist/rest/identity.d.ts +31 -0
- package/dist/rest/index.d.ts +97 -6
- package/dist/rest/index.js +1355 -220
- 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/webhook.d.ts +18 -6
- package/dist/sharding/bus.d.ts +4 -2
- package/dist/sharding/cluster.d.ts +29 -1
- package/dist/sharding/health.d.ts +1 -1
- package/dist/sharding/host.d.ts +13 -2
- package/dist/sharding/identify.d.ts +2 -0
- package/dist/sharding/index.d.ts +8 -0
- package/dist/sharding/index.js +579 -65
- package/dist/sharding/index.js.map +17 -15
- package/dist/sharding/leader.d.ts +34 -0
- package/dist/sharding/remote.d.ts +36 -2
- package/dist/sharding/supervisor.d.ts +1 -1
- package/dist/sharding/thread.d.ts +30 -0
- package/dist/sharding/transport.d.ts +6 -5
- package/dist/sharding/worker.d.ts +11 -1
- 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 +4 -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 +2 -0
- package/dist/ws/index.js +32 -4
- package/dist/ws/index.js.map +5 -5
- package/package.json +21 -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.3",
|
|
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
|
|
|
@@ -128,15 +284,17 @@ class RESTError extends Error {
|
|
|
128
284
|
this.path = options.path === undefined ? undefined : redactPath(options.path);
|
|
129
285
|
}
|
|
130
286
|
get retryable() {
|
|
131
|
-
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";
|
|
132
288
|
}
|
|
133
289
|
}
|
|
134
290
|
function abortError(path, reason) {
|
|
135
291
|
return new RESTError(reason instanceof Error ? reason.message : "Discord REST request was aborted", 0, undefined, undefined, { path, cause: reason, kind: "aborted" });
|
|
136
292
|
}
|
|
293
|
+
var MAX_TIMER_MS = 2147483647;
|
|
137
294
|
async function sleep(delay, signal, path) {
|
|
138
295
|
if (delay <= 0)
|
|
139
296
|
return;
|
|
297
|
+
delay = Math.min(delay, MAX_TIMER_MS);
|
|
140
298
|
await new Promise((resolve, reject) => {
|
|
141
299
|
const finish = (error) => {
|
|
142
300
|
clearTimeout(timer);
|
|
@@ -176,7 +334,7 @@ import { createHash } from "crypto";
|
|
|
176
334
|
function normalizeRoutePath(path) {
|
|
177
335
|
const queryIndex = path.indexOf("?");
|
|
178
336
|
const routePath = queryIndex === -1 ? path : path.slice(0, queryIndex);
|
|
179
|
-
return redactPath(routePath).replace(/\/\d+(?=\/|$)/g, "/:id");
|
|
337
|
+
return redactPath(routePath).replace(/\/\d+(?=\/|$)/g, "/:id").replace(/\/reactions\/[^/]+/i, "/reactions/:emoji");
|
|
180
338
|
}
|
|
181
339
|
function majorParameter(path) {
|
|
182
340
|
const match = /^\/(channels|guilds|webhooks)\/(\d+)(?:\/([^/?]+))?/i.exec(path);
|
|
@@ -215,9 +373,22 @@ function serializeQuery(query) {
|
|
|
215
373
|
class RequestScheduler {
|
|
216
374
|
static MAX_REMAPS = 2;
|
|
217
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
|
+
}
|
|
218
386
|
get size() {
|
|
219
387
|
return this.#queues.size;
|
|
220
388
|
}
|
|
389
|
+
get queued() {
|
|
390
|
+
return this.#queued;
|
|
391
|
+
}
|
|
221
392
|
async runResolved(resolveKey, signal, path, task) {
|
|
222
393
|
let key = await resolveKey();
|
|
223
394
|
for (let hop = 0;hop < RequestScheduler.MAX_REMAPS; hop++) {
|
|
@@ -234,7 +405,10 @@ class RequestScheduler {
|
|
|
234
405
|
return this.run(key, signal, path, (release) => task(key, release));
|
|
235
406
|
}
|
|
236
407
|
async run(key, signal, path, task) {
|
|
237
|
-
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" });
|
|
238
412
|
let open;
|
|
239
413
|
const gate = new Promise((resolve) => {
|
|
240
414
|
open = resolve;
|
|
@@ -250,7 +424,21 @@ class RequestScheduler {
|
|
|
250
424
|
this.#queues.delete(key);
|
|
251
425
|
};
|
|
252
426
|
try {
|
|
253
|
-
|
|
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);
|
|
254
442
|
return await task(release);
|
|
255
443
|
} finally {
|
|
256
444
|
release();
|
|
@@ -258,87 +446,498 @@ class RequestScheduler {
|
|
|
258
446
|
}
|
|
259
447
|
}
|
|
260
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
|
+
|
|
261
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
|
+
|
|
262
698
|
class RateLimiter {
|
|
263
699
|
#store;
|
|
264
|
-
#
|
|
700
|
+
#maxBackoff;
|
|
701
|
+
#telemetry;
|
|
702
|
+
#probeMs;
|
|
703
|
+
#routes = new Map;
|
|
704
|
+
#globalLimit;
|
|
705
|
+
#localGlobal = new Map;
|
|
265
706
|
constructor(store, options = {}) {
|
|
266
707
|
this.#store = store;
|
|
267
|
-
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);
|
|
268
724
|
}
|
|
269
725
|
get store() {
|
|
270
726
|
return this.#store;
|
|
271
727
|
}
|
|
272
|
-
async resolveBucketKey(route, major) {
|
|
273
|
-
return scopeBucket(await this.#store.getBucketHash(route) ?? route, major);
|
|
274
|
-
}
|
|
275
728
|
get reserves() {
|
|
276
729
|
return typeof this.#store.reserve === "function";
|
|
277
730
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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));
|
|
282
764
|
if (bucket) {
|
|
283
765
|
const delay = bucket.resetAt - Date.now();
|
|
284
766
|
if (bucket.remaining <= 0 && delay > 0)
|
|
285
|
-
await
|
|
767
|
+
await this.#wait("bucket", delay, signal, path);
|
|
286
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)
|
|
287
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);
|
|
288
838
|
}
|
|
289
|
-
for (
|
|
290
|
-
const
|
|
291
|
-
if (
|
|
839
|
+
for (;; ) {
|
|
840
|
+
const wait = takeGlobalSlot(log, Date.now(), this.#globalLimit);
|
|
841
|
+
if (wait === 0)
|
|
292
842
|
return;
|
|
293
|
-
await
|
|
294
|
-
await this.#waitGlobal(signal, path);
|
|
843
|
+
await this.#wait("global", wait, signal, path);
|
|
295
844
|
}
|
|
296
845
|
}
|
|
297
|
-
async#waitGlobal(signal, path) {
|
|
298
|
-
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();
|
|
299
848
|
if (globalDelay > 0)
|
|
300
|
-
await
|
|
301
|
-
}
|
|
302
|
-
async noteGlobalReset(resetAt) {
|
|
303
|
-
const current = await this.#store.getGlobalReset();
|
|
304
|
-
await this.#store.setGlobalReset(Math.max(current, resetAt));
|
|
849
|
+
await this.#wait("global", globalDelay, signal, path);
|
|
305
850
|
}
|
|
306
|
-
async
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
currentKey = scopeBucket(serverBucket, major);
|
|
312
|
-
}
|
|
313
|
-
const stored = await this.#store.getBucket(currentKey);
|
|
314
|
-
const bucket = stored ? { ...stored } : { remaining: 1, resetAt: 0 };
|
|
315
|
-
const windowActive = this.#concurrent && !!stored && stored.resetAt > Date.now();
|
|
316
|
-
const remaining = Number(response.headers.get("X-RateLimit-Remaining"));
|
|
317
|
-
const resetAfter = Number(response.headers.get("X-RateLimit-Reset-After"));
|
|
318
|
-
const reset = Number(response.headers.get("X-RateLimit-Reset"));
|
|
319
|
-
if (Number.isFinite(remaining))
|
|
320
|
-
bucket.remaining = Math.max(0, remaining);
|
|
321
|
-
if (Number.isFinite(resetAfter))
|
|
322
|
-
bucket.resetAt = Date.now() + Math.max(0, resetAfter) * 1000;
|
|
323
|
-
else if (Number.isFinite(reset))
|
|
324
|
-
bucket.resetAt = reset * 1000;
|
|
325
|
-
if (windowActive && stored) {
|
|
326
|
-
bucket.remaining = Math.min(bucket.remaining, stored.remaining);
|
|
327
|
-
bucket.resetAt = Math.max(bucket.resetAt, stored.resetAt);
|
|
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;
|
|
328
856
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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);
|
|
871
|
+
let currentKey = bucketKey;
|
|
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}`;
|
|
333
886
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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);
|
|
339
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);
|
|
340
917
|
return currentKey;
|
|
341
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 };
|
|
342
941
|
}
|
|
343
942
|
|
|
344
943
|
// packages/rest/src/transport.ts
|
|
@@ -354,11 +953,11 @@ function parseBaseURL(raw, allowInsecure = false) {
|
|
|
354
953
|
throw new TypeError("baseURL must use https.");
|
|
355
954
|
if (url.username || url.password)
|
|
356
955
|
throw new TypeError("baseURL must not contain credentials.");
|
|
357
|
-
if (url.search || url.hash)
|
|
956
|
+
if (url.search || url.hash || /[?#]/.test(raw))
|
|
358
957
|
throw new TypeError("baseURL must not contain a query or fragment.");
|
|
359
958
|
if (url.protocol === "http:" && !allowInsecure && !LOOPBACK.test(url.hostname))
|
|
360
959
|
throw new TypeError("baseURL must use https: the bot token would travel in cleartext (allowInsecureBaseURL: true overrides this for a trusted network).");
|
|
361
|
-
return
|
|
960
|
+
return url.href.replace(/\/$/, "");
|
|
362
961
|
}
|
|
363
962
|
|
|
364
963
|
class TransportError extends Error {
|
|
@@ -414,6 +1013,7 @@ class HttpTransport {
|
|
|
414
1013
|
|
|
415
1014
|
// packages/rest/src/decoder.ts
|
|
416
1015
|
class ResponseDecoder {
|
|
1016
|
+
constructor() {}
|
|
417
1017
|
async read(response) {
|
|
418
1018
|
if (response.status === 204)
|
|
419
1019
|
return;
|
|
@@ -582,36 +1182,186 @@ var RESTErrorCode = {
|
|
|
582
1182
|
CannotReplyWithoutReadHistory: 160002
|
|
583
1183
|
};
|
|
584
1184
|
// packages/rest/src/redis.ts
|
|
585
|
-
var
|
|
586
|
-
local
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
|
1225
|
+
end
|
|
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}
|
|
1240
|
+
end
|
|
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
|
|
591
1281
|
end
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
redis.call('SET', KEYS[1], cjson.encode(state), 'PX', math.ceil(wait))
|
|
598
|
-
return {1, 0}
|
|
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
|
|
599
1287
|
end
|
|
600
|
-
|
|
1288
|
+
save(KEYS[1], n, now, linger)
|
|
1289
|
+
return 1
|
|
601
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";
|
|
602
1328
|
|
|
603
1329
|
class RedisRateLimitStore {
|
|
604
1330
|
#client;
|
|
605
1331
|
#prefix;
|
|
606
1332
|
#onErrorCallback;
|
|
1333
|
+
#policy;
|
|
1334
|
+
#timeout;
|
|
1335
|
+
#telemetry;
|
|
1336
|
+
#atomic;
|
|
607
1337
|
#local = new MemoryRateLimitStore;
|
|
608
1338
|
#lastError;
|
|
1339
|
+
#warnedSlots = false;
|
|
1340
|
+
#lastFailureAt;
|
|
1341
|
+
#failures = 0;
|
|
1342
|
+
#mode;
|
|
609
1343
|
constructor(options) {
|
|
610
1344
|
this.#client = options.client;
|
|
611
|
-
this.#prefix = options.prefix ?? "lunibee:rest:"
|
|
1345
|
+
this.#prefix = `${options.prefix ?? "lunibee:rest:"}${SCHEMA}:`;
|
|
612
1346
|
this.#onErrorCallback = options.onError;
|
|
613
|
-
|
|
614
|
-
|
|
1347
|
+
this.#policy = options.failurePolicy ?? "degraded-local";
|
|
1348
|
+
if (this.#policy !== "degraded-local" && this.#policy !== "fail-closed")
|
|
1349
|
+
throw new TypeError('failurePolicy must be "degraded-local" or "fail-closed".');
|
|
1350
|
+
this.#timeout = options.commandTimeout ?? 2000;
|
|
1351
|
+
if (!(this.#timeout > 0))
|
|
1352
|
+
throw new RangeError("commandTimeout must be a positive number.");
|
|
1353
|
+
this.#telemetry = options.telemetry;
|
|
1354
|
+
this.#atomic = typeof options.client.eval === "function";
|
|
1355
|
+
if (options.requireAtomic && !this.#atomic)
|
|
1356
|
+
throw new TypeError("requireAtomic: the Redis client has no eval method, so reservations cannot be atomic.");
|
|
1357
|
+
this.#mode = this.#atomic ? "distributed" : "observational";
|
|
1358
|
+
if (this.#atomic) {
|
|
1359
|
+
this.reserve = (key, opts) => this.#reserve(key, opts);
|
|
1360
|
+
this.reconcile = (key, update) => this.#reconcile(key, update);
|
|
1361
|
+
this.extendGlobalReset = (scope, waitMs) => this.#extendGlobal(scope, waitMs);
|
|
1362
|
+
this.release = (key, grant, refund) => this.#release(key, grant, refund);
|
|
1363
|
+
this.reservesGlobal = true;
|
|
1364
|
+
}
|
|
615
1365
|
}
|
|
616
1366
|
get lastError() {
|
|
617
1367
|
return this.#lastError;
|
|
@@ -619,107 +1369,208 @@ class RedisRateLimitStore {
|
|
|
619
1369
|
isHealthy() {
|
|
620
1370
|
return this.#lastError === undefined;
|
|
621
1371
|
}
|
|
622
|
-
|
|
623
|
-
this.#
|
|
624
|
-
console.warn(`[lunibee/rest] Redis rate-limit store ${operation} failed; ` + `falling back to local limiting for this call.`, error);
|
|
625
|
-
try {
|
|
626
|
-
this.#onErrorCallback?.(operation, error);
|
|
627
|
-
} catch {}
|
|
1372
|
+
get mode() {
|
|
1373
|
+
return this.#mode;
|
|
628
1374
|
}
|
|
629
|
-
|
|
630
|
-
this.#
|
|
1375
|
+
get supportsReservation() {
|
|
1376
|
+
return this.#atomic;
|
|
631
1377
|
}
|
|
632
|
-
|
|
1378
|
+
diagnostics() {
|
|
1379
|
+
const error = this.#lastError;
|
|
1380
|
+
return {
|
|
1381
|
+
mode: this.#mode,
|
|
1382
|
+
failurePolicy: this.#policy,
|
|
1383
|
+
atomic: this.#atomic,
|
|
1384
|
+
failures: this.#failures,
|
|
1385
|
+
lastFailureAt: this.#lastFailureAt,
|
|
1386
|
+
lastError: error === undefined ? undefined : String(sanitizeCause(error)?.message ?? error)
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
async verify() {
|
|
1390
|
+
if (!this.#atomic)
|
|
1391
|
+
throw new TypeError("The Redis client has no eval method: reservations cannot be atomic.");
|
|
633
1392
|
try {
|
|
634
|
-
|
|
635
|
-
this.#markHealthy();
|
|
636
|
-
return hash ?? undefined;
|
|
1393
|
+
await this.#withTimeout(this.#client.eval(VERIFY_SCRIPT, 0));
|
|
637
1394
|
} catch (error) {
|
|
638
|
-
|
|
639
|
-
return this.#local.getBucketHash(route);
|
|
1395
|
+
throw new RESTError("The Redis rate-limit store cannot run its scripts.", 0, undefined, undefined, { kind: "coordination", cause: sanitizeCause(error) });
|
|
640
1396
|
}
|
|
641
1397
|
}
|
|
642
|
-
|
|
643
|
-
this.#
|
|
1398
|
+
#setMode(mode) {
|
|
1399
|
+
if (mode === this.#mode)
|
|
1400
|
+
return;
|
|
1401
|
+
const previous = this.#mode;
|
|
1402
|
+
this.#mode = mode;
|
|
1403
|
+
emitTelemetry(this.#telemetry, { type: "mode", mode, previous });
|
|
1404
|
+
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.");
|
|
1405
|
+
}
|
|
1406
|
+
#withTimeout(promise) {
|
|
1407
|
+
let timer;
|
|
1408
|
+
return Promise.race([
|
|
1409
|
+
promise,
|
|
1410
|
+
new Promise((_, reject) => {
|
|
1411
|
+
timer = setTimeout(() => reject(new Error(`Redis command timed out after ${this.#timeout}ms`)), this.#timeout);
|
|
1412
|
+
})
|
|
1413
|
+
]).finally(() => clearTimeout(timer));
|
|
1414
|
+
}
|
|
1415
|
+
async#run(operation, command, fallback, enforcing) {
|
|
1416
|
+
const started = performance.now();
|
|
644
1417
|
try {
|
|
645
|
-
await this.#
|
|
646
|
-
this.#
|
|
1418
|
+
const value = await this.#withTimeout(command());
|
|
1419
|
+
emitTelemetry(this.#telemetry, {
|
|
1420
|
+
type: "store",
|
|
1421
|
+
operation,
|
|
1422
|
+
latencyMs: performance.now() - started,
|
|
1423
|
+
ok: true
|
|
1424
|
+
});
|
|
1425
|
+
this.#lastError = undefined;
|
|
1426
|
+
this.#setMode(this.#atomic ? "distributed" : "observational");
|
|
1427
|
+
return value;
|
|
647
1428
|
} catch (error) {
|
|
648
|
-
this.#
|
|
1429
|
+
emitTelemetry(this.#telemetry, {
|
|
1430
|
+
type: "store",
|
|
1431
|
+
operation,
|
|
1432
|
+
latencyMs: performance.now() - started,
|
|
1433
|
+
ok: false
|
|
1434
|
+
});
|
|
1435
|
+
this.#lastError = error;
|
|
1436
|
+
this.#lastFailureAt = Date.now();
|
|
1437
|
+
this.#failures++;
|
|
1438
|
+
if (!this.#warnedSlots && /CROSSSLOT/.test(String(error))) {
|
|
1439
|
+
this.#warnedSlots = true;
|
|
1440
|
+
console.warn('[lunibee/rest] Redis Cluster refused a script whose keys hash to different slots (CROSSSLOT): use a hash-tagged prefix such as "{mybot}:rest:".');
|
|
1441
|
+
}
|
|
1442
|
+
try {
|
|
1443
|
+
this.#onErrorCallback?.(operation, error);
|
|
1444
|
+
} catch {}
|
|
1445
|
+
if (this.#policy === "fail-closed") {
|
|
1446
|
+
this.#setMode("unavailable");
|
|
1447
|
+
if (enforcing)
|
|
1448
|
+
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) });
|
|
1449
|
+
} else
|
|
1450
|
+
this.#setMode("degraded-local");
|
|
1451
|
+
return fallback();
|
|
649
1452
|
}
|
|
650
1453
|
}
|
|
1454
|
+
#bucketKey(key) {
|
|
1455
|
+
return `${this.#prefix}bucket:${key}`;
|
|
1456
|
+
}
|
|
1457
|
+
#globalKey(scope) {
|
|
1458
|
+
return `${this.#prefix}global:${scope}`;
|
|
1459
|
+
}
|
|
1460
|
+
async getBucketHash(route) {
|
|
1461
|
+
return this.#run("getBucketHash", async () => await this.#client.get(`${this.#prefix}route:${route}`) ?? undefined, () => this.#local.getBucketHash(route), false);
|
|
1462
|
+
}
|
|
1463
|
+
async setBucketHash(route, hash) {
|
|
1464
|
+
this.#local.setBucketHash(route, hash);
|
|
1465
|
+
await this.#run("setBucketHash", async () => {
|
|
1466
|
+
await this.#client.set(`${this.#prefix}route:${route}`, hash, "EX", 604800);
|
|
1467
|
+
}, () => {
|
|
1468
|
+
return;
|
|
1469
|
+
}, false);
|
|
1470
|
+
}
|
|
651
1471
|
async getBucket(key) {
|
|
652
|
-
|
|
653
|
-
const data = await this.#client.get(
|
|
654
|
-
this.#markHealthy();
|
|
1472
|
+
return this.#run("getBucket", async () => {
|
|
1473
|
+
const data = await this.#client.get(this.#bucketKey(key));
|
|
655
1474
|
if (!data)
|
|
656
1475
|
return;
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
return this.#local.getBucket(key);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
async updateBucket(key, state) {
|
|
664
|
-
this.#local.updateBucket(key, state);
|
|
665
|
-
const bucketKey = `${this.#prefix}bucket:${key}`;
|
|
666
|
-
const ttl = Math.ceil((state.resetAt - Date.now()) / 1000);
|
|
667
|
-
try {
|
|
668
|
-
if (ttl <= 0) {
|
|
669
|
-
await this.#client.del(bucketKey);
|
|
670
|
-
this.#markHealthy();
|
|
1476
|
+
try {
|
|
1477
|
+
return JSON.parse(data);
|
|
1478
|
+
} catch {
|
|
671
1479
|
return;
|
|
672
1480
|
}
|
|
673
|
-
|
|
674
|
-
this.#markHealthy();
|
|
675
|
-
} catch (error) {
|
|
676
|
-
this.#onError("updateBucket", error);
|
|
677
|
-
}
|
|
1481
|
+
}, () => this.#local.getBucket(key), true);
|
|
678
1482
|
}
|
|
679
|
-
|
|
680
|
-
|
|
1483
|
+
async updateBucket(key, state) {
|
|
1484
|
+
this.#local.updateBucket(key, state);
|
|
1485
|
+
const ttl = Math.ceil(state.resetAt - Date.now());
|
|
1486
|
+
await this.#run("updateBucket", async () => {
|
|
1487
|
+
if (ttl <= 0)
|
|
1488
|
+
await this.#client.del(this.#bucketKey(key));
|
|
1489
|
+
else
|
|
1490
|
+
await this.#client.set(this.#bucketKey(key), JSON.stringify(state), "PX", ttl);
|
|
1491
|
+
}, () => {
|
|
1492
|
+
return;
|
|
1493
|
+
}, false);
|
|
681
1494
|
}
|
|
682
1495
|
reserve;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
return this.#local.reserve(key);
|
|
1496
|
+
reconcile;
|
|
1497
|
+
extendGlobalReset;
|
|
1498
|
+
release;
|
|
1499
|
+
reservesGlobal;
|
|
1500
|
+
async#reserve(key, options = {}) {
|
|
1501
|
+
const keys = [this.#bucketKey(key)];
|
|
1502
|
+
if (options.global !== undefined) {
|
|
1503
|
+
keys.push(this.#globalKey(options.global));
|
|
1504
|
+
if (options.globalLimit)
|
|
1505
|
+
keys.push(`${this.#prefix}global-log:${options.global}`);
|
|
694
1506
|
}
|
|
1507
|
+
return this.#run("reserve", async () => {
|
|
1508
|
+
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);
|
|
1509
|
+
return parseReservation(result);
|
|
1510
|
+
}, () => this.#local.reserve(key, options), true);
|
|
1511
|
+
}
|
|
1512
|
+
async#reconcile(key, update) {
|
|
1513
|
+
this.#local.reconcile(key, update);
|
|
1514
|
+
const arg = (value) => value === undefined ? "" : String(value);
|
|
1515
|
+
await this.#run("reconcile", async () => {
|
|
1516
|
+
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);
|
|
1517
|
+
}, () => {
|
|
1518
|
+
return;
|
|
1519
|
+
}, false);
|
|
695
1520
|
}
|
|
696
|
-
async
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
this.#
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
return this.#local.getGlobalReset();
|
|
704
|
-
}
|
|
1521
|
+
async#extendGlobal(scope, waitMs) {
|
|
1522
|
+
this.#local.extendGlobalReset(scope, waitMs);
|
|
1523
|
+
await this.#run("extendGlobalReset", async () => {
|
|
1524
|
+
await this.#client.eval(EXTEND_GLOBAL_SCRIPT, 1, this.#globalKey(scope), Math.max(0, Math.ceil(waitMs)));
|
|
1525
|
+
}, () => {
|
|
1526
|
+
return;
|
|
1527
|
+
}, false);
|
|
705
1528
|
}
|
|
706
|
-
async
|
|
707
|
-
this.#local.
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
1529
|
+
async#release(key, grant, refund) {
|
|
1530
|
+
this.#local.release(key, grant, refund);
|
|
1531
|
+
if (grant.resetAt === undefined)
|
|
1532
|
+
return;
|
|
1533
|
+
await this.#run("release", async () => {
|
|
1534
|
+
await this.#client.eval(RELEASE_SCRIPT, 1, this.#bucketKey(key), grant.resetAt, refund ? "1" : "0");
|
|
1535
|
+
}, () => {
|
|
1536
|
+
return;
|
|
1537
|
+
}, false);
|
|
1538
|
+
}
|
|
1539
|
+
async getGlobalReset(scope = "") {
|
|
1540
|
+
return this.#run("getGlobalReset", async () => {
|
|
1541
|
+
const resetAt = await this.#client.get(this.#globalKey(scope));
|
|
1542
|
+
return resetAt ? Number(resetAt) || 0 : 0;
|
|
1543
|
+
}, () => this.#local.getGlobalReset(scope), true);
|
|
1544
|
+
}
|
|
1545
|
+
async setGlobalReset(resetAt, scope = "") {
|
|
1546
|
+
this.#local.setGlobalReset(resetAt, scope);
|
|
1547
|
+
const ttl = Math.ceil(resetAt - Date.now());
|
|
1548
|
+
await this.#run("setGlobalReset", async () => {
|
|
1549
|
+
if (ttl <= 0)
|
|
1550
|
+
await this.#client.del(this.#globalKey(scope));
|
|
1551
|
+
else
|
|
1552
|
+
await this.#client.set(this.#globalKey(scope), String(resetAt), "PX", ttl);
|
|
1553
|
+
}, () => {
|
|
1554
|
+
return;
|
|
1555
|
+
}, false);
|
|
721
1556
|
}
|
|
722
1557
|
}
|
|
1558
|
+
function parseReservation(result) {
|
|
1559
|
+
if (!Array.isArray(result) || result.length < 3)
|
|
1560
|
+
throw new Error("Unexpected reply from the reservation script.");
|
|
1561
|
+
const [granted, value, flag] = result;
|
|
1562
|
+
const n = Number(value);
|
|
1563
|
+
if (!Number.isFinite(n))
|
|
1564
|
+
throw new Error("Unexpected reply from the reservation script.");
|
|
1565
|
+
if (Number(granted) === 1)
|
|
1566
|
+
return { granted: true, known: Number(flag) === 1, resetAt: n };
|
|
1567
|
+
const reason = String(flag);
|
|
1568
|
+
return {
|
|
1569
|
+
granted: false,
|
|
1570
|
+
retryAfterMs: Math.max(0, n),
|
|
1571
|
+
reason: reason === "global" || reason === "probe" ? reason : "bucket"
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
723
1574
|
// packages/rest/src/webhook.ts
|
|
724
1575
|
class WebhookClient {
|
|
725
1576
|
id;
|
|
@@ -740,29 +1591,49 @@ class WebhookClient {
|
|
|
740
1591
|
}
|
|
741
1592
|
this.#rest = new REST(restOptions);
|
|
742
1593
|
}
|
|
743
|
-
|
|
1594
|
+
#prepare(options, base = {}) {
|
|
744
1595
|
const payload = typeof options === "string" ? { content: options } : options;
|
|
745
|
-
const {
|
|
746
|
-
|
|
1596
|
+
const {
|
|
1597
|
+
thread_id: threadWire,
|
|
1598
|
+
threadId,
|
|
1599
|
+
avatarURL,
|
|
1600
|
+
allowedMentions,
|
|
1601
|
+
files,
|
|
1602
|
+
...body
|
|
1603
|
+
} = payload;
|
|
1604
|
+
const thread_id = threadWire ?? threadId;
|
|
1605
|
+
if (avatarURL !== undefined && body.avatar_url === undefined)
|
|
1606
|
+
body.avatar_url = avatarURL;
|
|
1607
|
+
if (allowedMentions !== undefined && body.allowed_mentions === undefined)
|
|
1608
|
+
body.allowed_mentions = allowedMentions;
|
|
1609
|
+
const params = new URLSearchParams(base);
|
|
747
1610
|
if (thread_id)
|
|
748
1611
|
params.set("thread_id", String(thread_id));
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
1612
|
+
const json = { ...body };
|
|
1613
|
+
if (body.embeds)
|
|
1614
|
+
json.embeds = body.embeds.map((e) => ("toJSON" in e) ? e.toJSON() : e);
|
|
1615
|
+
if (body.components)
|
|
1616
|
+
json.components = body.components.map((c) => ("toJSON" in c) ? c.toJSON() : c);
|
|
1617
|
+
const text = params.toString();
|
|
1618
|
+
return { query: text ? `?${text}` : "", json, files };
|
|
1619
|
+
}
|
|
1620
|
+
async send(options) {
|
|
1621
|
+
const { query, json, files } = this.#prepare(options, { wait: "true" });
|
|
752
1622
|
const path = `${Routes.webhook(this.id, this.token)}${query}`;
|
|
753
|
-
const json = {
|
|
754
|
-
...body,
|
|
755
|
-
embeds: formattedEmbeds,
|
|
756
|
-
components: formattedComponents
|
|
757
|
-
};
|
|
758
1623
|
return files?.length ? this.#rest.postWithFiles(path, json, files) : this.#rest.post(path, json);
|
|
759
1624
|
}
|
|
1625
|
+
async fetchMessage(messageId, options = {}) {
|
|
1626
|
+
const query = options.threadId ? `?${new URLSearchParams({ thread_id: options.threadId })}` : "";
|
|
1627
|
+
return this.#rest.get(`${Routes.webhookMessage(this.id, this.token, messageId)}${query}`);
|
|
1628
|
+
}
|
|
760
1629
|
async editMessage(messageId, options) {
|
|
761
|
-
const
|
|
762
|
-
|
|
1630
|
+
const { query, json, files } = this.#prepare(options);
|
|
1631
|
+
const path = `${Routes.webhookMessage(this.id, this.token, messageId)}${query}`;
|
|
1632
|
+
return files?.length ? this.#rest.patchWithFiles(path, json, files) : this.#rest.patch(path, json);
|
|
763
1633
|
}
|
|
764
|
-
async deleteMessage(messageId) {
|
|
765
|
-
|
|
1634
|
+
async deleteMessage(messageId, options = {}) {
|
|
1635
|
+
const query = options.threadId ? `?${new URLSearchParams({ thread_id: options.threadId })}` : "";
|
|
1636
|
+
await this.#rest.delete(`${Routes.webhookMessage(this.id, this.token, messageId)}${query}`);
|
|
766
1637
|
}
|
|
767
1638
|
}
|
|
768
1639
|
|
|
@@ -771,7 +1642,9 @@ var LIBRARY_VERSION = package_default.version;
|
|
|
771
1642
|
var LIBRARY_URL = "https://github.com/Ekretos/lunibee";
|
|
772
1643
|
var USER_AGENT = `DiscordBot (${LIBRARY_URL}, ${LIBRARY_VERSION})`;
|
|
773
1644
|
function assertSafePath(path) {
|
|
774
|
-
|
|
1645
|
+
if (/[\u0000-\u0020\u007f#]/.test(path))
|
|
1646
|
+
throw new TypeError("REST paths must not contain control characters, spaces or '#'.");
|
|
1647
|
+
const end = path.indexOf("?");
|
|
775
1648
|
const pathname = end === -1 ? path : path.slice(0, end);
|
|
776
1649
|
if (pathname.includes("\\"))
|
|
777
1650
|
throw new TypeError("REST paths must not contain backslashes.");
|
|
@@ -782,7 +1655,11 @@ function assertSafePath(path) {
|
|
|
782
1655
|
} catch {
|
|
783
1656
|
throw new TypeError("REST path has malformed percent-encoding.");
|
|
784
1657
|
}
|
|
785
|
-
|
|
1658
|
+
const bare = decoded.replace(/[\u0000-\u0020\u007f]/g, "");
|
|
1659
|
+
if (bare === "." || bare === ".." || /[\\/]/.test(decoded) && decoded.split(/[\\/]/).some((s) => {
|
|
1660
|
+
const part = s.replace(/[\u0000-\u0020\u007f]/g, "");
|
|
1661
|
+
return part === "." || part === "..";
|
|
1662
|
+
}))
|
|
786
1663
|
throw new TypeError("REST paths must not contain '.' or '..' segments.");
|
|
787
1664
|
}
|
|
788
1665
|
}
|
|
@@ -793,7 +1670,8 @@ var REQUEST_DATA_KEYS = new Set([
|
|
|
793
1670
|
"headers",
|
|
794
1671
|
"reason",
|
|
795
1672
|
"auth",
|
|
796
|
-
"signal"
|
|
1673
|
+
"signal",
|
|
1674
|
+
"maxQueueWait"
|
|
797
1675
|
]);
|
|
798
1676
|
function isRequestData(value) {
|
|
799
1677
|
if (value === null || typeof value !== "object")
|
|
@@ -829,17 +1707,49 @@ function createRetryPolicy(maxRetries = 2) {
|
|
|
829
1707
|
};
|
|
830
1708
|
}
|
|
831
1709
|
|
|
1710
|
+
class QueueWaitExceeded extends Error {
|
|
1711
|
+
ms;
|
|
1712
|
+
constructor(ms) {
|
|
1713
|
+
super("maxQueueWait exceeded");
|
|
1714
|
+
this.ms = ms;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
function checkWait(ms) {
|
|
1718
|
+
if (ms === undefined)
|
|
1719
|
+
return;
|
|
1720
|
+
if (!(ms >= 0) || !Number.isFinite(ms))
|
|
1721
|
+
throw new RangeError("maxQueueWait must be a non-negative number.");
|
|
1722
|
+
return ms;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
832
1725
|
class REST {
|
|
833
1726
|
#retryPolicy;
|
|
834
1727
|
#token;
|
|
1728
|
+
#fingerprint;
|
|
1729
|
+
#telemetry;
|
|
1730
|
+
#closing = new AbortController;
|
|
1731
|
+
#inFlight = 0;
|
|
1732
|
+
#idle;
|
|
1733
|
+
#rejected;
|
|
1734
|
+
#stopOnInvalidToken;
|
|
1735
|
+
#maxQueueWait;
|
|
1736
|
+
#invalid = new Array(10).fill(0);
|
|
1737
|
+
#invalidMinute = 0;
|
|
1738
|
+
#drained;
|
|
835
1739
|
#limiter;
|
|
836
|
-
#scheduler
|
|
1740
|
+
#scheduler;
|
|
837
1741
|
#transport;
|
|
838
1742
|
#decoder = new ResponseDecoder;
|
|
839
1743
|
#hooks = {};
|
|
840
1744
|
#concurrentBuckets;
|
|
841
1745
|
constructor(options = {}) {
|
|
842
1746
|
this.#token = options.token;
|
|
1747
|
+
this.#fingerprint = options.token ? tokenFingerprint(options.token) : undefined;
|
|
1748
|
+
this.#telemetry = options.telemetry;
|
|
1749
|
+
this.#scheduler = new RequestScheduler({
|
|
1750
|
+
maxQueued: options.maxQueued,
|
|
1751
|
+
maxQueuedPerBucket: options.maxQueuedPerBucket
|
|
1752
|
+
});
|
|
843
1753
|
this.#retryPolicy = options.retryPolicy ?? createRetryPolicy(options.retries ?? 2);
|
|
844
1754
|
this.#transport = options.transport ?? new HttpTransport({
|
|
845
1755
|
baseURL: options.baseURL,
|
|
@@ -849,8 +1759,14 @@ class REST {
|
|
|
849
1759
|
const store = options.store ?? new MemoryRateLimitStore;
|
|
850
1760
|
this.#concurrentBuckets = (options.concurrentBuckets ?? true) && typeof store.reserve === "function";
|
|
851
1761
|
this.#limiter = new RateLimiter(store, {
|
|
852
|
-
concurrent: this.#concurrentBuckets
|
|
1762
|
+
concurrent: this.#concurrentBuckets,
|
|
1763
|
+
maxBackoff: options.maxBackoff,
|
|
1764
|
+
probeMs: this.#transport.timeout > 0 ? this.#transport.timeout + 1000 : undefined,
|
|
1765
|
+
telemetry: options.telemetry,
|
|
1766
|
+
globalLimit: options.globalLimit === false ? undefined : options.globalLimit ?? 50
|
|
853
1767
|
});
|
|
1768
|
+
this.#stopOnInvalidToken = options.stopOnInvalidToken ?? true;
|
|
1769
|
+
this.#maxQueueWait = checkWait(options.maxQueueWait);
|
|
854
1770
|
if (options.hooks)
|
|
855
1771
|
this.#hooks = options.hooks;
|
|
856
1772
|
}
|
|
@@ -865,9 +1781,64 @@ class REST {
|
|
|
865
1781
|
if (!token.trim())
|
|
866
1782
|
throw new TypeError("A Discord bot token is required.");
|
|
867
1783
|
this.#token = token;
|
|
1784
|
+
this.#fingerprint = tokenFingerprint(token);
|
|
1785
|
+
this.#rejected = undefined;
|
|
868
1786
|
}
|
|
869
|
-
|
|
870
|
-
|
|
1787
|
+
diagnostics() {
|
|
1788
|
+
const store = this.#limiter.store;
|
|
1789
|
+
return {
|
|
1790
|
+
mode: store.mode ?? (store instanceof MemoryRateLimitStore ? "local" : "distributed"),
|
|
1791
|
+
queued: this.#scheduler.queued,
|
|
1792
|
+
inFlight: this.#inFlight,
|
|
1793
|
+
buckets: this.#scheduler.size,
|
|
1794
|
+
closed: this.#closing.signal.aborted,
|
|
1795
|
+
invalidRequests: this.#invalidCount(0),
|
|
1796
|
+
tokenRejected: this.#rejected !== undefined && this.#rejected === this.#fingerprint
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
async close() {
|
|
1800
|
+
if (!this.#closing.signal.aborted)
|
|
1801
|
+
this.#closing.abort(new Error("The REST client was closed."));
|
|
1802
|
+
if (this.#inFlight === 0)
|
|
1803
|
+
return;
|
|
1804
|
+
this.#drained ??= new Promise((resolve) => {
|
|
1805
|
+
this.#idle = resolve;
|
|
1806
|
+
});
|
|
1807
|
+
await this.#drained;
|
|
1808
|
+
}
|
|
1809
|
+
#invalidCount(add) {
|
|
1810
|
+
const minute = Math.floor(Date.now() / 60000);
|
|
1811
|
+
const elapsed = Math.min(10, minute - this.#invalidMinute);
|
|
1812
|
+
for (let i = 1;i <= elapsed; i++)
|
|
1813
|
+
this.#invalid[(this.#invalidMinute + i) % 10] = 0;
|
|
1814
|
+
if (elapsed > 0)
|
|
1815
|
+
this.#invalidMinute = minute;
|
|
1816
|
+
this.#invalid[minute % 10] += add;
|
|
1817
|
+
return this.#invalid.reduce((a, b) => a + b, 0);
|
|
1818
|
+
}
|
|
1819
|
+
#queueChanged() {
|
|
1820
|
+
emitTelemetry(this.#telemetry, {
|
|
1821
|
+
type: "queue",
|
|
1822
|
+
queued: this.#scheduler.queued,
|
|
1823
|
+
inFlight: this.#inFlight
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
async request(methodOrOptions, pathArg, bodyArg, optionsArg = {}) {
|
|
1827
|
+
let method;
|
|
1828
|
+
let path;
|
|
1829
|
+
let body = bodyArg;
|
|
1830
|
+
let options = optionsArg;
|
|
1831
|
+
if (typeof methodOrOptions === "object" && methodOrOptions !== null) {
|
|
1832
|
+
const { method: m, path: p, fullRoute, ...rest } = methodOrOptions;
|
|
1833
|
+
method = m;
|
|
1834
|
+
path = p ?? fullRoute ?? "";
|
|
1835
|
+
body = rest.body;
|
|
1836
|
+
options = rest;
|
|
1837
|
+
} else {
|
|
1838
|
+
method = methodOrOptions;
|
|
1839
|
+
path = pathArg;
|
|
1840
|
+
}
|
|
1841
|
+
if (typeof method !== "string" || !method.toUpperCase())
|
|
871
1842
|
throw new TypeError("REST method is required.");
|
|
872
1843
|
if (!path.startsWith("/"))
|
|
873
1844
|
throw new TypeError("REST paths must start with '/'.");
|
|
@@ -875,22 +1846,101 @@ class REST {
|
|
|
875
1846
|
const route = createRouteKey(method, path);
|
|
876
1847
|
const files = options.files;
|
|
877
1848
|
if (files && files.length > 0 && !(body instanceof FormData))
|
|
878
|
-
body = this.#fileForm(body, files)
|
|
1849
|
+
body = this.#fileForm(body, await Promise.all(files.map(async (file) => ({
|
|
1850
|
+
...file,
|
|
1851
|
+
data: await file.data
|
|
1852
|
+
}))));
|
|
879
1853
|
const query = serializeQuery(options.query);
|
|
880
1854
|
const requestPath = query ? `${path}${query}` : path;
|
|
881
|
-
|
|
1855
|
+
if (this.#closing.signal.aborted)
|
|
1856
|
+
throw abortError(path, this.#closing.signal.reason);
|
|
1857
|
+
const identity = requestIdentity(path, options.auth !== false && this.#token ? this.#fingerprint : undefined);
|
|
1858
|
+
if (identity.bucket === this.#fingerprint && this.#rejected === this.#fingerprint)
|
|
1859
|
+
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" });
|
|
1860
|
+
const queueWait = checkWait(options.maxQueueWait) ?? this.#maxQueueWait;
|
|
1861
|
+
let deadline;
|
|
1862
|
+
const sources = [this.#closing.signal];
|
|
1863
|
+
if (options.signal)
|
|
1864
|
+
sources.push(options.signal);
|
|
1865
|
+
if (queueWait !== undefined) {
|
|
1866
|
+
const expired = new AbortController;
|
|
1867
|
+
deadline = setTimeout(() => expired.abort(new QueueWaitExceeded(queueWait)), queueWait);
|
|
1868
|
+
sources.push(expired.signal);
|
|
1869
|
+
}
|
|
1870
|
+
const waitSignal = sources.length === 1 ? sources[0] : AbortSignal.any(sources);
|
|
1871
|
+
const state = { attempts: 0, status: 0 };
|
|
1872
|
+
const started = performance.now();
|
|
1873
|
+
this.#inFlight++;
|
|
1874
|
+
this.#queueChanged();
|
|
1875
|
+
try {
|
|
1876
|
+
return await this.#scheduler.runResolved(() => this.#limiter.resolveBucketKey(route.route, route.major, identity.bucket), waitSignal, path, (bucketKey, release) => {
|
|
1877
|
+
this.#queueChanged();
|
|
1878
|
+
return this.#attempts(route, requestPath, body, options, { identity, waitSignal, state }, bucketKey, release);
|
|
1879
|
+
});
|
|
1880
|
+
} catch (caught) {
|
|
1881
|
+
let error = caught;
|
|
1882
|
+
if (error instanceof RESTError && error.kind === "aborted" && error.cause instanceof QueueWaitExceeded)
|
|
1883
|
+
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" });
|
|
1884
|
+
if (error instanceof RESTError) {
|
|
1885
|
+
if (error.kind === "aborted" && state.attempts === 0)
|
|
1886
|
+
emitTelemetry(this.#telemetry, {
|
|
1887
|
+
type: "cancelled",
|
|
1888
|
+
stage: "queued"
|
|
1889
|
+
});
|
|
1890
|
+
else if (error.kind === "overloaded")
|
|
1891
|
+
emitTelemetry(this.#telemetry, { type: "overloaded" });
|
|
1892
|
+
if (state.status === 0)
|
|
1893
|
+
state.status = error.status;
|
|
1894
|
+
}
|
|
1895
|
+
throw error;
|
|
1896
|
+
} finally {
|
|
1897
|
+
clearTimeout(deadline);
|
|
1898
|
+
emitTelemetry(this.#telemetry, {
|
|
1899
|
+
type: "request",
|
|
1900
|
+
method: route.method,
|
|
1901
|
+
route: route.route,
|
|
1902
|
+
status: state.status,
|
|
1903
|
+
attempts: state.attempts,
|
|
1904
|
+
durationMs: performance.now() - started,
|
|
1905
|
+
ok: state.status >= 200 && state.status < 300
|
|
1906
|
+
});
|
|
1907
|
+
if (--this.#inFlight === 0 && this.#idle) {
|
|
1908
|
+
this.#idle();
|
|
1909
|
+
this.#idle = undefined;
|
|
1910
|
+
this.#drained = undefined;
|
|
1911
|
+
}
|
|
1912
|
+
this.#queueChanged();
|
|
1913
|
+
}
|
|
882
1914
|
}
|
|
883
|
-
async#attempts(route, requestPath, body, options, initialBucketKey, release) {
|
|
1915
|
+
async#attempts(route, requestPath, body, options, context, initialBucketKey, release) {
|
|
884
1916
|
const { method, path } = route;
|
|
1917
|
+
const { identity, waitSignal, state } = context;
|
|
1918
|
+
const label = route.route;
|
|
885
1919
|
let bucketKey = initialBucketKey;
|
|
886
1920
|
for (let attempt = 0;attempt <= this.#retryPolicy.maxRetries; attempt++) {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1921
|
+
let grant;
|
|
1922
|
+
try {
|
|
1923
|
+
grant = await this.#limiter.acquire(bucketKey, waitSignal, path, { global: identity.global });
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
if (error instanceof RESTError && error.kind === "aborted")
|
|
1926
|
+
emitTelemetry(this.#telemetry, {
|
|
1927
|
+
type: "cancelled",
|
|
1928
|
+
stage: "waiting"
|
|
1929
|
+
});
|
|
1930
|
+
throw error;
|
|
1931
|
+
}
|
|
1932
|
+
if (this.#concurrentBuckets && grant.known)
|
|
1933
|
+
release();
|
|
1934
|
+
if (waitSignal.aborted) {
|
|
1935
|
+
await this.#limiter.releaseGrant(bucketKey, grant, true);
|
|
1936
|
+
emitTelemetry(this.#telemetry, {
|
|
1937
|
+
type: "cancelled",
|
|
1938
|
+
stage: "waiting"
|
|
1939
|
+
});
|
|
1940
|
+
throw abortError(path, waitSignal.reason);
|
|
892
1941
|
}
|
|
893
1942
|
this.#emit(this.#hooks.onRequest, { method, path, attempt });
|
|
1943
|
+
state.attempts = attempt + 1;
|
|
894
1944
|
const requestStart = Date.now();
|
|
895
1945
|
let response;
|
|
896
1946
|
try {
|
|
@@ -902,13 +1952,44 @@ class REST {
|
|
|
902
1952
|
signal: options.signal
|
|
903
1953
|
});
|
|
904
1954
|
} catch (error) {
|
|
905
|
-
|
|
1955
|
+
const notSent = error instanceof RESTError && error.kind === "aborted";
|
|
1956
|
+
await this.#limiter.releaseGrant(bucketKey, grant, notSent);
|
|
1957
|
+
if (error instanceof RESTError) {
|
|
1958
|
+
if (error.kind === "aborted")
|
|
1959
|
+
emitTelemetry(this.#telemetry, {
|
|
1960
|
+
type: "cancelled",
|
|
1961
|
+
stage: "transport"
|
|
1962
|
+
});
|
|
906
1963
|
throw error;
|
|
907
|
-
|
|
1964
|
+
}
|
|
1965
|
+
if (options.signal?.aborted) {
|
|
1966
|
+
emitTelemetry(this.#telemetry, {
|
|
1967
|
+
type: "cancelled",
|
|
1968
|
+
stage: "transport"
|
|
1969
|
+
});
|
|
908
1970
|
throw abortError(path, options.signal.reason);
|
|
909
|
-
|
|
1971
|
+
}
|
|
1972
|
+
const failure = error instanceof TransportError ? error : new TransportError("Discord REST request failed", false, sanitizeCause(error));
|
|
1973
|
+
emitTelemetry(this.#telemetry, {
|
|
1974
|
+
type: "networkError",
|
|
1975
|
+
timedOut: failure.timedOut
|
|
1976
|
+
});
|
|
910
1977
|
if (!failure.timedOut && attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, 0)) {
|
|
911
|
-
|
|
1978
|
+
const delay = this.#retryPolicy.getDelay(attempt);
|
|
1979
|
+
this.#emit(this.#hooks.onRetry, {
|
|
1980
|
+
method,
|
|
1981
|
+
path,
|
|
1982
|
+
attempt,
|
|
1983
|
+
status: 0,
|
|
1984
|
+
delayMs: delay
|
|
1985
|
+
});
|
|
1986
|
+
emitTelemetry(this.#telemetry, {
|
|
1987
|
+
type: "retry",
|
|
1988
|
+
route: label,
|
|
1989
|
+
reason: "network",
|
|
1990
|
+
attempt
|
|
1991
|
+
});
|
|
1992
|
+
await this.#retrySleep(delay, waitSignal, path);
|
|
912
1993
|
continue;
|
|
913
1994
|
}
|
|
914
1995
|
throw new RESTError(failure.message, 0, undefined, undefined, {
|
|
@@ -918,30 +1999,60 @@ class REST {
|
|
|
918
1999
|
kind: failure.timedOut ? "timeout" : "network"
|
|
919
2000
|
});
|
|
920
2001
|
}
|
|
2002
|
+
state.status = response.status;
|
|
2003
|
+
if (response.status === 401 || response.status === 403 || response.status === 429 && response.headers.get("X-RateLimit-Scope") !== "shared")
|
|
2004
|
+
this.#invalidCount(1);
|
|
2005
|
+
if (response.status === 401 && this.#stopOnInvalidToken && identity.bucket === this.#fingerprint) {
|
|
2006
|
+
this.#rejected = this.#fingerprint;
|
|
2007
|
+
emitTelemetry(this.#telemetry, { type: "tokenRejected" });
|
|
2008
|
+
}
|
|
2009
|
+
const durationMs = Date.now() - requestStart;
|
|
921
2010
|
this.#emit(this.#hooks.onResponse, {
|
|
922
2011
|
method,
|
|
923
2012
|
path,
|
|
924
2013
|
status: response.status,
|
|
925
|
-
durationMs
|
|
2014
|
+
durationMs
|
|
2015
|
+
});
|
|
2016
|
+
emitTelemetry(this.#telemetry, {
|
|
2017
|
+
type: "response",
|
|
2018
|
+
method,
|
|
2019
|
+
route: label,
|
|
2020
|
+
status: response.status,
|
|
2021
|
+
durationMs
|
|
926
2022
|
});
|
|
927
|
-
bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major);
|
|
928
2023
|
const payload = await this.#decoder.read(response);
|
|
2024
|
+
const data = response.ok ? {} : this.#decoder.errorData(payload);
|
|
2025
|
+
bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major, {
|
|
2026
|
+
identity: identity.bucket,
|
|
2027
|
+
global: identity.global,
|
|
2028
|
+
body: data,
|
|
2029
|
+
grant
|
|
2030
|
+
});
|
|
929
2031
|
if (response.ok)
|
|
930
2032
|
return response.status === 204 ? undefined : payload;
|
|
931
|
-
const
|
|
932
|
-
const
|
|
933
|
-
if (
|
|
934
|
-
|
|
935
|
-
|
|
2033
|
+
const limited = response.status === 429;
|
|
2034
|
+
const retryAfterMs = limited ? this.#limiter.retryAfterMs(response, data) : undefined;
|
|
2035
|
+
if (limited) {
|
|
2036
|
+
const global = data.global === true || response.headers.get("X-RateLimit-Global") === "true";
|
|
2037
|
+
const scope = response.headers.get("X-RateLimit-Scope")?.toLowerCase();
|
|
2038
|
+
emitTelemetry(this.#telemetry, {
|
|
2039
|
+
type: "rateLimited",
|
|
2040
|
+
route: label,
|
|
2041
|
+
scope: global ? "global" : scope === "user" || scope === "shared" ? scope : "unknown",
|
|
2042
|
+
retryAfterMs
|
|
2043
|
+
});
|
|
936
2044
|
this.#emit(this.#hooks.onRateLimit, {
|
|
937
2045
|
method,
|
|
938
2046
|
path,
|
|
939
|
-
retryAfterMs
|
|
940
|
-
global
|
|
941
|
-
bucket: await this.#limiter.store.getBucketHash(route.route)
|
|
2047
|
+
retryAfterMs,
|
|
2048
|
+
global,
|
|
2049
|
+
bucket: response.headers.get("X-RateLimit-Bucket") ?? await this.#limiter.store.getBucketHash(route.route)
|
|
942
2050
|
});
|
|
2051
|
+
}
|
|
943
2052
|
if (attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, response.status)) {
|
|
944
|
-
const delay = this.#retryPolicy.getDelay(attempt,
|
|
2053
|
+
const delay = this.#retryPolicy.getDelay(attempt, limited ? retryAfterMs / 1000 : undefined);
|
|
2054
|
+
if (delay > this.#limiter.maxBackoff)
|
|
2055
|
+
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 });
|
|
945
2056
|
this.#emit(this.#hooks.onRetry, {
|
|
946
2057
|
method,
|
|
947
2058
|
path,
|
|
@@ -949,13 +2060,30 @@ class REST {
|
|
|
949
2060
|
status: response.status,
|
|
950
2061
|
delayMs: delay
|
|
951
2062
|
});
|
|
952
|
-
|
|
2063
|
+
emitTelemetry(this.#telemetry, {
|
|
2064
|
+
type: "retry",
|
|
2065
|
+
route: label,
|
|
2066
|
+
reason: limited ? "rateLimited" : "server",
|
|
2067
|
+
attempt
|
|
2068
|
+
});
|
|
2069
|
+
await this.#retrySleep(limited ? delay - retryAfterMs : delay, waitSignal, path);
|
|
953
2070
|
continue;
|
|
954
2071
|
}
|
|
955
2072
|
throw new RESTError(data.message ?? response.statusText ?? `Discord REST request failed with status ${response.status}`, response.status, data.code, payload, { method, path });
|
|
956
2073
|
}
|
|
957
2074
|
throw new RESTError("Discord REST request exhausted its retry attempts", 0, undefined, undefined, { method, path });
|
|
958
2075
|
}
|
|
2076
|
+
async#retrySleep(delay, signal, path) {
|
|
2077
|
+
try {
|
|
2078
|
+
await sleep(delay, signal, path);
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
emitTelemetry(this.#telemetry, {
|
|
2081
|
+
type: "cancelled",
|
|
2082
|
+
stage: "retry"
|
|
2083
|
+
});
|
|
2084
|
+
throw error;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
959
2087
|
#headers(body, options) {
|
|
960
2088
|
const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
|
|
961
2089
|
const headers = { ...options.headers };
|
|
@@ -1007,15 +2135,15 @@ class REST {
|
|
|
1007
2135
|
const blob = file.data instanceof Blob ? file.data : new Blob([file.data], {
|
|
1008
2136
|
type: file.contentType ?? "application/octet-stream"
|
|
1009
2137
|
});
|
|
1010
|
-
form.append(`files[${i}]`, blob, file.name);
|
|
2138
|
+
form.append(`files[${i}]`, blob, file.name.replace(/[\r\n"\\]/g, "_"));
|
|
1011
2139
|
}
|
|
1012
2140
|
return form;
|
|
1013
2141
|
}
|
|
1014
2142
|
postWithFiles(path, payload, files, options) {
|
|
1015
|
-
return this.request("POST", path,
|
|
2143
|
+
return this.request("POST", path, payload, { ...options, files });
|
|
1016
2144
|
}
|
|
1017
2145
|
patchWithFiles(path, payload, files, options) {
|
|
1018
|
-
return this.request("PATCH", path,
|
|
2146
|
+
return this.request("PATCH", path, payload, { ...options, files });
|
|
1019
2147
|
}
|
|
1020
2148
|
#emit(hook, ctx) {
|
|
1021
2149
|
if (!hook)
|
|
@@ -1027,12 +2155,14 @@ class REST {
|
|
|
1027
2155
|
}
|
|
1028
2156
|
}
|
|
1029
2157
|
export {
|
|
2158
|
+
DEFAULT_MAX_BACKOFF_MS,
|
|
1030
2159
|
RESTError as DiscordAPIError,
|
|
1031
2160
|
HttpTransport,
|
|
1032
2161
|
MemoryRateLimitStore,
|
|
1033
2162
|
REST,
|
|
1034
2163
|
RESTError,
|
|
1035
2164
|
RESTErrorCode,
|
|
2165
|
+
RESTMetrics,
|
|
1036
2166
|
RateLimiter,
|
|
1037
2167
|
RedisRateLimitStore,
|
|
1038
2168
|
RequestScheduler,
|
|
@@ -1043,11 +2173,16 @@ export {
|
|
|
1043
2173
|
abortError,
|
|
1044
2174
|
createRetryPolicy,
|
|
1045
2175
|
createRouteKey,
|
|
2176
|
+
fromBunRedis,
|
|
2177
|
+
headerNumber,
|
|
1046
2178
|
majorParameter,
|
|
1047
2179
|
normalizeRoutePath,
|
|
2180
|
+
parseRateLimitHeaders,
|
|
1048
2181
|
redactPath,
|
|
2182
|
+
requestIdentity,
|
|
1049
2183
|
scopeBucket,
|
|
1050
|
-
serializeQuery
|
|
2184
|
+
serializeQuery,
|
|
2185
|
+
tokenFingerprint
|
|
1051
2186
|
};
|
|
1052
2187
|
|
|
1053
|
-
//# debugId=
|
|
2188
|
+
//# debugId=94C16174A190B15864756E2164756E21
|