@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.mjs
CHANGED
|
@@ -94,6 +94,12 @@ var Cache = class {
|
|
|
94
94
|
this.cache.set(key, value);
|
|
95
95
|
return value;
|
|
96
96
|
}
|
|
97
|
+
pop(key) {
|
|
98
|
+
this.cache.delete(key);
|
|
99
|
+
}
|
|
100
|
+
empty() {
|
|
101
|
+
this.cache.clear();
|
|
102
|
+
}
|
|
97
103
|
};
|
|
98
104
|
|
|
99
105
|
// src/duration.ts
|
|
@@ -102,7 +108,7 @@ function ms(d) {
|
|
|
102
108
|
if (!match) {
|
|
103
109
|
throw new Error(`Unable to parse window size: ${d}`);
|
|
104
110
|
}
|
|
105
|
-
const time = parseInt(match[1]);
|
|
111
|
+
const time = Number.parseInt(match[1]);
|
|
106
112
|
const unit = match[2];
|
|
107
113
|
switch (unit) {
|
|
108
114
|
case "ms":
|
|
@@ -120,6 +126,115 @@ function ms(d) {
|
|
|
120
126
|
}
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
// src/lua-scripts/multi.ts
|
|
130
|
+
var fixedWindowLimitScript = `
|
|
131
|
+
local key = KEYS[1]
|
|
132
|
+
local id = ARGV[1]
|
|
133
|
+
local window = ARGV[2]
|
|
134
|
+
local incrementBy = tonumber(ARGV[3])
|
|
135
|
+
|
|
136
|
+
redis.call("HSET", key, id, incrementBy)
|
|
137
|
+
local fields = redis.call("HGETALL", key)
|
|
138
|
+
if #fields == 1 and tonumber(fields[1])==incrementBy then
|
|
139
|
+
-- The first time this key is set, and the value will be equal to incrementBy.
|
|
140
|
+
-- So we only need the expire command once
|
|
141
|
+
redis.call("PEXPIRE", key, window)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
return fields
|
|
145
|
+
`;
|
|
146
|
+
var fixedWindowRemainingTokensScript = `
|
|
147
|
+
local key = KEYS[1]
|
|
148
|
+
local tokens = 0
|
|
149
|
+
|
|
150
|
+
local fields = redis.call("HGETALL", key)
|
|
151
|
+
|
|
152
|
+
return fields
|
|
153
|
+
`;
|
|
154
|
+
var slidingWindowLimitScript = `
|
|
155
|
+
local currentKey = KEYS[1] -- identifier including prefixes
|
|
156
|
+
local previousKey = KEYS[2] -- key of the previous bucket
|
|
157
|
+
local tokens = tonumber(ARGV[1]) -- tokens per window
|
|
158
|
+
local now = ARGV[2] -- current timestamp in milliseconds
|
|
159
|
+
local window = ARGV[3] -- interval in milliseconds
|
|
160
|
+
local requestId = ARGV[4] -- uuid for this request
|
|
161
|
+
local incrementBy = tonumber(ARGV[5]) -- custom rate, default is 1
|
|
162
|
+
|
|
163
|
+
local currentFields = redis.call("HGETALL", currentKey)
|
|
164
|
+
local requestsInCurrentWindow = 0
|
|
165
|
+
for i = 2, #currentFields, 2 do
|
|
166
|
+
requestsInCurrentWindow = requestsInCurrentWindow + tonumber(currentFields[i])
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
local previousFields = redis.call("HGETALL", previousKey)
|
|
170
|
+
local requestsInPreviousWindow = 0
|
|
171
|
+
for i = 2, #previousFields, 2 do
|
|
172
|
+
requestsInPreviousWindow = requestsInPreviousWindow + tonumber(previousFields[i])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
local percentageInCurrent = ( now % window) / window
|
|
176
|
+
if requestsInPreviousWindow * (1 - percentageInCurrent ) + requestsInCurrentWindow >= tokens then
|
|
177
|
+
return {currentFields, previousFields, false}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
redis.call("HSET", currentKey, requestId, incrementBy)
|
|
181
|
+
|
|
182
|
+
if requestsInCurrentWindow == 0 then
|
|
183
|
+
-- The first time this key is set, the value will be equal to incrementBy.
|
|
184
|
+
-- So we only need the expire command once
|
|
185
|
+
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
|
|
186
|
+
end
|
|
187
|
+
return {currentFields, previousFields, true}
|
|
188
|
+
`;
|
|
189
|
+
var slidingWindowRemainingTokensScript = `
|
|
190
|
+
local currentKey = KEYS[1] -- identifier including prefixes
|
|
191
|
+
local previousKey = KEYS[2] -- key of the previous bucket
|
|
192
|
+
local now = ARGV[1] -- current timestamp in milliseconds
|
|
193
|
+
local window = ARGV[2] -- interval in milliseconds
|
|
194
|
+
|
|
195
|
+
local currentFields = redis.call("HGETALL", currentKey)
|
|
196
|
+
local requestsInCurrentWindow = 0
|
|
197
|
+
for i = 2, #currentFields, 2 do
|
|
198
|
+
requestsInCurrentWindow = requestsInCurrentWindow + tonumber(currentFields[i])
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
local previousFields = redis.call("HGETALL", previousKey)
|
|
202
|
+
local requestsInPreviousWindow = 0
|
|
203
|
+
for i = 2, #previousFields, 2 do
|
|
204
|
+
requestsInPreviousWindow = requestsInPreviousWindow + tonumber(previousFields[i])
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
local percentageInCurrent = ( now % window) / window
|
|
208
|
+
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
|
|
209
|
+
|
|
210
|
+
return requestsInCurrentWindow + requestsInPreviousWindow
|
|
211
|
+
`;
|
|
212
|
+
|
|
213
|
+
// src/lua-scripts/reset.ts
|
|
214
|
+
var resetScript = `
|
|
215
|
+
local pattern = KEYS[1]
|
|
216
|
+
|
|
217
|
+
-- Initialize cursor to start from 0
|
|
218
|
+
local cursor = "0"
|
|
219
|
+
|
|
220
|
+
repeat
|
|
221
|
+
-- Scan for keys matching the pattern
|
|
222
|
+
local scan_result = redis.call('SCAN', cursor, 'MATCH', pattern)
|
|
223
|
+
|
|
224
|
+
-- Extract cursor for the next iteration
|
|
225
|
+
cursor = scan_result[1]
|
|
226
|
+
|
|
227
|
+
-- Extract keys from the scan result
|
|
228
|
+
local keys = scan_result[2]
|
|
229
|
+
|
|
230
|
+
for i=1, #keys do
|
|
231
|
+
redis.call('DEL', keys[i])
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
-- Continue scanning until cursor is 0 (end of keyspace)
|
|
235
|
+
until cursor == "0"
|
|
236
|
+
`;
|
|
237
|
+
|
|
123
238
|
// src/ratelimit.ts
|
|
124
239
|
var Ratelimit = class {
|
|
125
240
|
limiter;
|
|
@@ -160,12 +275,29 @@ var Ratelimit = class {
|
|
|
160
275
|
* }
|
|
161
276
|
* return "Yes"
|
|
162
277
|
* ```
|
|
278
|
+
*
|
|
279
|
+
* @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.
|
|
280
|
+
*
|
|
281
|
+
* Usage with `req.rate`
|
|
282
|
+
* @example
|
|
283
|
+
* ```ts
|
|
284
|
+
* const ratelimit = new Ratelimit({
|
|
285
|
+
* redis: Redis.fromEnv(),
|
|
286
|
+
* limiter: Ratelimit.slidingWindow(100, "10 s")
|
|
287
|
+
* })
|
|
288
|
+
*
|
|
289
|
+
* const { success } = await ratelimit.limit(id, {rate: 10})
|
|
290
|
+
* if (!success){
|
|
291
|
+
* return "Nope"
|
|
292
|
+
* }
|
|
293
|
+
* return "Yes"
|
|
294
|
+
* ```
|
|
163
295
|
*/
|
|
164
296
|
limit = async (identifier, req) => {
|
|
165
297
|
const key = [this.prefix, identifier].join(":");
|
|
166
298
|
let timeoutId = null;
|
|
167
299
|
try {
|
|
168
|
-
const arr = [this.limiter(this.ctx, key)];
|
|
300
|
+
const arr = [this.limiter().limit(this.ctx, key, req?.rate)];
|
|
169
301
|
if (this.timeout > 0) {
|
|
170
302
|
arr.push(
|
|
171
303
|
new Promise((resolve) => {
|
|
@@ -249,6 +381,14 @@ var Ratelimit = class {
|
|
|
249
381
|
}
|
|
250
382
|
return res;
|
|
251
383
|
};
|
|
384
|
+
resetUsedTokens = async (identifier) => {
|
|
385
|
+
const pattern = [this.prefix, identifier].join(":");
|
|
386
|
+
await this.limiter().resetTokens(this.ctx, pattern);
|
|
387
|
+
};
|
|
388
|
+
getRemaining = async (identifier) => {
|
|
389
|
+
const pattern = [this.prefix, identifier].join(":");
|
|
390
|
+
return await this.limiter().getRemaining(this.ctx, pattern);
|
|
391
|
+
};
|
|
252
392
|
};
|
|
253
393
|
|
|
254
394
|
// src/multi.ts
|
|
@@ -297,72 +437,122 @@ var MultiRegionRatelimit = class extends Ratelimit {
|
|
|
297
437
|
*/
|
|
298
438
|
static fixedWindow(tokens, window) {
|
|
299
439
|
const windowDuration = ms(window);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
return members
|
|
314
|
-
`;
|
|
315
|
-
return async function(ctx, identifier) {
|
|
316
|
-
if (ctx.cache) {
|
|
317
|
-
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
318
|
-
if (blocked) {
|
|
319
|
-
return {
|
|
320
|
-
success: false,
|
|
321
|
-
limit: tokens,
|
|
322
|
-
remaining: 0,
|
|
323
|
-
reset: reset2,
|
|
324
|
-
pending: Promise.resolve()
|
|
325
|
-
};
|
|
440
|
+
return () => ({
|
|
441
|
+
async limit(ctx, identifier, rate) {
|
|
442
|
+
if (ctx.cache) {
|
|
443
|
+
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
444
|
+
if (blocked) {
|
|
445
|
+
return {
|
|
446
|
+
success: false,
|
|
447
|
+
limit: tokens,
|
|
448
|
+
remaining: 0,
|
|
449
|
+
reset: reset2,
|
|
450
|
+
pending: Promise.resolve()
|
|
451
|
+
};
|
|
452
|
+
}
|
|
326
453
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
continue;
|
|
454
|
+
const requestId = randomId();
|
|
455
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
456
|
+
const key = [identifier, bucket].join(":");
|
|
457
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
458
|
+
const dbs = ctx.redis.map((redis) => ({
|
|
459
|
+
redis,
|
|
460
|
+
request: redis.eval(
|
|
461
|
+
fixedWindowLimitScript,
|
|
462
|
+
[key],
|
|
463
|
+
[requestId, windowDuration, incrementBy]
|
|
464
|
+
)
|
|
465
|
+
}));
|
|
466
|
+
const firstResponse = await Promise.any(dbs.map((s) => s.request));
|
|
467
|
+
const usedTokens = firstResponse.reduce((accTokens, usedToken, index) => {
|
|
468
|
+
let parsedToken = 0;
|
|
469
|
+
if (index % 2) {
|
|
470
|
+
parsedToken = Number.parseInt(usedToken);
|
|
345
471
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
472
|
+
return accTokens + parsedToken;
|
|
473
|
+
}, 0);
|
|
474
|
+
const remaining = tokens - usedTokens;
|
|
475
|
+
async function sync() {
|
|
476
|
+
const individualIDs = await Promise.all(dbs.map((s) => s.request));
|
|
477
|
+
const allIDs = Array.from(
|
|
478
|
+
new Set(
|
|
479
|
+
individualIDs.flatMap((_) => _).reduce((acc, curr, index) => {
|
|
480
|
+
if (index % 2 === 0) {
|
|
481
|
+
acc.push(curr);
|
|
482
|
+
}
|
|
483
|
+
return acc;
|
|
484
|
+
}, [])
|
|
485
|
+
).values()
|
|
486
|
+
);
|
|
487
|
+
for (const db of dbs) {
|
|
488
|
+
const usedDbTokens = (await db.request).reduce(
|
|
489
|
+
(accTokens, usedToken, index) => {
|
|
490
|
+
let parsedToken = 0;
|
|
491
|
+
if (index % 2) {
|
|
492
|
+
parsedToken = Number.parseInt(usedToken);
|
|
493
|
+
}
|
|
494
|
+
return accTokens + parsedToken;
|
|
495
|
+
},
|
|
496
|
+
0
|
|
497
|
+
);
|
|
498
|
+
const dbIds = (await db.request).reduce((ids, currentId, index) => {
|
|
499
|
+
if (index % 2 === 0) {
|
|
500
|
+
ids.push(currentId);
|
|
501
|
+
}
|
|
502
|
+
return ids;
|
|
503
|
+
}, []);
|
|
504
|
+
if (usedDbTokens >= tokens) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const diff = allIDs.filter((id) => !dbIds.includes(id));
|
|
508
|
+
if (diff.length === 0) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
for (const requestId2 of diff) {
|
|
512
|
+
await db.redis.hset(key, { [requestId2]: incrementBy });
|
|
513
|
+
}
|
|
349
514
|
}
|
|
350
|
-
|
|
515
|
+
}
|
|
516
|
+
const success = remaining > 0;
|
|
517
|
+
const reset = (bucket + 1) * windowDuration;
|
|
518
|
+
if (ctx.cache && !success) {
|
|
519
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
success,
|
|
523
|
+
limit: tokens,
|
|
524
|
+
remaining,
|
|
525
|
+
reset,
|
|
526
|
+
pending: sync()
|
|
527
|
+
};
|
|
528
|
+
},
|
|
529
|
+
async getRemaining(ctx, identifier) {
|
|
530
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
531
|
+
const key = [identifier, bucket].join(":");
|
|
532
|
+
const dbs = ctx.redis.map((redis) => ({
|
|
533
|
+
redis,
|
|
534
|
+
request: redis.eval(fixedWindowRemainingTokensScript, [key], [null])
|
|
535
|
+
}));
|
|
536
|
+
const firstResponse = await Promise.any(dbs.map((s) => s.request));
|
|
537
|
+
const usedTokens = firstResponse.reduce((accTokens, usedToken, index) => {
|
|
538
|
+
let parsedToken = 0;
|
|
539
|
+
if (index % 2) {
|
|
540
|
+
parsedToken = Number.parseInt(usedToken);
|
|
541
|
+
}
|
|
542
|
+
return accTokens + parsedToken;
|
|
543
|
+
}, 0);
|
|
544
|
+
return Math.max(0, tokens - usedTokens);
|
|
545
|
+
},
|
|
546
|
+
async resetTokens(ctx, identifier) {
|
|
547
|
+
const pattern = [identifier, "*"].join(":");
|
|
548
|
+
if (ctx.cache) {
|
|
549
|
+
ctx.cache.pop(identifier);
|
|
550
|
+
}
|
|
551
|
+
for (const db of ctx.redis) {
|
|
552
|
+
await db.eval(resetScript, [pattern], [null]);
|
|
351
553
|
}
|
|
352
554
|
}
|
|
353
|
-
|
|
354
|
-
const reset = (bucket + 1) * windowDuration;
|
|
355
|
-
if (ctx.cache && !success) {
|
|
356
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
357
|
-
}
|
|
358
|
-
return {
|
|
359
|
-
success,
|
|
360
|
-
limit: tokens,
|
|
361
|
-
remaining,
|
|
362
|
-
reset,
|
|
363
|
-
pending: sync()
|
|
364
|
-
};
|
|
365
|
-
};
|
|
555
|
+
});
|
|
366
556
|
}
|
|
367
557
|
/**
|
|
368
558
|
* Combined approach of `slidingLogs` and `fixedWindow` with lower storage
|
|
@@ -382,86 +572,277 @@ var MultiRegionRatelimit = class extends Ratelimit {
|
|
|
382
572
|
*/
|
|
383
573
|
static slidingWindow(tokens, window) {
|
|
384
574
|
const windowSize = ms(window);
|
|
385
|
-
const script = `
|
|
386
|
-
local currentKey = KEYS[1] -- identifier including prefixes
|
|
387
|
-
local previousKey = KEYS[2] -- key of the previous bucket
|
|
388
|
-
local tokens = tonumber(ARGV[1]) -- tokens per window
|
|
389
|
-
local now = ARGV[2] -- current timestamp in milliseconds
|
|
390
|
-
local window = ARGV[3] -- interval in milliseconds
|
|
391
|
-
local requestId = ARGV[4] -- uuid for this request
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
local currentMembers = redis.call("SMEMBERS", currentKey)
|
|
395
|
-
local requestsInCurrentWindow = #currentMembers
|
|
396
|
-
local previousMembers = redis.call("SMEMBERS", previousKey)
|
|
397
|
-
local requestsInPreviousWindow = #previousMembers
|
|
398
|
-
|
|
399
|
-
local percentageInCurrent = ( now % window) / window
|
|
400
|
-
if requestsInPreviousWindow * ( 1 - percentageInCurrent ) + requestsInCurrentWindow >= tokens then
|
|
401
|
-
return {currentMembers, previousMembers, false}
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
redis.call("SADD", currentKey, requestId)
|
|
405
|
-
table.insert(currentMembers, requestId)
|
|
406
|
-
if requestsInCurrentWindow == 0 then
|
|
407
|
-
-- The first time this key is set, the value will be 1.
|
|
408
|
-
-- So we only need the expire command once
|
|
409
|
-
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
|
|
410
|
-
end
|
|
411
|
-
return {currentMembers, previousMembers, true}
|
|
412
|
-
`;
|
|
413
575
|
const windowDuration = ms(window);
|
|
414
|
-
return
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
for (const db of dbs) {
|
|
439
|
-
const [ids] = await db.request;
|
|
440
|
-
if (ids.length >= tokens) {
|
|
441
|
-
continue;
|
|
576
|
+
return () => ({
|
|
577
|
+
async limit(ctx, identifier, rate) {
|
|
578
|
+
const requestId = randomId();
|
|
579
|
+
const now = Date.now();
|
|
580
|
+
const currentWindow = Math.floor(now / windowSize);
|
|
581
|
+
const currentKey = [identifier, currentWindow].join(":");
|
|
582
|
+
const previousWindow = currentWindow - 1;
|
|
583
|
+
const previousKey = [identifier, previousWindow].join(":");
|
|
584
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
585
|
+
const dbs = ctx.redis.map((redis) => ({
|
|
586
|
+
redis,
|
|
587
|
+
request: redis.eval(
|
|
588
|
+
slidingWindowLimitScript,
|
|
589
|
+
[currentKey, previousKey],
|
|
590
|
+
[tokens, now, windowDuration, requestId, incrementBy]
|
|
591
|
+
// lua seems to return `1` for true and `null` for false
|
|
592
|
+
)
|
|
593
|
+
}));
|
|
594
|
+
const percentageInCurrent = now % windowDuration / windowDuration;
|
|
595
|
+
const [current, previous, success] = await Promise.any(dbs.map((s) => s.request));
|
|
596
|
+
const previousUsedTokens = previous.reduce((accTokens, usedToken, index) => {
|
|
597
|
+
let parsedToken = 0;
|
|
598
|
+
if (index % 2) {
|
|
599
|
+
parsedToken = Number.parseInt(usedToken);
|
|
442
600
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
601
|
+
return accTokens + parsedToken;
|
|
602
|
+
}, 0);
|
|
603
|
+
const currentUsedTokens = current.reduce((accTokens, usedToken, index) => {
|
|
604
|
+
let parsedToken = 0;
|
|
605
|
+
if (index % 2) {
|
|
606
|
+
parsedToken = Number.parseInt(usedToken);
|
|
607
|
+
}
|
|
608
|
+
return accTokens + parsedToken;
|
|
609
|
+
}, 0);
|
|
610
|
+
const previousPartialUsed = previousUsedTokens * (1 - percentageInCurrent);
|
|
611
|
+
const usedTokens = previousPartialUsed + currentUsedTokens;
|
|
612
|
+
const remaining = tokens - usedTokens;
|
|
613
|
+
async function sync() {
|
|
614
|
+
const res = await Promise.all(dbs.map((s) => s.request));
|
|
615
|
+
const allCurrentIds = res.flatMap(([current2]) => current2).reduce((accCurrentIds, curr, index) => {
|
|
616
|
+
if (index % 2 === 0) {
|
|
617
|
+
accCurrentIds.push(curr);
|
|
618
|
+
}
|
|
619
|
+
return accCurrentIds;
|
|
620
|
+
}, []);
|
|
621
|
+
for (const db of dbs) {
|
|
622
|
+
const [_current, previous2, _success] = await db.request;
|
|
623
|
+
const dbIds = previous2.reduce((ids, currentId, index) => {
|
|
624
|
+
if (index % 2 === 0) {
|
|
625
|
+
ids.push(currentId);
|
|
626
|
+
}
|
|
627
|
+
return ids;
|
|
628
|
+
}, []);
|
|
629
|
+
const usedDbTokens = previous2.reduce((accTokens, usedToken, index) => {
|
|
630
|
+
let parsedToken = 0;
|
|
631
|
+
if (index % 2) {
|
|
632
|
+
parsedToken = Number.parseInt(usedToken);
|
|
633
|
+
}
|
|
634
|
+
return accTokens + parsedToken;
|
|
635
|
+
}, 0);
|
|
636
|
+
if (usedDbTokens >= tokens) {
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const diff = allCurrentIds.filter((id) => !dbIds.includes(id));
|
|
640
|
+
if (diff.length === 0) {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
for (const requestId2 of diff) {
|
|
644
|
+
await db.redis.hset(currentKey, { [requestId2]: incrementBy });
|
|
645
|
+
}
|
|
446
646
|
}
|
|
447
|
-
|
|
647
|
+
}
|
|
648
|
+
const reset = (currentWindow + 1) * windowDuration;
|
|
649
|
+
if (ctx.cache && !success) {
|
|
650
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
success: Boolean(success),
|
|
654
|
+
limit: tokens,
|
|
655
|
+
remaining: Math.max(0, remaining),
|
|
656
|
+
reset,
|
|
657
|
+
pending: sync()
|
|
658
|
+
};
|
|
659
|
+
},
|
|
660
|
+
async getRemaining(ctx, identifier) {
|
|
661
|
+
const now = Date.now();
|
|
662
|
+
const currentWindow = Math.floor(now / windowSize);
|
|
663
|
+
const currentKey = [identifier, currentWindow].join(":");
|
|
664
|
+
const previousWindow = currentWindow - 1;
|
|
665
|
+
const previousKey = [identifier, previousWindow].join(":");
|
|
666
|
+
const dbs = ctx.redis.map((redis) => ({
|
|
667
|
+
redis,
|
|
668
|
+
request: redis.eval(
|
|
669
|
+
slidingWindowRemainingTokensScript,
|
|
670
|
+
[currentKey, previousKey],
|
|
671
|
+
[now, windowSize]
|
|
672
|
+
// lua seems to return `1` for true and `null` for false
|
|
673
|
+
)
|
|
674
|
+
}));
|
|
675
|
+
const usedTokens = await Promise.any(dbs.map((s) => s.request));
|
|
676
|
+
return Math.max(0, tokens - usedTokens);
|
|
677
|
+
},
|
|
678
|
+
async resetTokens(ctx, identifier) {
|
|
679
|
+
const pattern = [identifier, "*"].join(":");
|
|
680
|
+
if (ctx.cache) {
|
|
681
|
+
ctx.cache.pop(identifier);
|
|
682
|
+
}
|
|
683
|
+
for (const db of ctx.redis) {
|
|
684
|
+
await db.eval(resetScript, [pattern], [null]);
|
|
448
685
|
}
|
|
449
686
|
}
|
|
450
|
-
|
|
451
|
-
if (ctx.cache && !success) {
|
|
452
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
453
|
-
}
|
|
454
|
-
return {
|
|
455
|
-
success: Boolean(success),
|
|
456
|
-
limit: tokens,
|
|
457
|
-
remaining,
|
|
458
|
-
reset,
|
|
459
|
-
pending: sync()
|
|
460
|
-
};
|
|
461
|
-
};
|
|
687
|
+
});
|
|
462
688
|
}
|
|
463
689
|
};
|
|
464
690
|
|
|
691
|
+
// src/lua-scripts/single.ts
|
|
692
|
+
var fixedWindowLimitScript2 = `
|
|
693
|
+
local key = KEYS[1]
|
|
694
|
+
local window = ARGV[1]
|
|
695
|
+
local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1
|
|
696
|
+
|
|
697
|
+
local r = redis.call("INCRBY", key, incrementBy)
|
|
698
|
+
if r == tonumber(incrementBy) then
|
|
699
|
+
-- The first time this key is set, the value will be equal to incrementBy.
|
|
700
|
+
-- So we only need the expire command once
|
|
701
|
+
redis.call("PEXPIRE", key, window)
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
return r
|
|
705
|
+
`;
|
|
706
|
+
var fixedWindowRemainingTokensScript2 = `
|
|
707
|
+
local key = KEYS[1]
|
|
708
|
+
local tokens = 0
|
|
709
|
+
|
|
710
|
+
local value = redis.call('GET', key)
|
|
711
|
+
if value then
|
|
712
|
+
tokens = value
|
|
713
|
+
end
|
|
714
|
+
return tokens
|
|
715
|
+
`;
|
|
716
|
+
var slidingWindowLimitScript2 = `
|
|
717
|
+
local currentKey = KEYS[1] -- identifier including prefixes
|
|
718
|
+
local previousKey = KEYS[2] -- key of the previous bucket
|
|
719
|
+
local tokens = tonumber(ARGV[1]) -- tokens per window
|
|
720
|
+
local now = ARGV[2] -- current timestamp in milliseconds
|
|
721
|
+
local window = ARGV[3] -- interval in milliseconds
|
|
722
|
+
local incrementBy = ARGV[4] -- increment rate per request at a given value, default is 1
|
|
723
|
+
|
|
724
|
+
local requestsInCurrentWindow = redis.call("GET", currentKey)
|
|
725
|
+
if requestsInCurrentWindow == false then
|
|
726
|
+
requestsInCurrentWindow = 0
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
local requestsInPreviousWindow = redis.call("GET", previousKey)
|
|
730
|
+
if requestsInPreviousWindow == false then
|
|
731
|
+
requestsInPreviousWindow = 0
|
|
732
|
+
end
|
|
733
|
+
local percentageInCurrent = ( now % window ) / window
|
|
734
|
+
-- weighted requests to consider from the previous window
|
|
735
|
+
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
|
|
736
|
+
if requestsInPreviousWindow + requestsInCurrentWindow >= tokens then
|
|
737
|
+
return -1
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
local newValue = redis.call("INCRBY", currentKey, incrementBy)
|
|
741
|
+
if newValue == tonumber(incrementBy) then
|
|
742
|
+
-- The first time this key is set, the value will be equal to incrementBy.
|
|
743
|
+
-- So we only need the expire command once
|
|
744
|
+
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
|
|
745
|
+
end
|
|
746
|
+
return tokens - ( newValue + requestsInPreviousWindow )
|
|
747
|
+
`;
|
|
748
|
+
var slidingWindowRemainingTokensScript2 = `
|
|
749
|
+
local currentKey = KEYS[1] -- identifier including prefixes
|
|
750
|
+
local previousKey = KEYS[2] -- key of the previous bucket
|
|
751
|
+
local now = ARGV[1] -- current timestamp in milliseconds
|
|
752
|
+
local window = ARGV[2] -- interval in milliseconds
|
|
753
|
+
|
|
754
|
+
local requestsInCurrentWindow = redis.call("GET", currentKey)
|
|
755
|
+
if requestsInCurrentWindow == false then
|
|
756
|
+
requestsInCurrentWindow = 0
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
local requestsInPreviousWindow = redis.call("GET", previousKey)
|
|
760
|
+
if requestsInPreviousWindow == false then
|
|
761
|
+
requestsInPreviousWindow = 0
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
local percentageInCurrent = ( now % window ) / window
|
|
765
|
+
-- weighted requests to consider from the previous window
|
|
766
|
+
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
|
|
767
|
+
|
|
768
|
+
return requestsInPreviousWindow + requestsInCurrentWindow
|
|
769
|
+
`;
|
|
770
|
+
var tokenBucketLimitScript = `
|
|
771
|
+
local key = KEYS[1] -- identifier including prefixes
|
|
772
|
+
local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens
|
|
773
|
+
local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
|
|
774
|
+
local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval
|
|
775
|
+
local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
|
|
776
|
+
local incrementBy = tonumber(ARGV[5]) -- how many tokens to consume, default is 1
|
|
777
|
+
|
|
778
|
+
local bucket = redis.call("HMGET", key, "refilledAt", "tokens")
|
|
779
|
+
|
|
780
|
+
local refilledAt
|
|
781
|
+
local tokens
|
|
782
|
+
|
|
783
|
+
if bucket[1] == false then
|
|
784
|
+
refilledAt = now
|
|
785
|
+
tokens = maxTokens
|
|
786
|
+
else
|
|
787
|
+
refilledAt = tonumber(bucket[1])
|
|
788
|
+
tokens = tonumber(bucket[2])
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
if now >= refilledAt + interval then
|
|
792
|
+
local numRefills = math.floor((now - refilledAt) / interval)
|
|
793
|
+
tokens = math.min(maxTokens, tokens + numRefills * refillRate)
|
|
794
|
+
|
|
795
|
+
refilledAt = refilledAt + numRefills * interval
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
if tokens == 0 then
|
|
799
|
+
return {-1, refilledAt + interval}
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
local remaining = tokens - incrementBy
|
|
803
|
+
local expireAt = math.ceil(((maxTokens - remaining) / refillRate)) * interval
|
|
804
|
+
|
|
805
|
+
redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining)
|
|
806
|
+
redis.call("PEXPIRE", key, expireAt)
|
|
807
|
+
return {remaining, refilledAt + interval}
|
|
808
|
+
`;
|
|
809
|
+
var tokenBucketRemainingTokensScript = `
|
|
810
|
+
local key = KEYS[1]
|
|
811
|
+
local maxTokens = tonumber(ARGV[1])
|
|
812
|
+
|
|
813
|
+
local bucket = redis.call("HMGET", key, "tokens")
|
|
814
|
+
|
|
815
|
+
if bucket[1] == false then
|
|
816
|
+
return maxTokens
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
return tonumber(bucket[1])
|
|
820
|
+
`;
|
|
821
|
+
var cachedFixedWindowLimitScript = `
|
|
822
|
+
local key = KEYS[1]
|
|
823
|
+
local window = ARGV[1]
|
|
824
|
+
local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1
|
|
825
|
+
|
|
826
|
+
local r = redis.call("INCRBY", key, incrementBy)
|
|
827
|
+
if r == incrementBy then
|
|
828
|
+
-- The first time this key is set, the value will be equal to incrementBy.
|
|
829
|
+
-- So we only need the expire command once
|
|
830
|
+
redis.call("PEXPIRE", key, window)
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
return r
|
|
834
|
+
`;
|
|
835
|
+
var cachedFixedWindowRemainingTokenScript = `
|
|
836
|
+
local key = KEYS[1]
|
|
837
|
+
local tokens = 0
|
|
838
|
+
|
|
839
|
+
local value = redis.call('GET', key)
|
|
840
|
+
if value then
|
|
841
|
+
tokens = value
|
|
842
|
+
end
|
|
843
|
+
return tokens
|
|
844
|
+
`;
|
|
845
|
+
|
|
465
846
|
// src/single.ts
|
|
466
847
|
var RegionRatelimit = class extends Ratelimit {
|
|
467
848
|
/**
|
|
@@ -499,52 +880,60 @@ var RegionRatelimit = class extends Ratelimit {
|
|
|
499
880
|
*/
|
|
500
881
|
static fixedWindow(tokens, window) {
|
|
501
882
|
const windowDuration = ms(window);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const key = [identifier, bucket].join(":");
|
|
518
|
-
if (ctx.cache) {
|
|
519
|
-
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
520
|
-
if (blocked) {
|
|
521
|
-
return {
|
|
522
|
-
success: false,
|
|
523
|
-
limit: tokens,
|
|
524
|
-
remaining: 0,
|
|
525
|
-
reset: reset2,
|
|
526
|
-
pending: Promise.resolve()
|
|
527
|
-
};
|
|
883
|
+
return () => ({
|
|
884
|
+
async limit(ctx, identifier, rate) {
|
|
885
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
886
|
+
const key = [identifier, bucket].join(":");
|
|
887
|
+
if (ctx.cache) {
|
|
888
|
+
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
889
|
+
if (blocked) {
|
|
890
|
+
return {
|
|
891
|
+
success: false,
|
|
892
|
+
limit: tokens,
|
|
893
|
+
remaining: 0,
|
|
894
|
+
reset: reset2,
|
|
895
|
+
pending: Promise.resolve()
|
|
896
|
+
};
|
|
897
|
+
}
|
|
528
898
|
}
|
|
899
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
900
|
+
const usedTokensAfterUpdate = await ctx.redis.eval(
|
|
901
|
+
fixedWindowLimitScript2,
|
|
902
|
+
[key],
|
|
903
|
+
[windowDuration, incrementBy]
|
|
904
|
+
);
|
|
905
|
+
const success = usedTokensAfterUpdate <= tokens;
|
|
906
|
+
const remainingTokens = Math.max(0, tokens - usedTokensAfterUpdate);
|
|
907
|
+
const reset = (bucket + 1) * windowDuration;
|
|
908
|
+
if (ctx.cache && !success) {
|
|
909
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
success,
|
|
913
|
+
limit: tokens,
|
|
914
|
+
remaining: remainingTokens,
|
|
915
|
+
reset,
|
|
916
|
+
pending: Promise.resolve()
|
|
917
|
+
};
|
|
918
|
+
},
|
|
919
|
+
async getRemaining(ctx, identifier) {
|
|
920
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
921
|
+
const key = [identifier, bucket].join(":");
|
|
922
|
+
const usedTokens = await ctx.redis.eval(
|
|
923
|
+
fixedWindowRemainingTokensScript2,
|
|
924
|
+
[key],
|
|
925
|
+
[null]
|
|
926
|
+
);
|
|
927
|
+
return Math.max(0, tokens - usedTokens);
|
|
928
|
+
},
|
|
929
|
+
async resetTokens(ctx, identifier) {
|
|
930
|
+
const pattern = [identifier, "*"].join(":");
|
|
931
|
+
if (ctx.cache) {
|
|
932
|
+
ctx.cache.pop(identifier);
|
|
933
|
+
}
|
|
934
|
+
await ctx.redis.eval(resetScript, [pattern], [null]);
|
|
529
935
|
}
|
|
530
|
-
|
|
531
|
-
script,
|
|
532
|
-
[key],
|
|
533
|
-
[windowDuration]
|
|
534
|
-
);
|
|
535
|
-
const success = usedTokensAfterUpdate <= tokens;
|
|
536
|
-
const reset = (bucket + 1) * windowDuration;
|
|
537
|
-
if (ctx.cache && !success) {
|
|
538
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
539
|
-
}
|
|
540
|
-
return {
|
|
541
|
-
success,
|
|
542
|
-
limit: tokens,
|
|
543
|
-
remaining: Math.max(0, tokens - usedTokensAfterUpdate),
|
|
544
|
-
reset,
|
|
545
|
-
pending: Promise.resolve()
|
|
546
|
-
};
|
|
547
|
-
};
|
|
936
|
+
});
|
|
548
937
|
}
|
|
549
938
|
/**
|
|
550
939
|
* Combined approach of `slidingLogs` and `fixedWindow` with lower storage
|
|
@@ -563,74 +952,66 @@ var RegionRatelimit = class extends Ratelimit {
|
|
|
563
952
|
* @param window - The duration in which the user can max X requests.
|
|
564
953
|
*/
|
|
565
954
|
static slidingWindow(tokens, window) {
|
|
566
|
-
const script = `
|
|
567
|
-
local currentKey = KEYS[1] -- identifier including prefixes
|
|
568
|
-
local previousKey = KEYS[2] -- key of the previous bucket
|
|
569
|
-
local tokens = tonumber(ARGV[1]) -- tokens per window
|
|
570
|
-
local now = ARGV[2] -- current timestamp in milliseconds
|
|
571
|
-
local window = ARGV[3] -- interval in milliseconds
|
|
572
|
-
|
|
573
|
-
local requestsInCurrentWindow = redis.call("GET", currentKey)
|
|
574
|
-
if requestsInCurrentWindow == false then
|
|
575
|
-
requestsInCurrentWindow = 0
|
|
576
|
-
end
|
|
577
|
-
|
|
578
|
-
local requestsInPreviousWindow = redis.call("GET", previousKey)
|
|
579
|
-
if requestsInPreviousWindow == false then
|
|
580
|
-
requestsInPreviousWindow = 0
|
|
581
|
-
end
|
|
582
|
-
local percentageInCurrent = ( now % window ) / window
|
|
583
|
-
-- weighted requests to consider from the previous window
|
|
584
|
-
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
|
|
585
|
-
if requestsInPreviousWindow + requestsInCurrentWindow >= tokens then
|
|
586
|
-
return -1
|
|
587
|
-
end
|
|
588
|
-
|
|
589
|
-
local newValue = redis.call("INCR", currentKey)
|
|
590
|
-
if newValue == 1 then
|
|
591
|
-
-- The first time this key is set, the value will be 1.
|
|
592
|
-
-- So we only need the expire command once
|
|
593
|
-
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
|
|
594
|
-
end
|
|
595
|
-
return tokens - ( newValue + requestsInPreviousWindow )
|
|
596
|
-
`;
|
|
597
955
|
const windowSize = ms(window);
|
|
598
|
-
return
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
956
|
+
return () => ({
|
|
957
|
+
async limit(ctx, identifier, rate) {
|
|
958
|
+
const now = Date.now();
|
|
959
|
+
const currentWindow = Math.floor(now / windowSize);
|
|
960
|
+
const currentKey = [identifier, currentWindow].join(":");
|
|
961
|
+
const previousWindow = currentWindow - 1;
|
|
962
|
+
const previousKey = [identifier, previousWindow].join(":");
|
|
963
|
+
if (ctx.cache) {
|
|
964
|
+
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
965
|
+
if (blocked) {
|
|
966
|
+
return {
|
|
967
|
+
success: false,
|
|
968
|
+
limit: tokens,
|
|
969
|
+
remaining: 0,
|
|
970
|
+
reset: reset2,
|
|
971
|
+
pending: Promise.resolve()
|
|
972
|
+
};
|
|
973
|
+
}
|
|
614
974
|
}
|
|
975
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
976
|
+
const remainingTokens = await ctx.redis.eval(
|
|
977
|
+
slidingWindowLimitScript2,
|
|
978
|
+
[currentKey, previousKey],
|
|
979
|
+
[tokens, now, windowSize, incrementBy]
|
|
980
|
+
);
|
|
981
|
+
const success = remainingTokens >= 0;
|
|
982
|
+
const reset = (currentWindow + 1) * windowSize;
|
|
983
|
+
if (ctx.cache && !success) {
|
|
984
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
success,
|
|
988
|
+
limit: tokens,
|
|
989
|
+
remaining: Math.max(0, remainingTokens),
|
|
990
|
+
reset,
|
|
991
|
+
pending: Promise.resolve()
|
|
992
|
+
};
|
|
993
|
+
},
|
|
994
|
+
async getRemaining(ctx, identifier) {
|
|
995
|
+
const now = Date.now();
|
|
996
|
+
const currentWindow = Math.floor(now / windowSize);
|
|
997
|
+
const currentKey = [identifier, currentWindow].join(":");
|
|
998
|
+
const previousWindow = currentWindow - 1;
|
|
999
|
+
const previousKey = [identifier, previousWindow].join(":");
|
|
1000
|
+
const usedTokens = await ctx.redis.eval(
|
|
1001
|
+
slidingWindowRemainingTokensScript2,
|
|
1002
|
+
[currentKey, previousKey],
|
|
1003
|
+
[now, windowSize]
|
|
1004
|
+
);
|
|
1005
|
+
return Math.max(0, tokens - usedTokens);
|
|
1006
|
+
},
|
|
1007
|
+
async resetTokens(ctx, identifier) {
|
|
1008
|
+
const pattern = [identifier, "*"].join(":");
|
|
1009
|
+
if (ctx.cache) {
|
|
1010
|
+
ctx.cache.pop(identifier);
|
|
1011
|
+
}
|
|
1012
|
+
await ctx.redis.eval(resetScript, [pattern], [null]);
|
|
615
1013
|
}
|
|
616
|
-
|
|
617
|
-
script,
|
|
618
|
-
[currentKey, previousKey],
|
|
619
|
-
[tokens, now, windowSize]
|
|
620
|
-
);
|
|
621
|
-
const success = remaining >= 0;
|
|
622
|
-
const reset = (currentWindow + 1) * windowSize;
|
|
623
|
-
if (ctx.cache && !success) {
|
|
624
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
625
|
-
}
|
|
626
|
-
return {
|
|
627
|
-
success,
|
|
628
|
-
limit: tokens,
|
|
629
|
-
remaining: Math.max(0, remaining),
|
|
630
|
-
reset,
|
|
631
|
-
pending: Promise.resolve()
|
|
632
|
-
};
|
|
633
|
-
};
|
|
1014
|
+
});
|
|
634
1015
|
}
|
|
635
1016
|
/**
|
|
636
1017
|
* You have a bucket filled with `{maxTokens}` tokens that refills constantly
|
|
@@ -646,76 +1027,56 @@ var RegionRatelimit = class extends Ratelimit {
|
|
|
646
1027
|
* than `refillRate`
|
|
647
1028
|
*/
|
|
648
1029
|
static tokenBucket(refillRate, interval, maxTokens) {
|
|
649
|
-
const script = `
|
|
650
|
-
local key = KEYS[1] -- identifier including prefixes
|
|
651
|
-
local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens
|
|
652
|
-
local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
|
|
653
|
-
local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval
|
|
654
|
-
local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
|
|
655
|
-
|
|
656
|
-
local bucket = redis.call("HMGET", key, "refilledAt", "tokens")
|
|
657
|
-
|
|
658
|
-
local refilledAt
|
|
659
|
-
local tokens
|
|
660
|
-
|
|
661
|
-
if bucket[1] == false then
|
|
662
|
-
refilledAt = now
|
|
663
|
-
tokens = maxTokens
|
|
664
|
-
else
|
|
665
|
-
refilledAt = tonumber(bucket[1])
|
|
666
|
-
tokens = tonumber(bucket[2])
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
if now >= refilledAt + interval then
|
|
670
|
-
local numRefills = math.floor((now - refilledAt) / interval)
|
|
671
|
-
tokens = math.min(maxTokens, tokens + numRefills * refillRate)
|
|
672
|
-
|
|
673
|
-
refilledAt = refilledAt + numRefills * interval
|
|
674
|
-
end
|
|
675
|
-
|
|
676
|
-
if tokens == 0 then
|
|
677
|
-
return {-1, refilledAt + interval}
|
|
678
|
-
end
|
|
679
|
-
|
|
680
|
-
local remaining = tokens - 1
|
|
681
|
-
local expireAt = math.ceil(((maxTokens - remaining) / refillRate)) * interval
|
|
682
|
-
|
|
683
|
-
redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining)
|
|
684
|
-
redis.call("PEXPIRE", key, expireAt)
|
|
685
|
-
return {remaining, refilledAt + interval}
|
|
686
|
-
`;
|
|
687
1030
|
const intervalDuration = ms(interval);
|
|
688
|
-
return
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1031
|
+
return () => ({
|
|
1032
|
+
async limit(ctx, identifier, rate) {
|
|
1033
|
+
if (ctx.cache) {
|
|
1034
|
+
const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
|
|
1035
|
+
if (blocked) {
|
|
1036
|
+
return {
|
|
1037
|
+
success: false,
|
|
1038
|
+
limit: maxTokens,
|
|
1039
|
+
remaining: 0,
|
|
1040
|
+
reset: reset2,
|
|
1041
|
+
pending: Promise.resolve()
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
699
1044
|
}
|
|
1045
|
+
const now = Date.now();
|
|
1046
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
1047
|
+
const [remaining, reset] = await ctx.redis.eval(
|
|
1048
|
+
tokenBucketLimitScript,
|
|
1049
|
+
[identifier],
|
|
1050
|
+
[maxTokens, intervalDuration, refillRate, now, incrementBy]
|
|
1051
|
+
);
|
|
1052
|
+
const success = remaining >= 0;
|
|
1053
|
+
if (ctx.cache && !success) {
|
|
1054
|
+
ctx.cache.blockUntil(identifier, reset);
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
success,
|
|
1058
|
+
limit: maxTokens,
|
|
1059
|
+
remaining,
|
|
1060
|
+
reset,
|
|
1061
|
+
pending: Promise.resolve()
|
|
1062
|
+
};
|
|
1063
|
+
},
|
|
1064
|
+
async getRemaining(ctx, identifier) {
|
|
1065
|
+
const remainingTokens = await ctx.redis.eval(
|
|
1066
|
+
tokenBucketRemainingTokensScript,
|
|
1067
|
+
[identifier],
|
|
1068
|
+
[maxTokens]
|
|
1069
|
+
);
|
|
1070
|
+
return remainingTokens;
|
|
1071
|
+
},
|
|
1072
|
+
async resetTokens(ctx, identifier) {
|
|
1073
|
+
const pattern = identifier;
|
|
1074
|
+
if (ctx.cache) {
|
|
1075
|
+
ctx.cache.pop(identifier);
|
|
1076
|
+
}
|
|
1077
|
+
await ctx.redis.eval(resetScript, [pattern], [null]);
|
|
700
1078
|
}
|
|
701
|
-
|
|
702
|
-
const [remaining, reset] = await ctx.redis.eval(
|
|
703
|
-
script,
|
|
704
|
-
[identifier],
|
|
705
|
-
[maxTokens, intervalDuration, refillRate, now]
|
|
706
|
-
);
|
|
707
|
-
const success = remaining >= 0;
|
|
708
|
-
if (ctx.cache && !success) {
|
|
709
|
-
ctx.cache.blockUntil(identifier, reset);
|
|
710
|
-
}
|
|
711
|
-
return {
|
|
712
|
-
success,
|
|
713
|
-
limit: maxTokens,
|
|
714
|
-
remaining,
|
|
715
|
-
reset,
|
|
716
|
-
pending: Promise.resolve()
|
|
717
|
-
};
|
|
718
|
-
};
|
|
1079
|
+
});
|
|
719
1080
|
}
|
|
720
1081
|
/**
|
|
721
1082
|
* cachedFixedWindow first uses the local cache to decide if a request may pass and then updates
|
|
@@ -743,56 +1104,72 @@ var RegionRatelimit = class extends Ratelimit {
|
|
|
743
1104
|
*/
|
|
744
1105
|
static cachedFixedWindow(tokens, window) {
|
|
745
1106
|
const windowDuration = ms(window);
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
1107
|
+
return () => ({
|
|
1108
|
+
async limit(ctx, identifier, rate) {
|
|
1109
|
+
if (!ctx.cache) {
|
|
1110
|
+
throw new Error("This algorithm requires a cache");
|
|
1111
|
+
}
|
|
1112
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
1113
|
+
const key = [identifier, bucket].join(":");
|
|
1114
|
+
const reset = (bucket + 1) * windowDuration;
|
|
1115
|
+
const incrementBy = rate ? Math.max(1, rate) : 1;
|
|
1116
|
+
const hit = typeof ctx.cache.get(key) === "number";
|
|
1117
|
+
if (hit) {
|
|
1118
|
+
const cachedTokensAfterUpdate = ctx.cache.incr(key);
|
|
1119
|
+
const success = cachedTokensAfterUpdate < tokens;
|
|
1120
|
+
const pending = success ? ctx.redis.eval(cachedFixedWindowLimitScript, [key], [windowDuration, incrementBy]).then((t) => {
|
|
1121
|
+
ctx.cache.set(key, t);
|
|
1122
|
+
}) : Promise.resolve();
|
|
1123
|
+
return {
|
|
1124
|
+
success,
|
|
1125
|
+
limit: tokens,
|
|
1126
|
+
remaining: tokens - cachedTokensAfterUpdate,
|
|
1127
|
+
reset,
|
|
1128
|
+
pending
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
const usedTokensAfterUpdate = await ctx.redis.eval(
|
|
1132
|
+
cachedFixedWindowLimitScript,
|
|
1133
|
+
[key],
|
|
1134
|
+
[windowDuration, incrementBy]
|
|
1135
|
+
);
|
|
1136
|
+
ctx.cache.set(key, usedTokensAfterUpdate);
|
|
1137
|
+
const remaining = tokens - usedTokensAfterUpdate;
|
|
773
1138
|
return {
|
|
774
|
-
success,
|
|
1139
|
+
success: remaining >= 0,
|
|
775
1140
|
limit: tokens,
|
|
776
|
-
remaining
|
|
1141
|
+
remaining,
|
|
777
1142
|
reset,
|
|
778
|
-
pending
|
|
1143
|
+
pending: Promise.resolve()
|
|
779
1144
|
};
|
|
1145
|
+
},
|
|
1146
|
+
async getRemaining(ctx, identifier) {
|
|
1147
|
+
if (!ctx.cache) {
|
|
1148
|
+
throw new Error("This algorithm requires a cache");
|
|
1149
|
+
}
|
|
1150
|
+
const bucket = Math.floor(Date.now() / windowDuration);
|
|
1151
|
+
const key = [identifier, bucket].join(":");
|
|
1152
|
+
const hit = typeof ctx.cache.get(key) === "number";
|
|
1153
|
+
if (hit) {
|
|
1154
|
+
const cachedUsedTokens = ctx.cache.get(key) ?? 0;
|
|
1155
|
+
return Math.max(0, tokens - cachedUsedTokens);
|
|
1156
|
+
}
|
|
1157
|
+
const usedTokens = await ctx.redis.eval(
|
|
1158
|
+
cachedFixedWindowRemainingTokenScript,
|
|
1159
|
+
[key],
|
|
1160
|
+
[null]
|
|
1161
|
+
);
|
|
1162
|
+
return Math.max(0, tokens - usedTokens);
|
|
1163
|
+
},
|
|
1164
|
+
async resetTokens(ctx, identifier) {
|
|
1165
|
+
const pattern = [identifier, "*"].join(":");
|
|
1166
|
+
if (!ctx.cache) {
|
|
1167
|
+
throw new Error("This algorithm requires a cache");
|
|
1168
|
+
}
|
|
1169
|
+
ctx.cache.pop(identifier);
|
|
1170
|
+
await ctx.redis.eval(resetScript, [pattern], [null]);
|
|
780
1171
|
}
|
|
781
|
-
|
|
782
|
-
script,
|
|
783
|
-
[key],
|
|
784
|
-
[windowDuration]
|
|
785
|
-
);
|
|
786
|
-
ctx.cache.set(key, usedTokensAfterUpdate);
|
|
787
|
-
const remaining = tokens - usedTokensAfterUpdate;
|
|
788
|
-
return {
|
|
789
|
-
success: remaining >= 0,
|
|
790
|
-
limit: tokens,
|
|
791
|
-
remaining,
|
|
792
|
-
reset,
|
|
793
|
-
pending: Promise.resolve()
|
|
794
|
-
};
|
|
795
|
-
};
|
|
1172
|
+
});
|
|
796
1173
|
}
|
|
797
1174
|
};
|
|
798
1175
|
export {
|