LivePersonInc/circuit-breakerjs

View on GitHub
src/CircuitBreaker.js

Summary

Maintainability
C
1 day
Test Coverage
;(function (root, factory) {
    "use strict";

    /* istanbul ignore if */
    //<amd>
    if ("function" === typeof define && define.amd) {

        // AMD. Register as an anonymous module.
        define("CircuitBreaker", ["exports"], function () {
            if (!root.CircuitBreaker) {
                factory(root);
            }

            return root.CircuitBreaker;
        });

        return;
    }
    //</amd>
    /* istanbul ignore else */
    if ("object" === typeof exports) {
        // CommonJS
        factory(exports);
    }
    else {
        factory(root);
    }
}(typeof CircuitRoot === "undefined" ? this : CircuitRoot , function (root) {
    "use strict";

    /*jshint validthis:true */
    /**
     * @type {{OPEN: number, HALF_OPEN: number, CLOSED: number}}
     * State representation for the circuit
     */
    var STATE = {
        OPEN: 0,
        HALF_OPEN: 1,
        CLOSED: 2
    };

    /**
     * @type {{FAILURE: string, SUCCESS: string, TIMEOUT: string, OUTAGE: string}}
     * Measure types for each bucket
     */
    var MEASURE = {
        FAILURE: "failure",
        SUCCESS: "success",
        TIMEOUT: "timeout",
        OUTAGE: "outage"
    };

    /**
     * CircuitBreaker constructor
     * @constructor
     * @param {Object} [options] the configuration options for the instance
     * @param {Number} [options.slidingTimeWindow = 30000] - the time window that will be used for state calculations [milliseconds]
     * @param {Number} [options.bucketsNumber = 10] - the number of the buckets that the time window will be split to (a bucket is a sliding unit that is added/remove from the time window)
     * @param {Number} [options.tolerance = 50] - the tolerance before opening the circuit in percentage
     * @param {Number} [options.calibration = 5] - the calibration of minimum calls before starting to validate measurements [number]
     * @param {Number} [options.timeout = 0] - optional timeout parameter to apply and time the command [number]
     * @param {Function} [options.onopen] - handler for open
     * @param {Function} [options.onclose] - handler for close
     */
    function CircuitBreaker(options) {
        // For forcing new keyword
        if (false === (this instanceof CircuitBreaker)) {
            return new CircuitBreaker(options);
        }

        this.initialize(options);
    }

    CircuitBreaker.prototype = (function () {
        /**
         * Method for initialization
         * @param {Object} [options] the configuration options for the instance
         * @param {Number} [options.slidingTimeWindow = 30000] - the time window that will be used for state calculations [milliseconds]
         * @param {Number} [options.bucketsNumber = 10] - the number of the buckets that the time window will be split to (a bucket is a sliding unit that is added/remove from the time window)
         * @param {Number} [options.tolerance = 50] - the tolerance before opening the circuit in percentage
         * @param {Number} [options.calibration = 5] - the calibration of minimum calls before starting to validate measurements [number]
         * @param {Number} [options.timeout = 0] - optional timeout parameter to apply and time the command [number]
         * @param {Function} [options.onopen] - handler for open
         * @param {Function} [options.onclose] - handler for close
         */
        function initialize(options) {
            if (!this.initialized) {
                options = options || {};

                this.slidingTimeWindow = !isNaN(options.slidingTimeWindow) && 0 < options.slidingTimeWindow ? parseInt(options.slidingTimeWindow, 10) : 30000;
                this.bucketsNumber = !isNaN(options.bucketsNumber) && 0 < options.bucketsNumber ? parseInt(options.bucketsNumber, 10) : 10;
                this.tolerance = !isNaN(options.tolerance) && 0 < options.tolerance ? parseInt(options.tolerance, 10) : 50;
                this.calibration = !isNaN(options.calibration) && 0 < options.calibration ? parseInt(options.calibration, 10) : 5;
                this.timeout = !isNaN(options.timeout) && 0 < options.timeout ? parseInt(options.timeout, 10) : 0;
                this.onopen = ("function" === typeof options.onopen) ? options.onopen : function() {};
                this.onclose = ("function" === typeof options.onclose) ? options.onclose : function() {};
                this.buckets = [_createBucket.call(this)];

                this.state = STATE.CLOSED;
                this.initialized = true;

                _startTicking.call(this);
            }
        }

        /**
         * Method for assigning a defer execution
         * Code waiting for this promise uses this method
         * @param {Function} command - the command to run via the circuit
         * @param {Function} [fallback] - the fallback to run when circuit is opened
         * @param {Function} [timeout] - the timeout for the executed command
         */
        function run(command, fallback, timeout) {
            if (fallback && "function" !== typeof fallback) {
                timeout = fallback;
                fallback = void 0;
            }

            if (isOpen.call(this)) {
                _fallback.call(this, fallback || function() {});
                return false;
            }
            else {
                return _execute.call(this, command, timeout);
            }
        }

        /**
         * Method for forcing the circuit to open
         */
        function open() {
            this.forced = this.state;
            this.state = STATE.OPEN;
        }

        /**
         * Method for forcing the circuit to close
         */
        function close() {
            this.forced = this.state;
            this.state = STATE.CLOSED;
        }

        /**
         * Method for resetting the forcing
         */
        function reset() {
            this.state = this.forced;
            this.forced = void 0;
        }

        /**
         * Method for checking whether the circuit is open
         */
        function isOpen() {
            return STATE.OPEN === this.state;
        }

        /**
         * Method for calculating the needed metrics based on all calculation buckets
         */
        function calculate() {
            var bucketErrors;
            var percent;
            var total = 0;
            var error = 0;


            for (var i = 0; i < this.buckets.length; i++) {
                bucketErrors = (this.buckets[i][MEASURE.FAILURE] + this.buckets[i][MEASURE.TIMEOUT]);
                error += bucketErrors;
                total += bucketErrors + this.buckets[i][MEASURE.SUCCESS];
            }

            percent = (error / (total > 0 ? total : 1)) * 100;

            return {
                total: total,
                error: error,
                percent: percent
            };
        }

        /**
         * Method for the timer tick which manages the buckets
         * @private
         */
        function _tick() {
            if (this.timer) {
                clearTimeout(this.timer);
            }

            _createNextSlidingBucket.call(this);

            if (this.bucketIndex > this.bucketsNumber) {
                this.bucketIndex = 0;

                if (isOpen.call(this)) {
                    this.state = STATE.HALF_OPEN;
                }
            }

            this.timer = setTimeout(_tick.bind(this), this.bucket);
        }

        /**
         * Method for starting the timer and creating the metrics buckets for calculations
         * @private
         */
        function _startTicking() {
            this.bucketIndex = 0;
            this.bucket = this.slidingTimeWindow / this.bucketsNumber;

            if (this.timer) {
                clearTimeout(this.timer);
            }

            this.timer = setTimeout(_tick.bind(this), this.bucket);
        }

        /**
         * Method for creating a single metrics bucket for calculations
         * @private
         */
        function _createBucket() {
            var bucket = {};

            bucket[MEASURE.FAILURE] = 0;
            bucket[MEASURE.SUCCESS] = 0;
            bucket[MEASURE.TIMEOUT] = 0;
            bucket[MEASURE.OUTAGE] = 0;

            return bucket;
        }

        /**
         * Method for retrieving the last metrics bucket for calculations
         * @private
         */
        function _getLastBucket() {
            return this.buckets[this.buckets.length - 1];
        }

        /**
         * Method for creating the next bucket and removing the first bucket in case we got to the needed buckets number
         * @private
         */
        function _createNextSlidingBucket() {
            this.bucketIndex++;

            this.buckets.push(_createBucket.call(this));

            if (this.buckets.length > this.bucketsNumber) {
                this.buckets.shift();
            }
        }

        /**
         * Method for adding a calculation measure for a command
         * @param {CircuitBreaker.MEASURE} prop - the measurement property (success, error, timeout)
         * @param {Object} status - the status of the command (A single command can only be resolved once and represent a single measurement)
         * @private
         */
        function _measure(prop, status) {
            return function() {
                if (status.done) {
                    return;
                }
                else if (status.timer) {
                    clearTimeout(status.timer);
                    status.timer = null;
                    delete status.timer;
                }

                var bucket = _getLastBucket.call(this);
                bucket[prop]++;

                if (!this.forced) {
                    _updateState.call(this);
                }

                status.done = true;
            }.bind(this);
        }

        /**
         * Method for executing a command via the circuit and counting the needed metrics
         * @param {Function} command - the command to run via the circuit
         * @param {Number} timeout - optional timeout for the command
         * @private
         */
        function _execute(command, timeout) {
            var status = {
                done: false
            };
            var markSuccess = _measure.call(this, MEASURE.SUCCESS, status);
            var markFailure = _measure.call(this, MEASURE.FAILURE, status);
            var markTimeout = _measure.call(this, MEASURE.TIMEOUT, status);

            timeout = !isNaN(timeout) && 0 < timeout ? parseInt(timeout, 10) : this.timeout;

            if (0 < timeout) {
                status.timer = setTimeout(markTimeout, timeout);
            }

            try {
                command(markSuccess, markFailure, markTimeout);
            }
            catch(ex) {
                // TODO: Deal with errors
                markFailure();
            }
        }

        /**
         * Method for executing a command fallback via the circuit and counting the needed metrics
         * @param {Function} fallback - the command fallback to run via the circuit
         * @private
         */
        function _fallback(fallback) {
            try {
                fallback();
            }
            catch(ex) {
                // TODO: Deal with errors
            }

            var bucket = _getLastBucket.call(this);
            bucket[MEASURE.OUTAGE]++;
        }

        /**
         * Method for updating the circuit state based on the last command or existing metrics
         * @private
         */
        function _updateState() {
            var metrics = calculate.call(this);

            if (STATE.HALF_OPEN === this.state) {
                var lastCommandFailed = !_getLastBucket.call(this)[MEASURE.SUCCESS] && 0 < metrics.error;

                if (lastCommandFailed) {
                    this.state = STATE.OPEN;
                }
                else {
                    this.state = STATE.CLOSED;
                    this.onclose(metrics);
                }
            }
            else {
                var toleranceDeviation = metrics.percent > this.tolerance;
                var calibrationDeviation = metrics.total > this.calibration;
                var deviation = calibrationDeviation && toleranceDeviation;

                if (deviation) {
                    this.state = STATE.OPEN;
                    this.onopen(metrics);
                }
            }
        }

        return {
            initialize: initialize,
            run: run,
            close: close,
            open: open,
            reset: reset,
            isOpen: isOpen,
            calculate: calculate
        };
    }());

    /**
     * @type {{OPEN: number, HALF_OPEN: number, CLOSED: number}}
     * State representation for the circuit
     */
    CircuitBreaker.STATE = STATE;

    /**
     * Method to polyfill bind native functionality in case it does not exist
     * Based on implementation from:
     * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
     * @param {Object} object - the object to bind to
     * @returns {Function} the bound function
     */
    /* istanbul ignore next */
    function bind(object) {
        /*jshint validthis:true */
        var args;
        var fn;

        if ("function" !== typeof this) {
            // Closest thing possible to the ECMAScript 5
            // Internal IsCallable function
            throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
        }

        args = Array.prototype.slice.call(arguments, 1);
        fn = this;

        function Empty() {}

        function bound() {
            return fn.apply(this instanceof Empty && object ? this : object,
                args.concat(Array.prototype.slice.call(arguments)));
        }

        Empty.prototype = this.prototype;
        bound.prototype = new Empty();

        return bound;
    }

    /* istanbul ignore if  */
    if (!Function.prototype.bind) {
        Function.prototype.bind = bind;
    }

    // attach properties to the exports object to define
    // the exported module properties.
    root.CircuitBreaker = root.CircuitBreaker || CircuitBreaker;
}));