@d-fischer/rate-limiter
Version:
Rate limit your requests.
164 lines (163 loc) • 7.22 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ResponseBasedRateLimiter = void 0;
const logger_1 = require("@d-fischer/logger");
const shared_utils_1 = require("@d-fischer/shared-utils");
const RateLimitReachedError_1 = require("../errors/RateLimitReachedError");
const RetryAfterError_1 = require("../errors/RetryAfterError");
class ResponseBasedRateLimiter {
constructor({ logger }) {
this._queue = [];
this._batchRunning = false;
this._paused = false;
this._logger = (0, logger_1.createLogger)({ name: 'rate-limiter', emoji: true, ...logger });
}
async request(req, options) {
this._logger.trace('request start');
return await new Promise((resolve, reject) => {
var _a;
const reqSpec = {
req,
resolve,
reject,
limitReachedBehavior: (_a = options === null || options === void 0 ? void 0 : options.limitReachedBehavior) !== null && _a !== void 0 ? _a : 'enqueue'
};
if (this._batchRunning || !!this._nextBatchTimer || this._paused) {
this._logger.trace(`request queued batchRunning:${this._batchRunning.toString()} hasNextBatchTimer:${(!!this
._nextBatchTimer).toString()} paused:${this._paused.toString()}`);
this._queue.push(reqSpec);
}
else {
void this._runRequestBatch([reqSpec]);
}
});
}
clear() {
this._queue = [];
}
pause() {
this._paused = true;
}
resume() {
this._paused = false;
this._runNextBatch();
}
get stats() {
var _a, _b, _c, _d, _e;
return {
lastKnownLimit: (_b = (_a = this._parameters) === null || _a === void 0 ? void 0 : _a.limit) !== null && _b !== void 0 ? _b : null,
lastKnownRemainingRequests: (_d = (_c = this._parameters) === null || _c === void 0 ? void 0 : _c.remaining) !== null && _d !== void 0 ? _d : null,
lastKnownResetDate: (0, shared_utils_1.mapNullable)((_e = this._parameters) === null || _e === void 0 ? void 0 : _e.resetsAt, v => new Date(v))
};
}
async _runRequestBatch(reqSpecs) {
this._logger.trace(`runRequestBatch start specs:${reqSpecs.length}`);
this._batchRunning = true;
if (this._parameters) {
this._logger.debug(`Remaining requests: ${this._parameters.remaining}`);
}
this._logger.debug(`Doing ${reqSpecs.length} requests, new queue length is ${this._queue.length}`);
const promises = reqSpecs.map(async (reqSpec) => {
const { req, resolve, reject } = reqSpec;
try {
const result = await this.doRequest(req);
const retry = this.needsToRetryAfter(result);
if (retry !== null) {
this._queue.unshift(reqSpec);
this._logger.info(`Retrying after ${retry} ms`);
throw new RetryAfterError_1.RetryAfterError(retry);
}
const params = this.getParametersFromResponse(result);
resolve(result);
return params;
}
catch (e) {
if (e instanceof RetryAfterError_1.RetryAfterError) {
throw e;
}
reject(e);
return undefined;
}
});
// downleveling problem hack, see https://github.com/es-shims/Promise.allSettled/issues/5
const settledPromises = await Promise.allSettled(promises);
const rejectedPromises = settledPromises.filter((p) => p.status === 'rejected');
const now = Date.now();
if (rejectedPromises.length) {
this._logger.trace('runRequestBatch some rejected');
const retryAt = Math.max(now, ...rejectedPromises.map((p) => p.reason.retryAt));
const retryAfter = retryAt - now;
this._logger.warn(`Waiting for ${retryAfter} ms because the rate limit was exceeded`);
this._nextBatchTimer = setTimeout(() => {
this._parameters = undefined;
this._runNextBatch();
}, retryAfter);
}
else {
this._logger.trace('runRequestBatch none rejected');
const params = settledPromises
.filter((p) => p.status === 'fulfilled' && p.value !== undefined)
.map(p => p.value)
.reduce((carry, v) => {
if (!carry) {
return v;
}
// return v.resetsAt > carry.resetsAt ? v : carry;
return v.remaining < carry.remaining ? v : carry;
}, undefined);
this._batchRunning = false;
if (params) {
this._parameters = params;
if (params.resetsAt < now || params.remaining > 0) {
this._logger.trace('runRequestBatch canRunMore');
this._runNextBatch();
}
else {
const delay = params.resetsAt - now;
this._logger.trace(`runRequestBatch delay:${delay}`);
this._logger.warn(`Waiting for ${delay} ms because the rate limit was reached`);
this._queue = this._queue.filter(entry => {
switch (entry.limitReachedBehavior) {
case 'enqueue': {
return true;
}
case 'null': {
entry.resolve(null);
return false;
}
case 'throw': {
entry.reject(new RateLimitReachedError_1.RateLimitReachedError('Request removed from queue because the rate limit was reached'));
return false;
}
default: {
throw new Error('this should never happen');
}
}
});
this._nextBatchTimer = setTimeout(() => {
this._parameters = undefined;
this._runNextBatch();
}, delay);
}
}
}
this._logger.trace('runRequestBatch end');
}
_runNextBatch() {
if (this._paused) {
return;
}
this._logger.trace('runNextBatch start');
if (this._nextBatchTimer) {
clearTimeout(this._nextBatchTimer);
this._nextBatchTimer = undefined;
}
const amount = this._parameters ? Math.min(this._parameters.remaining, this._parameters.limit / 10) : 1;
const reqSpecs = this._queue.splice(0, amount);
if (reqSpecs.length) {
void this._runRequestBatch(reqSpecs);
}
this._logger.trace('runNextBatch end');
}
}
exports.ResponseBasedRateLimiter = ResponseBasedRateLimiter;