diff --git a/lib/circuit.js b/lib/circuit.js index 64d19c3b..9feef8cb 100644 --- a/lib/circuit.js +++ b/lib/circuit.js @@ -107,6 +107,10 @@ Please use options.errorThresholdPercentage`; * 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 @@ -193,6 +197,10 @@ class CircuitBreaker extends EventEmitter { ); } + 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' @@ -295,6 +303,9 @@ class CircuitBreaker extends EventEmitter { function _halfOpen (circuit) { circuit[STATE] = HALF_OPEN; circuit[PENDING_CLOSE] = true; + if (circuit.options.autoRenewAbortController) { + circuit.options.abortController = new AbortController(); + } /** * Emitted after `options.resetTimeout` has elapsed, allowing for * a single attempt to call the service again. If that attempt is @@ -347,6 +358,13 @@ class CircuitBreaker extends EventEmitter { } this[STATE] = CLOSED; this[PENDING_CLOSE] = false; + if ( + this.options.autoRenewAbortController && + this.options.abortController && + this.options.abortController.signal.aborted + ) { + this.options.abortController = new AbortController(); + } /** * Emitted when the breaker is reset allowing the action to execute again * @event CircuitBreaker#close @@ -810,6 +828,31 @@ class CircuitBreaker extends EventEmitter { 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) { diff --git a/test/test.js b/test/test.js index a454c71f..ae48a45a 100644 --- a/test/test.js +++ b/test/test.js @@ -241,6 +241,77 @@ test('When options.abortController is provided, abort controller should not be a .then(t.end); }); +test('When options.autoRenewAbortController is not provided, signal should not be provided', t => { + t.plan(1); + + const breaker = new CircuitBreaker( + passFail + ); + + const signal = breaker.getSignal(); + t.false(signal, 'AbortSignal is empty'); + + breaker.shutdown(); + t.end(); +}); + +test('When options.autoRenewAbortController is provided, signal should be provided', t => { + t.plan(1); + + const breaker = new CircuitBreaker( + passFail, + { autoRenewAbortController: true } + ); + + const signal = breaker.getSignal(); + t.true(signal, 'AbortSignal has instance'); + + breaker.shutdown(); + t.end(); +}); + +test('When autoRenewAbortController option is provided, the signal should be reset', t => { + t.plan(1); + + const breaker = new CircuitBreaker( + passFail, + { + autoRenewAbortController: true, + resetTimeout: 10, + timeout: 1 + } + ); + + breaker.fire(10) + .catch(() => new Promise(resolve => { + const signal = breaker.getSignal(); + t.true(signal.aborted, 'AbortSignal is aborted after timeout'); + setTimeout(() => { + const signal = breaker.getSignal(); + t.false(signal.aborted, 'A new AbortSignal is created upon half-open state'); + resolve(); + }, 20); + })).finally(() => { + breaker.shutdown(); + t.end(); + }); +}); + +test('When options.autoRenewAbortController is provided, abortController should be provided', t => { + t.plan(1); + + const breaker = new CircuitBreaker( + passFail, + { autoRenewAbortController: true } + ); + + const ab = breaker.getAbortController(); + t.true(ab, 'AbortController has instance'); + + breaker.shutdown(); + t.end(); +}); + test('Works with functions that do not return a promise', t => { t.plan(1); const breaker = new CircuitBreaker(nonPromise);