From 7a9cbdf179e5adc89be8266b41a07841c9f0ef93 Mon Sep 17 00:00:00 2001 From: Johannes Loher Date: Thu, 18 Jul 2024 00:03:00 +0200 Subject: [PATCH] feat: add CountBreaker (#93) --- readme.md | 28 ++++++- src/breaker/Breaker.ts | 1 + src/breaker/CountBreaker.test.ts | 124 +++++++++++++++++++++++++++++++ src/breaker/CountBreaker.ts | 124 +++++++++++++++++++++++++++++++ src/breaker/SamplingBreaker.ts | 2 +- 5 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 src/breaker/CountBreaker.test.ts create mode 100644 src/breaker/CountBreaker.ts diff --git a/readme.md b/readme.md index 292de8e..669e530 100644 --- a/readme.md +++ b/readme.md @@ -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) @@ -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. @@ -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, { diff --git a/src/breaker/Breaker.ts b/src/breaker/Breaker.ts index 4ee6752..c958d91 100644 --- a/src/breaker/Breaker.ts +++ b/src/breaker/Breaker.ts @@ -17,3 +17,4 @@ export interface IBreaker { export * from './SamplingBreaker'; export * from './ConsecutiveBreaker'; +export * from './CountBreaker'; diff --git a/src/breaker/CountBreaker.test.ts b/src/breaker/CountBreaker.test.ts new file mode 100644 index 0000000..cba23f7 --- /dev/null +++ b/src/breaker/CountBreaker.test.ts @@ -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; + }); + }); +}); diff --git a/src/breaker/CountBreaker.ts b/src/breaker/CountBreaker.ts new file mode 100644 index 0000000..24fcffd --- /dev/null +++ b/src/breaker/CountBreaker.ts @@ -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({ 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; + } +} diff --git a/src/breaker/SamplingBreaker.ts b/src/breaker/SamplingBreaker.ts index bebfda5..cc837eb 100644 --- a/src/breaker/SamplingBreaker.ts +++ b/src/breaker/SamplingBreaker.ts @@ -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) {