1 | import { Interval, TokenBucket } from "./TokenBucket";
|
2 | import { getMilliseconds, wait } from "./clock";
|
3 |
|
4 | export type RateLimiterOpts = {
|
5 | tokensPerInterval: number;
|
6 | interval: Interval;
|
7 | fireImmediately?: boolean;
|
8 | };
|
9 |
|
10 | /**
|
11 | * A generic rate limiter. Underneath the hood, this uses a token bucket plus
|
12 | * an additional check to limit how many tokens we can remove each interval.
|
13 | *
|
14 | * @param options
|
15 | * @param options.tokensPerInterval Maximum number of tokens that can be
|
16 | * removed at any given moment and over the course of one interval.
|
17 | * @param options.interval The interval length in milliseconds, or as
|
18 | * one of the following strings: 'second', 'minute', 'hour', day'.
|
19 | * @param options.fireImmediately Whether or not the promise will resolve
|
20 | * immediately when rate limiting is in effect (default is false).
|
21 | */
|
22 | export class RateLimiter {
|
23 | tokenBucket: TokenBucket;
|
24 | curIntervalStart: number;
|
25 | tokensThisInterval: number;
|
26 | fireImmediately: boolean;
|
27 |
|
28 | constructor({ tokensPerInterval, interval, fireImmediately }: RateLimiterOpts) {
|
29 | this.tokenBucket = new TokenBucket({
|
30 | bucketSize: tokensPerInterval,
|
31 | tokensPerInterval,
|
32 | interval,
|
33 | });
|
34 |
|
35 | // Fill the token bucket to start
|
36 | this.tokenBucket.content = tokensPerInterval;
|
37 |
|
38 | this.curIntervalStart = getMilliseconds();
|
39 | this.tokensThisInterval = 0;
|
40 | this.fireImmediately = fireImmediately ?? false;
|
41 | }
|
42 |
|
43 | /**
|
44 | * Remove the requested number of tokens. If the rate limiter contains enough
|
45 | * tokens and we haven't spent too many tokens in this interval already, this
|
46 | * will happen immediately. Otherwise, the removal will happen when enough
|
47 | * tokens become available.
|
48 | * @param count The number of tokens to remove.
|
49 | * @returns A promise for the remainingTokens count.
|
50 | */
|
51 | async removeTokens(count: number): Promise<number> {
|
52 | // Make sure the request isn't for more than we can handle
|
53 | if (count > this.tokenBucket.bucketSize) {
|
54 | throw new Error(
|
55 | `Requested tokens ${count} exceeds maximum tokens per interval ${this.tokenBucket.bucketSize}`
|
56 | );
|
57 | }
|
58 |
|
59 | const now = getMilliseconds();
|
60 |
|
61 | // Advance the current interval and reset the current interval token count
|
62 | // if needed
|
63 | if (now < this.curIntervalStart || now - this.curIntervalStart >= this.tokenBucket.interval) {
|
64 | this.curIntervalStart = now;
|
65 | this.tokensThisInterval = 0;
|
66 | }
|
67 |
|
68 | // If we don't have enough tokens left in this interval, wait until the
|
69 | // next interval
|
70 | if (count > this.tokenBucket.tokensPerInterval - this.tokensThisInterval) {
|
71 | if (this.fireImmediately) {
|
72 | return -1;
|
73 | } else {
|
74 | const waitMs = Math.ceil(this.curIntervalStart + this.tokenBucket.interval - now);
|
75 | await wait(waitMs);
|
76 | const remainingTokens = await this.tokenBucket.removeTokens(count);
|
77 | this.tokensThisInterval += count;
|
78 | return remainingTokens;
|
79 | }
|
80 | }
|
81 |
|
82 | // Remove the requested number of tokens from the token bucket
|
83 | const remainingTokens = await this.tokenBucket.removeTokens(count);
|
84 | this.tokensThisInterval += count;
|
85 | return remainingTokens;
|
86 | }
|
87 |
|
88 | /**
|
89 | * Attempt to remove the requested number of tokens and return immediately.
|
90 | * If the bucket (and any parent buckets) contains enough tokens and we
|
91 | * haven't spent too many tokens in this interval already, this will return
|
92 | * true. Otherwise, false is returned.
|
93 | * @param {Number} count The number of tokens to remove.
|
94 | * @param {Boolean} True if the tokens were successfully removed, otherwise
|
95 | * false.
|
96 | */
|
97 | tryRemoveTokens(count: number): boolean {
|
98 | // Make sure the request isn't for more than we can handle
|
99 | if (count > this.tokenBucket.bucketSize) return false;
|
100 |
|
101 | const now = getMilliseconds();
|
102 |
|
103 | // Advance the current interval and reset the current interval token count
|
104 | // if needed
|
105 | if (now < this.curIntervalStart || now - this.curIntervalStart >= this.tokenBucket.interval) {
|
106 | this.curIntervalStart = now;
|
107 | this.tokensThisInterval = 0;
|
108 | }
|
109 |
|
110 | // If we don't have enough tokens left in this interval, return false
|
111 | if (count > this.tokenBucket.tokensPerInterval - this.tokensThisInterval) return false;
|
112 |
|
113 | // Try to remove the requested number of tokens from the token bucket
|
114 | const removed = this.tokenBucket.tryRemoveTokens(count);
|
115 | if (removed) {
|
116 | this.tokensThisInterval += count;
|
117 | }
|
118 | return removed;
|
119 | }
|
120 |
|
121 | /**
|
122 | * Returns the number of tokens remaining in the TokenBucket.
|
123 | * @returns {Number} The number of tokens remaining.
|
124 | */
|
125 | getTokensRemaining(): number {
|
126 | this.tokenBucket.drip();
|
127 | return this.tokenBucket.content;
|
128 | }
|
129 | }
|