@upstash/ratelimit 1.0.1 → 1.1.0-canary-1
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/README.md +25 -352
- package/dist/index.d.mts +40 -15
- package/dist/index.d.ts +40 -15
- package/dist/index.js +733 -356
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +733 -356
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -122,6 +122,12 @@ var Cache = class {
|
|
|
122
122
|
this.cache.set(key, value);
|
|
123
123
|
return value;
|
|
124
124
|
}
|
|
125
|
+
pop(key) {
|
|
126
|
+
this.cache.delete(key);
|
|
127
|
+
}
|
|
128
|
+
empty() {
|
|
129
|
+
this.cache.clear();
|
|
130
|
+
}
|
|
125
131
|
};
|
|
126
132
|
|
|
127
133
|
// src/duration.ts
|
|
@@ -130,7 +136,7 @@ function ms(d) {
|
|
|
130
136
|
if (!match) {
|
|
131
137
|
throw new Error(`Unable to parse window size: ${d}`);
|
|
132
138
|
}
|
|
133
|
-
const time = parseInt(match[1]);
|
|
139
|
+
const time = Number.parseInt(match[1]);
|
|
134
140
|
const unit = match[2];
|
|
135
141
|
switch (unit) {
|
|
136
142
|
case "ms":
|
|
@@ -148,6 +154,115 @@ function ms(d) {
|
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
// src/lua-scripts/multi.ts
|
|
158
|
+
var fixedWindowLimitScript = `
|
|
159
|
+
local key = KEYS[1]
|
|
160
|
+
local id = ARGV[1]
|
|
161
|
+
local window = ARGV[2]
|
|
162
|
+
local incrementBy = tonumber(ARGV[3])
|
|
163
|
+
|
|
164
|
+
redis.call("HSET", key, id, incrementBy)
|
|
165
|
+
local fields = redis.call("HGETALL", key)
|
|
166
|
+
if #fields == 1 and tonumber(fields[1])==incrementBy then
|
|
167
|
+
-- The first time this key is set, and the value will be equal to incrementBy.
|
|
168
|
+
-- So we only need the expire command once
|
|
169
|
+
redis.call("PEXPIRE", key, window)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
return fields
|
|
173
|
+
`;
|
|
174
|
+
var fixedWindowRemainingTokensScript = `
|
|
175
|
+
local key = KEYS[1]
|
|
176
|
+
local tokens = 0
|
|
177
|
+
|
|
178
|
+
local fields = redis.call("HGETALL", key)
|
|
179
|
+
|
|
180
|
+
return fields
|
|
181
|
+
`;
|
|
182
|
+
var slidingWindowLimitScript = `
|
|
183
|
+
local currentKey = KEYS[1] -- identifier including prefixes
|
|
184
|
+
local previousKey = KEYS[2] -- key of the previous bucket
|
|
185
|
+
local tokens = tonumber(ARGV[1]) -- tokens per window
|
|
186
|
+
local now = ARGV[2] -- current timestamp in milliseconds
|
|
187
|
+
local window = ARGV[3] -- interval in milliseconds
|
|
188
|
+
local requestId = ARGV[4] -- uuid for this request
|
|
189
|
+
local incrementBy = tonumber(ARGV[5]) -- custom rate, default is 1
|
|
190
|
+
|
|
191
|
+
local currentFields = redis.call("HGETALL", currentKey)
|
|
192
|
+
local requestsInCurrentWindow = 0
|
|
193
|
+
for i = 2, #currentFields, 2 do
|
|
194
|
+
requestsInCurrentWindow = requestsInCurrentWindow + tonumber(currentFields[i])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
local previousFields = redis.call("HGETALL", previousKey)
|
|
198
|
+
local requestsInPreviousWindow = 0
|
|
199
|
+
for i = 2, #previousFields, 2 do
|
|
200
|
+
requestsInPreviousWindow = requestsInPreviousWindow + tonumber(previousFields[i])
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
local percentageInCurrent = ( now % window) / window
|
|
204
|
+
if requestsInPreviousWindow * (1 - percentageInCurrent ) + requestsInCurrentWindow >= tokens then
|
|
205
|
+
return {currentFields, previousFields, false}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
redis.call("HSET", currentKey, requestId, incrementBy)
|
|
209
|
+
|
|
210
|
+
if requestsInCurrentWindow == 0 then
|
|
211
|
+
-- The first time this key is set, the value will be equal to incrementBy.
|
|
212
|
+
-- So we only need the expire command once
|
|
213
|
+
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
|
|
214
|
+
end
|
|
215
|
+
return {currentFields, previousFields, true}
|
|
216
|
+
`;
|
|
217
|
+
var slidingWindowRemainingTokensScript = `
|
|
218
|
+
local currentKey = KEYS[1] -- identifier including prefixes
|
|
219
|
+
local previousKey = KEYS[2] -- key of the previous bucket
|
|
220
|
+
local now = ARGV[1] -- current timestamp in milliseconds
|
|
221
|
+
local window = ARGV[2] -- interval in milliseconds
|
|
222
|
+
|
|
223
|
+
local currentFields = redis.call("HGETALL", currentKey)
|
|
224
|
+
local requestsInCurrentWindow = 0
|
|
225
|
+
for i = 2, #currentFields, 2 do
|
|
226
|
+
requestsInCurrentWindow = requestsInCurrentWindow + tonumber(currentFields[i])
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
local previousFields = redis.call("HGETALL", previousKey)
|
|
230
|
+
local requestsInPreviousWindow = 0
|
|
231
|
+
for i = 2, #previousFields, 2 do
|
|
232
|
+
requestsInPreviousWindow = requestsInPreviousWindow + tonumber(previousFields[i])
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
local percentageInCurrent = ( now % window) / window
|
|
236
|
+
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
|
|
237
|
+
|
|
238
|
+
return requestsInCurrentWindow + requestsInPreviousWindow
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
// src/lua-scripts/reset.ts
|
|
242
|
+
var resetScript = `
|
|
243
|
+
local pattern = KEYS[1]
|
|
244
|
+
|
|
245
|
+
-- Initialize cursor to start from 0
|
|
246
|
+
local cursor = "0"
|
|
247
|
+
|
|
248
|
+
repeat
|
|
249
|
+
-- Scan for keys matching the pattern
|
|
250
|
+
local scan_result = redis.call('SCAN', cursor, 'MATCH', pattern)
|
|
251
|
+
|
|
252
|
+
-- Extract cursor for the next iteration
|
|
253
|
+
cursor = scan_result[1]
|
|
254
|
+
|
|
255
|
+
-- Extract keys from the scan result
|
|
256
|
+
local keys = scan_result[2]
|
|
257
|
+
|
|
258
|
+
for i=1, #keys do
|
|
259
|
+
redis.call('DEL', keys[i])
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
-- Continue scanning until cursor is 0 (end of keyspace)
|
|
263
|
+
until cursor == "0"
|
|
264
|
+
`;
|
|
265
|
+
|
|
151
266
|
// src/ratelimit.ts
|
|
152
267
|
var Ratelimit = class {
|
|
153
268
|
limiter;
|
|
@@ -188,12 +303,29 @@ var Ratelimit = class {
|
|
|
188
303
|
* }
|
|
189
304
|
* return "Yes"
|
|
190
305
|
* ```
|
|
306
|
+
*
|
|
307
|
+
* @param req.rate - The rate at which tokens will be added or consumed from the token bucket. A higher rate allows for more requests to be processed. Defaults to 1 token per interval if not specified.
|
|
308
|
+
*
|
|
309
|
+
* Usage with `req.rate`
|
|
310
|
+
* @example
|
|
311
|
+
* ```ts
|
|
312
|
+
* const ratelimit = new Ratelimit({
|
|
313
|
+
* redis: Redis.fromEnv(),
|
|
314
|
+
* limiter: Ratelimit.slidingWindow(100, "10 s")
|
|
315
|
+
* })
|
|
316
|
+
*
|
|
317
|
+
* const { success } = await ratelimit.limit(id, {rate: 10})
|
|
318
|
+
* if (!success){
|
|
319
|
+
* return "Nope"
|
|
320
|
+
* }
|
|
321
|
+
* return "Yes"
|
|
322
|
+
* ```
|
|
191
323
|
*/
|
|
192
324
|
limit = async (identifier, req) => {
|
|
193
325
|
const key = [this.prefix, identifier].join(":");
|
|
194
326
|
let timeoutId = null;
|
|
195
327
|
try {
|
|
196
|
-
const arr = [this.limiter(this.ctx, key)];
|
|
328
|
+
const arr = [this.limiter().limit(this.ctx, key, req?.rate)];
|
|
197
329
|
if (this.timeout > 0) {
|
|
198
330
|
arr.push(
|
|
199
331
|
new Promise((resolve) => {
|
|
@@ -277,6 +409,14 @@ var Ratelimit = class {
|
|
|
277
409
|
}
|
|
278
410
|
return res;
|
|
279
411
|
};
|
|
412
|
+
resetUsedTokens = async (identifier) => {
|
|
413
|
+
const pattern = [this.prefix, identifier].join(":");
|
|
414
|
+
await this.limiter().resetTokens(this.ctx, pattern);
|
|
415
|
+
};
|
|
416
|
+
getRemaining = async (identifier) => {
|
|
417
|
+
const pattern = [this.prefix, identifier].join(":");
|
|
418
|
+
return await this.limiter().getRemaining(this.ctx, pattern);
|
|
419
|
+
};
|
|
280
420
|
};
|
|
281
421
|
|
|
282
422
|
// src/multi.ts
|
|
@@ -325,72 +465,122 @@ var MultiRegionRatelimit = class extends Ratelimit {
|
|
|
325
465
|
*/
|
|
326
466
|
static fixedWindow(tokens, window) {
|
|
327
467
|
const windowDuration = ms(window);
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
return members
|
|
342
|
-
`;
|
|
343
|
-
return async function(ctx, identifier) {
|
|
344
|
-
if (ctx.cache) {
|
|
345
|
-
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
346
|
-
if (blocked) {
|
|
347
|
-
return {
|
|
348
|
-
success: false,
|
|
349
|
-
limit: tokens,
|
|
350
|
-
remaining: 0,
|
|
351
|
-
reset: reset2,
|
|
352
|
-
pending: Promise.resolve()
|
|
353
|
-
};
|
|
468
|
+
return () => ({
|
|
469
|
+
async limit(ctx, identifier, rate) {
|
|
470
|
+
if (ctx.cache) {
|
|
471
|
+
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
472
|
+
if (blocked) {
|
|
473
|
+
return {
|
|
474
|
+
success: false,
|
|
475
|
+
limit: tokens,
|
|
476
|
+
remaining: 0,
|
|
477
|
+
reset: reset2,
|
|
478
|
+
pending: Promise.resolve()
|
|
479
|
+
};
|
|
480
|
+
}
|
|
354
481
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
continue;
|
|
482
|
+
const requestId = randomId();
|
|
483
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
484
|
+
const key = [identifier, bucket].join(":");
|
|
485
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
486
|
+
const dbs = ctx.redis.map((redis) => ({
|
|
487
|
+
redis,
|
|
488
|
+
request: redis.eval(
|
|
489
|
+
fixedWindowLimitScript,
|
|
490
|
+
[key],
|
|
491
|
+
[requestId, windowDuration, incrementBy]
|
|
492
|
+
)
|
|
493
|
+
}));
|
|
494
|
+
const firstResponse = await Promise.any(dbs.map((s) => s.request));
|
|
495
|
+
const usedTokens = firstResponse.reduce((accTokens, usedToken, index) => {
|
|
496
|
+
let parsedToken = 0;
|
|
497
|
+
if (index % 2) {
|
|
498
|
+
parsedToken = Number.parseInt(usedToken);
|
|
373
499
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
500
|
+
return accTokens + parsedToken;
|
|
501
|
+
}, 0);
|
|
502
|
+
const remaining = tokens - usedTokens;
|
|
503
|
+
async function sync() {
|
|
504
|
+
const individualIDs = await Promise.all(dbs.map((s) => s.request));
|
|
505
|
+
const allIDs = Array.from(
|
|
506
|
+
new Set(
|
|
507
|
+
individualIDs.flatMap((_) => _).reduce((acc, curr, index) => {
|
|
508
|
+
if (index % 2 === 0) {
|
|
509
|
+
acc.push(curr);
|
|
510
|
+
}
|
|
511
|
+
return acc;
|
|
512
|
+
}, [])
|
|
513
|
+
).values()
|
|
514
|
+
);
|
|
515
|
+
for (const db of dbs) {
|
|
516
|
+
const usedDbTokens = (await db.request).reduce(
|
|
517
|
+
(accTokens, usedToken, index) => {
|
|
518
|
+
let parsedToken = 0;
|
|
519
|
+
if (index % 2) {
|
|
520
|
+
parsedToken = Number.parseInt(usedToken);
|
|
521
|
+
}
|
|
522
|
+
return accTokens + parsedToken;
|
|
523
|
+
},
|
|
524
|
+
0
|
|
525
|
+
);
|
|
526
|
+
const dbIds = (await db.request).reduce((ids, currentId, index) => {
|
|
527
|
+
if (index % 2 === 0) {
|
|
528
|
+
ids.push(currentId);
|
|
529
|
+
}
|
|
530
|
+
return ids;
|
|
531
|
+
}, []);
|
|
532
|
+
if (usedDbTokens >= tokens) {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const diff = allIDs.filter((id) => !dbIds.includes(id));
|
|
536
|
+
if (diff.length === 0) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
for (const requestId2 of diff) {
|
|
540
|
+
await db.redis.hset(key, { [requestId2]: incrementBy });
|
|
541
|
+
}
|
|
377
542
|
}
|
|
378
|
-
|
|
543
|
+
}
|
|
544
|
+
const success = remaining > 0;
|
|
545
|
+
const reset = (bucket + 1) * windowDuration;
|
|
546
|
+
if (ctx.cache && !success) {
|
|
547
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
success,
|
|
551
|
+
limit: tokens,
|
|
552
|
+
remaining,
|
|
553
|
+
reset,
|
|
554
|
+
pending: sync()
|
|
555
|
+
};
|
|
556
|
+
},
|
|
557
|
+
async getRemaining(ctx, identifier) {
|
|
558
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
559
|
+
const key = [identifier, bucket].join(":");
|
|
560
|
+
const dbs = ctx.redis.map((redis) => ({
|
|
561
|
+
redis,
|
|
562
|
+
request: redis.eval(fixedWindowRemainingTokensScript, [key], [null])
|
|
563
|
+
}));
|
|
564
|
+
const firstResponse = await Promise.any(dbs.map((s) => s.request));
|
|
565
|
+
const usedTokens = firstResponse.reduce((accTokens, usedToken, index) => {
|
|
566
|
+
let parsedToken = 0;
|
|
567
|
+
if (index % 2) {
|
|
568
|
+
parsedToken = Number.parseInt(usedToken);
|
|
569
|
+
}
|
|
570
|
+
return accTokens + parsedToken;
|
|
571
|
+
}, 0);
|
|
572
|
+
return Math.max(0, tokens - usedTokens);
|
|
573
|
+
},
|
|
574
|
+
async resetTokens(ctx, identifier) {
|
|
575
|
+
const pattern = [identifier, "*"].join(":");
|
|
576
|
+
if (ctx.cache) {
|
|
577
|
+
ctx.cache.pop(identifier);
|
|
578
|
+
}
|
|
579
|
+
for (const db of ctx.redis) {
|
|
580
|
+
await db.eval(resetScript, [pattern], [null]);
|
|
379
581
|
}
|
|
380
582
|
}
|
|
381
|
-
|
|
382
|
-
const reset = (bucket + 1) * windowDuration;
|
|
383
|
-
if (ctx.cache && !success) {
|
|
384
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
385
|
-
}
|
|
386
|
-
return {
|
|
387
|
-
success,
|
|
388
|
-
limit: tokens,
|
|
389
|
-
remaining,
|
|
390
|
-
reset,
|
|
391
|
-
pending: sync()
|
|
392
|
-
};
|
|
393
|
-
};
|
|
583
|
+
});
|
|
394
584
|
}
|
|
395
585
|
/**
|
|
396
586
|
* Combined approach of `slidingLogs` and `fixedWindow` with lower storage
|
|
@@ -410,86 +600,277 @@ var MultiRegionRatelimit = class extends Ratelimit {
|
|
|
410
600
|
*/
|
|
411
601
|
static slidingWindow(tokens, window) {
|
|
412
602
|
const windowSize = ms(window);
|
|
413
|
-
const script = `
|
|
414
|
-
local currentKey = KEYS[1] -- identifier including prefixes
|
|
415
|
-
local previousKey = KEYS[2] -- key of the previous bucket
|
|
416
|
-
local tokens = tonumber(ARGV[1]) -- tokens per window
|
|
417
|
-
local now = ARGV[2] -- current timestamp in milliseconds
|
|
418
|
-
local window = ARGV[3] -- interval in milliseconds
|
|
419
|
-
local requestId = ARGV[4] -- uuid for this request
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
local currentMembers = redis.call("SMEMBERS", currentKey)
|
|
423
|
-
local requestsInCurrentWindow = #currentMembers
|
|
424
|
-
local previousMembers = redis.call("SMEMBERS", previousKey)
|
|
425
|
-
local requestsInPreviousWindow = #previousMembers
|
|
426
|
-
|
|
427
|
-
local percentageInCurrent = ( now % window) / window
|
|
428
|
-
if requestsInPreviousWindow * ( 1 - percentageInCurrent ) + requestsInCurrentWindow >= tokens then
|
|
429
|
-
return {currentMembers, previousMembers, false}
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
redis.call("SADD", currentKey, requestId)
|
|
433
|
-
table.insert(currentMembers, requestId)
|
|
434
|
-
if requestsInCurrentWindow == 0 then
|
|
435
|
-
-- The first time this key is set, the value will be 1.
|
|
436
|
-
-- So we only need the expire command once
|
|
437
|
-
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
|
|
438
|
-
end
|
|
439
|
-
return {currentMembers, previousMembers, true}
|
|
440
|
-
`;
|
|
441
603
|
const windowDuration = ms(window);
|
|
442
|
-
return
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
for (const db of dbs) {
|
|
467
|
-
const [ids] = await db.request;
|
|
468
|
-
if (ids.length >= tokens) {
|
|
469
|
-
continue;
|
|
604
|
+
return () => ({
|
|
605
|
+
async limit(ctx, identifier, rate) {
|
|
606
|
+
const requestId = randomId();
|
|
607
|
+
const now = Date.now();
|
|
608
|
+
const currentWindow = Math.floor(now / windowSize);
|
|
609
|
+
const currentKey = [identifier, currentWindow].join(":");
|
|
610
|
+
const previousWindow = currentWindow - 1;
|
|
611
|
+
const previousKey = [identifier, previousWindow].join(":");
|
|
612
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
613
|
+
const dbs = ctx.redis.map((redis) => ({
|
|
614
|
+
redis,
|
|
615
|
+
request: redis.eval(
|
|
616
|
+
slidingWindowLimitScript,
|
|
617
|
+
[currentKey, previousKey],
|
|
618
|
+
[tokens, now, windowDuration, requestId, incrementBy]
|
|
619
|
+
// lua seems to return `1` for true and `null` for false
|
|
620
|
+
)
|
|
621
|
+
}));
|
|
622
|
+
const percentageInCurrent = now % windowDuration / windowDuration;
|
|
623
|
+
const [current, previous, success] = await Promise.any(dbs.map((s) => s.request));
|
|
624
|
+
const previousUsedTokens = previous.reduce((accTokens, usedToken, index) => {
|
|
625
|
+
let parsedToken = 0;
|
|
626
|
+
if (index % 2) {
|
|
627
|
+
parsedToken = Number.parseInt(usedToken);
|
|
470
628
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
629
|
+
return accTokens + parsedToken;
|
|
630
|
+
}, 0);
|
|
631
|
+
const currentUsedTokens = current.reduce((accTokens, usedToken, index) => {
|
|
632
|
+
let parsedToken = 0;
|
|
633
|
+
if (index % 2) {
|
|
634
|
+
parsedToken = Number.parseInt(usedToken);
|
|
635
|
+
}
|
|
636
|
+
return accTokens + parsedToken;
|
|
637
|
+
}, 0);
|
|
638
|
+
const previousPartialUsed = previousUsedTokens * (1 - percentageInCurrent);
|
|
639
|
+
const usedTokens = previousPartialUsed + currentUsedTokens;
|
|
640
|
+
const remaining = tokens - usedTokens;
|
|
641
|
+
async function sync() {
|
|
642
|
+
const res = await Promise.all(dbs.map((s) => s.request));
|
|
643
|
+
const allCurrentIds = res.flatMap(([current2]) => current2).reduce((accCurrentIds, curr, index) => {
|
|
644
|
+
if (index % 2 === 0) {
|
|
645
|
+
accCurrentIds.push(curr);
|
|
646
|
+
}
|
|
647
|
+
return accCurrentIds;
|
|
648
|
+
}, []);
|
|
649
|
+
for (const db of dbs) {
|
|
650
|
+
const [_current, previous2, _success] = await db.request;
|
|
651
|
+
const dbIds = previous2.reduce((ids, currentId, index) => {
|
|
652
|
+
if (index % 2 === 0) {
|
|
653
|
+
ids.push(currentId);
|
|
654
|
+
}
|
|
655
|
+
return ids;
|
|
656
|
+
}, []);
|
|
657
|
+
const usedDbTokens = previous2.reduce((accTokens, usedToken, index) => {
|
|
658
|
+
let parsedToken = 0;
|
|
659
|
+
if (index % 2) {
|
|
660
|
+
parsedToken = Number.parseInt(usedToken);
|
|
661
|
+
}
|
|
662
|
+
return accTokens + parsedToken;
|
|
663
|
+
}, 0);
|
|
664
|
+
if (usedDbTokens >= tokens) {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
const diff = allCurrentIds.filter((id) => !dbIds.includes(id));
|
|
668
|
+
if (diff.length === 0) {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
for (const requestId2 of diff) {
|
|
672
|
+
await db.redis.hset(currentKey, { [requestId2]: incrementBy });
|
|
673
|
+
}
|
|
474
674
|
}
|
|
475
|
-
|
|
675
|
+
}
|
|
676
|
+
const reset = (currentWindow + 1) * windowDuration;
|
|
677
|
+
if (ctx.cache && !success) {
|
|
678
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
679
|
+
}
|
|
680
|
+
return {
|
|
681
|
+
success: Boolean(success),
|
|
682
|
+
limit: tokens,
|
|
683
|
+
remaining: Math.max(0, remaining),
|
|
684
|
+
reset,
|
|
685
|
+
pending: sync()
|
|
686
|
+
};
|
|
687
|
+
},
|
|
688
|
+
async getRemaining(ctx, identifier) {
|
|
689
|
+
const now = Date.now();
|
|
690
|
+
const currentWindow = Math.floor(now / windowSize);
|
|
691
|
+
const currentKey = [identifier, currentWindow].join(":");
|
|
692
|
+
const previousWindow = currentWindow - 1;
|
|
693
|
+
const previousKey = [identifier, previousWindow].join(":");
|
|
694
|
+
const dbs = ctx.redis.map((redis) => ({
|
|
695
|
+
redis,
|
|
696
|
+
request: redis.eval(
|
|
697
|
+
slidingWindowRemainingTokensScript,
|
|
698
|
+
[currentKey, previousKey],
|
|
699
|
+
[now, windowSize]
|
|
700
|
+
// lua seems to return `1` for true and `null` for false
|
|
701
|
+
)
|
|
702
|
+
}));
|
|
703
|
+
const usedTokens = await Promise.any(dbs.map((s) => s.request));
|
|
704
|
+
return Math.max(0, tokens - usedTokens);
|
|
705
|
+
},
|
|
706
|
+
async resetTokens(ctx, identifier) {
|
|
707
|
+
const pattern = [identifier, "*"].join(":");
|
|
708
|
+
if (ctx.cache) {
|
|
709
|
+
ctx.cache.pop(identifier);
|
|
710
|
+
}
|
|
711
|
+
for (const db of ctx.redis) {
|
|
712
|
+
await db.eval(resetScript, [pattern], [null]);
|
|
476
713
|
}
|
|
477
714
|
}
|
|
478
|
-
|
|
479
|
-
if (ctx.cache && !success) {
|
|
480
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
481
|
-
}
|
|
482
|
-
return {
|
|
483
|
-
success: Boolean(success),
|
|
484
|
-
limit: tokens,
|
|
485
|
-
remaining,
|
|
486
|
-
reset,
|
|
487
|
-
pending: sync()
|
|
488
|
-
};
|
|
489
|
-
};
|
|
715
|
+
});
|
|
490
716
|
}
|
|
491
717
|
};
|
|
492
718
|
|
|
719
|
+
// src/lua-scripts/single.ts
|
|
720
|
+
var fixedWindowLimitScript2 = `
|
|
721
|
+
local key = KEYS[1]
|
|
722
|
+
local window = ARGV[1]
|
|
723
|
+
local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1
|
|
724
|
+
|
|
725
|
+
local r = redis.call("INCRBY", key, incrementBy)
|
|
726
|
+
if r == tonumber(incrementBy) then
|
|
727
|
+
-- The first time this key is set, the value will be equal to incrementBy.
|
|
728
|
+
-- So we only need the expire command once
|
|
729
|
+
redis.call("PEXPIRE", key, window)
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
return r
|
|
733
|
+
`;
|
|
734
|
+
var fixedWindowRemainingTokensScript2 = `
|
|
735
|
+
local key = KEYS[1]
|
|
736
|
+
local tokens = 0
|
|
737
|
+
|
|
738
|
+
local value = redis.call('GET', key)
|
|
739
|
+
if value then
|
|
740
|
+
tokens = value
|
|
741
|
+
end
|
|
742
|
+
return tokens
|
|
743
|
+
`;
|
|
744
|
+
var slidingWindowLimitScript2 = `
|
|
745
|
+
local currentKey = KEYS[1] -- identifier including prefixes
|
|
746
|
+
local previousKey = KEYS[2] -- key of the previous bucket
|
|
747
|
+
local tokens = tonumber(ARGV[1]) -- tokens per window
|
|
748
|
+
local now = ARGV[2] -- current timestamp in milliseconds
|
|
749
|
+
local window = ARGV[3] -- interval in milliseconds
|
|
750
|
+
local incrementBy = ARGV[4] -- increment rate per request at a given value, default is 1
|
|
751
|
+
|
|
752
|
+
local requestsInCurrentWindow = redis.call("GET", currentKey)
|
|
753
|
+
if requestsInCurrentWindow == false then
|
|
754
|
+
requestsInCurrentWindow = 0
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
local requestsInPreviousWindow = redis.call("GET", previousKey)
|
|
758
|
+
if requestsInPreviousWindow == false then
|
|
759
|
+
requestsInPreviousWindow = 0
|
|
760
|
+
end
|
|
761
|
+
local percentageInCurrent = ( now % window ) / window
|
|
762
|
+
-- weighted requests to consider from the previous window
|
|
763
|
+
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
|
|
764
|
+
if requestsInPreviousWindow + requestsInCurrentWindow >= tokens then
|
|
765
|
+
return -1
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
local newValue = redis.call("INCRBY", currentKey, incrementBy)
|
|
769
|
+
if newValue == tonumber(incrementBy) then
|
|
770
|
+
-- The first time this key is set, the value will be equal to incrementBy.
|
|
771
|
+
-- So we only need the expire command once
|
|
772
|
+
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
|
|
773
|
+
end
|
|
774
|
+
return tokens - ( newValue + requestsInPreviousWindow )
|
|
775
|
+
`;
|
|
776
|
+
var slidingWindowRemainingTokensScript2 = `
|
|
777
|
+
local currentKey = KEYS[1] -- identifier including prefixes
|
|
778
|
+
local previousKey = KEYS[2] -- key of the previous bucket
|
|
779
|
+
local now = ARGV[1] -- current timestamp in milliseconds
|
|
780
|
+
local window = ARGV[2] -- interval in milliseconds
|
|
781
|
+
|
|
782
|
+
local requestsInCurrentWindow = redis.call("GET", currentKey)
|
|
783
|
+
if requestsInCurrentWindow == false then
|
|
784
|
+
requestsInCurrentWindow = 0
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
local requestsInPreviousWindow = redis.call("GET", previousKey)
|
|
788
|
+
if requestsInPreviousWindow == false then
|
|
789
|
+
requestsInPreviousWindow = 0
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
local percentageInCurrent = ( now % window ) / window
|
|
793
|
+
-- weighted requests to consider from the previous window
|
|
794
|
+
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
|
|
795
|
+
|
|
796
|
+
return requestsInPreviousWindow + requestsInCurrentWindow
|
|
797
|
+
`;
|
|
798
|
+
var tokenBucketLimitScript = `
|
|
799
|
+
local key = KEYS[1] -- identifier including prefixes
|
|
800
|
+
local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens
|
|
801
|
+
local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
|
|
802
|
+
local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval
|
|
803
|
+
local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
|
|
804
|
+
local incrementBy = tonumber(ARGV[5]) -- how many tokens to consume, default is 1
|
|
805
|
+
|
|
806
|
+
local bucket = redis.call("HMGET", key, "refilledAt", "tokens")
|
|
807
|
+
|
|
808
|
+
local refilledAt
|
|
809
|
+
local tokens
|
|
810
|
+
|
|
811
|
+
if bucket[1] == false then
|
|
812
|
+
refilledAt = now
|
|
813
|
+
tokens = maxTokens
|
|
814
|
+
else
|
|
815
|
+
refilledAt = tonumber(bucket[1])
|
|
816
|
+
tokens = tonumber(bucket[2])
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
if now >= refilledAt + interval then
|
|
820
|
+
local numRefills = math.floor((now - refilledAt) / interval)
|
|
821
|
+
tokens = math.min(maxTokens, tokens + numRefills * refillRate)
|
|
822
|
+
|
|
823
|
+
refilledAt = refilledAt + numRefills * interval
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
if tokens == 0 then
|
|
827
|
+
return {-1, refilledAt + interval}
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
local remaining = tokens - incrementBy
|
|
831
|
+
local expireAt = math.ceil(((maxTokens - remaining) / refillRate)) * interval
|
|
832
|
+
|
|
833
|
+
redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining)
|
|
834
|
+
redis.call("PEXPIRE", key, expireAt)
|
|
835
|
+
return {remaining, refilledAt + interval}
|
|
836
|
+
`;
|
|
837
|
+
var tokenBucketRemainingTokensScript = `
|
|
838
|
+
local key = KEYS[1]
|
|
839
|
+
local maxTokens = tonumber(ARGV[1])
|
|
840
|
+
|
|
841
|
+
local bucket = redis.call("HMGET", key, "tokens")
|
|
842
|
+
|
|
843
|
+
if bucket[1] == false then
|
|
844
|
+
return maxTokens
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
return tonumber(bucket[1])
|
|
848
|
+
`;
|
|
849
|
+
var cachedFixedWindowLimitScript = `
|
|
850
|
+
local key = KEYS[1]
|
|
851
|
+
local window = ARGV[1]
|
|
852
|
+
local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1
|
|
853
|
+
|
|
854
|
+
local r = redis.call("INCRBY", key, incrementBy)
|
|
855
|
+
if r == incrementBy then
|
|
856
|
+
-- The first time this key is set, the value will be equal to incrementBy.
|
|
857
|
+
-- So we only need the expire command once
|
|
858
|
+
redis.call("PEXPIRE", key, window)
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
return r
|
|
862
|
+
`;
|
|
863
|
+
var cachedFixedWindowRemainingTokenScript = `
|
|
864
|
+
local key = KEYS[1]
|
|
865
|
+
local tokens = 0
|
|
866
|
+
|
|
867
|
+
local value = redis.call('GET', key)
|
|
868
|
+
if value then
|
|
869
|
+
tokens = value
|
|
870
|
+
end
|
|
871
|
+
return tokens
|
|
872
|
+
`;
|
|
873
|
+
|
|
493
874
|
// src/single.ts
|
|
494
875
|
var RegionRatelimit = class extends Ratelimit {
|
|
495
876
|
/**
|
|
@@ -527,52 +908,60 @@ var RegionRatelimit = class extends Ratelimit {
|
|
|
527
908
|
*/
|
|
528
909
|
static fixedWindow(tokens, window) {
|
|
529
910
|
const windowDuration = ms(window);
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const key = [identifier, bucket].join(":");
|
|
546
|
-
if (ctx.cache) {
|
|
547
|
-
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
548
|
-
if (blocked) {
|
|
549
|
-
return {
|
|
550
|
-
success: false,
|
|
551
|
-
limit: tokens,
|
|
552
|
-
remaining: 0,
|
|
553
|
-
reset: reset2,
|
|
554
|
-
pending: Promise.resolve()
|
|
555
|
-
};
|
|
911
|
+
return () => ({
|
|
912
|
+
async limit(ctx, identifier, rate) {
|
|
913
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
914
|
+
const key = [identifier, bucket].join(":");
|
|
915
|
+
if (ctx.cache) {
|
|
916
|
+
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
917
|
+
if (blocked) {
|
|
918
|
+
return {
|
|
919
|
+
success: false,
|
|
920
|
+
limit: tokens,
|
|
921
|
+
remaining: 0,
|
|
922
|
+
reset: reset2,
|
|
923
|
+
pending: Promise.resolve()
|
|
924
|
+
};
|
|
925
|
+
}
|
|
556
926
|
}
|
|
927
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
928
|
+
const usedTokensAfterUpdate = await ctx.redis.eval(
|
|
929
|
+
fixedWindowLimitScript2,
|
|
930
|
+
[key],
|
|
931
|
+
[windowDuration, incrementBy]
|
|
932
|
+
);
|
|
933
|
+
const success = usedTokensAfterUpdate <= tokens;
|
|
934
|
+
const remainingTokens = Math.max(0, tokens - usedTokensAfterUpdate);
|
|
935
|
+
const reset = (bucket + 1) * windowDuration;
|
|
936
|
+
if (ctx.cache && !success) {
|
|
937
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
success,
|
|
941
|
+
limit: tokens,
|
|
942
|
+
remaining: remainingTokens,
|
|
943
|
+
reset,
|
|
944
|
+
pending: Promise.resolve()
|
|
945
|
+
};
|
|
946
|
+
},
|
|
947
|
+
async getRemaining(ctx, identifier) {
|
|
948
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
949
|
+
const key = [identifier, bucket].join(":");
|
|
950
|
+
const usedTokens = await ctx.redis.eval(
|
|
951
|
+
fixedWindowRemainingTokensScript2,
|
|
952
|
+
[key],
|
|
953
|
+
[null]
|
|
954
|
+
);
|
|
955
|
+
return Math.max(0, tokens - usedTokens);
|
|
956
|
+
},
|
|
957
|
+
async resetTokens(ctx, identifier) {
|
|
958
|
+
const pattern = [identifier, "*"].join(":");
|
|
959
|
+
if (ctx.cache) {
|
|
960
|
+
ctx.cache.pop(identifier);
|
|
961
|
+
}
|
|
962
|
+
await ctx.redis.eval(resetScript, [pattern], [null]);
|
|
557
963
|
}
|
|
558
|
-
|
|
559
|
-
script,
|
|
560
|
-
[key],
|
|
561
|
-
[windowDuration]
|
|
562
|
-
);
|
|
563
|
-
const success = usedTokensAfterUpdate <= tokens;
|
|
564
|
-
const reset = (bucket + 1) * windowDuration;
|
|
565
|
-
if (ctx.cache && !success) {
|
|
566
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
567
|
-
}
|
|
568
|
-
return {
|
|
569
|
-
success,
|
|
570
|
-
limit: tokens,
|
|
571
|
-
remaining: Math.max(0, tokens - usedTokensAfterUpdate),
|
|
572
|
-
reset,
|
|
573
|
-
pending: Promise.resolve()
|
|
574
|
-
};
|
|
575
|
-
};
|
|
964
|
+
});
|
|
576
965
|
}
|
|
577
966
|
/**
|
|
578
967
|
* Combined approach of `slidingLogs` and `fixedWindow` with lower storage
|
|
@@ -591,74 +980,66 @@ var RegionRatelimit = class extends Ratelimit {
|
|
|
591
980
|
* @param window - The duration in which the user can max X requests.
|
|
592
981
|
*/
|
|
593
982
|
static slidingWindow(tokens, window) {
|
|
594
|
-
const script = `
|
|
595
|
-
local currentKey = KEYS[1] -- identifier including prefixes
|
|
596
|
-
local previousKey = KEYS[2] -- key of the previous bucket
|
|
597
|
-
local tokens = tonumber(ARGV[1]) -- tokens per window
|
|
598
|
-
local now = ARGV[2] -- current timestamp in milliseconds
|
|
599
|
-
local window = ARGV[3] -- interval in milliseconds
|
|
600
|
-
|
|
601
|
-
local requestsInCurrentWindow = redis.call("GET", currentKey)
|
|
602
|
-
if requestsInCurrentWindow == false then
|
|
603
|
-
requestsInCurrentWindow = 0
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
local requestsInPreviousWindow = redis.call("GET", previousKey)
|
|
607
|
-
if requestsInPreviousWindow == false then
|
|
608
|
-
requestsInPreviousWindow = 0
|
|
609
|
-
end
|
|
610
|
-
local percentageInCurrent = ( now % window ) / window
|
|
611
|
-
-- weighted requests to consider from the previous window
|
|
612
|
-
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
|
|
613
|
-
if requestsInPreviousWindow + requestsInCurrentWindow >= tokens then
|
|
614
|
-
return -1
|
|
615
|
-
end
|
|
616
|
-
|
|
617
|
-
local newValue = redis.call("INCR", currentKey)
|
|
618
|
-
if newValue == 1 then
|
|
619
|
-
-- The first time this key is set, the value will be 1.
|
|
620
|
-
-- So we only need the expire command once
|
|
621
|
-
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
|
|
622
|
-
end
|
|
623
|
-
return tokens - ( newValue + requestsInPreviousWindow )
|
|
624
|
-
`;
|
|
625
983
|
const windowSize = ms(window);
|
|
626
|
-
return
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
984
|
+
return () => ({
|
|
985
|
+
async limit(ctx, identifier, rate) {
|
|
986
|
+
const now = Date.now();
|
|
987
|
+
const currentWindow = Math.floor(now / windowSize);
|
|
988
|
+
const currentKey = [identifier, currentWindow].join(":");
|
|
989
|
+
const previousWindow = currentWindow - 1;
|
|
990
|
+
const previousKey = [identifier, previousWindow].join(":");
|
|
991
|
+
if (ctx.cache) {
|
|
992
|
+
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
993
|
+
if (blocked) {
|
|
994
|
+
return {
|
|
995
|
+
success: false,
|
|
996
|
+
limit: tokens,
|
|
997
|
+
remaining: 0,
|
|
998
|
+
reset: reset2,
|
|
999
|
+
pending: Promise.resolve()
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
642
1002
|
}
|
|
1003
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
1004
|
+
const remainingTokens = await ctx.redis.eval(
|
|
1005
|
+
slidingWindowLimitScript2,
|
|
1006
|
+
[currentKey, previousKey],
|
|
1007
|
+
[tokens, now, windowSize, incrementBy]
|
|
1008
|
+
);
|
|
1009
|
+
const success = remainingTokens >= 0;
|
|
1010
|
+
const reset = (currentWindow + 1) * windowSize;
|
|
1011
|
+
if (ctx.cache && !success) {
|
|
1012
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
success,
|
|
1016
|
+
limit: tokens,
|
|
1017
|
+
remaining: Math.max(0, remainingTokens),
|
|
1018
|
+
reset,
|
|
1019
|
+
pending: Promise.resolve()
|
|
1020
|
+
};
|
|
1021
|
+
},
|
|
1022
|
+
async getRemaining(ctx, identifier) {
|
|
1023
|
+
const now = Date.now();
|
|
1024
|
+
const currentWindow = Math.floor(now / windowSize);
|
|
1025
|
+
const currentKey = [identifier, currentWindow].join(":");
|
|
1026
|
+
const previousWindow = currentWindow - 1;
|
|
1027
|
+
const previousKey = [identifier, previousWindow].join(":");
|
|
1028
|
+
const usedTokens = await ctx.redis.eval(
|
|
1029
|
+
slidingWindowRemainingTokensScript2,
|
|
1030
|
+
[currentKey, previousKey],
|
|
1031
|
+
[now, windowSize]
|
|
1032
|
+
);
|
|
1033
|
+
return Math.max(0, tokens - usedTokens);
|
|
1034
|
+
},
|
|
1035
|
+
async resetTokens(ctx, identifier) {
|
|
1036
|
+
const pattern = [identifier, "*"].join(":");
|
|
1037
|
+
if (ctx.cache) {
|
|
1038
|
+
ctx.cache.pop(identifier);
|
|
1039
|
+
}
|
|
1040
|
+
await ctx.redis.eval(resetScript, [pattern], [null]);
|
|
643
1041
|
}
|
|
644
|
-
|
|
645
|
-
script,
|
|
646
|
-
[currentKey, previousKey],
|
|
647
|
-
[tokens, now, windowSize]
|
|
648
|
-
);
|
|
649
|
-
const success = remaining >= 0;
|
|
650
|
-
const reset = (currentWindow + 1) * windowSize;
|
|
651
|
-
if (ctx.cache && !success) {
|
|
652
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
653
|
-
}
|
|
654
|
-
return {
|
|
655
|
-
success,
|
|
656
|
-
limit: tokens,
|
|
657
|
-
remaining: Math.max(0, remaining),
|
|
658
|
-
reset,
|
|
659
|
-
pending: Promise.resolve()
|
|
660
|
-
};
|
|
661
|
-
};
|
|
1042
|
+
});
|
|
662
1043
|
}
|
|
663
1044
|
/**
|
|
664
1045
|
* You have a bucket filled with `{maxTokens}` tokens that refills constantly
|
|
@@ -674,76 +1055,56 @@ var RegionRatelimit = class extends Ratelimit {
|
|
|
674
1055
|
* than `refillRate`
|
|
675
1056
|
*/
|
|
676
1057
|
static tokenBucket(refillRate, interval, maxTokens) {
|
|
677
|
-
const script = `
|
|
678
|
-
local key = KEYS[1] -- identifier including prefixes
|
|
679
|
-
local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens
|
|
680
|
-
local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
|
|
681
|
-
local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval
|
|
682
|
-
local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
|
|
683
|
-
|
|
684
|
-
local bucket = redis.call("HMGET", key, "refilledAt", "tokens")
|
|
685
|
-
|
|
686
|
-
local refilledAt
|
|
687
|
-
local tokens
|
|
688
|
-
|
|
689
|
-
if bucket[1] == false then
|
|
690
|
-
refilledAt = now
|
|
691
|
-
tokens = maxTokens
|
|
692
|
-
else
|
|
693
|
-
refilledAt = tonumber(bucket[1])
|
|
694
|
-
tokens = tonumber(bucket[2])
|
|
695
|
-
end
|
|
696
|
-
|
|
697
|
-
if now >= refilledAt + interval then
|
|
698
|
-
local numRefills = math.floor((now - refilledAt) / interval)
|
|
699
|
-
tokens = math.min(maxTokens, tokens + numRefills * refillRate)
|
|
700
|
-
|
|
701
|
-
refilledAt = refilledAt + numRefills * interval
|
|
702
|
-
end
|
|
703
|
-
|
|
704
|
-
if tokens == 0 then
|
|
705
|
-
return {-1, refilledAt + interval}
|
|
706
|
-
end
|
|
707
|
-
|
|
708
|
-
local remaining = tokens - 1
|
|
709
|
-
local expireAt = math.ceil(((maxTokens - remaining) / refillRate)) * interval
|
|
710
|
-
|
|
711
|
-
redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining)
|
|
712
|
-
redis.call("PEXPIRE", key, expireAt)
|
|
713
|
-
return {remaining, refilledAt + interval}
|
|
714
|
-
`;
|
|
715
1058
|
const intervalDuration = ms(interval);
|
|
716
|
-
return
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1059
|
+
return () => ({
|
|
1060
|
+
async limit(ctx, identifier, rate) {
|
|
1061
|
+
if (ctx.cache) {
|
|
1062
|
+
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
1063
|
+
if (blocked) {
|
|
1064
|
+
return {
|
|
1065
|
+
success: false,
|
|
1066
|
+
limit: maxTokens,
|
|
1067
|
+
remaining: 0,
|
|
1068
|
+
reset: reset2,
|
|
1069
|
+
pending: Promise.resolve()
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
727
1072
|
}
|
|
1073
|
+
const now = Date.now();
|
|
1074
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
1075
|
+
const [remaining, reset] = await ctx.redis.eval(
|
|
1076
|
+
tokenBucketLimitScript,
|
|
1077
|
+
[identifier],
|
|
1078
|
+
[maxTokens, intervalDuration, refillRate, now, incrementBy]
|
|
1079
|
+
);
|
|
1080
|
+
const success = remaining >= 0;
|
|
1081
|
+
if (ctx.cache && !success) {
|
|
1082
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
success,
|
|
1086
|
+
limit: maxTokens,
|
|
1087
|
+
remaining,
|
|
1088
|
+
reset,
|
|
1089
|
+
pending: Promise.resolve()
|
|
1090
|
+
};
|
|
1091
|
+
},
|
|
1092
|
+
async getRemaining(ctx, identifier) {
|
|
1093
|
+
const remainingTokens = await ctx.redis.eval(
|
|
1094
|
+
tokenBucketRemainingTokensScript,
|
|
1095
|
+
[identifier],
|
|
1096
|
+
[maxTokens]
|
|
1097
|
+
);
|
|
1098
|
+
return remainingTokens;
|
|
1099
|
+
},
|
|
1100
|
+
async resetTokens(ctx, identifier) {
|
|
1101
|
+
const pattern = identifier;
|
|
1102
|
+
if (ctx.cache) {
|
|
1103
|
+
ctx.cache.pop(identifier);
|
|
1104
|
+
}
|
|
1105
|
+
await ctx.redis.eval(resetScript, [pattern], [null]);
|
|
728
1106
|
}
|
|
729
|
-
|
|
730
|
-
const [remaining, reset] = await ctx.redis.eval(
|
|
731
|
-
script,
|
|
732
|
-
[identifier],
|
|
733
|
-
[maxTokens, intervalDuration, refillRate, now]
|
|
734
|
-
);
|
|
735
|
-
const success = remaining >= 0;
|
|
736
|
-
if (ctx.cache && !success) {
|
|
737
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
738
|
-
}
|
|
739
|
-
return {
|
|
740
|
-
success,
|
|
741
|
-
limit: maxTokens,
|
|
742
|
-
remaining,
|
|
743
|
-
reset,
|
|
744
|
-
pending: Promise.resolve()
|
|
745
|
-
};
|
|
746
|
-
};
|
|
1107
|
+
});
|
|
747
1108
|
}
|
|
748
1109
|
/**
|
|
749
1110
|
* cachedFixedWindow first uses the local cache to decide if a request may pass and then updates
|
|
@@ -771,56 +1132,72 @@ var RegionRatelimit = class extends Ratelimit {
|
|
|
771
1132
|
*/
|
|
772
1133
|
static cachedFixedWindow(tokens, window) {
|
|
773
1134
|
const windowDuration = ms(window);
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
1135
|
+
return () => ({
|
|
1136
|
+
async limit(ctx, identifier, rate) {
|
|
1137
|
+
if (!ctx.cache) {
|
|
1138
|
+
throw new Error("This algorithm requires a cache");
|
|
1139
|
+
}
|
|
1140
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
1141
|
+
const key = [identifier, bucket].join(":");
|
|
1142
|
+
const reset = (bucket + 1) * windowDuration;
|
|
1143
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
1144
|
+
const hit = typeof ctx.cache.get(key) === "number";
|
|
1145
|
+
if (hit) {
|
|
1146
|
+
const cachedTokensAfterUpdate = ctx.cache.incr(key);
|
|
1147
|
+
const success = cachedTokensAfterUpdate < tokens;
|
|
1148
|
+
const pending = success ? ctx.redis.eval(cachedFixedWindowLimitScript, [key], [windowDuration, incrementBy]).then((t) => {
|
|
1149
|
+
ctx.cache.set(key, t);
|
|
1150
|
+
}) : Promise.resolve();
|
|
1151
|
+
return {
|
|
1152
|
+
success,
|
|
1153
|
+
limit: tokens,
|
|
1154
|
+
remaining: tokens - cachedTokensAfterUpdate,
|
|
1155
|
+
reset,
|
|
1156
|
+
pending
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
const usedTokensAfterUpdate = await ctx.redis.eval(
|
|
1160
|
+
cachedFixedWindowLimitScript,
|
|
1161
|
+
[key],
|
|
1162
|
+
[windowDuration, incrementBy]
|
|
1163
|
+
);
|
|
1164
|
+
ctx.cache.set(key, usedTokensAfterUpdate);
|
|
1165
|
+
const remaining = tokens - usedTokensAfterUpdate;
|
|
801
1166
|
return {
|
|
802
|
-
success,
|
|
1167
|
+
success: remaining >= 0,
|
|
803
1168
|
limit: tokens,
|
|
804
|
-
remaining
|
|
1169
|
+
remaining,
|
|
805
1170
|
reset,
|
|
806
|
-
pending
|
|
1171
|
+
pending: Promise.resolve()
|
|
807
1172
|
};
|
|
1173
|
+
},
|
|
1174
|
+
async getRemaining(ctx, identifier) {
|
|
1175
|
+
if (!ctx.cache) {
|
|
1176
|
+
throw new Error("This algorithm requires a cache");
|
|
1177
|
+
}
|
|
1178
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
1179
|
+
const key = [identifier, bucket].join(":");
|
|
1180
|
+
const hit = typeof ctx.cache.get(key) === "number";
|
|
1181
|
+
if (hit) {
|
|
1182
|
+
const cachedUsedTokens = ctx.cache.get(key) ?? 0;
|
|
1183
|
+
return Math.max(0, tokens - cachedUsedTokens);
|
|
1184
|
+
}
|
|
1185
|
+
const usedTokens = await ctx.redis.eval(
|
|
1186
|
+
cachedFixedWindowRemainingTokenScript,
|
|
1187
|
+
[key],
|
|
1188
|
+
[null]
|
|
1189
|
+
);
|
|
1190
|
+
return Math.max(0, tokens - usedTokens);
|
|
1191
|
+
},
|
|
1192
|
+
async resetTokens(ctx, identifier) {
|
|
1193
|
+
const pattern = [identifier, "*"].join(":");
|
|
1194
|
+
if (!ctx.cache) {
|
|
1195
|
+
throw new Error("This algorithm requires a cache");
|
|
1196
|
+
}
|
|
1197
|
+
ctx.cache.pop(identifier);
|
|
1198
|
+
await ctx.redis.eval(resetScript, [pattern], [null]);
|
|
808
1199
|
}
|
|
809
|
-
|
|
810
|
-
script,
|
|
811
|
-
[key],
|
|
812
|
-
[windowDuration]
|
|
813
|
-
);
|
|
814
|
-
ctx.cache.set(key, usedTokensAfterUpdate);
|
|
815
|
-
const remaining = tokens - usedTokensAfterUpdate;
|
|
816
|
-
return {
|
|
817
|
-
success: remaining >= 0,
|
|
818
|
-
limit: tokens,
|
|
819
|
-
remaining,
|
|
820
|
-
reset,
|
|
821
|
-
pending: Promise.resolve()
|
|
822
|
-
};
|
|
823
|
-
};
|
|
1200
|
+
});
|
|
824
1201
|
}
|
|
825
1202
|
};
|
|
826
1203
|
// Annotate the CommonJS export names for ESM import in node:
|