@posthog/core 1.3.1 → 1.4.0
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/dist/utils/bucketed-rate-limiter.d.ts +3 -7
- package/dist/utils/bucketed-rate-limiter.d.ts.map +1 -1
- package/dist/utils/bucketed-rate-limiter.js +33 -33
- package/dist/utils/bucketed-rate-limiter.mjs +33 -33
- package/package.json +1 -1
- package/src/utils/bucketed-rate-limiter.spec.ts +264 -7
- package/src/utils/bucketed-rate-limiter.ts +43 -61
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
import { Logger } from '../types';
|
|
2
2
|
export declare class BucketedRateLimiter<T extends string | number> {
|
|
3
|
-
private readonly _options;
|
|
4
3
|
private _bucketSize;
|
|
5
4
|
private _refillRate;
|
|
6
5
|
private _refillInterval;
|
|
7
6
|
private _onBucketRateLimited?;
|
|
8
7
|
private _buckets;
|
|
9
|
-
|
|
10
|
-
constructor(_options: {
|
|
8
|
+
constructor(options: {
|
|
11
9
|
bucketSize: number;
|
|
12
10
|
refillRate: number;
|
|
13
11
|
refillInterval: number;
|
|
14
12
|
_logger: Logger;
|
|
15
13
|
_onBucketRateLimited?: (key: T) => void;
|
|
16
14
|
});
|
|
17
|
-
private
|
|
18
|
-
|
|
19
|
-
private _setBucket;
|
|
20
|
-
consumeRateLimit: (key: T) => boolean;
|
|
15
|
+
private _applyRefill;
|
|
16
|
+
consumeRateLimit(key: T): boolean;
|
|
21
17
|
stop(): void;
|
|
22
18
|
}
|
|
23
19
|
//# sourceMappingURL=bucketed-rate-limiter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bucketed-rate-limiter.d.ts","sourceRoot":"","sources":["../../src/utils/bucketed-rate-limiter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"bucketed-rate-limiter.d.ts","sourceRoot":"","sources":["../../src/utils/bucketed-rate-limiter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAMjC,qBAAa,mBAAmB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IACxD,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,eAAe,CAAQ;IAC/B,OAAO,CAAC,oBAAoB,CAAC,CAAkB;IAC/C,OAAO,CAAC,QAAQ,CAA6B;gBAEjC,OAAO,EAAE;QACnB,UAAU,EAAE,MAAM,CAAA;QAClB,UAAU,EAAE,MAAM,CAAA;QAClB,cAAc,EAAE,MAAM,CAAA;QACtB,OAAO,EAAE,MAAM,CAAA;QACf,oBAAoB,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,CAAA;KACxC;IAOD,OAAO,CAAC,YAAY;IAWb,gBAAgB,CAAC,GAAG,EAAE,CAAC,GAAG,OAAO;IA0BjC,IAAI,IAAI,IAAI;CAGpB"}
|
|
@@ -27,43 +27,43 @@ __webpack_require__.d(__webpack_exports__, {
|
|
|
27
27
|
BucketedRateLimiter: ()=>BucketedRateLimiter
|
|
28
28
|
});
|
|
29
29
|
const external_number_utils_js_namespaceObject = require("./number-utils.js");
|
|
30
|
+
const ONE_DAY_IN_MS = 86400000;
|
|
30
31
|
class BucketedRateLimiter {
|
|
31
|
-
constructor(
|
|
32
|
-
this._options = _options;
|
|
32
|
+
constructor(options){
|
|
33
33
|
this._buckets = {};
|
|
34
|
-
this.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
else this._setBucket(key, newTokens);
|
|
39
|
-
});
|
|
40
|
-
};
|
|
41
|
-
this._getBucket = (key)=>this._buckets[String(key)];
|
|
42
|
-
this._setBucket = (key, value)=>{
|
|
43
|
-
this._buckets[String(key)] = value;
|
|
44
|
-
};
|
|
45
|
-
this.consumeRateLimit = (key)=>{
|
|
46
|
-
let tokens = this._getBucket(key) ?? this._bucketSize;
|
|
47
|
-
tokens = Math.max(tokens - 1, 0);
|
|
48
|
-
if (0 === tokens) return true;
|
|
49
|
-
this._setBucket(key, tokens);
|
|
50
|
-
const hasReachedZero = 0 === tokens;
|
|
51
|
-
if (hasReachedZero) this._onBucketRateLimited?.(key);
|
|
52
|
-
return hasReachedZero;
|
|
53
|
-
};
|
|
54
|
-
this._onBucketRateLimited = this._options._onBucketRateLimited;
|
|
55
|
-
this._bucketSize = (0, external_number_utils_js_namespaceObject.clampToRange)(this._options.bucketSize, 0, 100, this._options._logger);
|
|
56
|
-
this._refillRate = (0, external_number_utils_js_namespaceObject.clampToRange)(this._options.refillRate, 0, this._bucketSize, this._options._logger);
|
|
57
|
-
this._refillInterval = (0, external_number_utils_js_namespaceObject.clampToRange)(this._options.refillInterval, 0, 86400000, this._options._logger);
|
|
58
|
-
this._removeInterval = setInterval(()=>{
|
|
59
|
-
this._refillBuckets();
|
|
60
|
-
}, this._refillInterval);
|
|
34
|
+
this._onBucketRateLimited = options._onBucketRateLimited;
|
|
35
|
+
this._bucketSize = (0, external_number_utils_js_namespaceObject.clampToRange)(options.bucketSize, 0, 100, options._logger);
|
|
36
|
+
this._refillRate = (0, external_number_utils_js_namespaceObject.clampToRange)(options.refillRate, 0, this._bucketSize, options._logger);
|
|
37
|
+
this._refillInterval = (0, external_number_utils_js_namespaceObject.clampToRange)(options.refillInterval, 0, ONE_DAY_IN_MS, options._logger);
|
|
61
38
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
39
|
+
_applyRefill(bucket, now) {
|
|
40
|
+
const elapsedMs = now - bucket.lastAccess;
|
|
41
|
+
const refillIntervals = Math.floor(elapsedMs / this._refillInterval);
|
|
42
|
+
if (refillIntervals > 0) {
|
|
43
|
+
const tokensToAdd = refillIntervals * this._refillRate;
|
|
44
|
+
bucket.tokens = Math.min(bucket.tokens + tokensToAdd, this._bucketSize);
|
|
45
|
+
bucket.lastAccess = bucket.lastAccess + refillIntervals * this._refillInterval;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
consumeRateLimit(key) {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const keyStr = String(key);
|
|
51
|
+
let bucket = this._buckets[keyStr];
|
|
52
|
+
if (bucket) this._applyRefill(bucket, now);
|
|
53
|
+
else {
|
|
54
|
+
bucket = {
|
|
55
|
+
tokens: this._bucketSize,
|
|
56
|
+
lastAccess: now
|
|
57
|
+
};
|
|
58
|
+
this._buckets[keyStr] = bucket;
|
|
66
59
|
}
|
|
60
|
+
if (0 === bucket.tokens) return true;
|
|
61
|
+
bucket.tokens--;
|
|
62
|
+
if (0 === bucket.tokens) this._onBucketRateLimited?.(key);
|
|
63
|
+
return 0 === bucket.tokens;
|
|
64
|
+
}
|
|
65
|
+
stop() {
|
|
66
|
+
this._buckets = {};
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
exports.BucketedRateLimiter = __webpack_exports__.BucketedRateLimiter;
|
|
@@ -1,41 +1,41 @@
|
|
|
1
1
|
import { clampToRange } from "./number-utils.mjs";
|
|
2
|
+
const ONE_DAY_IN_MS = 86400000;
|
|
2
3
|
class BucketedRateLimiter {
|
|
3
|
-
constructor(
|
|
4
|
-
this._options = _options;
|
|
4
|
+
constructor(options){
|
|
5
5
|
this._buckets = {};
|
|
6
|
-
this.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
else this._setBucket(key, newTokens);
|
|
11
|
-
});
|
|
12
|
-
};
|
|
13
|
-
this._getBucket = (key)=>this._buckets[String(key)];
|
|
14
|
-
this._setBucket = (key, value)=>{
|
|
15
|
-
this._buckets[String(key)] = value;
|
|
16
|
-
};
|
|
17
|
-
this.consumeRateLimit = (key)=>{
|
|
18
|
-
let tokens = this._getBucket(key) ?? this._bucketSize;
|
|
19
|
-
tokens = Math.max(tokens - 1, 0);
|
|
20
|
-
if (0 === tokens) return true;
|
|
21
|
-
this._setBucket(key, tokens);
|
|
22
|
-
const hasReachedZero = 0 === tokens;
|
|
23
|
-
if (hasReachedZero) this._onBucketRateLimited?.(key);
|
|
24
|
-
return hasReachedZero;
|
|
25
|
-
};
|
|
26
|
-
this._onBucketRateLimited = this._options._onBucketRateLimited;
|
|
27
|
-
this._bucketSize = clampToRange(this._options.bucketSize, 0, 100, this._options._logger);
|
|
28
|
-
this._refillRate = clampToRange(this._options.refillRate, 0, this._bucketSize, this._options._logger);
|
|
29
|
-
this._refillInterval = clampToRange(this._options.refillInterval, 0, 86400000, this._options._logger);
|
|
30
|
-
this._removeInterval = setInterval(()=>{
|
|
31
|
-
this._refillBuckets();
|
|
32
|
-
}, this._refillInterval);
|
|
6
|
+
this._onBucketRateLimited = options._onBucketRateLimited;
|
|
7
|
+
this._bucketSize = clampToRange(options.bucketSize, 0, 100, options._logger);
|
|
8
|
+
this._refillRate = clampToRange(options.refillRate, 0, this._bucketSize, options._logger);
|
|
9
|
+
this._refillInterval = clampToRange(options.refillInterval, 0, ONE_DAY_IN_MS, options._logger);
|
|
33
10
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
11
|
+
_applyRefill(bucket, now) {
|
|
12
|
+
const elapsedMs = now - bucket.lastAccess;
|
|
13
|
+
const refillIntervals = Math.floor(elapsedMs / this._refillInterval);
|
|
14
|
+
if (refillIntervals > 0) {
|
|
15
|
+
const tokensToAdd = refillIntervals * this._refillRate;
|
|
16
|
+
bucket.tokens = Math.min(bucket.tokens + tokensToAdd, this._bucketSize);
|
|
17
|
+
bucket.lastAccess = bucket.lastAccess + refillIntervals * this._refillInterval;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
consumeRateLimit(key) {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const keyStr = String(key);
|
|
23
|
+
let bucket = this._buckets[keyStr];
|
|
24
|
+
if (bucket) this._applyRefill(bucket, now);
|
|
25
|
+
else {
|
|
26
|
+
bucket = {
|
|
27
|
+
tokens: this._bucketSize,
|
|
28
|
+
lastAccess: now
|
|
29
|
+
};
|
|
30
|
+
this._buckets[keyStr] = bucket;
|
|
38
31
|
}
|
|
32
|
+
if (0 === bucket.tokens) return true;
|
|
33
|
+
bucket.tokens--;
|
|
34
|
+
if (0 === bucket.tokens) this._onBucketRateLimited?.(key);
|
|
35
|
+
return 0 === bucket.tokens;
|
|
36
|
+
}
|
|
37
|
+
stop() {
|
|
38
|
+
this._buckets = {};
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
export { BucketedRateLimiter };
|
package/package.json
CHANGED
|
@@ -19,15 +19,272 @@ describe('BucketedRateLimiter', () => {
|
|
|
19
19
|
jest.clearAllMocks()
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
describe('basic consumption', () => {
|
|
23
|
+
test('it is not rate limited by default', () => {
|
|
24
|
+
const result = rateLimiter.consumeRateLimit('ResizeObserver')
|
|
25
|
+
expect(result).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('returns bucket is rate limited if no mutations are left', () => {
|
|
29
|
+
rateLimiter['_buckets']['ResizeObserver'] = { tokens: 0, lastAccess: Date.now() }
|
|
30
|
+
|
|
31
|
+
const result = rateLimiter.consumeRateLimit('ResizeObserver')
|
|
32
|
+
expect(result).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test.each([
|
|
36
|
+
{ bucketSize: 1, consumptions: 1 },
|
|
37
|
+
{ bucketSize: 5, consumptions: 5 },
|
|
38
|
+
{ bucketSize: 10, consumptions: 10 },
|
|
39
|
+
{ bucketSize: 50, consumptions: 50 },
|
|
40
|
+
])('exhausts bucket of size $bucketSize after $consumptions consumptions', ({ bucketSize, consumptions }) => {
|
|
41
|
+
const limiter = new BucketedRateLimiter({
|
|
42
|
+
bucketSize,
|
|
43
|
+
refillRate: 1,
|
|
44
|
+
refillInterval: 1000,
|
|
45
|
+
_logger: {} as unknown as Logger,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < consumptions - 1; i++) {
|
|
49
|
+
expect(limiter.consumeRateLimit('test')).toBe(false)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
expect(limiter.consumeRateLimit('test')).toBe(true)
|
|
53
|
+
// can check the same bucket more than once
|
|
54
|
+
expect(limiter.consumeRateLimit('test')).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('refill behavior', () => {
|
|
59
|
+
test('refills tokens based on elapsed time', () => {
|
|
60
|
+
const key = 'ResizeObserver'
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < 9; i++) {
|
|
63
|
+
expect(rateLimiter.consumeRateLimit(key)).toBe(false)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expect(rateLimiter.consumeRateLimit(key)).toBe(true)
|
|
67
|
+
|
|
68
|
+
jest.advanceTimersByTime(2000)
|
|
69
|
+
|
|
70
|
+
const result = rateLimiter.consumeRateLimit(key)
|
|
71
|
+
expect(result).toBe(false)
|
|
72
|
+
|
|
73
|
+
const bucket = rateLimiter['_buckets'][key]
|
|
74
|
+
expect(bucket.tokens).toBe(1)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('refills to bucket size maximum', () => {
|
|
78
|
+
const key = 'ResizeObserver'
|
|
79
|
+
rateLimiter.consumeRateLimit(key)
|
|
80
|
+
|
|
81
|
+
jest.advanceTimersByTime(20000)
|
|
82
|
+
|
|
83
|
+
rateLimiter.consumeRateLimit(key)
|
|
84
|
+
|
|
85
|
+
const bucket = rateLimiter['_buckets'][key]
|
|
86
|
+
expect(bucket.tokens).toBe(9)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('partial refill intervals do not refill tokens', () => {
|
|
90
|
+
const key = 'test'
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < 9; i++) {
|
|
93
|
+
rateLimiter.consumeRateLimit(key)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
jest.advanceTimersByTime(999)
|
|
97
|
+
|
|
98
|
+
rateLimiter.consumeRateLimit(key)
|
|
99
|
+
expect(rateLimiter['_buckets'][key].tokens).toBe(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test.each([
|
|
103
|
+
{ refillRate: 1, intervals: 1, expected: 9 },
|
|
104
|
+
{ refillRate: 2, intervals: 1, expected: 9 },
|
|
105
|
+
{ refillRate: 1, intervals: 2, expected: 9 },
|
|
106
|
+
{ refillRate: 3, intervals: 1, tokensLeft: 5, expected: 7 },
|
|
107
|
+
{ refillRate: 2, intervals: 2, tokensLeft: 5, expected: 8 },
|
|
108
|
+
])(
|
|
109
|
+
'with rate $refillRate, $intervals intervals, starting at $tokensLeft tokens, ends at $expected',
|
|
110
|
+
({ refillRate, intervals, tokensLeft = 9, expected }) => {
|
|
111
|
+
const limiter = new BucketedRateLimiter({
|
|
112
|
+
bucketSize: 10,
|
|
113
|
+
refillRate,
|
|
114
|
+
refillInterval: 1000,
|
|
115
|
+
_logger: {} as unknown as Logger,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const consumptions = 10 - tokensLeft
|
|
119
|
+
for (let i = 0; i < consumptions; i++) {
|
|
120
|
+
limiter.consumeRateLimit('test')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
jest.advanceTimersByTime(intervals * 1000)
|
|
124
|
+
|
|
125
|
+
limiter.consumeRateLimit('test')
|
|
126
|
+
expect(limiter['_buckets']['test'].tokens).toBe(expected)
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('bucket isolation', () => {
|
|
132
|
+
test('different keys maintain separate buckets', () => {
|
|
133
|
+
const key1 = 'bucket1'
|
|
134
|
+
const key2 = 'bucket2'
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < 9; i++) {
|
|
137
|
+
rateLimiter.consumeRateLimit(key1)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
expect(rateLimiter.consumeRateLimit(key1)).toBe(true)
|
|
141
|
+
expect(rateLimiter.consumeRateLimit(key2)).toBe(false)
|
|
142
|
+
|
|
143
|
+
expect(rateLimiter['_buckets'][key1].tokens).toBe(0)
|
|
144
|
+
expect(rateLimiter['_buckets'][key2].tokens).toBe(9)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('supports both string and number keys', () => {
|
|
148
|
+
const limiter = new BucketedRateLimiter<string | number>({
|
|
149
|
+
bucketSize: 5,
|
|
150
|
+
refillRate: 1,
|
|
151
|
+
refillInterval: 1000,
|
|
152
|
+
_logger: {} as unknown as Logger,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < 4; i++) {
|
|
156
|
+
limiter.consumeRateLimit('string-key')
|
|
157
|
+
limiter.consumeRateLimit(123)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
expect(limiter['_buckets']['string-key'].tokens).toBe(1)
|
|
161
|
+
expect(limiter['_buckets']['123'].tokens).toBe(1)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('callback behavior', () => {
|
|
166
|
+
test('invokes callback when bucket reaches zero', () => {
|
|
167
|
+
const callback = jest.fn()
|
|
168
|
+
const limiter = new BucketedRateLimiter({
|
|
169
|
+
bucketSize: 3,
|
|
170
|
+
refillRate: 1,
|
|
171
|
+
refillInterval: 1000,
|
|
172
|
+
_logger: {} as unknown as Logger,
|
|
173
|
+
_onBucketRateLimited: callback,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
limiter.consumeRateLimit('test')
|
|
177
|
+
limiter.consumeRateLimit('test')
|
|
178
|
+
expect(callback).not.toHaveBeenCalled()
|
|
179
|
+
|
|
180
|
+
limiter.consumeRateLimit('test')
|
|
181
|
+
expect(callback).toHaveBeenCalledWith('test')
|
|
182
|
+
expect(callback).toHaveBeenCalledTimes(1)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('does not invoke callback for subsequent calls when already at zero', () => {
|
|
186
|
+
const callback = jest.fn()
|
|
187
|
+
const limiter = new BucketedRateLimiter({
|
|
188
|
+
bucketSize: 2,
|
|
189
|
+
refillRate: 1,
|
|
190
|
+
refillInterval: 1000,
|
|
191
|
+
_logger: {} as unknown as Logger,
|
|
192
|
+
_onBucketRateLimited: callback,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
limiter.consumeRateLimit('test')
|
|
196
|
+
limiter.consumeRateLimit('test')
|
|
197
|
+
expect(callback).toHaveBeenCalledTimes(1)
|
|
198
|
+
|
|
199
|
+
limiter.consumeRateLimit('test')
|
|
200
|
+
limiter.consumeRateLimit('test')
|
|
201
|
+
expect(callback).toHaveBeenCalledTimes(1)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('invokes callback again after refill and re-exhaustion', () => {
|
|
205
|
+
const callback = jest.fn()
|
|
206
|
+
const limiter = new BucketedRateLimiter({
|
|
207
|
+
bucketSize: 2,
|
|
208
|
+
refillRate: 1,
|
|
209
|
+
refillInterval: 1000,
|
|
210
|
+
_logger: {} as unknown as Logger,
|
|
211
|
+
_onBucketRateLimited: callback,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
limiter.consumeRateLimit('test')
|
|
215
|
+
limiter.consumeRateLimit('test')
|
|
216
|
+
expect(callback).toHaveBeenCalledTimes(1)
|
|
217
|
+
|
|
218
|
+
jest.advanceTimersByTime(2000)
|
|
219
|
+
|
|
220
|
+
limiter.consumeRateLimit('test')
|
|
221
|
+
limiter.consumeRateLimit('test')
|
|
222
|
+
expect(callback).toHaveBeenCalledTimes(2)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('stop method', () => {
|
|
227
|
+
test('clears all buckets', () => {
|
|
228
|
+
rateLimiter.consumeRateLimit('key1')
|
|
229
|
+
rateLimiter.consumeRateLimit('key2')
|
|
230
|
+
|
|
231
|
+
expect(Object.keys(rateLimiter['_buckets']).length).toBe(2)
|
|
232
|
+
|
|
233
|
+
rateLimiter.stop()
|
|
234
|
+
|
|
235
|
+
expect(Object.keys(rateLimiter['_buckets']).length).toBe(0)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('resets state after stop', () => {
|
|
239
|
+
for (let i = 0; i < 9; i++) {
|
|
240
|
+
rateLimiter.consumeRateLimit('test')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
rateLimiter.stop()
|
|
244
|
+
|
|
245
|
+
expect(rateLimiter.consumeRateLimit('test')).toBe(false)
|
|
246
|
+
expect(rateLimiter['_buckets']['test'].tokens).toBe(9)
|
|
247
|
+
})
|
|
25
248
|
})
|
|
26
249
|
|
|
27
|
-
|
|
28
|
-
|
|
250
|
+
describe('timestamp tracking', () => {
|
|
251
|
+
test('preserves fractional intervals', () => {
|
|
252
|
+
const key = 'test'
|
|
253
|
+
const startTime = Date.now()
|
|
254
|
+
|
|
255
|
+
rateLimiter.consumeRateLimit(key)
|
|
256
|
+
expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime)
|
|
257
|
+
expect(rateLimiter['_buckets'][key].tokens).toBe(9)
|
|
258
|
+
|
|
259
|
+
jest.advanceTimersByTime(500)
|
|
260
|
+
|
|
261
|
+
rateLimiter.consumeRateLimit(key)
|
|
262
|
+
expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime)
|
|
263
|
+
expect(rateLimiter['_buckets'][key].tokens).toBe(8)
|
|
29
264
|
|
|
30
|
-
|
|
31
|
-
|
|
265
|
+
jest.advanceTimersByTime(600)
|
|
266
|
+
|
|
267
|
+
rateLimiter.consumeRateLimit(key)
|
|
268
|
+
expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime + 1000)
|
|
269
|
+
expect(rateLimiter['_buckets'][key].tokens).toBe(8)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('advances lastAccess by complete intervals on refill', () => {
|
|
273
|
+
const key = 'test'
|
|
274
|
+
const startTime = Date.now()
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < 9; i++) {
|
|
277
|
+
rateLimiter.consumeRateLimit(key)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime)
|
|
281
|
+
|
|
282
|
+
jest.advanceTimersByTime(2500)
|
|
283
|
+
|
|
284
|
+
rateLimiter.consumeRateLimit(key)
|
|
285
|
+
|
|
286
|
+
expect(rateLimiter['_buckets'][key].lastAccess).toBe(startTime + 2000)
|
|
287
|
+
expect(rateLimiter['_buckets'][key].tokens).toBe(2)
|
|
288
|
+
})
|
|
32
289
|
})
|
|
33
290
|
})
|
|
@@ -1,85 +1,67 @@
|
|
|
1
1
|
import { Logger } from '../types'
|
|
2
2
|
import { clampToRange } from './number-utils'
|
|
3
3
|
|
|
4
|
+
type Bucket = { tokens: number; lastAccess: number }
|
|
5
|
+
const ONE_DAY_IN_MS = 86400000
|
|
6
|
+
|
|
4
7
|
export class BucketedRateLimiter<T extends string | number> {
|
|
5
|
-
private _bucketSize
|
|
6
|
-
private _refillRate
|
|
7
|
-
private _refillInterval
|
|
8
|
+
private _bucketSize: number
|
|
9
|
+
private _refillRate: number
|
|
10
|
+
private _refillInterval: number
|
|
8
11
|
private _onBucketRateLimited?: (key: T) => void
|
|
12
|
+
private _buckets: Record<string, Bucket> = {}
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
) {
|
|
22
|
-
this._onBucketRateLimited = this._options._onBucketRateLimited
|
|
23
|
-
this._bucketSize = clampToRange(this._options.bucketSize, 0, 100, this._options._logger)
|
|
24
|
-
this._refillRate = clampToRange(
|
|
25
|
-
this._options.refillRate,
|
|
26
|
-
0,
|
|
27
|
-
this._bucketSize, // never refill more than bucket size
|
|
28
|
-
this._options._logger
|
|
29
|
-
)
|
|
30
|
-
this._refillInterval = clampToRange(
|
|
31
|
-
this._options.refillInterval,
|
|
32
|
-
0,
|
|
33
|
-
86400000, // one day in milliseconds
|
|
34
|
-
this._options._logger
|
|
35
|
-
)
|
|
36
|
-
this._removeInterval = setInterval(() => {
|
|
37
|
-
this._refillBuckets()
|
|
38
|
-
}, this._refillInterval)
|
|
14
|
+
constructor(options: {
|
|
15
|
+
bucketSize: number
|
|
16
|
+
refillRate: number
|
|
17
|
+
refillInterval: number
|
|
18
|
+
_logger: Logger
|
|
19
|
+
_onBucketRateLimited?: (key: T) => void
|
|
20
|
+
}) {
|
|
21
|
+
this._onBucketRateLimited = options._onBucketRateLimited
|
|
22
|
+
this._bucketSize = clampToRange(options.bucketSize, 0, 100, options._logger)
|
|
23
|
+
this._refillRate = clampToRange(options.refillRate, 0, this._bucketSize, options._logger)
|
|
24
|
+
this._refillInterval = clampToRange(options.refillInterval, 0, ONE_DAY_IN_MS, options._logger)
|
|
39
25
|
}
|
|
40
26
|
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
|
|
27
|
+
private _applyRefill(bucket: Bucket, now: number): void {
|
|
28
|
+
const elapsedMs = now - bucket.lastAccess
|
|
29
|
+
const refillIntervals = Math.floor(elapsedMs / this._refillInterval)
|
|
44
30
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
})
|
|
31
|
+
if (refillIntervals > 0) {
|
|
32
|
+
const tokensToAdd = refillIntervals * this._refillRate
|
|
33
|
+
bucket.tokens = Math.min(bucket.tokens + tokensToAdd, this._bucketSize)
|
|
34
|
+
bucket.lastAccess = bucket.lastAccess + refillIntervals * this._refillInterval
|
|
35
|
+
}
|
|
51
36
|
}
|
|
52
37
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
private _setBucket = (key: T | string, value: number) => {
|
|
57
|
-
this._buckets[String(key)] = value
|
|
58
|
-
}
|
|
38
|
+
public consumeRateLimit(key: T): boolean {
|
|
39
|
+
const now = Date.now()
|
|
40
|
+
const keyStr = String(key)
|
|
59
41
|
|
|
60
|
-
|
|
61
|
-
let tokens = this._getBucket(key) ?? this._bucketSize
|
|
62
|
-
tokens = Math.max(tokens - 1, 0)
|
|
42
|
+
let bucket = this._buckets[keyStr]
|
|
63
43
|
|
|
64
|
-
if (
|
|
65
|
-
|
|
44
|
+
if (!bucket) {
|
|
45
|
+
bucket = { tokens: this._bucketSize, lastAccess: now }
|
|
46
|
+
this._buckets[keyStr] = bucket
|
|
47
|
+
} else {
|
|
48
|
+
this._applyRefill(bucket, now)
|
|
66
49
|
}
|
|
67
50
|
|
|
68
|
-
|
|
51
|
+
if (bucket.tokens === 0) {
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
69
54
|
|
|
70
|
-
|
|
55
|
+
bucket.tokens--
|
|
71
56
|
|
|
72
|
-
if (
|
|
57
|
+
if (bucket.tokens === 0) {
|
|
73
58
|
this._onBucketRateLimited?.(key)
|
|
74
59
|
}
|
|
75
60
|
|
|
76
|
-
return
|
|
61
|
+
return bucket.tokens === 0
|
|
77
62
|
}
|
|
78
63
|
|
|
79
|
-
public stop() {
|
|
80
|
-
|
|
81
|
-
clearInterval(this._removeInterval)
|
|
82
|
-
this._removeInterval = undefined
|
|
83
|
-
}
|
|
64
|
+
public stop(): void {
|
|
65
|
+
this._buckets = {}
|
|
84
66
|
}
|
|
85
67
|
}
|