UNPKG

rate-limiter-flexible

Version:

Node.js atomic and non-atomic counters, rate limiting tools, protection from DoS and brute-force attacks at scale

163 lines (145 loc) 5.48 kB
const RateLimiterQueueError = require('./component/RateLimiterQueueError') const MAX_QUEUE_SIZE = 4294967295; const KEY_DEFAULT = 'limiter'; module.exports = class RateLimiterQueue { constructor(limiterFlexible, opts = {}) { const maxQueueSize = opts.maxQueueSize !== undefined ? opts.maxQueueSize : MAX_QUEUE_SIZE; this._queueLimiters = { KEY_DEFAULT: new RateLimiterQueueInternal(limiterFlexible, { ...opts, maxQueueSize, key: KEY_DEFAULT }), }; this._limiterFlexible = limiterFlexible; this._maxQueueSize = maxQueueSize; } getTokensRemaining(key = KEY_DEFAULT) { if (this._queueLimiters[key]) { return this._queueLimiters[key].getTokensRemaining() } else { return Promise.resolve(this._limiterFlexible.points) } } removeTokens(tokens, key = KEY_DEFAULT, expiresUnixAt = 0) { if (!this._queueLimiters[key]) { this._queueLimiters[key] = new RateLimiterQueueInternal( this._limiterFlexible, { key, maxQueueSize: this._maxQueueSize, }) } return this._queueLimiters[key].removeTokens(tokens, expiresUnixAt) } }; class RateLimiterQueueInternal { constructor(limiterFlexible, opts = { maxQueueSize: MAX_QUEUE_SIZE, key: KEY_DEFAULT, }) { this._key = opts.key; this._waitTimeout = null; this._queue = []; this._limiterFlexible = limiterFlexible; // Set to true once a request carrying an expiration deadline // (expiresUnixAt > 0) has been queued. While false, _processFIFO skips the // expiry sweep entirely, so projects that never pass expiresUnixAt pay no // extra cost. It is only ever set, never cleared by the sweep (see // _processFIFO for why clearing it would be unsafe). this._hasExpiringRequests = false; this._maxQueueSize = opts.maxQueueSize } getTokensRemaining() { return this._limiterFlexible.get(this._key) .then((rlRes) => { return rlRes !== null ? rlRes.remainingPoints : this._limiterFlexible.points; }) } removeTokens(tokens, expiresUnixAt = 0) { const _this = this; return new Promise((resolve, reject) => { if (tokens > _this._limiterFlexible.points) { reject(new RateLimiterQueueError(`Requested tokens ${tokens} exceeds maximum ${_this._limiterFlexible.points} tokens per interval`)); return } if (_this._queue.length > 0) { _this._queueRequest.call(_this, resolve, reject, tokens, expiresUnixAt); } else { _this._limiterFlexible.consume(_this._key, tokens) .then((res) => { resolve(res.remainingPoints); }) .catch((rej) => { if (rej instanceof Error) { reject(rej); } else { _this._queueRequest.call(_this, resolve, reject, tokens, expiresUnixAt); if (_this._waitTimeout === null) { _this._waitTimeout = setTimeout(_this._processFIFO.bind(_this), rej.msBeforeNext); } } }); } }) } _queueRequest(resolve, reject, tokens, expiresUnixAt = 0) { const _this = this; if (_this._queue.length < _this._maxQueueSize) { _this._queue.push({resolve, reject, tokens, expiresUnixAt}); if (expiresUnixAt > 0) { _this._hasExpiringRequests = true; } } else { reject(new RateLimiterQueueError(`Number of requests reached it's maximum ${_this._maxQueueSize}`)) } } _processFIFO() { const _this = this; if (_this._waitTimeout !== null) { clearTimeout(_this._waitTimeout); _this._waitTimeout = null; } // Reject any queued requests that have reached their expiration deadline // (expiresUnixAt, in Unix seconds) before they could be fulfilled. The // sweep is skipped until a request with a deadline has been queued, so // projects that never pass expiresUnixAt keep the original O(1) cost here. // // The flag is deliberately only ever set, never cleared from a snapshot of // _queue: while a request is being consumed it is momentarily shift()ed out // of _queue and may be unshift()ed back by the rate-limit retry path below // (without going through _queueRequest). Clearing the flag from a snapshot // that excludes such an in-flight request could strand it with the sweep // permanently disabled, so it would never expire. if (_this._hasExpiringRequests) { const nowSecs = Math.floor(Date.now() / 1000); _this._queue = _this._queue.filter((item) => { if (item.expiresUnixAt > 0 && nowSecs >= item.expiresUnixAt) { item.reject(new RateLimiterQueueError('The request to remove tokens expired before it could be fulfilled')); return false; } return true; }); } if (_this._queue.length === 0) { return; } const item = _this._queue.shift(); _this._limiterFlexible.consume(_this._key, item.tokens) .then((res) => { item.resolve(res.remainingPoints); _this._processFIFO.call(_this); }) .catch((rej) => { if (rej instanceof Error) { item.reject(rej); _this._processFIFO.call(_this); } else { _this._queue.unshift(item); if (_this._waitTimeout === null) { _this._waitTimeout = setTimeout(_this._processFIFO.bind(_this), rej.msBeforeNext); } } }); } }