From 2ba3a31def3f11305778490c7c2b858f02d8ca2a Mon Sep 17 00:00:00 2001 From: Willian Agostini Date: Mon, 28 Oct 2024 10:47:28 -0300 Subject: [PATCH] feat: renewing the AbortController when the circuit enters the 'halfClose' or 'close' state (#892) * feat: add option to auto-recreate AbortController if aborted during specific states When state changes to 'halfOpen' or 'close', the AbortController will be recreated to handle reuse. #861 * feat(circuit-breaker): extract abort controller renewal logic into separate function #861 * docs: update README to include autoRenewAbortController configuration options #861 --- README.md | 38 +++++++++++++++++++++++++++ lib/circuit.js | 51 ++++++++++++++++++++++++++++++++++++ test/test.js | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/README.md b/README.md index 080d66fc..637f3734 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,44 @@ breaker.fire(abortController.signal) .catch(console.error); ``` +### Auto Renew AbortController + +The `autoRenewAbortController` option allows the automatic renewal of the `AbortController` when the circuit breaker transitions into the `halfOpen` or `closed` states. This feature ensures that the `AbortController` can be reused properly for ongoing requests without manual intervention. + +```javascript +const CircuitBreaker = require('opossum'); +const http = require('http'); + +function asyncFunctionThatCouldFail(abortSignal, x, y) { + return new Promise((resolve, reject) => { + http.get( + 'http://httpbin.org/delay/10', + { signal: abortSignal }, + (res) => { + if(res.statusCode < 300) { + resolve(res.statusCode); + return; + } + + reject(res.statusCode); + } + ); + }); +} + +const abortController = new AbortController(); +const options = { + autoRenewAbortController: true, + timeout: 3000, // If our function takes longer than 3 seconds, trigger a failure +}; +const breaker = new CircuitBreaker(asyncFunctionThatCouldFail, options); + +const signal = breaker.getSignal(); +breaker.fire(signal) + .then(console.log) + .catch(console.error); +``` + ### Fallback You can also provide a fallback function that will be executed in the diff --git a/lib/circuit.js b/lib/circuit.js index c730634e..caed0cf2 100644 --- a/lib/circuit.js +++ b/lib/circuit.js @@ -123,6 +123,11 @@ 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 @@ -217,6 +222,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' @@ -321,6 +330,7 @@ class CircuitBreaker extends EventEmitter { 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 @@ -361,6 +371,21 @@ class CircuitBreaker extends EventEmitter { } } + /** + * 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 @@ -373,6 +398,7 @@ class CircuitBreaker extends EventEmitter { } this[STATE] = CLOSED; this[PENDING_CLOSE] = false; + this._renewAbortControllerIfNeeded(); /** * Emitted when the breaker is reset allowing the action to execute again * @event CircuitBreaker#close @@ -873,6 +899,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..9cb4ece5 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(2); + + 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);