Skip to content

Commit

Permalink
feat: renewing the AbortController when the circuit enters the 'halfC…
Browse files Browse the repository at this point in the history
…lose' 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
  • Loading branch information
WillianAgostini authored Oct 28, 2024
1 parent d0ee9e6 commit 2ba3a31
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 0 deletions.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions lib/circuit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit 2ba3a31

Please sign in to comment.