UNPKG

@graphql-hive/core

Version:
927 lines (926 loc) • 39.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const events_1 = tslib_1.__importDefault(require("events")); const cache_js_1 = tslib_1.__importDefault(require("./cache.js")); const semaphore_js_1 = tslib_1.__importDefault(require("./semaphore.js")); const status_js_1 = tslib_1.__importDefault(require("./status.js")); const STATE = Symbol('state'); const OPEN = Symbol('open'); const CLOSED = Symbol('closed'); const HALF_OPEN = Symbol('half-open'); const PENDING_CLOSE = Symbol('pending-close'); const SHUTDOWN = Symbol('shutdown'); const FALLBACK_FUNCTION = Symbol('fallback'); const STATUS = Symbol('status'); const NAME = Symbol('name'); const GROUP = Symbol('group'); const ENABLED = Symbol('Enabled'); const WARMING_UP = Symbol('warming-up'); const VOLUME_THRESHOLD = Symbol('volume-threshold'); const OUR_ERROR = Symbol('our-error'); const RESET_TIMEOUT = Symbol('reset-timeout'); const WARMUP_TIMEOUT = Symbol('warmup-timeout'); const LAST_TIMER_AT = Symbol('last-timer-at'); const deprecation = `options.maxFailures is deprecated. \ Please use options.errorThresholdPercentage`; /** * Constructs a {@link CircuitBreaker}. * * @class CircuitBreaker * @extends EventEmitter * @param {Function} action The action to fire for this {@link CircuitBreaker} * @param {Object} options Options for the {@link CircuitBreaker} * @param {Status} options.status A {@link Status} object that might * have pre-prime stats * @param {Number} options.timeout The time in milliseconds that action should * be allowed to execute before timing out. Timeout can be disabled by setting * this to `false`. Default 10000 (10 seconds) * @param {Number} options.maxFailures (Deprecated) The number of times the * circuit can fail before opening. Default 10. * @param {Number} options.resetTimeout The time in milliseconds to wait before * setting the breaker to `halfOpen` state, and trying the action again. * Default: 30000 (30 seconds) * @param {Number} options.rollingCountTimeout Sets the duration of the * statistical rolling window, in milliseconds. This is how long Opossum keeps * metrics for the circuit breaker to use and for publishing. Default: 10000 * @param {Number} options.rollingCountBuckets Sets the number of buckets the * rolling statistical window is divided into. So, if * options.rollingCountTimeout is 10000, and options.rollingCountBuckets is 10, * then the statistical window will be 1000/1 second snapshots in the * statistical window. Default: 10 * @param {String} options.name the circuit name to use when reporting stats. * Default: the name of the function this circuit controls. * @param {boolean} options.rollingPercentilesEnabled This property indicates * whether execution latencies should be tracked and calculated as percentiles. * If they are disabled, all summary statistics (mean, percentiles) are * returned as -1. Default: true * @param {Number} options.capacity the number of concurrent requests allowed. * If the number currently executing function calls is equal to * options.capacity, further calls to `fire()` are rejected until at least one * of the current requests completes. Default: `Number.MAX_SAFE_INTEGER`. * @param {Number} options.errorThresholdPercentage the error percentage at * which to open the circuit and start short-circuiting requests to fallback. * Default: 50 * @param {boolean} options.enabled whether this circuit is enabled upon * construction. Default: true * @param {boolean} options.allowWarmUp determines whether to allow failures * without opening the circuit during a brief warmup period (this is the * `rollingCountTimeout` property). Default: false * This can help in situations where no matter what your * `errorThresholdPercentage` is, if the first execution times out or fails, * the circuit immediately opens. * @param {Number} options.volumeThreshold the minimum number of requests within * the rolling statistical window that must exist before the circuit breaker * can open. This is similar to `options.allowWarmUp` in that no matter how many * failures there are, if the number of requests within the statistical window * does not exceed this threshold, the circuit will remain closed. Default: 0 * @param {Function} options.errorFilter an optional function that will be * called when the circuit's function fails (returns a rejected Promise). If * this function returns truthy, the circuit's failPure statistics will not be * incremented. This is useful, for example, when you don't want HTTP 404 to * trip the circuit, but still want to handle it as a failure case. * @param {boolean} options.cache whether the return value of the first * successful execution of the circuit's function will be cached. Once a value * has been cached that value will be returned for every subsequent execution: * the cache can be cleared using `clearCache`. (The metrics `cacheHit` and * `cacheMiss` reflect cache activity.) Default: false * @param {Number} options.cacheTTL the time to live for the cache * in milliseconds. Set 0 for infinity cache. Default: 0 (no TTL) * @param {Number} options.cacheSize the max amount of entries in the internal * cache. Only used when cacheTransport is not defined. * Default: max size of JS map (2^24). * @param {Function} options.cacheGetKey function that returns the key to use * when caching the result of the circuit's fire. * Better to use custom one, because `JSON.stringify` is not good * from performance perspective. * Default: `(...args) => JSON.stringify(args)` * @param {CacheTransport} options.cacheTransport custom cache transport * should implement `get`, `set` and `flush` methods. * @param {boolean} options.coalesce If true, this provides coalescing of * requests to this breaker, in other words: the promise will be cached. * Only one action (with same cache key) is executed at a time, and the other * pending actions wait for the result. Performance will improve when rapidly * firing the circuitbreaker with the same request, especially on a slower * action (e.g. multiple end-users fetching same data from remote). * Will use internal cache only. Can be used in combination with options.cache. * The metrics `coalesceCacheHit` and `coalesceCacheMiss` are available. * Default: false * @param {Number} options.coalesceTTL the time to live for the coalescing * in milliseconds. Set 0 for infinity cache. Default: same as options.timeout * @param {Number} options.coalesceSize the max amount of entries in the * coalescing cache. Default: max size of JS map (2^24). * @param {string[]} options.coalesceResetOn when to reset the coalesce cache. * Options: `error`, `success`, `timeout`. Default: not set, reset using TTL. * @param {AbortController} options.abortController this allows Opossum to * signal upon timeout and properly abort your on going requests instead of * leaving it in the background * @param {boolean} options.enableSnapshots whether to enable the rolling * stats snapshots that opossum emits at the bucketInterval. Disable this * as an optimization if you don't listen to the 'snapshot' event to reduce * the number of timers opossum initiates. * @param {EventEmitter} options.rotateBucketController if you have multiple * breakers in your app, the number of timers across breakers can get costly. * This option allows you to provide an EventEmitter that rotates the buckets * so you can have one global timer in your app. Make sure that you are * emitting a 'rotate' event from this EventEmitter * @param {boolean} options.autoRenewAbortController Automatically recreates * the instance of AbortController whenever the circuit transitions to * 'halfOpen' or 'closed' state. This ensures that new requests are not * impacted by previous signals that were triggered when the circuit was 'open'. * Default: false * * * @fires CircuitBreaker#halfOpen * @fires CircuitBreaker#close * @fires CircuitBreaker#open * @fires CircuitBreaker#fire * @fires CircuitBreaker#cacheHit * @fires CircuitBreaker#cacheMiss * @fires CircuitBreaker#coalesceCacheHit * @fires CircuitBreaker#coalesceCacheMiss * @fires CircuitBreaker#reject * @fires CircuitBreaker#timeout * @fires CircuitBreaker#success * @fires CircuitBreaker#semaphoreLocked * @fires CircuitBreaker#healthCheckFailed * @fires CircuitBreaker#fallback * @fires CircuitBreaker#failure */ class CircuitBreaker extends events_1.default { /** * Returns true if the provided error was generated here. It will be false * if the error came from the action itself. * @param {Error} error The Error to check. * @returns {Boolean} true if the error was generated here */ static isOurError(error) { return !!error[OUR_ERROR]; } /** * Create a new Status object, * helpful when you need to prime a breaker with stats * @param {Object} options - * @param {Number} options.rollingCountBuckets number of buckets in the window * @param {Number} options.rollingCountTimeout the duration of the window * @param {Boolean} options.rollingPercentilesEnabled whether to calculate * @param {Object} options.stats user supplied stats * @returns {Status} a new {@link Status} object */ static newStatus(options) { return new status_js_1.default(options); } constructor(action, options = {}) { var _a, _b, _c, _d, _e, _f, _g, _h, _j; super(); this.options = options; this.options.timeout = (_a = options.timeout) !== null && _a !== void 0 ? _a : 10000; this.options.resetTimeout = (_b = options.resetTimeout) !== null && _b !== void 0 ? _b : 30000; this.options.errorThresholdPercentage = (_c = options.errorThresholdPercentage) !== null && _c !== void 0 ? _c : 50; this.options.rollingCountTimeout = (_d = options.rollingCountTimeout) !== null && _d !== void 0 ? _d : 10000; this.options.rollingCountBuckets = (_e = options.rollingCountBuckets) !== null && _e !== void 0 ? _e : 10; this.options.rollingPercentilesEnabled = options.rollingPercentilesEnabled !== false; this.options.capacity = Number.isInteger(options.capacity) ? options.capacity : Number.MAX_SAFE_INTEGER; this.options.errorFilter = options.errorFilter || (_ => false); this.options.cacheTTL = (_f = options.cacheTTL) !== null && _f !== void 0 ? _f : 0; this.options.cacheGetKey = (_g = options.cacheGetKey) !== null && _g !== void 0 ? _g : ((...args) => JSON.stringify(args)); this.options.enableSnapshots = options.enableSnapshots !== false; this.options.rotateBucketController = options.rotateBucketController; this.options.coalesce = !!options.coalesce; this.options.coalesceTTL = (_h = options.coalesceTTL) !== null && _h !== void 0 ? _h : this.options.timeout; this.options.coalesceResetOn = ((_j = options.coalesceResetOn) === null || _j === void 0 ? void 0 : _j.filter(o => ['error', 'success', 'timeout'].includes(o))) || []; // Set default cache transport if not provided if (this.options.cache) { if (this.options.cacheTransport === undefined) { this.options.cacheTransport = new cache_js_1.default(options.cacheSize); } else if (typeof this.options.cacheTransport !== 'object' || !this.options.cacheTransport.get || !this.options.cacheTransport.set || !this.options.cacheTransport.flush) { throw new TypeError('options.cacheTransport should be an object with `get`, `set` and `flush` methods'); } } if (this.options.coalesce) { this.options.coalesceCache = new cache_js_1.default(options.coalesceSize); } this.semaphore = new semaphore_js_1.default(this.options.capacity); // check if action is defined if (!action) { throw new TypeError('No action provided. Cannot construct a CircuitBreaker without an invocable action.'); } if (options.autoRenewAbortController && !options.abortController) { options.abortController = new AbortController(); } if (options.abortController && typeof options.abortController.abort !== 'function') { throw new TypeError('AbortController does not contain `abort()` method'); } this[VOLUME_THRESHOLD] = Number.isInteger(options.volumeThreshold) ? options.volumeThreshold : 0; this[WARMING_UP] = options.allowWarmUp === true; // The user can pass in a Status object to initialize the Status/stats if (this.options.status) { // Do a check that this is a Status Object, if (this.options.status instanceof status_js_1.default) { this[STATUS] = this.options.status; } else { this[STATUS] = new status_js_1.default({ stats: this.options.status }); } } else { this[STATUS] = new status_js_1.default(this.options); } this[STATE] = CLOSED; if (options.state) { this[ENABLED] = options.state.enabled !== false; this[WARMING_UP] = options.state.warmUp || this[WARMING_UP]; // Closed if nothing is passed in this[CLOSED] = options.state.closed !== false; // These should be in sync this[HALF_OPEN] = this[PENDING_CLOSE] = options.state.halfOpen || false; // Open should be the opposite of closed, // but also the opposite of half_open this[OPEN] = !this[CLOSED] && !this[HALF_OPEN]; this[SHUTDOWN] = options.state.shutdown || false; } else { this[PENDING_CLOSE] = false; this[ENABLED] = options.enabled !== false; } this[FALLBACK_FUNCTION] = null; this[NAME] = options.name || action.name || nextName(); this[GROUP] = options.group || this[NAME]; if (this[WARMING_UP]) { const timer = (this[WARMUP_TIMEOUT] = setTimeout(_ => (this[WARMING_UP] = false), this.options.rollingCountTimeout)); if (typeof timer.unref === 'function') { timer.unref(); } } if (typeof action !== 'function') { this.action = _ => Promise.resolve(action); } else this.action = action; if (options.maxFailures) console.error(deprecation); const increment = property => (result, runTime) => this[STATUS].increment(property, runTime); this.on('success', increment('successes')); this.on('failure', increment('failures')); this.on('fallback', increment('fallbacks')); this.on('timeout', increment('timeouts')); this.on('fire', increment('fires')); this.on('reject', increment('rejects')); this.on('cacheHit', increment('cacheHits')); this.on('cacheMiss', increment('cacheMisses')); this.on('coalesceCacheHit', increment('coalesceCacheHits')); this.on('coalesceCacheMiss', increment('coalesceCacheMisses')); this.on('open', _ => this[STATUS].open()); this.on('close', _ => this[STATUS].close()); this.on('semaphoreLocked', increment('semaphoreRejections')); /** * @param {CircuitBreaker} circuit This current circuit * @returns {function(): void} A bound reset callback * @private */ function _startTimer(circuit) { circuit[LAST_TIMER_AT] = Date.now(); return _ => { const timer = (circuit[RESET_TIMEOUT] = setTimeout(() => { _halfOpen(circuit); }, circuit.options.resetTimeout)); if (typeof timer.unref === 'function') { timer.unref(); } }; } /** * Sets the circuit breaker to half open * @private * @param {CircuitBreaker} circuit The current circuit breaker * @returns {void} */ function _halfOpen(circuit) { circuit[STATE] = HALF_OPEN; circuit[PENDING_CLOSE] = true; circuit._renewAbortControllerIfNeeded(); /** * Emitted after `options.resetTimeout` has elapsed, allowing for * a single attempt to call the service again. If that attempt is * successful, the circuit will be closed. Otherwise it remains open. * * @event CircuitBreaker#halfOpen * @type {Number} how long the circuit remained open */ circuit.emit('halfOpen', circuit.options.resetTimeout); } this.on('open', _startTimer(this)); this.on('success', _ => { if (this.halfOpen) { this.close(); } }); // Prepopulate the State of the Breaker if (this[SHUTDOWN]) { this[STATE] = SHUTDOWN; this.shutdown(); } else if (this[CLOSED]) { this.close(); } else if (this[OPEN]) { // If the state being passed in is OPEN but more time has elapsed // than the resetTimeout, then we should be in halfOpen state if (this.options.state.lastTimerAt !== undefined && Date.now() - this.options.state.lastTimerAt > this.options.resetTimeout) { _halfOpen(this); } else { this.open(); } } else if (this[HALF_OPEN]) { // Not sure if anything needs to be done here this[STATE] = HALF_OPEN; } } /** * Renews the abort controller if needed * @private * @returns {void} */ _renewAbortControllerIfNeeded() { if (this.options.autoRenewAbortController && this.options.abortController && this.options.abortController.signal.aborted) { this.options.abortController = new AbortController(); } } /** * Closes the breaker, allowing the action to execute again * @fires CircuitBreaker#close * @returns {void} */ close() { if (this[STATE] !== CLOSED) { if (this[RESET_TIMEOUT]) { clearTimeout(this[RESET_TIMEOUT]); } this[STATE] = CLOSED; this[PENDING_CLOSE] = false; this._renewAbortControllerIfNeeded(); /** * Emitted when the breaker is reset allowing the action to execute again * @event CircuitBreaker#close */ this.emit('close'); } } /** * Opens the breaker. Each time the breaker is fired while the circuit is * opened, a failed Promise is returned, or if any fallback function * has been provided, it is invoked. * * If the breaker is already open this call does nothing. * @fires CircuitBreaker#open * @returns {void} */ open() { if (this[STATE] !== OPEN) { this[STATE] = OPEN; this[PENDING_CLOSE] = false; /** * Emitted when the breaker opens because the action has * failure percentage greater than `options.errorThresholdPercentage`. * @event CircuitBreaker#open */ this.emit('open'); } } /** * Shuts down this circuit breaker. All subsequent calls to the * circuit will fail, returning a rejected promise. * @returns {void} */ shutdown() { /** * Emitted when the circuit breaker has been shut down. * @event CircuitBreaker#shutdown */ this.emit('shutdown'); this.disable(); this.removeAllListeners(); if (this[RESET_TIMEOUT]) { clearTimeout(this[RESET_TIMEOUT]); } if (this[WARMUP_TIMEOUT]) { clearTimeout(this[WARMUP_TIMEOUT]); } this.status.shutdown(); this[STATE] = SHUTDOWN; // clear cache on shutdown this.clearCache(); } /** * Determines if the circuit has been shutdown. * @type {Boolean} */ get isShutdown() { return this[STATE] === SHUTDOWN; } /** * Gets the name of this circuit * @type {String} */ get name() { return this[NAME]; } /** * Gets the name of this circuit group * @type {String} */ get group() { return this[GROUP]; } /** * Gets whether this circuit is in the `pendingClosed` state * @type {Boolean} */ get pendingClose() { return this[PENDING_CLOSE]; } /** * True if the circuit is currently closed. False otherwise. * @type {Boolean} */ get closed() { return this[STATE] === CLOSED; } /** * True if the circuit is currently opened. False otherwise. * @type {Boolean} */ get opened() { return this[STATE] === OPEN; } /** * True if the circuit is currently half opened. False otherwise. * @type {Boolean} */ get halfOpen() { return this[STATE] === HALF_OPEN; } /** * The current {@link Status} of this {@link CircuitBreaker} * @type {Status} */ get status() { return this[STATUS]; } /** * Get the current stats for the circuit. * @see Status#stats * @type {Object} */ get stats() { return this[STATUS].stats; } toJSON() { return { state: { name: this.name, enabled: this.enabled, closed: this.closed, open: this.opened, halfOpen: this.halfOpen, warmUp: this.warmUp, shutdown: this.isShutdown, lastTimerAt: this[LAST_TIMER_AT], }, status: this.status.stats, }; } /** * Gets whether the circuit is enabled or not * @type {Boolean} */ get enabled() { return this[ENABLED]; } /** * Gets whether the circuit is currently in warm up phase * @type {Boolean} */ get warmUp() { return this[WARMING_UP]; } /** * Gets the volume threshold for this circuit * @type {Boolean} */ get volumeThreshold() { return this[VOLUME_THRESHOLD]; } /** * Provide a fallback function for this {@link CircuitBreaker}. This * function will be executed when the circuit is `fire`d and fails. * It will always be preceded by a `failure` event, and `breaker.fire` returns * a rejected Promise. * @param {Function | CircuitBreaker} func the fallback function to execute * when the breaker has opened or when a timeout or error occurs. * @return {CircuitBreaker} this */ fallback(func) { let fb = func; if (func instanceof CircuitBreaker) { fb = (...args) => func.fire(...args); } this[FALLBACK_FUNCTION] = fb; return this; } /** * Execute the action for this circuit. If the action fails or times out, the * returned promise will be rejected. If the action succeeds, the promise will * resolve with the resolved value from action. If a fallback function was * provided, it will be invoked in the event of any failure or timeout. * * Any parameters passed to this function will be proxied to the circuit * function. * * @return {Promise<any>} promise resolves with the circuit function's return * value on success or is rejected on failure of the action. Use isOurError() * to determine if a rejection was a result of the circuit breaker or the * action. * * @fires CircuitBreaker#failure * @fires CircuitBreaker#fallback * @fires CircuitBreaker#fire * @fires CircuitBreaker#reject * @fires CircuitBreaker#success * @fires CircuitBreaker#timeout * @fires CircuitBreaker#semaphoreLocked */ fire(...args) { return this.call(this.action, ...args); } /** * Execute the action for this circuit using `context` as `this`. * If the action fails or times out, the * returned promise will be rejected. If the action succeeds, the promise will * resolve with the resolved value from action. If a fallback function was * provided, it will be invoked in the event of any failure or timeout. * * Any parameters in addition to `context will be passed to the * circuit function. * * @param {any} context the `this` context used for function execution * @param {any} rest the arguments passed to the action * * @return {Promise<any>} promise resolves with the circuit function's return * value on success or is rejected on failure of the action. * * @fires CircuitBreaker#failure * @fires CircuitBreaker#fallback * @fires CircuitBreaker#fire * @fires CircuitBreaker#reject * @fires CircuitBreaker#success * @fires CircuitBreaker#timeout * @fires CircuitBreaker#semaphoreLocked */ call(context, ...rest) { if (this.isShutdown) { const err = buildError('The circuit has been shutdown.', 'ESHUTDOWN'); return Promise.reject(err); } const args = rest.slice(); /** * Emitted when the circuit breaker action is executed * @event CircuitBreaker#fire * @type {any} the arguments passed to the fired function */ this.emit('fire', args); // Protection, caches and coalesce disabled. if (!this[ENABLED]) { const result = this.action.apply(context, args); return typeof result.then === 'function' ? result : Promise.resolve(result); } // Generate cachekey only when cache and/or coalesce is enabled. const cacheKey = this.options.cache || this.options.coalesce ? this.options.cacheGetKey.apply(this, rest) : ''; // If cache is enabled, check if we have a cached value if (this.options.cache) { const cached = this.options.cacheTransport.get(cacheKey); if (cached) { /** * Emitted when the circuit breaker is using the cache * and finds a value. * @event CircuitBreaker#cacheHit */ this.emit('cacheHit'); return cached; } /** * Emitted when the circuit breaker does not find a value in * the cache, but the cache option is enabled. * @event CircuitBreaker#cacheMiss */ this.emit('cacheMiss'); } /* When coalesce is enabled, check coalesce cache and return promise, if any. */ if (this.options.coalesce) { const cachedCall = this.options.coalesceCache.get(cacheKey); if (cachedCall) { /** * Emitted when the circuit breaker is using coalesce cache * and finds a cached promise. * @event CircuitBreaker#coalesceCacheHit */ this.emit('coalesceCacheHit'); return cachedCall; } /** * Emitted when the circuit breaker does not find a value in * coalesce cache, but the coalesce option is enabled. * @event CircuitBreaker#coalesceCacheMiss */ this.emit('coalesceCacheMiss'); } if (!this.closed && !this.pendingClose) { /** * Emitted when the circuit breaker is open and failing fast * @event CircuitBreaker#reject * @type {Error} */ const error = buildError('Breaker is open', 'EOPENBREAKER'); this.emit('reject', error); return fallback(this, error, args) || Promise.reject(error); } this[PENDING_CLOSE] = false; let timeout; let timeoutError = false; const call = new Promise((resolve, reject) => { const latencyStartTime = Date.now(); if (this.semaphore.test()) { if (this.options.timeout) { timeout = setTimeout(() => { timeoutError = true; const error = buildError(`Timed out after ${this.options.timeout}ms`, 'ETIMEDOUT'); const latency = Date.now() - latencyStartTime; this.semaphore.release(); /** * Emitted when the circuit breaker action takes longer than * `options.timeout` * @event CircuitBreaker#timeout * @type {Error} */ this.emit('timeout', error, latency, args); handleError(error, this, timeout, args, latency, resolve, reject); resetCoalesce(this, cacheKey, 'timeout'); if (this.options.abortController) { this.options.abortController.abort(); } }, this.options.timeout); } try { const result = this.action.apply(context, args); const promise = typeof result.then === 'function' ? result : Promise.resolve(result); promise .then(result => { if (!timeoutError) { clearTimeout(timeout); /** * Emitted when the circuit breaker action succeeds * @event CircuitBreaker#success * @type {any} the return value from the circuit */ this.emit('success', result, Date.now() - latencyStartTime); resetCoalesce(this, cacheKey, 'success'); this.semaphore.release(); resolve(result); if (this.options.cache) { this.options.cacheTransport.set(cacheKey, promise, this.options.cacheTTL > 0 ? Date.now() + this.options.cacheTTL : 0); } } }) .catch(error => { if (!timeoutError) { this.semaphore.release(); const latencyEndTime = Date.now() - latencyStartTime; handleError(error, this, timeout, args, latencyEndTime, resolve, reject); resetCoalesce(this, cacheKey, 'error'); } }); } catch (error) { this.semaphore.release(); const latency = Date.now() - latencyStartTime; handleError(error, this, timeout, args, latency, resolve, reject); resetCoalesce(this, cacheKey, 'error'); } } else { const latency = Date.now() - latencyStartTime; const err = buildError('Semaphore locked', 'ESEMLOCKED'); /** * Emitted when the rate limit has been reached and there * are no more locks to be obtained. * @event CircuitBreaker#semaphoreLocked * @type {Error} */ this.emit('semaphoreLocked', err, latency); handleError(err, this, timeout, args, latency, resolve, reject); resetCoalesce(this, cacheKey); } }); /* When coalesce is enabled, store promise in coalesceCache */ if (this.options.coalesce) { this.options.coalesceCache.set(cacheKey, call, this.options.coalesceTTL > 0 ? Date.now() + this.options.coalesceTTL : 0); } return call; } /** * Clears the cache of this {@link CircuitBreaker} * @returns {void} */ clearCache() { if (this.options.cache) { this.options.cacheTransport.flush(); } if (this.options.coalesceCache) { this.options.coalesceCache.flush(); } } /** * Provide a health check function to be called periodically. The function * should return a Promise. If the promise is rejected the circuit will open. * This is in addition to the existing circuit behavior as defined by * `options.errorThresholdPercentage` in the constructor. For example, if the * health check function provided here always returns a resolved promise, the * circuit can still trip and open if there are failures exceeding the * configured threshold. The health check function is executed within the * circuit breaker's execution context, so `this` within the function is the * circuit breaker itself. * * @param {Function} func a health check function which returns a promise. * @param {Number} [interval] the amount of time between calls to the health * check function. Default: 5000 (5 seconds) * * @returns {void} * * @fires CircuitBreaker#healthCheckFailed * @throws {TypeError} if `interval` is supplied but not a number */ healthCheck(func, interval) { interval = interval || 5000; if (typeof func !== 'function') { throw new TypeError('Health check function must be a function'); } if (isNaN(interval)) { throw new TypeError('Health check interval must be a number'); } const check = _ => { func.apply(this).catch(e => { /** * Emitted with the user-supplied health check function * returns a rejected promise. * @event CircuitBreaker#healthCheckFailed * @type {Error} */ this.emit('healthCheckFailed', e); this.open(); }); }; const timer = setInterval(check, interval); if (typeof timer.unref === 'function') { timer.unref(); } check(); } /** * Enables this circuit. If the circuit is the disabled * state, it will be re-enabled. If not, this is essentially * a noop. * @returns {void} */ enable() { this[ENABLED] = true; this.status.startListeneningForRotateEvent(); } /** * Disables this circuit, causing all calls to the circuit's function * to be executed without circuit or fallback protection. * @returns {void} */ disable() { this[ENABLED] = false; this.status.removeRotateBucketControllerListener(); } /** * Retrieves the current AbortSignal from the abortController, if available. * This signal can be used to monitor ongoing requests. * @returns {AbortSignal|undefined} The AbortSignal if present, * otherwise undefined. */ getSignal() { if (this.options.abortController && this.options.abortController.signal) { return this.options.abortController.signal; } return undefined; } /** * Retrieves the current AbortController instance. * This controller can be used to manually abort ongoing requests or create * a new signal. * @returns {AbortController|undefined} The AbortController if present, * otherwise undefined. */ getAbortController() { return this.options.abortController; } } function handleError(error, circuit, timeout, args, latency, resolve, reject) { clearTimeout(timeout); if (circuit.options.errorFilter(error, ...args)) { // The error was filtered, so emit 'success' circuit.emit('success', error, latency); } else { // Error was not filtered, so emit 'failure' fail(circuit, error, args, latency); // Only call the fallback function if errorFilter doesn't succeed // If the fallback function succeeds, resolve const fb = fallback(circuit, error, args); if (fb) return resolve(fb); } // In all other cases, reject reject(error); } function fallback(circuit, err, args) { if (circuit[FALLBACK_FUNCTION]) { try { const result = circuit[FALLBACK_FUNCTION].apply(circuit[FALLBACK_FUNCTION], [...args, err]); /** * Emitted when the circuit breaker executes a fallback function * @event CircuitBreaker#fallback * @type {any} the return value of the fallback function */ circuit.emit('fallback', result, err); if (result instanceof Promise) return result; return Promise.resolve(result); } catch (e) { return Promise.reject(e); } } } function fail(circuit, err, args, latency) { /** * Emitted when the circuit breaker action fails * @event CircuitBreaker#failure * @type {Error} */ circuit.emit('failure', err, latency, args); if (circuit.warmUp) return; // check stats to see if the circuit should be opened const stats = circuit.stats; if (stats.fires < circuit.volumeThreshold && !circuit.halfOpen) return; const errorRate = (stats.failures / stats.fires) * 100; if (errorRate > circuit.options.errorThresholdPercentage || circuit.halfOpen) { circuit.open(); } } function resetCoalesce(circuit, cacheKey, event) { var _a; /** * Reset coalesce cache for this cacheKey, depending on * options.coalesceResetOn set. * @param {@link CircuitBreaker} circuit what circuit is to be cleared * @param {string} cacheKey cache key to clear. * @param {string} event optional, can be `error`, `success`, `timeout` * @returns {void} */ if (!event || circuit.options.coalesceResetOn.includes(event)) { (_a = circuit.options.coalesceCache) === null || _a === void 0 ? void 0 : _a.delete(cacheKey); } } function buildError(msg, code) { const error = new Error(msg); error.code = code; error[OUR_ERROR] = true; return error; } // http://stackoverflow.com/a/2117523 const nextName = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); exports.default = CircuitBreaker;