UNPKG

5.58 kBPlain TextView Raw
1import { getMilliseconds, wait } from "./clock";
2
3export type Interval = number | "second" | "sec" | "minute" | "min" | "hour" | "hr" | "day";
4
5export type TokenBucketOpts = {
6 bucketSize: number;
7 tokensPerInterval: number;
8 interval: Interval;
9 parentBucket?: TokenBucket;
10};
11
12/**
13 * A hierarchical token bucket for rate limiting. See
14 * http://en.wikipedia.org/wiki/Token_bucket for more information.
15 *
16 * @param options
17 * @param options.bucketSize Maximum number of tokens to hold in the bucket.
18 * Also known as the burst rate.
19 * @param options.tokensPerInterval Number of tokens to drip into the bucket
20 * over the course of one interval.
21 * @param options.interval The interval length in milliseconds, or as
22 * one of the following strings: 'second', 'minute', 'hour', day'.
23 * @param options.parentBucket Optional. A token bucket that will act as
24 * the parent of this bucket.
25 */
26export class TokenBucket {
27 bucketSize: number;
28 tokensPerInterval: number;
29 interval: number;
30 parentBucket?: TokenBucket;
31 content: number;
32 lastDrip: number;
33
34 constructor({ bucketSize, tokensPerInterval, interval, parentBucket }: TokenBucketOpts) {
35 this.bucketSize = bucketSize;
36 this.tokensPerInterval = tokensPerInterval;
37
38 if (typeof interval === "string") {
39 switch (interval) {
40 case "sec":
41 case "second":
42 this.interval = 1000;
43 break;
44 case "min":
45 case "minute":
46 this.interval = 1000 * 60;
47 break;
48 case "hr":
49 case "hour":
50 this.interval = 1000 * 60 * 60;
51 break;
52 case "day":
53 this.interval = 1000 * 60 * 60 * 24;
54 break;
55 default:
56 throw new Error("Invalid interval " + interval);
57 }
58 } else {
59 this.interval = interval;
60 }
61
62 this.parentBucket = parentBucket;
63 this.content = 0;
64 this.lastDrip = getMilliseconds();
65 }
66
67 /**
68 * Remove the requested number of tokens. If the bucket (and any parent
69 * buckets) contains enough tokens this will happen immediately. Otherwise,
70 * the removal will happen when enough tokens become available.
71 * @param count The number of tokens to remove.
72 * @returns A promise for the remainingTokens count.
73 */
74 async removeTokens(count: number): Promise<number> {
75 // Is this an infinite size bucket?
76 if (this.bucketSize === 0) {
77 return Number.POSITIVE_INFINITY;
78 }
79
80 // Make sure the bucket can hold the requested number of tokens
81 if (count > this.bucketSize) {
82 throw new Error(`Requested tokens ${count} exceeds bucket size ${this.bucketSize}`);
83 }
84
85 // Drip new tokens into this bucket
86 this.drip();
87
88 const comeBackLater = async () => {
89 // How long do we need to wait to make up the difference in tokens?
90 const waitMs = Math.ceil((count - this.content) * (this.interval / this.tokensPerInterval));
91 await wait(waitMs);
92 return this.removeTokens(count);
93 };
94
95 // If we don't have enough tokens in this bucket, come back later
96 if (count > this.content) return comeBackLater();
97
98 if (this.parentBucket != undefined) {
99 // Remove the requested from the parent bucket first
100 const remainingTokens = await this.parentBucket.removeTokens(count);
101
102 // Check that we still have enough tokens in this bucket
103 if (count > this.content) return comeBackLater();
104
105 // Tokens were removed from the parent bucket, now remove them from
106 // this bucket. Note that we look at the current bucket and parent
107 // bucket's remaining tokens and return the smaller of the two values
108 this.content -= count;
109
110 return Math.min(remainingTokens, this.content);
111 } else {
112 // Remove the requested tokens from this bucket
113 this.content -= count;
114 return this.content;
115 }
116 }
117
118 /**
119 * Attempt to remove the requested number of tokens and return immediately.
120 * If the bucket (and any parent buckets) contains enough tokens this will
121 * return true, otherwise false is returned.
122 * @param {Number} count The number of tokens to remove.
123 * @param {Boolean} True if the tokens were successfully removed, otherwise
124 * false.
125 */
126 tryRemoveTokens(count: number): boolean {
127 // Is this an infinite size bucket?
128 if (!this.bucketSize) return true;
129
130 // Make sure the bucket can hold the requested number of tokens
131 if (count > this.bucketSize) return false;
132
133 // Drip new tokens into this bucket
134 this.drip();
135
136 // If we don't have enough tokens in this bucket, return false
137 if (count > this.content) return false;
138
139 // Try to remove the requested tokens from the parent bucket
140 if (this.parentBucket && !this.parentBucket.tryRemoveTokens(count)) return false;
141
142 // Remove the requested tokens from this bucket and return
143 this.content -= count;
144 return true;
145 }
146
147 /**
148 * Add any new tokens to the bucket since the last drip.
149 * @returns {Boolean} True if new tokens were added, otherwise false.
150 */
151 drip(): boolean {
152 if (this.tokensPerInterval === 0) {
153 const prevContent = this.content;
154 this.content = this.bucketSize;
155 return this.content > prevContent;
156 }
157
158 const now = getMilliseconds();
159 const deltaMS = Math.max(now - this.lastDrip, 0);
160 this.lastDrip = now;
161
162 const dripAmount = deltaMS * (this.tokensPerInterval / this.interval);
163 const prevContent = this.content;
164 this.content = Math.min(this.content + dripAmount, this.bucketSize);
165 return Math.floor(this.content) > Math.floor(prevContent);
166 }
167}