lunibee 0.3.1 → 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/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 +6213 -4716
- package/dist/core/index.js.map +46 -39
- 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 +8272 -6085
- package/dist/index.js.map +64 -58
- 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 +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 +1350 -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 +1 -1
- package/dist/sharding/health.d.ts +1 -1
- package/dist/sharding/host.d.ts +6 -0
- package/dist/sharding/identify.d.ts +2 -0
- package/dist/sharding/index.d.ts +6 -0
- package/dist/sharding/index.js +205 -39
- package/dist/sharding/index.js.map +14 -14
- package/dist/sharding/remote.d.ts +18 -2
- package/dist/sharding/supervisor.d.ts +1 -1
- 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 +19 -16
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
|
|
|
@@ -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,185 @@ 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
|
+
#lastFailureAt;
|
|
1340
|
+
#failures = 0;
|
|
1341
|
+
#mode;
|
|
609
1342
|
constructor(options) {
|
|
610
1343
|
this.#client = options.client;
|
|
611
|
-
this.#prefix = options.prefix ?? "lunibee:rest:"
|
|
1344
|
+
this.#prefix = `${options.prefix ?? "lunibee:rest:"}${SCHEMA}:`;
|
|
612
1345
|
this.#onErrorCallback = options.onError;
|
|
613
|
-
|
|
614
|
-
|
|
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
|
+
}
|
|
615
1364
|
}
|
|
616
1365
|
get lastError() {
|
|
617
1366
|
return this.#lastError;
|
|
@@ -619,107 +1368,204 @@ class RedisRateLimitStore {
|
|
|
619
1368
|
isHealthy() {
|
|
620
1369
|
return this.#lastError === undefined;
|
|
621
1370
|
}
|
|
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 {}
|
|
1371
|
+
get mode() {
|
|
1372
|
+
return this.#mode;
|
|
628
1373
|
}
|
|
629
|
-
|
|
630
|
-
this.#
|
|
1374
|
+
get supportsReservation() {
|
|
1375
|
+
return this.#atomic;
|
|
631
1376
|
}
|
|
632
|
-
|
|
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.");
|
|
633
1391
|
try {
|
|
634
|
-
|
|
635
|
-
this.#markHealthy();
|
|
636
|
-
return hash ?? undefined;
|
|
1392
|
+
await this.#withTimeout(this.#client.eval(VERIFY_SCRIPT, 0));
|
|
637
1393
|
} catch (error) {
|
|
638
|
-
|
|
639
|
-
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) });
|
|
640
1395
|
}
|
|
641
1396
|
}
|
|
642
|
-
|
|
643
|
-
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();
|
|
644
1416
|
try {
|
|
645
|
-
await this.#
|
|
646
|
-
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;
|
|
647
1427
|
} catch (error) {
|
|
648
|
-
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();
|
|
649
1447
|
}
|
|
650
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
|
+
}
|
|
651
1466
|
async getBucket(key) {
|
|
652
|
-
|
|
653
|
-
const data = await this.#client.get(
|
|
654
|
-
this.#markHealthy();
|
|
1467
|
+
return this.#run("getBucket", async () => {
|
|
1468
|
+
const data = await this.#client.get(this.#bucketKey(key));
|
|
655
1469
|
if (!data)
|
|
656
1470
|
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();
|
|
1471
|
+
try {
|
|
1472
|
+
return JSON.parse(data);
|
|
1473
|
+
} catch {
|
|
671
1474
|
return;
|
|
672
1475
|
}
|
|
673
|
-
|
|
674
|
-
this.#markHealthy();
|
|
675
|
-
} catch (error) {
|
|
676
|
-
this.#onError("updateBucket", error);
|
|
677
|
-
}
|
|
1476
|
+
}, () => this.#local.getBucket(key), true);
|
|
678
1477
|
}
|
|
679
|
-
|
|
680
|
-
|
|
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);
|
|
681
1489
|
}
|
|
682
1490
|
reserve;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
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}`);
|
|
694
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);
|
|
695
1515
|
}
|
|
696
|
-
async
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
this.#
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
return this.#local.getGlobalReset();
|
|
704
|
-
}
|
|
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);
|
|
705
1523
|
}
|
|
706
|
-
async
|
|
707
|
-
this.#local.
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
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);
|
|
721
1551
|
}
|
|
722
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
|
+
}
|
|
723
1569
|
// packages/rest/src/webhook.ts
|
|
724
1570
|
class WebhookClient {
|
|
725
1571
|
id;
|
|
@@ -740,29 +1586,49 @@ class WebhookClient {
|
|
|
740
1586
|
}
|
|
741
1587
|
this.#rest = new REST(restOptions);
|
|
742
1588
|
}
|
|
743
|
-
|
|
1589
|
+
#prepare(options, base = {}) {
|
|
744
1590
|
const payload = typeof options === "string" ? { content: options } : options;
|
|
745
|
-
const {
|
|
746
|
-
|
|
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);
|
|
747
1605
|
if (thread_id)
|
|
748
1606
|
params.set("thread_id", String(thread_id));
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
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" });
|
|
752
1617
|
const path = `${Routes.webhook(this.id, this.token)}${query}`;
|
|
753
|
-
const json = {
|
|
754
|
-
...body,
|
|
755
|
-
embeds: formattedEmbeds,
|
|
756
|
-
components: formattedComponents
|
|
757
|
-
};
|
|
758
1618
|
return files?.length ? this.#rest.postWithFiles(path, json, files) : this.#rest.post(path, json);
|
|
759
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
|
+
}
|
|
760
1624
|
async editMessage(messageId, options) {
|
|
761
|
-
const
|
|
762
|
-
|
|
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);
|
|
763
1628
|
}
|
|
764
|
-
async deleteMessage(messageId) {
|
|
765
|
-
|
|
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}`);
|
|
766
1632
|
}
|
|
767
1633
|
}
|
|
768
1634
|
|
|
@@ -771,7 +1637,9 @@ var LIBRARY_VERSION = package_default.version;
|
|
|
771
1637
|
var LIBRARY_URL = "https://github.com/Ekretos/lunibee";
|
|
772
1638
|
var USER_AGENT = `DiscordBot (${LIBRARY_URL}, ${LIBRARY_VERSION})`;
|
|
773
1639
|
function assertSafePath(path) {
|
|
774
|
-
|
|
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("?");
|
|
775
1643
|
const pathname = end === -1 ? path : path.slice(0, end);
|
|
776
1644
|
if (pathname.includes("\\"))
|
|
777
1645
|
throw new TypeError("REST paths must not contain backslashes.");
|
|
@@ -782,7 +1650,11 @@ function assertSafePath(path) {
|
|
|
782
1650
|
} catch {
|
|
783
1651
|
throw new TypeError("REST path has malformed percent-encoding.");
|
|
784
1652
|
}
|
|
785
|
-
|
|
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
|
+
}))
|
|
786
1658
|
throw new TypeError("REST paths must not contain '.' or '..' segments.");
|
|
787
1659
|
}
|
|
788
1660
|
}
|
|
@@ -793,7 +1665,8 @@ var REQUEST_DATA_KEYS = new Set([
|
|
|
793
1665
|
"headers",
|
|
794
1666
|
"reason",
|
|
795
1667
|
"auth",
|
|
796
|
-
"signal"
|
|
1668
|
+
"signal",
|
|
1669
|
+
"maxQueueWait"
|
|
797
1670
|
]);
|
|
798
1671
|
function isRequestData(value) {
|
|
799
1672
|
if (value === null || typeof value !== "object")
|
|
@@ -829,17 +1702,49 @@ function createRetryPolicy(maxRetries = 2) {
|
|
|
829
1702
|
};
|
|
830
1703
|
}
|
|
831
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
|
+
|
|
832
1720
|
class REST {
|
|
833
1721
|
#retryPolicy;
|
|
834
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;
|
|
835
1734
|
#limiter;
|
|
836
|
-
#scheduler
|
|
1735
|
+
#scheduler;
|
|
837
1736
|
#transport;
|
|
838
1737
|
#decoder = new ResponseDecoder;
|
|
839
1738
|
#hooks = {};
|
|
840
1739
|
#concurrentBuckets;
|
|
841
1740
|
constructor(options = {}) {
|
|
842
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
|
+
});
|
|
843
1748
|
this.#retryPolicy = options.retryPolicy ?? createRetryPolicy(options.retries ?? 2);
|
|
844
1749
|
this.#transport = options.transport ?? new HttpTransport({
|
|
845
1750
|
baseURL: options.baseURL,
|
|
@@ -849,8 +1754,14 @@ class REST {
|
|
|
849
1754
|
const store = options.store ?? new MemoryRateLimitStore;
|
|
850
1755
|
this.#concurrentBuckets = (options.concurrentBuckets ?? true) && typeof store.reserve === "function";
|
|
851
1756
|
this.#limiter = new RateLimiter(store, {
|
|
852
|
-
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
|
|
853
1762
|
});
|
|
1763
|
+
this.#stopOnInvalidToken = options.stopOnInvalidToken ?? true;
|
|
1764
|
+
this.#maxQueueWait = checkWait(options.maxQueueWait);
|
|
854
1765
|
if (options.hooks)
|
|
855
1766
|
this.#hooks = options.hooks;
|
|
856
1767
|
}
|
|
@@ -865,9 +1776,64 @@ class REST {
|
|
|
865
1776
|
if (!token.trim())
|
|
866
1777
|
throw new TypeError("A Discord bot token is required.");
|
|
867
1778
|
this.#token = token;
|
|
1779
|
+
this.#fingerprint = tokenFingerprint(token);
|
|
1780
|
+
this.#rejected = undefined;
|
|
1781
|
+
}
|
|
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
|
+
};
|
|
868
1793
|
}
|
|
869
|
-
async
|
|
870
|
-
if (!
|
|
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())
|
|
871
1837
|
throw new TypeError("REST method is required.");
|
|
872
1838
|
if (!path.startsWith("/"))
|
|
873
1839
|
throw new TypeError("REST paths must start with '/'.");
|
|
@@ -875,22 +1841,101 @@ class REST {
|
|
|
875
1841
|
const route = createRouteKey(method, path);
|
|
876
1842
|
const files = options.files;
|
|
877
1843
|
if (files && files.length > 0 && !(body instanceof FormData))
|
|
878
|
-
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
|
+
}))));
|
|
879
1848
|
const query = serializeQuery(options.query);
|
|
880
1849
|
const requestPath = query ? `${path}${query}` : path;
|
|
881
|
-
|
|
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
|
+
}
|
|
882
1909
|
}
|
|
883
|
-
async#attempts(route, requestPath, body, options, initialBucketKey, release) {
|
|
1910
|
+
async#attempts(route, requestPath, body, options, context, initialBucketKey, release) {
|
|
884
1911
|
const { method, path } = route;
|
|
1912
|
+
const { identity, waitSignal, state } = context;
|
|
1913
|
+
const label = route.route;
|
|
885
1914
|
let bucketKey = initialBucketKey;
|
|
886
1915
|
for (let attempt = 0;attempt <= this.#retryPolicy.maxRetries; attempt++) {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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);
|
|
892
1936
|
}
|
|
893
1937
|
this.#emit(this.#hooks.onRequest, { method, path, attempt });
|
|
1938
|
+
state.attempts = attempt + 1;
|
|
894
1939
|
const requestStart = Date.now();
|
|
895
1940
|
let response;
|
|
896
1941
|
try {
|
|
@@ -902,13 +1947,44 @@ class REST {
|
|
|
902
1947
|
signal: options.signal
|
|
903
1948
|
});
|
|
904
1949
|
} catch (error) {
|
|
905
|
-
|
|
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
|
+
});
|
|
906
1958
|
throw error;
|
|
907
|
-
|
|
1959
|
+
}
|
|
1960
|
+
if (options.signal?.aborted) {
|
|
1961
|
+
emitTelemetry(this.#telemetry, {
|
|
1962
|
+
type: "cancelled",
|
|
1963
|
+
stage: "transport"
|
|
1964
|
+
});
|
|
908
1965
|
throw abortError(path, options.signal.reason);
|
|
909
|
-
|
|
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
|
+
});
|
|
910
1972
|
if (!failure.timedOut && attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, 0)) {
|
|
911
|
-
|
|
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);
|
|
912
1988
|
continue;
|
|
913
1989
|
}
|
|
914
1990
|
throw new RESTError(failure.message, 0, undefined, undefined, {
|
|
@@ -918,30 +1994,60 @@ class REST {
|
|
|
918
1994
|
kind: failure.timedOut ? "timeout" : "network"
|
|
919
1995
|
});
|
|
920
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;
|
|
921
2005
|
this.#emit(this.#hooks.onResponse, {
|
|
922
2006
|
method,
|
|
923
2007
|
path,
|
|
924
2008
|
status: response.status,
|
|
925
|
-
durationMs
|
|
2009
|
+
durationMs
|
|
2010
|
+
});
|
|
2011
|
+
emitTelemetry(this.#telemetry, {
|
|
2012
|
+
type: "response",
|
|
2013
|
+
method,
|
|
2014
|
+
route: label,
|
|
2015
|
+
status: response.status,
|
|
2016
|
+
durationMs
|
|
926
2017
|
});
|
|
927
|
-
bucketKey = await this.#limiter.applyResponse(response, bucketKey, route.route, route.major);
|
|
928
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
|
+
});
|
|
929
2026
|
if (response.ok)
|
|
930
2027
|
return response.status === 204 ? undefined : payload;
|
|
931
|
-
const
|
|
932
|
-
const
|
|
933
|
-
if (
|
|
934
|
-
|
|
935
|
-
|
|
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
|
+
});
|
|
936
2039
|
this.#emit(this.#hooks.onRateLimit, {
|
|
937
2040
|
method,
|
|
938
2041
|
path,
|
|
939
|
-
retryAfterMs
|
|
940
|
-
global
|
|
941
|
-
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)
|
|
942
2045
|
});
|
|
2046
|
+
}
|
|
943
2047
|
if (attempt < this.#retryPolicy.maxRetries && this.#retryPolicy.shouldRetry(method, response.status)) {
|
|
944
|
-
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 });
|
|
945
2051
|
this.#emit(this.#hooks.onRetry, {
|
|
946
2052
|
method,
|
|
947
2053
|
path,
|
|
@@ -949,13 +2055,30 @@ class REST {
|
|
|
949
2055
|
status: response.status,
|
|
950
2056
|
delayMs: delay
|
|
951
2057
|
});
|
|
952
|
-
|
|
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);
|
|
953
2065
|
continue;
|
|
954
2066
|
}
|
|
955
2067
|
throw new RESTError(data.message ?? response.statusText ?? `Discord REST request failed with status ${response.status}`, response.status, data.code, payload, { method, path });
|
|
956
2068
|
}
|
|
957
2069
|
throw new RESTError("Discord REST request exhausted its retry attempts", 0, undefined, undefined, { method, path });
|
|
958
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
|
+
}
|
|
959
2082
|
#headers(body, options) {
|
|
960
2083
|
const isFormData = typeof FormData !== "undefined" && body instanceof FormData;
|
|
961
2084
|
const headers = { ...options.headers };
|
|
@@ -1007,15 +2130,15 @@ class REST {
|
|
|
1007
2130
|
const blob = file.data instanceof Blob ? file.data : new Blob([file.data], {
|
|
1008
2131
|
type: file.contentType ?? "application/octet-stream"
|
|
1009
2132
|
});
|
|
1010
|
-
form.append(`files[${i}]`, blob, file.name);
|
|
2133
|
+
form.append(`files[${i}]`, blob, file.name.replace(/[\r\n"\\]/g, "_"));
|
|
1011
2134
|
}
|
|
1012
2135
|
return form;
|
|
1013
2136
|
}
|
|
1014
2137
|
postWithFiles(path, payload, files, options) {
|
|
1015
|
-
return this.request("POST", path,
|
|
2138
|
+
return this.request("POST", path, payload, { ...options, files });
|
|
1016
2139
|
}
|
|
1017
2140
|
patchWithFiles(path, payload, files, options) {
|
|
1018
|
-
return this.request("PATCH", path,
|
|
2141
|
+
return this.request("PATCH", path, payload, { ...options, files });
|
|
1019
2142
|
}
|
|
1020
2143
|
#emit(hook, ctx) {
|
|
1021
2144
|
if (!hook)
|
|
@@ -1027,12 +2150,14 @@ class REST {
|
|
|
1027
2150
|
}
|
|
1028
2151
|
}
|
|
1029
2152
|
export {
|
|
2153
|
+
DEFAULT_MAX_BACKOFF_MS,
|
|
1030
2154
|
RESTError as DiscordAPIError,
|
|
1031
2155
|
HttpTransport,
|
|
1032
2156
|
MemoryRateLimitStore,
|
|
1033
2157
|
REST,
|
|
1034
2158
|
RESTError,
|
|
1035
2159
|
RESTErrorCode,
|
|
2160
|
+
RESTMetrics,
|
|
1036
2161
|
RateLimiter,
|
|
1037
2162
|
RedisRateLimitStore,
|
|
1038
2163
|
RequestScheduler,
|
|
@@ -1043,11 +2168,16 @@ export {
|
|
|
1043
2168
|
abortError,
|
|
1044
2169
|
createRetryPolicy,
|
|
1045
2170
|
createRouteKey,
|
|
2171
|
+
fromBunRedis,
|
|
2172
|
+
headerNumber,
|
|
1046
2173
|
majorParameter,
|
|
1047
2174
|
normalizeRoutePath,
|
|
2175
|
+
parseRateLimitHeaders,
|
|
1048
2176
|
redactPath,
|
|
2177
|
+
requestIdentity,
|
|
1049
2178
|
scopeBucket,
|
|
1050
|
-
serializeQuery
|
|
2179
|
+
serializeQuery,
|
|
2180
|
+
tokenFingerprint
|
|
1051
2181
|
};
|
|
1052
2182
|
|
|
1053
|
-
//# debugId=
|
|
2183
|
+
//# debugId=8464D7D05D07B1F864756E2164756E21
|