limitation
Version:
An efficient rate limiter with several backend, including a Kademlia DHT.
256 lines (224 loc) • 7.83 kB
JavaScript
/**
* Kademlia DHT based rate limiter.
*
* Features:
* - Checks are cheap in-memory operations.
* - Distributes counters across many nodes, using the Kademlia DHT.
* - Supports multiple limits per key.
* - Configurable bursting and update scaling via `interval` option.
*/
;
var events = require('events');
var util = require('util');
var P = require('bluebird');
var MemoryBackend = require('./lib/memory_backend');
var KadBackend = require('./lib/kad_backend');
/**
* Limitation constructor
*
* @param {object} options:
* - `listen`: {object} describing the local interface to listen on. Default:
* `{ address: 'localhost', port: 3050 }`. If this port is used, a random
* port is used instead.
* - `seeds`: {[object]} describing seeds nodes, containing `port` and
* `address` string properties
* - `interval`: Update interval in ms. Default: 10000ms. Longer intervals
* reduce load, but also increase detection latency.
* - `minValue`: Drop global counters below this value. Default: 0.1.
*/
function Limitation(options) {
events.EventEmitter(this);
this._options = options = options || {};
if (options.interval === undefined) {
options.interval = 10000;
}
// Local counters. Contain objects with `value` and `limits` properties.
this._counters = {};
this._blocks = {};
this._end = false;
this._globalUpdatesTimeout = null;
if (!options.seeds || !options.seeds.length) {
// Single-node operation
this._store = new MemoryBackend(options);
} else if (options.seeds && options.seeds.length) {
this._store = new KadBackend(options);
}
}
util.inherits(Limitation, events.EventEmitter);
/**
* Synchronous limit check
*
* @param {string} key
* @param {number} limit
* @param {number} increment, default 1
* @return {boolean}: `true` if the request rate is below the limit, `false`
* if the limit is exceeded.
*/
Limitation.prototype.isAboveLimit = function(key, limit, increment) {
var counter = this._counters[key];
if (!counter) {
counter = this._counters[key] = {
value: 0,
limits: {},
};
}
counter.value += increment || 1;
counter.limits[limit] = counter.limits[limit] || Date.now();
if (this._blocks[key]) {
return this._blocks[key].value > limit;
} else {
return false;
}
};
/**
* Set up / connect the limiter.
* @returns {P<Limitation>
*/
Limitation.prototype.setup = function() {
var self = this;
return self._store.setup()
.then(function(store) {
self._store = store;
// Start periodic global updates
self._globalUpdatesTimeout = setTimeout(function() {
return self._globalUpdates();
}, self._getRandomizedInterval(0.5));
return self;
});
};
Limitation.prototype.stop = function() {
this._end = true;
this._store.stop();
clearTimeout(this._globalUpdatesTimeout);
};
/**
* Randomize the configured interval slightly
*/
Limitation.prototype._getRandomizedInterval = function(multiplier) {
var interval = this._options.interval * (multiplier || 1);
return interval + (Math.random() - 0.5) * interval * 0.1;
};
/**
* Report local counts to the global DHT, and update local blocks.
*/
Limitation.prototype._globalUpdates = function() {
var self = this;
// Set up an empty local counter object for the next interval
var lastCounters = self._counters;
self._counters = {};
// New blocks. Only update these after the full iteration.
var newBlocks = {};
// For each local counter, update the DHT & check for limits
var errCount = 0;
return P.map(Object.keys(lastCounters), function(key) {
var counter = lastCounters[key];
return self._store.put(key, counter.value)
.then(function(counterVal) {
counterVal = self._normalizeCounter(counterVal);
var minLimit = Math.min.apply(null, Object.keys(counter.limits));
// console.log('put val', counterVal, minLimit, counter.value);
if (counterVal > minLimit) {
newBlocks[key] = {
value: counterVal,
limits: counter.limits
};
}
})
// Ignore update errors.
.catch(function() {
errCount++;
});
}, { concurrency: 50 })
.then(function() {
return self._updateBlocks(newBlocks);
})
.catch(function(err) {
console.log(err.stack);
})
.finally(function(err) {
self.emit('blocks', self._blocks);
// Schedule the next iteration
if (self._end) { return; }
self._globalUpdatesTimeout = setTimeout(function() {
self._globalUpdates();
}, self._getRandomizedInterval());
});
};
Limitation.prototype._normalizeCounter = function(val) {
val = val || 0;
// Compensate for exponential decay with factor 2, and scale to 1/s rates.
// Bias against false negatives by diving by 2.2 instead of 2.0.
return val / 2.2 / this._options.interval * 1000;
};
/**
* Re-check old blocks against newBlocks and the DHT state.
*
* This method ensures that we keep blocking requests when the global request
* rate is a bit above the limit, even if the local request rates occasionally
* drops below the limit.
*
* @param {object} newBlocks, new blocks based on the put response from local counters.
*/
Limitation.prototype._updateBlocks = function(newBlocks) {
var self = this;
// Stop checking for old limits if they haven't been reached in the last
// 600 seconds.
var maxAge = Date.now() - (600 * 1000);
var asyncChecks = [];
var oldBlocks = this._blocks;
var oldBlockKeys = Object.keys(self._blocks);
// Fast handling for blocks that remain
for (var i = 0; i < oldBlockKeys.length; i++) {
var key = oldBlockKeys[i];
if (newBlocks[key]) {
var newBlockLimits = newBlocks[key].limits;
// Still blocked. See if other limits need to be added.
var oldLimits = self._blocks[key].limits;
var oldLimitKeys = Object.keys(oldLimits);
for (var j = 0; j < oldLimitKeys.length; j++) {
var limit = oldLimitKeys[j];
if (oldLimits[limit] > maxAge && !newBlockLimits[limit]) {
newBlockLimits[limit] = oldLimits[limit];
}
}
} else {
asyncChecks.push(key);
}
}
self._blocks = newBlocks;
// Async re-checks for previous blocks that didn't see any requests in the
// last interval.
return P.map(asyncChecks, function(key) {
var block = oldBlocks[key];
// Only consider
var currentLimits = Object.keys(block.limits)
.filter(function(limit) {
return block.limits[limit] > maxAge;
});
if (!currentLimits.length) {
// Nothing to do.
return;
}
// Need to get the current value
return self._store.get(key)
.then(function(counterVal) {
counterVal = self._normalizeCounter(counterVal);
var limitObj = {};
var curTime = Date.now();
currentLimits.forEach(function(limit) {
if (Number(limit) > counterVal) {
limitObj[limit] = curTime;
} else {
limitObj[limit] = block.limits[limit];
}
});
newBlocks[key] = {
value: counterVal,
limits: limitObj,
};
})
// Ignore individual update errors.
.catch(function() {});
}, { concurrency: 50 });
};
module.exports = Limitation;