-
Notifications
You must be signed in to change notification settings - Fork 51
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
277 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters