1 | import { getMilliseconds, wait } from "./clock";
|
2 |
|
3 | export type Interval = number | "second" | "sec" | "minute" | "min" | "hour" | "hr" | "day";
|
4 |
|
5 | export type TokenBucketOpts = {
|
6 | bucketSize: number;
|
7 | tokensPerInterval: number;
|
8 | interval: Interval;
|
9 | parentBucket?: TokenBucket;
|
10 | };
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 | export 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 |
|
69 |
|
70 |
|
71 |
|
72 |
|
73 |
|
74 | async removeTokens(count: number): Promise<number> {
|
75 |
|
76 | if (this.bucketSize === 0) {
|
77 | return Number.POSITIVE_INFINITY;
|
78 | }
|
79 |
|
80 |
|
81 | if (count > this.bucketSize) {
|
82 | throw new Error(`Requested tokens ${count} exceeds bucket size ${this.bucketSize}`);
|
83 | }
|
84 |
|
85 |
|
86 | this.drip();
|
87 |
|
88 | const comeBackLater = async () => {
|
89 |
|
90 | const waitMs = Math.ceil((count - this.content) * (this.interval / this.tokensPerInterval));
|
91 | await wait(waitMs);
|
92 | return this.removeTokens(count);
|
93 | };
|
94 |
|
95 |
|
96 | if (count > this.content) return comeBackLater();
|
97 |
|
98 | if (this.parentBucket != undefined) {
|
99 |
|
100 | const remainingTokens = await this.parentBucket.removeTokens(count);
|
101 |
|
102 |
|
103 | if (count > this.content) return comeBackLater();
|
104 |
|
105 |
|
106 |
|
107 |
|
108 | this.content -= count;
|
109 |
|
110 | return Math.min(remainingTokens, this.content);
|
111 | } else {
|
112 |
|
113 | this.content -= count;
|
114 | return this.content;
|
115 | }
|
116 | }
|
117 |
|
118 | |
119 |
|
120 |
|
121 |
|
122 |
|
123 |
|
124 |
|
125 |
|
126 | tryRemoveTokens(count: number): boolean {
|
127 |
|
128 | if (!this.bucketSize) return true;
|
129 |
|
130 |
|
131 | if (count > this.bucketSize) return false;
|
132 |
|
133 |
|
134 | this.drip();
|
135 |
|
136 |
|
137 | if (count > this.content) return false;
|
138 |
|
139 |
|
140 | if (this.parentBucket && !this.parentBucket.tryRemoveTokens(count)) return false;
|
141 |
|
142 |
|
143 | this.content -= count;
|
144 | return true;
|
145 | }
|
146 |
|
147 | |
148 |
|
149 |
|
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 | }
|