dynatrace-api-balancer
Version:
A wrapper around Axios that balances and throttles requests across tenants, clusters and cluster nodes.
118 lines (106 loc) • 4.37 kB
JavaScript
'use strict';
/**
* The Throttle rate-limits access to a resource over a moving time window
* using a 'leaky bucket' algorithm.
*/
class Throttle {
#queue = [];
#size = null;
#rate = null;
#fill = null;
#last = null;
/**
* Resolves next promise in the queue and keeps emptying it.
* @private
*/
#next = () => {
if (this.#queue.length === 0) return;
setInterval(this.#next, this.waitTime); // Keep emptying the queue.
this.#fill--; // Consume a drop from the bucket.
this.#queue.shift()(); // Call next 'resolve()' in the queue.
}
/**
* Creates a throttle.
* @constructor
* @param {number} limit - Number of requests allowed.
* @param {number} window - Per this time window (in ms).
*/
constructor(limit, window) {
// Window is a timespan (ms) to which the limit appplies.
this.#size = limit; // The bucket size is the maximum requests per timespan.
this.#rate = window / limit; // Drip rate: if limit = 30 req/min, add 1 drop every 2s.
this.#fill = 0; // Number of drops in the bucket.
this.#last = 0; // Time we last added more drops.
}
/** Resets the throttle to maximum capacity. */
reset() {
this.#last = (new Date()).getTime();
this.#fill = this.#size;
}
/**
* Returns how much capacity is left for this time window. This value is useful
* for selecting the least constricted resource among a pool of throttled resources.
*/
get remainder() {
return this.#fill;
}
/**
* Returns the time (ms) until the throttle opens again (plus 1ms).
* Note that this getter just returns the delay - it does not update the throttle.
*/
get nextSlot() {
const now = (new Date()).getTime();
return (this.#last + this.#rate + 1) - now;
}
/**
* Returns the time (ms) until a next request can be honored (plus 1ms if there's a wait).
* This is useful in case multiple throttles need to be checked before a request can be
* consumed. Note that this getter updates the throttle's state before it produces a value.
*/
get waitTime() {
// First refill the bucket proportional to the time elapsed since the last refill.
const now = (new Date()).getTime();
const added = Math.floor((now - this.#last) / this.#rate); // How many drops should be added?
this.#fill = Math.min(this.#size, this.#fill + added); // Don't exceed bucket capacity.
this.#last = Math.min(now, this.#last + (added * this.#rate)); // Update the refill timestamp.
// The bucket has been updated. Return 0 if there are drops, or the time until the next drop drips.
return this.#fill > 0 ? 0 : (this.#last + this.#rate + 1) - now;
}
/**
* Consumes one unit of capacity. Should only be called if {@link Throttle#waitTime waitTime} > 0.
* @example
* function doSomething() {
* const delay = myThrottle.waitTime;
* if (delay > 0)
* return "I can't do this right now, but in " + delay + "ms I can.";
*
* myThrottle.consume();
* // Do it.
* return "I did it";
* }
*/
consume() {
this.#fill--;
}
/**
* Returns a promise that is guaranteed to resolve (in FIFO order), but not sooner
* than the throttle allows. For certain use cases this provides a more convenient
* alternative compared to using the {@link Throttle#waitTime waitTime} and
* {@link Throttle#consume consume()} pair.
* @example
* async function doSomething() {
* await myThrottle.permit(); // Resolves immediately or as soon as possible.
* // Do it.
* return "I did it";
* }
*/
permit() {
return new Promise((resolve, reject) => {
const delay = this.waitTime;
if (delay <= 0) resolve();
this.#queue.push(resolve);
setInterval(this.#next, delay);
});
}
}
module.exports = Throttle;