Skip to content

Commit

Permalink
feat: add CountBreaker (#93)
Browse files Browse the repository at this point in the history
  • Loading branch information
ghost91- authored Jul 17, 2024
1 parent 1885f25 commit 7a9cbdf
Show file tree
Hide file tree
Showing 5 changed files with 277 additions and 2 deletions.
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}`);
}
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) {
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

0 comments on commit 7a9cbdf

Please sign in to comment.