-
Notifications
You must be signed in to change notification settings - Fork 14
/
rottle.gs
252 lines (231 loc) · 5.91 KB
/
rottle.gs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
const _checkFunction = (func, fail = true) => {
const t = typeof func;
if (t === "function") return true;
if (fail) throw new Error(`Expected function but got ${t}`);
return false;
};
class RottlerEntry {
constructor() {
const now = new Date().getTime();
this.startedAt = now;
this.lastUsedAt = 0;
this.uses = [];
}
}
// a rate limiter tester
class Rottler {
/**
* A convenience function for conversion to ms from various units
* @param {string} [name='seconds'] the name of the period units - like hours, minutes etc
* @param {number} [value=1] how many
* @return number of ms.
*/
static ms(name = "seconds", value = 1) {
const seconds = 1000;
const minutes = seconds * 60;
const hours = minutes * 60;
const days = hours * 24;
const weeks = days * 7;
return (
{
// convert into ms from these
seconds,
minutes,
hours,
days,
weeks,
// convert from ms to these
msSeconds: 1 / seconds,
msMinutes: 1 / minutes,
msHours: 1 / hours,
msDays: 1 / days,
msWeeks: 1 / weeks,
}[name] * value
);
}
/**
*
* @param {object} options
* @param {number} [options.period = 60000] measure period in ms
* @param {number} [options.rate = 10] how many calls allowed in that period
* @param {number} [options.delay = 5] delay in ms between each call
* @param {function} [options.timeout] the setTimeout function (usuall setTimeout is the default)
* @param {boolean} [options.throwError = true] whether to throw an error on rate limit problem
* @return {Rottler}
*/
constructor({
period = 60 * 1000,
rate = 10,
delay = 5,
timeout,
throwError = true,
} = {}) {
this.period = period;
this.rate = rate;
this.delay = delay;
this._entry = new RottlerEntry();
this.throwError = throwError;
this._events = {
rate: {
name: "rate",
listener: null,
},
delay: {
name: "delay",
listener: null,
},
};
// this is needed because apps script doesnt have a setTimeout
// so a custom timeout can be passed over
this.setTimeout = timeout || setTimeout;
_checkFunction(this.setTimeout);
/**
*
* @param {number} ms number of ms to wait before calling
* @return {Promise} resolves to ms
*/
this.waiter = (ms) =>
new Promise((resolve) => this.setTimeout(() => resolve(ms), ms));
}
/**
* gets the entry
* @return {RottlerEntry}
*/
get entry() {
return this._entry;
}
/**
* clean any uses that have expired from this entry
* @return {RottlerEntry} the cleaned entry
*/
_cleanEntry() {
// they'll be sorted in ascending order
const entry = this.entry;
const expired = this._now() - Math.max(this.period, this.delay);
entry.uses = entry.uses.filter((f) => f > expired);
return entry;
}
/**
* @return {number} the time now
*/
_now() {
return new Date().getTime();
}
/**
* how many ms since last attempt
* @return {number} ms since last time
*/
sinceLast() {
return this.entry.lastUsedAt
? this._now() - this.entry.lastUsedAt
: Infinity;
}
/**
* is it too soon to do another?
* @return {boolean} whether its too soon
*/
tooSoon() {
return this.sinceLast() < this.delay;
}
/**
* how many have been done in period
* @return {number} number in period
*/
size() {
return this.entry.uses.length;
}
/**
* how can we do before end of period
* @return {number} number available in period
*/
available() {
return this.rate - this.size();
}
/**
* how long to wait before we can go again
* @return {number} number available in period
*/
waitTime() {
// how long to wait till next rate becomes available?
const passed = this.sinceLast();
// passed can never be infinity if avaiable > 0
const rateWait = this.available() > 0 ? 0 : this.period - passed;
// how long to wait before delay is expired ?
return this.tooSoon() ? Math.max(this.delay - passed, rateWait) : rateWait;
}
// clear trackers
reset() {
this._entry = new RottlerEntry();
return this.entry;
}
/**
* this can be used to resolve once enough time has passed
* @return {Promise} will be resolved when it's safe to go
*/
rottle() {
return this.waiter(this.waitTime());
}
/**
* like use but promisified
*/
useAsync() {
return new Promise((resolve, reject) => {
try {
resolve(this.use())
}
catch (err) {
reject (err)
}
})
}
/**
* test for quota left and update it if there is some
* @return {RottlerEntry} the entry if there is quota
*/
use() {
const entry = this._cleanEntry();
const now = this._now();
// if there's enough quota, then update it
if (this.available() < 1) {
if (this._events.rate.listener) {
this._events.rate.listener();
}
if (this.throwError) {
throw new Error(
`Rate limit error - attempt to use more than ${this.rate} times in ${this.period}ms`
);
}
} else if (this.waitTime() > 0) {
if (this._events.delay.listener) {
this._events.delay.listener();
}
if (this.throwError) {
throw new Error(
`Rate limit delay error - attempt to use ${this.sinceLast()}ms after last - min delay is ${
this.delay
}ms`
);
}
} else {
entry.uses.push(now);
entry.lastUsedAt = now;
}
return entry;
}
/**
* set listeners
*/
on(name, func) {
if (!this._events[name]) {
throw new Error(`event ${name} doesnt exist`);
}
_checkFunction(func);
this._events[name].listener = () => func();
}
off(name) {
if (!this._events[name]) {
throw new Error(`event ${name} doesnt exist`);
}
this._events[name].listener = null;
}
}