From 92230a7c5a65f77e3440bb989720e4fc08198741 Mon Sep 17 00:00:00 2001 From: Willian Agostini Date: Wed, 23 Oct 2024 12:42:09 -0300 Subject: [PATCH 1/3] 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 --- lib/circuit.js | 44 +++++++++++++++++++++++++++++++ test/test.js | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/lib/circuit.js b/lib/circuit.js index 64d19c3b..35fa0402 100644 --- a/lib/circuit.js +++ b/lib/circuit.js @@ -107,6 +107,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 @@ -193,6 +198,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 +304,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 +359,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 +829,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); From 3d3bd18c60a980690c71229b80401e00c571b89d Mon Sep 17 00:00:00 2001 From: Willian Agostini Date: Wed, 23 Oct 2024 13:16:49 -0300 Subject: [PATCH 2/3] feat(circuit-breaker): extract abort controller renewal logic into separate function #861 --- lib/circuit.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/circuit.js b/lib/circuit.js index 35fa0402..b2c60d8a 100644 --- a/lib/circuit.js +++ b/lib/circuit.js @@ -304,9 +304,7 @@ class CircuitBreaker extends EventEmitter { function _halfOpen (circuit) { circuit[STATE] = HALF_OPEN; circuit[PENDING_CLOSE] = true; - if (circuit.options.autoRenewAbortController) { - circuit.options.abortController = new AbortController(); - } + circuit._renewAbortControllerIfNeeded(); /** * Emitted after `options.resetTimeout` has elapsed, allowing for * a single attempt to call the service again. If that attempt is @@ -347,6 +345,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 @@ -359,13 +372,7 @@ 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(); - } + this._renewAbortControllerIfNeeded(); /** * Emitted when the breaker is reset allowing the action to execute again * @event CircuitBreaker#close From e7fea5596e8dee579b3deb91540535a8a1738949 Mon Sep 17 00:00:00 2001 From: Willian Agostini Date: Mon, 28 Oct 2024 08:57:38 -0300 Subject: [PATCH 3/3] docs: update README to include autoRenewAbortController configuration options #861 --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index cb024949..37e17a17 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