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

Add a count based sliding window breaker #93

Merged
merged 1 commit into from
Jul 17, 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
28 changes: 27 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ I recommend reading the [Polly wiki](https://github.com/App-vNext/Polly/wiki) fo
- [`circuitBreaker(policy, { halfOpenAfter, breaker })`](#circuitbreakerpolicy--halfopenafter-breaker-)
- [Breakers](#breakers)
- [`ConsecutiveBreaker`](#consecutivebreaker)
- [`CountBreaker`](#countbreaker)
- [`SamplingBreaker`](#samplingbreaker)
- [`breaker.execute(fn[, signal])`](#breakerexecutefn-signal)
- [`breaker.state`](#breakerstate)
Expand Down Expand Up @@ -623,6 +624,31 @@ const breaker = circuitBreaker(handleAll, {
});
```

#### `CountBreaker`

The `CountBreaker` breaks after a proportion of requests in a count based sliding window fail. It is inspired by the [Count-based sliding window in Resilience4j](https://resilience4j.readme.io/docs/circuitbreaker#count-based-sliding-window).

```js
// Break if more than 20% of requests fail in a sliding window of size 100:
const breaker = circuitBreaker(handleAll, {
halfOpenAfter: 10 * 1000,
breaker: new CountBreaker({ threshold: 0.2, size: 100 }),
});
```

You can specify a minimum minimum-number-of-calls value to use, to avoid opening the circuit when there are only few samples in the sliding window. By default this value is set to the sliding window size, but you can override it if necessary:

```js
const breaker = circuitBreaker(handleAll, {
halfOpenAfter: 10 * 1000,
breaker: new CountBreaker({
threshold: 0.2,
size: 100,
minimumNumberOfCalls: 50, // require 50 requests before we can break
}),
});
```

#### `SamplingBreaker`

The `SamplingBreaker` breaks after a proportion of requests over a time period fail.
Expand All @@ -635,7 +661,7 @@ const breaker = circuitBreaker(handleAll, {
});
```

You can specify a minimum requests-per-second value to use to avoid closing the circuit under period of low load. By default we'll choose a value such that you need 5 failures per second for the breaker to kick in, and you can configure this if it doesn't work for you:
You can specify a minimum requests-per-second value to use to avoid opening the circuit under periods of low load. By default we'll choose a value such that you need 5 failures per second for the breaker to kick in, and you can configure this if it doesn't work for you:

```js
const breaker = circuitBreaker(handleAll, {
Expand Down
1 change: 1 addition & 0 deletions src/breaker/Breaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export interface IBreaker {

export * from './SamplingBreaker';
export * from './ConsecutiveBreaker';
export * from './CountBreaker';
124 changes: 124 additions & 0 deletions src/breaker/CountBreaker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { expect, use } from 'chai';
import * as subset from 'chai-subset';
import { CircuitState } from '../CircuitBreakerPolicy';
import { CountBreaker } from './CountBreaker';

use(subset);

const getState = (b: CountBreaker) => {
const untyped: any = b;
return {
threshold: untyped.threshold,
minimumNumberOfCalls: untyped.minimumNumberOfCalls,
samples: [...untyped.samples],
successes: untyped.successes,
failures: untyped.failures,
currentSample: untyped.currentSample,
};
};

describe('CountBreaker', () => {
describe('parameter creation', () => {
it('rejects if threshold is out of range', () => {
expect(() => new CountBreaker({ threshold: -1, size: 100 })).to.throw(RangeError);
expect(() => new CountBreaker({ threshold: 0, size: 100 })).to.throw(RangeError);
expect(() => new CountBreaker({ threshold: 1, size: 100 })).to.throw(RangeError);
expect(() => new CountBreaker({ threshold: 10, size: 100 })).to.throw(RangeError);
});

it('rejects if size is invalid', () => {
expect(() => new CountBreaker({ threshold: 0.5, size: -1 })).to.throw(RangeError);
expect(() => new CountBreaker({ threshold: 0.5, size: 0 })).to.throw(RangeError);
expect(() => new CountBreaker({ threshold: 0.5, size: 0.5 })).to.throw(RangeError);
});

it('rejects if minimumNumberOfCalls is invalid', () => {
expect(
() => new CountBreaker({ threshold: 0.5, size: 100, minimumNumberOfCalls: -1 }),
).to.throw(RangeError);
expect(
() => new CountBreaker({ threshold: 0.5, size: 100, minimumNumberOfCalls: 0 }),
).to.throw(RangeError);
expect(
() => new CountBreaker({ threshold: 0.5, size: 100, minimumNumberOfCalls: 0.5 }),
).to.throw(RangeError);
expect(
() => new CountBreaker({ threshold: 0.5, size: 100, minimumNumberOfCalls: 101 }),
).to.throw(RangeError);
});

it('creates good initial params', () => {
const b = new CountBreaker({ threshold: 0.5, size: 100, minimumNumberOfCalls: 50 });
expect(getState(b)).to.containSubset({
threshold: 0.5,
minimumNumberOfCalls: 50,
});

expect(getState(b).samples).to.have.lengthOf(100);
});
});

describe('window', () => {
it('correctly wraps around when reaching the end of the window', () => {
const b = new CountBreaker({ threshold: 0.5, size: 5 });
for (let i = 0; i < 9; i++) {
if (i % 3 === 0) {
b.failure(CircuitState.Closed);
} else {
b.success(CircuitState.Closed);
}
}

const state = getState(b);
expect(state.currentSample).to.equal(4);
expect(state.samples).to.deep.equal([true, false, true, true, true]);
});
});

describe('functionality', () => {
let b: CountBreaker;

beforeEach(() => {
b = new CountBreaker({ threshold: 0.5, size: 100, minimumNumberOfCalls: 50 });
});

it('does not open as long as the minimum number of calls has not been reached', () => {
for (let i = 0; i < 49; i++) {
expect(b.failure(CircuitState.Closed)).to.be.false;
}
});

it('does not open when the minimum number of calls has been reached but the threshold has not been surpassed', () => {
for (let i = 0; i < 25; i++) {
b.success(CircuitState.Closed);
}
for (let i = 0; i < 24; i++) {
expect(b.failure(CircuitState.Closed)).to.be.false;
}
expect(b.failure(CircuitState.Closed)).to.be.false;
});

it('opens when the minimum number of calls has been reached and threshold has been surpassed', () => {
for (let i = 0; i < 24; i++) {
b.success(CircuitState.Closed);
}
for (let i = 0; i < 25; i++) {
expect(b.failure(CircuitState.Closed)).to.be.false;
}
expect(b.failure(CircuitState.Closed)).to.be.true;
});

it('resets when recoving from a half-open', () => {
for (let i = 0; i < 100; i++) {
b.failure(CircuitState.Closed);
}

b.success(CircuitState.HalfOpen);

const state = getState(b);
expect(state.failures).to.equal(0);
expect(state.successes).to.equal(1);
expect(b.failure(CircuitState.Closed)).to.be.false;
});
});
});
124 changes: 124 additions & 0 deletions src/breaker/CountBreaker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { CircuitState } from '../CircuitBreakerPolicy';
import { IBreaker } from './Breaker';

export interface ICountBreakerOptions {
/**
* Percentage (from 0 to 1) of requests that need to fail before we'll
* open the circuit.
*/
threshold: number;

/**
* Size of the count based sliding window.
*/
size: number;

/**
* Minimum number of calls needed to (potentially) open the circuit.
* Useful to avoid unnecessarily tripping when there are only few samples yet.
* Defaults to {@link ICountBreakerOptions.size}.
*/
minimumNumberOfCalls?: number;
}

export class CountBreaker implements IBreaker {
private readonly threshold: number;
private readonly minimumNumberOfCalls: number;

/**
* The samples in the sliding window. `true` means "success", `false` means
* "failure" and `undefined` means that there is no sample yet.
*/
private readonly samples: (boolean | undefined)[];
private successes = 0;
private failures = 0;
private currentSample = 0;

/**
* CountBreaker breaks if more than `threshold` percentage of the last `size`
* calls failed, so long as at least `minimumNumberOfCalls` calls have been
* performed (to avoid opening unnecessarily if there are only few samples
* in the sliding window yet).
*/
constructor({ threshold, size, minimumNumberOfCalls = size }: ICountBreakerOptions) {
if (threshold <= 0 || threshold >= 1) {
throw new RangeError(`CountBreaker threshold should be between (0, 1), got ${threshold}`);
connor4312 marked this conversation as resolved.
Show resolved Hide resolved
}
if (!Number.isSafeInteger(size) || size < 1) {
throw new RangeError(`CountBreaker size should be a positive integer, got ${size}`);
}
if (
!Number.isSafeInteger(minimumNumberOfCalls) ||
minimumNumberOfCalls < 1 ||
minimumNumberOfCalls > size
) {
throw new RangeError(
`CountBreaker size should be an integer between (1, size), got ${minimumNumberOfCalls}`,
);
}

this.threshold = threshold;
this.minimumNumberOfCalls = minimumNumberOfCalls;
this.samples = Array.from<undefined>({ length: size }).fill(undefined);
}

/**
* @inheritdoc
*/
public success(state: CircuitState) {
if (state === CircuitState.HalfOpen) {
this.reset();
}

this.sample(true);
}

/**
* @inheritdoc
*/
public failure(state: CircuitState) {
this.sample(false);

if (state !== CircuitState.Closed) {
return true;
}

const total = this.successes + this.failures;

if (total < this.minimumNumberOfCalls) {
return false;
}

if (this.failures > this.threshold * total) {
ghost91- marked this conversation as resolved.
Show resolved Hide resolved
return true;
}

return false;
}

private reset() {
for (let i = 0; i < this.samples.length; i++) {
this.samples[i] = undefined;
}
this.successes = 0;
this.failures = 0;
}

private sample(success: boolean) {
const current = this.samples[this.currentSample];
if (current === true) {
this.successes--;
} else if (current === false) {
this.failures--;
}

this.samples[this.currentSample] = success;
if (success) {
this.successes++;
} else {
this.failures++;
}

this.currentSample = (this.currentSample + 1) % this.samples.length;
}
}
2 changes: 1 addition & 1 deletion src/breaker/SamplingBreaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class SamplingBreaker implements IBreaker {
/**
* SamplingBreaker breaks if more than `threshold` percentage of calls over the
* last `samplingDuration`, so long as there's at least `minimumRps` (to avoid
* closing unnecessarily under low RPS).
* opening unnecessarily under low RPS).
*/
constructor({ threshold, duration: samplingDuration, minimumRps }: ISamplingBreakerOptions) {
if (threshold <= 0 || threshold >= 1) {
Expand Down