Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: renewing the AbortController when the circuit enters the 'halfClose' or 'close' state #892

Merged
merged 3 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -295,6 +304,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 @@ -335,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
Expand All @@ -347,6 +372,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 @@ -810,6 +836,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
Loading