diff --git a/examples/circuit.ts b/examples/circuit.ts new file mode 100644 index 0000000..b12f237 --- /dev/null +++ b/examples/circuit.ts @@ -0,0 +1,45 @@ +import { circuit } from '../lib'; + +class Service { + + private executionIndex = 0; + + @circuit(2, 1000) + public get() { + if (this.executionIndex < 2) { + this.executionIndex += 1; + throw new Error('Something went wrong'); + } + + return 42; + } + +} + +async function main() { + const service = new Service(); + + try { + service.get(); + } catch (error) { + console.log(error.message); // function throws error + } + try { + service.get(); + } catch (error) { + console.log(error.message); // function throws error + } + try { + service.get(); + } catch (error) { + console.log(error.message); // decorator throws error + } + + await new Promise(resolve => setTimeout( + () => resolve(), + 1000, + )); + console.log(service.get()); // prints: 42 +} + +main(); diff --git a/lib/circuit.ts b/lib/circuit/CircuitOptions.ts similarity index 67% rename from lib/circuit.ts rename to lib/circuit/CircuitOptions.ts index e2595df..e318825 100644 --- a/lib/circuit.ts +++ b/lib/circuit/CircuitOptions.ts @@ -41,17 +41,10 @@ export type CircuitOptions = { errorFilter?: (err: Error) => boolean, }; -/** - * A circuit breaker. - * After the method fails `threshold` count it enters the closed state and - * throws a `Circuit closed.` error. Once in closed state, the circuit fails - * for the provided `timeout` milliseconds. After the `timeout` interval expires - * the circuit transitions to half-opened state and allows next execution. - * If the execution succeeds then circuit transitions back to open state and resets - * the number of counted errors to zero. - * @param threshold the max number of failures until the circuit gets closed. - * @param timeout timeout in milliseconds to keep the circuit in closed state. - */ -export function circuit(threshold: number, timeout: number, options?: CircuitOptions) { - throw new Error('Not implemented.'); -} +export const DEFAULT_OPTIONS: Readonly = { + interval: undefined, + policy: 'errors', + onError: 'throw', + scope: 'class', + errorFilter: () => true, +}; diff --git a/lib/circuit/CircuitState/CircuitState.ts b/lib/circuit/CircuitState/CircuitState.ts new file mode 100644 index 0000000..e529eff --- /dev/null +++ b/lib/circuit/CircuitState/CircuitState.ts @@ -0,0 +1,91 @@ +import { Policy } from '../Policy/Policy'; + +export class CircuitState { + + private state: 'open' | 'close' | 'half-open' = 'open'; + private timers = new Set(); + + constructor( + private readonly timeout: number, + private readonly interval: number, + private readonly errorsFilter: (error: Error) => boolean, + private readonly policy: Policy, + private readonly clearCallback: () => unknown, + ) { } + + public allowExecution(): boolean { + return this.state !== 'close'; + } + + public register(error?: Error): this { + const isError = error && this.errorsFilter(error); + const type = isError ? 'error' : 'success'; + + this.exitHalfOpenState(isError); + + this.policy.registerCall(type); + this.registerCall(type); + + if (!this.policy.allowExecution()) { + this.close(); + } + + return this; + } + + private exitHalfOpenState(isError: boolean) { + if (this.state !== 'half-open') { + return; + } + + if (isError) { + this.close(); + } else { + this.open(); + } + } + + private registerCall(type: 'success' | 'error'): void { + if (typeof this.interval !== 'number') { + return; + } + + const timer = setTimeout( + () => { + this.policy.deleteCallData(type); + + if (this.state === 'open' && !this.policy.allowExecution()) { + this.close(); + } + + this.removeTimerData(timer as any); + }, + this.interval, + ); + + this.timers.add(timer as any); + } + + private open() { + this.state = 'open'; + this.policy.reset(); + + this.timers.forEach(timer => this.removeTimerData(timer)); + } + + private close() { + this.state = 'close'; + + setTimeout(() => this.state = 'half-open', this.timeout); + } + + private removeTimerData(timer: number) { + clearTimeout(timer as any); + this.timers.delete(timer as any); + + if (this.timers.size === 0) { + this.clearCallback(); + } + } + +} diff --git a/lib/circuit/CircuitState/factory.ts b/lib/circuit/CircuitState/factory.ts new file mode 100644 index 0000000..63a595f --- /dev/null +++ b/lib/circuit/CircuitState/factory.ts @@ -0,0 +1,21 @@ +import { Factory } from '../../interfaces/factory'; +import { PolicyFactory } from '../Policy/factory'; +import { CircuitState } from './CircuitState'; + +export class CircuitStateFactory implements Factory unknown)?]> { + + constructor( + private readonly timeout: number, + private readonly interval: number, + private readonly errorFilter: (error: Error) => boolean, + private readonly policyFactory: PolicyFactory, + private readonly policy: 'errors' | 'rate', + ) { } + + public create(clearCallback: () => unknown = () => { }): CircuitState { + const policy = this.policyFactory.create(this.policy); + + return new CircuitState(this.timeout, this.interval, this.errorFilter, policy, clearCallback); + } + +} diff --git a/lib/circuit/CircuitStateStorage/ArgumentsCircuitStateStorage.ts b/lib/circuit/CircuitStateStorage/ArgumentsCircuitStateStorage.ts new file mode 100644 index 0000000..e3f0f2a --- /dev/null +++ b/lib/circuit/CircuitStateStorage/ArgumentsCircuitStateStorage.ts @@ -0,0 +1,25 @@ +import { HashService } from '../../utils/hash'; +import { CircuitState } from '../CircuitState/CircuitState'; +import { CircuitStateFactory } from '../CircuitState/factory'; +import { CircuitStateStorage } from './CircuitStateStorage'; + +export class ArgumentsCircuitStateStorage implements CircuitStateStorage { + + private readonly argumentsStorage = new Map(); + + constructor( + private readonly circuitStateFactory: CircuitStateFactory, + private readonly hashService: HashService, + ) { } + + public get(args: any[]): CircuitState { + const key = this.hashService.calculate(args); + if (!this.argumentsStorage.has(key)) { + const circuitState = this.circuitStateFactory.create(() => this.argumentsStorage.delete(key)); + this.argumentsStorage.set(key, circuitState); + } + + return this.argumentsStorage.get(key); + } + +} diff --git a/lib/circuit/CircuitStateStorage/CircuitStateStorage.ts b/lib/circuit/CircuitStateStorage/CircuitStateStorage.ts new file mode 100644 index 0000000..667fb03 --- /dev/null +++ b/lib/circuit/CircuitStateStorage/CircuitStateStorage.ts @@ -0,0 +1,6 @@ +import { ClassType } from '../../interfaces/class'; +import { CircuitState } from '../CircuitState/CircuitState'; + +export interface CircuitStateStorage { + get(args: any[], instance: ClassType): CircuitState; +} diff --git a/lib/circuit/CircuitStateStorage/ClassCircuitStateStorage.ts b/lib/circuit/CircuitStateStorage/ClassCircuitStateStorage.ts new file mode 100644 index 0000000..baf60d5 --- /dev/null +++ b/lib/circuit/CircuitStateStorage/ClassCircuitStateStorage.ts @@ -0,0 +1,19 @@ +import { CircuitState } from '../CircuitState/CircuitState'; +import { CircuitStateFactory } from '../CircuitState/factory'; +import { CircuitStateStorage } from './CircuitStateStorage'; + +export class ClassCircuitStateStorage implements CircuitStateStorage { + + private circuitState: CircuitState = null; + + constructor(private readonly circuitStateFactory: CircuitStateFactory) { } + + public get(): CircuitState { + if (!this.circuitState) { + this.circuitState = this.circuitStateFactory.create(); + } + + return this.circuitState; + } + +} diff --git a/lib/circuit/CircuitStateStorage/InstanceCircuitStateStorage.ts b/lib/circuit/CircuitStateStorage/InstanceCircuitStateStorage.ts new file mode 100644 index 0000000..6a46f08 --- /dev/null +++ b/lib/circuit/CircuitStateStorage/InstanceCircuitStateStorage.ts @@ -0,0 +1,21 @@ +import { ClassType } from '../../interfaces/class'; +import { CircuitState } from '../CircuitState/CircuitState'; +import { CircuitStateFactory } from '../CircuitState/factory'; +import { CircuitStateStorage } from './CircuitStateStorage'; + +export class InstanceCircuitStateStorage implements CircuitStateStorage { + + private readonly instancesStorage = new WeakMap(); + + constructor(private readonly circuitStateFactory: CircuitStateFactory) { } + + public get(_: any[], instance: ClassType): CircuitState { + const hasState = this.instancesStorage.has(instance); + if (!hasState) { + this.instancesStorage.set(instance, this.circuitStateFactory.create()); + } + + return this.instancesStorage.get(instance); + } + +} diff --git a/lib/circuit/CircuitStateStorage/factory.ts b/lib/circuit/CircuitStateStorage/factory.ts new file mode 100644 index 0000000..9fda725 --- /dev/null +++ b/lib/circuit/CircuitStateStorage/factory.ts @@ -0,0 +1,33 @@ +import { Factory } from '../../interfaces/factory'; +import { HashService } from '../../utils/hash'; +import { CircuitStateFactory } from '../CircuitState/factory'; +import { ArgumentsCircuitStateStorage } from './ArgumentsCircuitStateStorage'; +import { CircuitStateStorage } from './CircuitStateStorage'; +import { ClassCircuitStateStorage } from './ClassCircuitStateStorage'; +import { InstanceCircuitStateStorage } from './InstanceCircuitStateStorage'; + +export class CircuitStateStorageFactory + implements Factory { + + constructor( + private readonly circuitStateFactory: CircuitStateFactory, + private readonly hashService: HashService, + ) { } + + public create(scope: 'args-hash' | 'class' | 'instance'): CircuitStateStorage { + switch (scope) { + case 'args-hash': + return new ArgumentsCircuitStateStorage(this.circuitStateFactory, this.hashService); + + case 'class': + return new ClassCircuitStateStorage(this.circuitStateFactory); + + case 'instance': + return new InstanceCircuitStateStorage(this.circuitStateFactory); + + default: + throw new Error(`@circuit unsuported scope option: ${scope}`); + } + } + +} diff --git a/lib/circuit/Policy/ErrorsPolicy.ts b/lib/circuit/Policy/ErrorsPolicy.ts new file mode 100644 index 0000000..5f37d7b --- /dev/null +++ b/lib/circuit/Policy/ErrorsPolicy.ts @@ -0,0 +1,33 @@ +import { Policy } from './Policy'; + +export class ErrorsPolicy implements Policy { + + private errors: number = 0; + + constructor( + private readonly threshold: number, + ) { } + + public registerCall(type: 'success' | 'error'): this { + this.errors += type === 'error' ? 1 : 0; + + return this; + } + + public deleteCallData(type: 'success' | 'error'): this { + this.errors -= type === 'error' ? 1 : 0; + + return this; + } + + public reset(): this { + this.errors = 0; + + return this; + } + + public allowExecution(): boolean { + return this.errors < this.threshold; + } + +} diff --git a/lib/circuit/Policy/Policy.ts b/lib/circuit/Policy/Policy.ts new file mode 100644 index 0000000..e7d6fae --- /dev/null +++ b/lib/circuit/Policy/Policy.ts @@ -0,0 +1,6 @@ +export interface Policy { + registerCall(type: 'success' | 'error'): this; + deleteCallData(type: 'success' | 'error'): this; + reset(): this; + allowExecution(): boolean; +} diff --git a/lib/circuit/Policy/RatePolicy.ts b/lib/circuit/Policy/RatePolicy.ts new file mode 100644 index 0000000..4b2206e --- /dev/null +++ b/lib/circuit/Policy/RatePolicy.ts @@ -0,0 +1,40 @@ +import { Policy } from './Policy'; + +export class RatePolicy implements Policy { + + private errors = 0; + private totalCalls = 0; + + constructor( + private readonly threshold: number, + ) { } + + public registerCall(type: 'success' | 'error'): this { + this.errors += type === 'error' ? 1 : 0; + this.totalCalls += 1; + + return this; + } + + public deleteCallData(type: 'success' | 'error'): this { + this.errors -= type === 'error' ? 1 : 0; + this.totalCalls -= 1; + + return this; + } + + public reset(): this { + this.errors = this.totalCalls = 0; + + return this; + } + + public allowExecution(): boolean { + return this.rate() < this.threshold; + } + + private rate(): number { + return this.totalCalls === 0 ? 0 : this.errors / this.totalCalls; + } + +} diff --git a/lib/circuit/Policy/factory.ts b/lib/circuit/Policy/factory.ts new file mode 100644 index 0000000..2f68742 --- /dev/null +++ b/lib/circuit/Policy/factory.ts @@ -0,0 +1,25 @@ +import { Factory } from '../../interfaces/factory'; +import { Policy } from './Policy'; +import { ErrorsPolicy } from './ErrorsPolicy'; +import { RatePolicy } from './RatePolicy'; + +export class PolicyFactory implements Factory { + + constructor( + private readonly threshold: number, + ) { } + + public create(policy: 'errors' | 'rate'): Policy { + switch (policy) { + case 'errors': + return new ErrorsPolicy(this.threshold); + + case 'rate': + return new RatePolicy(this.threshold); + + default: + throw new Error(`@circuit unsuported policy type: ${policy}`); + } + } + +} diff --git a/lib/circuit/index.ts b/lib/circuit/index.ts new file mode 100644 index 0000000..308f5f4 --- /dev/null +++ b/lib/circuit/index.ts @@ -0,0 +1,89 @@ +import { raiseStrategy } from '../utils'; +import { HashService } from '../utils/hash'; +import { isPromiseLike } from '../utils/isPromiseLike'; +import { CircuitOptions, DEFAULT_OPTIONS } from './CircuitOptions'; +import { CircuitStateFactory } from './CircuitState/factory'; +import { CircuitStateStorageFactory } from './CircuitStateStorage/factory'; +import { PolicyFactory } from './Policy/factory'; +import { CircuitStateStorage } from './CircuitStateStorage/CircuitStateStorage'; + +export { CircuitOptions }; + +/** + * A circuit breaker. + * After the method fails `threshold` count it enters the closed state and + * throws a `Circuit closed.` error. Once in closed state, the circuit fails + * for the provided `timeout` milliseconds. After the `timeout` interval expires + * the circuit transitions to half-opened state and allows next execution. + * If the execution succeeds then circuit transitions back to open state and resets + * the number of counted errors to zero. + * @param threshold the max number of failures until the circuit gets closed. + * @param timeout timeout in milliseconds to keep the circuit in closed state. + */ +export function circuit( + threshold: number, + timeout: number, + options?: CircuitOptions, +): MethodDecorator { + + const raise = raiseStrategy(options, 'throw'); + const circuitStateStorage = createCircuitStateStorage(threshold, timeout, options); + + return function (_: any, propertyKey: any, descriptor: PropertyDescriptor) { + const method: (...args: any[]) => any = descriptor.value; + + descriptor.value = function (...args: any[]) { + const state = circuitStateStorage.get(args, this); + const allowExecution = state.allowExecution(); + + if (!allowExecution) { + return raise(new Error(`@circuit: method ${propertyKey} is blocked.`)); + } + + function successRegister(data: T) { + state.register(); + + return data; + } + + function errorRegister(error: Error) { + state.register(error); + + return raise(error); + } + + try { + const result = method.apply(this, args); + + if (isPromiseLike(result)) { + return result.then( + data => successRegister(data), + error => errorRegister(error) as any, + ); + } + + return successRegister(result); + } catch (error) { + return errorRegister(error); + } + }; + + return descriptor; + }; + +} + +function createCircuitStateStorage( + threshold: number, + timeout: number, + options: CircuitOptions = DEFAULT_OPTIONS, +): CircuitStateStorage { + + const { interval, errorFilter = () => true, scope = 'class', policy = 'errors' } = options; + + const hashService = new HashService(); + const policyFactory = new PolicyFactory(threshold); + const cirucitStateFactory = + new CircuitStateFactory(timeout, interval, errorFilter, policyFactory, policy); + return new CircuitStateStorageFactory(cirucitStateFactory, hashService).create(scope); +} diff --git a/lib/interfaces/class.ts b/lib/interfaces/class.ts new file mode 100644 index 0000000..baa0161 --- /dev/null +++ b/lib/interfaces/class.ts @@ -0,0 +1,3 @@ +export type ClassType = { + new(...args: any[]): T; +}; diff --git a/lib/interfaces/factory.ts b/lib/interfaces/factory.ts new file mode 100644 index 0000000..0c709b3 --- /dev/null +++ b/lib/interfaces/factory.ts @@ -0,0 +1,3 @@ +export interface Factory { + create(...args: Args): T; +} diff --git a/lib/utils/hash/index.ts b/lib/utils/hash/index.ts new file mode 100644 index 0000000..9605660 --- /dev/null +++ b/lib/utils/hash/index.ts @@ -0,0 +1,9 @@ +import * as hash from 'object-hash'; + +export class HashService { + + public calculate(value: any): string { + return hash(value); + } + +} diff --git a/lib/utils/index.ts b/lib/utils/index.ts new file mode 100644 index 0000000..1b74309 --- /dev/null +++ b/lib/utils/index.ts @@ -0,0 +1,22 @@ +type RaiseStrategies = 'throw' | 'reject' | 'ignore' | 'ignoreAsync'; + +type StrategyOptions = { + onError?: RaiseStrategies; +}; + +export function raiseStrategy(options: StrategyOptions, defaultStrategy: RaiseStrategies) { + const value = options && options.onError || defaultStrategy; + + switch (value) { + case 'reject': + return err => Promise.reject(err); + case 'throw': + return (err) => { throw err; }; + case 'ignore': + return () => { }; + case 'ignoreAsync': + return () => Promise.resolve(); + default: + throw new Error(`Option ${value} is not supported for 'behavior'.`); + } +} diff --git a/lib/utils/isPromiseLike.ts b/lib/utils/isPromiseLike.ts new file mode 100644 index 0000000..aca71bd --- /dev/null +++ b/lib/utils/isPromiseLike.ts @@ -0,0 +1,3 @@ +export function isPromiseLike(data: any): data is PromiseLike { + return data && data.then && typeof data.then === 'function'; +} diff --git a/package.json b/package.json index fb711ae..5ebf3cc 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,9 @@ "devDependencies": { "@types/chai": "4.1.7", "@types/chai-as-promised": "7.1.0", + "@types/chai-spies": "1.0.0", "@types/mocha": "5.2.5", + "@types/object-hash": "1.3.0", "@types/sinon": "7.0.4", "chai": "4.2.0", "chai-as-promised": "7.1.1", diff --git a/test/circuit/CircuitState/CircuitState.spec.ts b/test/circuit/CircuitState/CircuitState.spec.ts new file mode 100644 index 0000000..e5b1915 --- /dev/null +++ b/test/circuit/CircuitState/CircuitState.spec.ts @@ -0,0 +1,252 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { CircuitState } from '../../../lib/circuit/CircuitState/CircuitState'; +import { ErrorsPolicy } from '../../../lib/circuit/Policy/ErrorsPolicy'; +import { Policy } from '../../../lib/circuit/Policy/Policy'; +import { delay } from '../../utils'; + +describe('@circuit CircuitState', () => { + + const timeout = 5; + const interval = timeout * 2; + let errorFilterStub: sinon.SinonStub<[Error], boolean>; + let policyStub: sinon.SinonStubbedInstance; + let clearCallbackStub: sinon.SinonStub<[], unknown>; + let service: CircuitState; + + beforeEach(() => { + errorFilterStub = sinon.stub().returns(true) as any; + clearCallbackStub = sinon.stub(); + policyStub = sinon.createStubInstance(ErrorsPolicy); + policyStub.allowExecution.returns(true); + + service = new CircuitState(timeout, interval, errorFilterStub, policyStub, clearCallbackStub); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(CircuitState)); + + }); + + describe('allow execution', () => { + + it('shuld return true if current state is open', () => { + expect(service.allowExecution()).to.be.true; + }); + + it('should return true if current state is half-open', async () => { + policyStub.allowExecution.returns(false); + service.register(new Error('error')); + + await delay(timeout); + + expect(service.allowExecution()).to.be.true; + }); + + it('should return false if current state is close', () => { + policyStub.allowExecution.returns(false); + service.register(new Error('error')); + + expect(service.allowExecution()).to.be.false; + }); + + }); + + describe('register', () => { + + it('should call errorFilter function to check if error should be counted', () => { + service.register(new Error('42')); + + expect(errorFilterStub.calledOnce).to.be.true; + }); + + describe('if state is half-open', () => { + + beforeEach(async () => { + policyStub.allowExecution.returns(false); + service.register(new Error('error')); + + await delay(timeout); + + policyStub.allowExecution.returns(true); + }); + + it('should became open if was called with success', () => { + service.register(); + + expect(service.allowExecution()).to.be.true; + }); + + it('should became close if was called with error', () => { + service.register(new Error('42')); + + expect(service.allowExecution()).to.be.false; + }); + + it('should became open if was called with error but errorFilter returned false', () => { + errorFilterStub.returns(false); + + service.register(new Error('42')); + + expect(service.allowExecution()).to.be.true; + }); + + }); + + describe('when state became open', () => { + + beforeEach(async () => { + policyStub.allowExecution.returns(false); + service.register(new Error('error')); + + await delay(timeout); + + policyStub.allowExecution.returns(true); + }); + + it('should call policy.reset', () => { + service.register(); + + expect(policyStub.reset.calledOnce).to.be.true; + }); + + it('should clear timeouts', async () => { + policyStub.deleteCallData.reset(); + + service.register(); + await delay(interval); + + expect(policyStub.deleteCallData.calledOnce).to.be.true; + }); + + it('should call clearCallback', () => { + service.register(); + + expect(clearCallbackStub.calledOnce).to.be.true; + }); + + }); + + describe('when state became close', () => { + + describe('should set state to half-open after timeout ms', () => { + + beforeEach(async () => { + policyStub.allowExecution.returns(false); + service.register(new Error('error')); + + await delay(timeout); + + policyStub.allowExecution.returns(true); + }); + + it('should allow execution after interval', async () => { + service.register(new Error('42')); + await delay(timeout); + + expect(service.allowExecution()).to.be.true; + }); + + it('should not allow execution if after interval it register error', async () => { + service.register(new Error('42')); + await delay(timeout); + + service.register(new Error('42')); + + expect(service.allowExecution()).to.be.false; + }); + + }); + + }); + + describe('should call policy.registerCall', () => { + + it('should call policy.registerCall', () => { + service.register(); + + expect(policyStub.registerCall.calledOnce).to.be.true; + }); + + it('should call polu.registerCall with "success" if is success call', () => { + service.register(); + + expect(policyStub.registerCall.calledWith('success')).to.be.true; + }); + + it('should call policy.registerCall with "error" if was error in execution', () => { + service.register(new Error('42')); + + expect(policyStub.registerCall.calledWith('error')).to.be.true; + }); + + }); + + describe('remove execution', () => { + + const interval = 3; + let service: CircuitState; + + beforeEach( + () => service = new CircuitState( + timeout, + interval, + errorFilterStub, + policyStub, + clearCallbackStub, + ), + ); + + it('should remove execution after interval ms', async () => { + service.register(); + + await delay(interval); + + expect(policyStub.deleteCallData.calledOnce).to.be.true; + }); + + it('should became close if state is open and policy don\'t allow execution', async () => { + policyStub.allowExecution.returns(true); + + service.register(); + policyStub.allowExecution.returns(false); + + await delay(interval); + + expect(service.allowExecution()).to.be.false; + }); + + it('should call policy.allowExecution twice', async () => { + policyStub.allowExecution.returns(true); + + service.register(); + policyStub.allowExecution.returns(false); + + await delay(interval); + + expect(policyStub.allowExecution.calledTwice).to.be.true; + }); + + }); + + it('should call policy.allowExecution after register call', () => { + service.register(); + + expect(policyStub.allowExecution.calledOnce).to.be.true; + }); + + it('should became close after register if policy don\'t allow execution', () => { + policyStub.allowExecution.returns(false); + + service.register(); + + expect(service.allowExecution()).to.be.false; + }); + + it('should return self instance', () => expect(service.register()).to.equals(service)); + + }); + +}); diff --git a/test/circuit/CircuitState/factory.spec.ts b/test/circuit/CircuitState/factory.spec.ts new file mode 100644 index 0000000..51b9bcd --- /dev/null +++ b/test/circuit/CircuitState/factory.spec.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { CircuitState } from '../../../lib/circuit/CircuitState/CircuitState'; +import { CircuitStateFactory } from '../../../lib/circuit/CircuitState/factory'; +import { PolicyFactory } from '../../../lib/circuit/Policy/factory'; + +describe('@circuit CircuitStateFactory', () => { + + const timeout = 10; + const interval = 5; + const errorFilter: (error: Error) => boolean = () => true; + let policyFactoryStub: sinon.SinonStubbedInstance; + let service: CircuitStateFactory; + + beforeEach(() => { + policyFactoryStub = sinon.createStubInstance(PolicyFactory); + + service = + new CircuitStateFactory(timeout, interval, errorFilter, policyFactoryStub as any, 'rate'); + }); + + it('should create', () => { + expect(service).to.be.instanceOf(CircuitStateFactory); + }); + + describe('create', () => { + + it('should call policyFactory.create to create policy', () => { + service.create(); + + expect(policyFactoryStub.create.calledOnce).to.be.true; + }); + + it('should return instance of CircuitState', () => { + expect(service.create()).to.be.instanceOf(CircuitState); + }); + + }); + +}); diff --git a/test/circuit/CircuitStateStorage/ArgumentsCircuitStateStorage.spec.ts b/test/circuit/CircuitStateStorage/ArgumentsCircuitStateStorage.spec.ts new file mode 100644 index 0000000..7047573 --- /dev/null +++ b/test/circuit/CircuitStateStorage/ArgumentsCircuitStateStorage.spec.ts @@ -0,0 +1,88 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { CircuitStateFactory } from '../../../lib/circuit/CircuitState/factory'; +import { + ArgumentsCircuitStateStorage, +} from '../../../lib/circuit/CircuitStateStorage/ArgumentsCircuitStateStorage'; +import { HashService } from '../../../lib/utils/hash'; + +describe('@circuit ArgumentsCircuitStateStorage', () => { + + let circuitStateFactoryStub: sinon.SinonStubbedInstance; + const hashedKey = 'key'; + let hashServiceStub: sinon.SinonStubbedInstance; + let service: ArgumentsCircuitStateStorage; + + beforeEach(() => { + circuitStateFactoryStub = sinon.createStubInstance(CircuitStateFactory); + hashServiceStub = sinon.createStubInstance(HashService); + hashServiceStub.calculate.returns(hashedKey); + + service = new ArgumentsCircuitStateStorage(circuitStateFactoryStub as any, hashServiceStub); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(ArgumentsCircuitStateStorage)); + + }); + + describe('get', () => { + + it('should use circuitStateFactory.create to create CircuitState instance', () => { + service.get([]); + + expect(circuitStateFactoryStub.create.calledOnce).to.be.true; + }); + + it('should not create new instance of CircuitState if have one for current arguments', () => { + circuitStateFactoryStub.create.returns({} as any); + service.get([]); + circuitStateFactoryStub.create.reset(); + + service.get([]); + + expect(circuitStateFactoryStub.create.called).to.be.false; + }); + + it('should create CircuitState if is called first time for current arguments', () => { + const args = []; + + circuitStateFactoryStub.create.returns({} as any); + service.get([{ some: 'arguments' }]); + hashServiceStub.calculate.returns('another hash'); + + const expected = {} as any; + circuitStateFactoryStub.create.returns(expected); + + expect(service.get(args)).to.equals(expected); + }); + + it('should use hashService to calculate arugments hash', () => { + service.get([]); + + expect(hashServiceStub.calculate.calledOnce).to.be.true; + }); + + it('should call hashService.calculate with correct arguments', () => { + const args = [{ some: 'arguments' }]; + + service.get(args); + + expect(hashServiceStub.calculate.calledWithExactly(args)).to.be.true; + }); + + it('should use create CircuitState if is called not first time for current arguments', () => { + const expected = {} as any; + const args = []; + circuitStateFactoryStub.create.returns(expected); + service.get(args); + circuitStateFactoryStub.create.returns(null); + + expect(service.get(args)).to.equals(expected); + }); + + }); + +}); diff --git a/test/circuit/CircuitStateStorage/ClassCircuitStateStorage.spec.ts b/test/circuit/CircuitStateStorage/ClassCircuitStateStorage.spec.ts new file mode 100644 index 0000000..208da97 --- /dev/null +++ b/test/circuit/CircuitStateStorage/ClassCircuitStateStorage.spec.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { + ClassCircuitStateStorage, +} from '../../../lib/circuit/CircuitStateStorage/ClassCircuitStateStorage'; +import { CircuitStateFactory } from '../../../lib/circuit/CircuitState/factory'; + +describe('@circuit ClassCircuitStateStorage', () => { + + let circuitStateFactoryStub: sinon.SinonStubbedInstance; + let service: ClassCircuitStateStorage; + + beforeEach(() => { + circuitStateFactoryStub = sinon.createStubInstance(CircuitStateFactory); + + service = new ClassCircuitStateStorage(circuitStateFactoryStub as any); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(ClassCircuitStateStorage)); + }); + + describe('get', () => { + + it('should create new instance of circuit state if is first call and return it', () => { + const expectedResult = {} as any; + circuitStateFactoryStub.create.returns(expectedResult); + + expect(service.get()).to.equals(expectedResult); + }); + + it('should return existent instance of circuit state if is not first call', () => { + const instance = {} as any; + circuitStateFactoryStub.create.returns(instance); + + expect(service.get()).to.equals(instance); + }); + + it('should use circuitStateFactory.create to create new instance of CircuitState', () => { + service.get(); + + expect(circuitStateFactoryStub.create.calledOnce).to.be.true; + }); + + it('should not call circuitStateFactory.create if CircuitState instance already exists', () => { + const instance = {} as any; + circuitStateFactoryStub.create.returns(instance); + service.get(); + circuitStateFactoryStub.create.reset(); + + service.get(); + + expect(circuitStateFactoryStub.create.calledOnce).to.be.false; + }); + + }); + +}); diff --git a/test/circuit/CircuitStateStorage/InstanceCircuitStateStorage.spec.ts b/test/circuit/CircuitStateStorage/InstanceCircuitStateStorage.spec.ts new file mode 100644 index 0000000..2f489b4 --- /dev/null +++ b/test/circuit/CircuitStateStorage/InstanceCircuitStateStorage.spec.ts @@ -0,0 +1,65 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { + InstanceCircuitStateStorage, +} from '../../../lib/circuit/CircuitStateStorage/InstanceCircuitStateStorage'; +import { CircuitStateFactory } from '../../../lib/circuit/CircuitState/factory'; + +describe('@circuit InstanceCircuitStateStorage', () => { + + let circuitStateFactoryStub: sinon.SinonStubbedInstance; + let service: InstanceCircuitStateStorage; + + beforeEach(() => { + circuitStateFactoryStub = sinon.createStubInstance(CircuitStateFactory); + + service = new InstanceCircuitStateStorage(circuitStateFactoryStub as any); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(InstanceCircuitStateStorage)); + + }); + + describe('get', () => { + + it('should create CircuitState if is called first time for current instance', () => { + const expected = {} as any; + circuitStateFactoryStub.create.returns(expected); + + expect(service.get([], {} as any)).to.equals(expected); + }); + + it('should not use create CircuitState if is called for current instance', () => { + const expected = {} as any; + const instance = {} as any; + circuitStateFactoryStub.create.returns(expected); + service.get([], instance); + circuitStateFactoryStub.create.reset(); + + expect(service.get([], instance)).to.equals(expected); + }); + + it('should use circuitStateFactory.create to create new instance', () => { + service.get([], {} as any); + + expect(circuitStateFactoryStub.create.calledOnce).to.be.true; + }); + + it('should not use circuitStateFactory.create if correct instance already exists', () => { + const expected = {} as any; + const instance = {} as any; + circuitStateFactoryStub.create.returns(expected); + service.get([], instance); + circuitStateFactoryStub.create.reset(); + + service.get([], instance); + + expect(circuitStateFactoryStub.create.called).to.be.false; + }); + + }); + +}); diff --git a/test/circuit/CircuitStateStorage/factory.spec.ts b/test/circuit/CircuitStateStorage/factory.spec.ts new file mode 100644 index 0000000..99fa7c3 --- /dev/null +++ b/test/circuit/CircuitStateStorage/factory.spec.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { CircuitStateFactory } from '../../../lib/circuit/CircuitState/factory'; +import { + ArgumentsCircuitStateStorage, +} from '../../../lib/circuit/CircuitStateStorage/ArgumentsCircuitStateStorage'; +import { + ClassCircuitStateStorage, +} from '../../../lib/circuit/CircuitStateStorage/ClassCircuitStateStorage'; +import { CircuitStateStorageFactory } from '../../../lib/circuit/CircuitStateStorage/factory'; +import { + InstanceCircuitStateStorage, +} from '../../../lib/circuit/CircuitStateStorage/InstanceCircuitStateStorage'; +import { HashService } from '../../../lib/utils/hash'; + +describe('@circuit CircuitStateStorageFactory', () => { + + let circuitStateFactoryStub: sinon.SinonStubbedInstance; + let hashServiceStub: sinon.SinonStubbedInstance; + + let service: CircuitStateStorageFactory; + + beforeEach(() => { + circuitStateFactoryStub = sinon.createStubInstance(CircuitStateFactory); + hashServiceStub = sinon.createStubInstance(HashService); + + service = + new CircuitStateStorageFactory(circuitStateFactoryStub as any, hashServiceStub); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(CircuitStateStorageFactory)); + + }); + + describe('create', () => { + + it('should create instance of ArgumentsCircuitStateStorage for "args-hash" argument', () => { + expect(service.create('args-hash')).to.be.instanceOf(ArgumentsCircuitStateStorage); + }); + + it('should create instance of ClassCircuitStateStorage for "class" argument', () => { + expect(service.create('class')).to.be.instanceOf(ClassCircuitStateStorage); + }); + + it('should create instance of InstanceCircuitStateStorage for "instance" argument', () => { + expect(service.create('instance')).to.be.instanceOf(InstanceCircuitStateStorage); + }); + + }); + +}); diff --git a/test/circuit/Policy/ErrorsPolicy.spec.ts b/test/circuit/Policy/ErrorsPolicy.spec.ts new file mode 100644 index 0000000..b71fb9c --- /dev/null +++ b/test/circuit/Policy/ErrorsPolicy.spec.ts @@ -0,0 +1,101 @@ +import { expect } from 'chai'; + +import { ErrorsPolicy } from '../../../lib/circuit/Policy/ErrorsPolicy'; +import { repeat } from '../../utils'; + +describe('@circuit ErrorsPolicy', () => { + + const threshold = 3; + let service: ErrorsPolicy; + + beforeEach(() => service = new ErrorsPolicy(threshold)); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(ErrorsPolicy)); + + }); + + describe('registerCall', () => { + + it('should increase number of errors with 1 if is error', () => { + repeat(() => service.registerCall('error'), threshold); + + expect(service.allowExecution()).to.be.false; + }); + + it('should not increase number of errors if is success execution', () => { + repeat(() => service.registerCall('success'), threshold); + + expect(service.allowExecution()).to.be.true; + }); + + it('should return self instance', () => { + expect(service.registerCall('success')).to.be.equals(service); + }); + + }); + + describe('deleteCallData', () => { + + it('should decrease number of errors with 1 if is error', () => { + repeat(() => service.registerCall('error'), threshold); + + service.deleteCallData('error'); + + expect(service.allowExecution()).to.be.true; + }); + + it('should not decrease number of errors if is success execution', () => { + repeat(() => service.registerCall('error'), threshold); + + service.deleteCallData('success'); + + expect(service.allowExecution()).to.be.false; + }); + + it('should return self instance', () => { + expect(service.deleteCallData('success')).to.be.equals(service); + }); + + }); + + describe('reset', () => { + + it('should set number of errors to 0', () => { + repeat(() => service.registerCall('error'), threshold); + + service.reset(); + + expect(service.allowExecution()).to.be.true; + }); + + it('should return self instance', () => { + expect(service.reset()).to.be.instanceOf(ErrorsPolicy); + }); + + }); + + describe('allowExecution', () => { + + it('should return true if number of errors is less than threshold', () => { + repeat(() => service.registerCall('error'), threshold - 1); + + expect(service.allowExecution()).to.be.true; + }); + + it('should return false if number of errors is equals with threshold', () => { + repeat(() => service.registerCall('error'), threshold); + + expect(service.allowExecution()).to.be.false; + }); + + it('should return false if number of errors is greater than threshold', () => { + repeat(() => service.registerCall('error'), threshold + 1); + + expect(service.allowExecution()).to.be.false; + }); + + }); + +}); diff --git a/test/circuit/Policy/RatePolicy.spec.ts b/test/circuit/Policy/RatePolicy.spec.ts new file mode 100644 index 0000000..63dc1ad --- /dev/null +++ b/test/circuit/Policy/RatePolicy.spec.ts @@ -0,0 +1,140 @@ +import { expect } from 'chai'; + +import { RatePolicy } from '../../../lib/circuit/Policy/RatePolicy'; +import { repeat } from '../../utils'; + +describe('@circuit RatePolicy', () => { + + const threshold = 0.6; + let service: RatePolicy; + + beforeEach(() => service = new RatePolicy(threshold)); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(RatePolicy)); + + }); + + describe('registerCall', () => { + + it('should increase number of errors with 1 if is error', () => { + service.registerCall('error'); + + expect(service.allowExecution()).to.be.false; + }); + + it('should not increase number of errors if is success execution', () => { + service.registerCall('success'); + + expect(service.allowExecution()).to.be.true; + }); + + it('should increase total calls with 1 if is error', () => { + service.registerCall('success').registerCall('success'); + + service.registerCall('error'); + + expect(service.allowExecution()).to.be.true; + }); + + it('should increase total calls with 1 if is success execution', () => { + service.registerCall('error').registerCall('success'); + + expect(service.allowExecution()).to.be.true; + }); + + it('should return self instance', () => { + expect(service.registerCall('success')).to.be.equals(service); + }); + + }); + + describe('deleteCallData', () => { + + it('should decrease number of errors with 1 if is error', () => { + service.registerCall('error'); + + service.deleteCallData('error'); + + expect(service.allowExecution()).to.be.true; + }); + + it('should not decrease number of errors if is success execution', () => { + service.registerCall('success').registerCall('error'); + + service.deleteCallData('success'); + + expect(service.allowExecution()).to.be.false; + }); + + it('should decrease total calls with 1 if is error', () => { + service.registerCall('error').registerCall('error').registerCall('success'); + + service.deleteCallData('error'); + + expect(service.allowExecution()).to.be.true; + }); + + it('should decrease total calls with 1 if is success execution', () => { + service.registerCall('success').registerCall('error'); + + service.deleteCallData('success'); + + expect(service.allowExecution()).to.be.false; + }); + + it('should return self instance', () => { + expect(service.deleteCallData('success')).to.be.equals(service); + }); + + }); + + describe('reset', () => { + + it('should set number of errors to 0', () => { + service.registerCall('error'); + + service.reset(); + + expect(service.allowExecution()).to.be.true; + }); + + it('should set number of totalCalls to 0', () => { + service.registerCall('error'); + + service.reset(); + + expect(service.allowExecution()).to.be.true; + }); + + it('should return self instance', () => { + expect(service.reset()).to.be.equals(service); + }); + + }); + + describe('allowExecution', () => { + + it('should return true if number of errors / total calls is less than threshold', () => { + service.registerCall('success').registerCall('error'); + + expect(service.allowExecution()).to.equals(true); + }); + + it('should return false if number of errors is equals with threshold', () => { + repeat(() => service.registerCall('success'), 2); + repeat(() => service.registerCall('error'), 3); + + expect(service.allowExecution()).to.equals(false); + }); + + it('should return false if number of errors is greater than threshold', () => { + service.registerCall('error'); + + expect(service.allowExecution()).to.equals(false); + }); + + }); + +}); diff --git a/test/circuit/Policy/factory.spec.ts b/test/circuit/Policy/factory.spec.ts new file mode 100644 index 0000000..ba271ca --- /dev/null +++ b/test/circuit/Policy/factory.spec.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; + +import { ErrorsPolicy } from '../../../lib/circuit/Policy/ErrorsPolicy'; +import { PolicyFactory } from '../../../lib/circuit/Policy/factory'; +import { RatePolicy } from '../../../lib/circuit/Policy/RatePolicy'; + +describe('@circuit PolicyFactory', () => { + + const threshold = 42; + let service: PolicyFactory; + + beforeEach(() => service = new PolicyFactory(threshold)); + + it('should create', () => expect(service).to.be.instanceOf(PolicyFactory)); + + describe('create', () => { + + it('should return instance of errors policy if policy is errors', () => { + expect(service.create('errors')).to.be.instanceOf(ErrorsPolicy); + }); + + it('should return instance of rate policy if policy is errors', () => { + expect(service.create('rate')).to.be.instanceOf(RatePolicy); + }); + + it('should throw if policy is not a valid policy', () => { + const policy = 'not a valid policy' as any; + + expect(() => service.create(policy)).to.throw(`@circuit unsuported policy type: ${policy}`); + }); + + }); + +}); diff --git a/test/circuit/circuit.spec.ts b/test/circuit/circuit.spec.ts new file mode 100644 index 0000000..fe7a098 --- /dev/null +++ b/test/circuit/circuit.spec.ts @@ -0,0 +1,461 @@ +import { expect } from 'chai'; + +import { circuit, CircuitOptions } from '../../lib'; +import { delay, repeat } from '../utils'; + +describe('@circuit', () => { + + function factory(threshold: number, timeout: number, options?: CircuitOptions) { + + class Test { + + @circuit(threshold, timeout, options) + public sqrt(x: number = 42 * 42) { + if (x < 0) { + throw new Error('Can not evaluate sqrt from an negative number'); + } + + return Math.sqrt(x); + } + + @circuit(threshold, timeout, options) + public async asyncSqrt(x: number = 42 * 42) { + if (x < 0) { + throw new Error('Can not evaluate sqrt from an negative number'); + } + + return Math.sqrt(x); + } + + } + + return Test; + } + + describe('defaults', () => { + + const threshold = 1; + const timeout = 20; + + it('should close method execution after threshold errors', () => { + const instance = new (factory(threshold, timeout)); + + try { + instance.sqrt(-1); + } catch { } + + expect(() => instance.sqrt()).to.throw('@circuit: method sqrt is blocked.'); + }); + + it('should change state from close to half-open after timeout', async () => { + const instance = new (factory(threshold, timeout)); + + try { + instance.sqrt(-1); + } catch { } + + await delay(timeout); + + expect(instance.sqrt()).to.equals(42); + }); + + it('should change state from half-open to open if executes with success', async () => { + const instance = new (factory(threshold, timeout)); + + try { + instance.sqrt(-1); + } catch { } + + await delay(timeout); + + instance.sqrt(); + expect(instance.sqrt()).to.equals(42); + }); + + it('should change state from half-open to close if executes with error', async () => { + const instance = new (factory(threshold, timeout)); + + try { + instance.sqrt(-1); + } catch { } + + await delay(timeout); + + try { + instance.sqrt(-1); + } catch { } + expect(() => instance.sqrt(-1)).to.throw('@circuit: method sqrt is blocked.'); + }); + + }); + + describe('it should not change method behaviour', () => { + + it('should work without parameters', () => { + const instance = new (factory(100, 100)); + + expect(instance.sqrt()).to.equals(42); + }); + + it('should work with parameters', () => { + const instance = new (factory(100, 100)); + + expect(instance.sqrt(100)).to.equals(10); + }); + + it('should throw initial error if method fail', () => { + const instance = new (factory(100, 100)); + + const message = 'Can not evaluate sqrt from an negative number'; + expect(() => instance.sqrt(-1)).to.throw(message); + }); + + it('should reject intial error if method is async', async () => { + const instance = new (factory(100, 100)); + + const message = 'Can not evaluate sqrt from an negative number'; + await expect(instance.asyncSqrt(-1)).to.be.rejectedWith(message); + }); + + }); + + describe('options', () => { + + it('should not throw if options was not provided', () => { + expect(() => new (factory(100, 100))).not.to.throw(); + }); + + it('should not throw if has valid options', () => { + const options: CircuitOptions = { + interval: 100, + errorFilter: () => false, + onError: 'ignore', + policy: 'rate', + scope: 'instance', + }; + + expect(() => new (factory(100, 100, options))).not.to.throw(); + }); + + it('should throw if onError does not have a valid value', () => { + const options: CircuitOptions = { onError: 'abc' as any }; + + const expectedMessage = 'Option abc is not supported for \'behavior\'.'; + expect(() => new (factory(100, 1000, options))).to.throw(expectedMessage); + }); + + it('should throw if policy does not have a valid value', () => { + const options: CircuitOptions = { policy: 'def' as any }; + const instance = new (factory(100, 1000, options)); + + const expectedMessage = '@circuit unsuported policy type: def'; + expect(() => instance.sqrt()).to.throw(expectedMessage); + }); + + it('should throw if scope does not have a valid value', () => { + const options: CircuitOptions = { scope: 'qwe' as any }; + + const expectedMessage = '@circuit unsuported scope option: qwe'; + expect(() => new (factory(100, 1000, options))).to.throw(expectedMessage); + }); + + }); + + describe('options behaviour', () => { + + describe('interval', () => { + + const timeout = 10; + const options: CircuitOptions = { interval: 6 }; + + it('should block execution after threshold errors in specified interval', () => { + const threshold = 0.1; + const instance = new (factory(threshold, timeout, { ...options, policy: 'rate' })); + + instance.sqrt(); + try { + instance.sqrt(-1); + } catch { } + + expect(() => instance.sqrt()).to.throw('@circuit: method sqrt is blocked.'); + }); + + it('should ignore calls after specified interval', async () => { + const threshold = 2; + const instance = new (factory(threshold, timeout, { ...options, policy: 'errors' })); + + try { + instance.sqrt(-1); + } catch { } + await delay(options.interval); + try { + instance.sqrt(-1); + } catch { } + + expect(instance.sqrt()).to.equals(42); + + }); + + }); + + describe('errorFilter', () => { + + it('should count only filtered errors', async () => { + const threshold = 2; + const timeout = 100; + + let errorIndex = 0; + const options: CircuitOptions = { + errorFilter: () => { + errorIndex = errorIndex + 1; + return errorIndex % 2 === 0; + }, + }; + + const instance = new (factory(threshold, timeout, options)); + + try { + instance.sqrt(-1); + } catch { } + try { + instance.sqrt(-1); + } catch { } + + expect(instance.sqrt()).to.equals(42); + }); + + }); + + describe('onError', () => { + + const threshold = 4; + const timeout = 200; + + describe('should throw if onError strategy is \'throw\'', () => { + + it('method error', () => { + const instance = new (factory(threshold, timeout, { onError: 'throw' })); + + expect(() => instance.sqrt(-1)).to.throw('Can not evaluate sqrt from an negative number'); + }); + + it('decorator error', () => { + const instance = new (factory(threshold, timeout, { onError: 'throw' })); + + repeat( + () => { + try { instance.sqrt(-1); } catch { } + }, + threshold, + ); + + expect(() => instance.sqrt(-1)).to.throw('@circuit: method sqrt is blocked.'); + }); + + }); + + describe('should reject if onError strategy is \'reject\'', () => { + + it('method error', async () => { + const instance = new (factory(threshold, timeout, { onError: 'reject' })); + + const message = 'Can not evaluate sqrt from an negative number'; + await expect(instance.asyncSqrt(-1)).to.be.rejectedWith(message); + }); + + it('decorator error', async () => { + const instance = new (factory(threshold, timeout, { onError: 'reject' })); + + const promises = repeat( + () => instance.asyncSqrt(-1).catch(() => { }), + threshold, + ); + await Promise.all(promises); + + const message = '@circuit: method asyncSqrt is blocked.'; + await expect(instance.asyncSqrt(-1)).to.be.rejectedWith(message); + }); + + }); + + describe('should return undefined if onError strategy is \'ignore\'', () => { + + it('method error', () => { + const instance = new (factory(threshold, timeout, { onError: 'ignore' })); + + expect(instance.sqrt(-1)).to.equals(undefined); + }); + + it('decorator error', () => { + const instance = new (factory(threshold, timeout, { onError: 'ignore' })); + + repeat(() => instance.sqrt(-1), threshold); + + expect(instance.sqrt(-1)).to.equals(undefined); + }); + + }); + + describe('should return resolved promise if onError strategy is \'ignoreAsync\'', () => { + + it('method error', async () => { + const instance = new (factory(threshold, timeout, { onError: 'ignoreAsync' })); + + expect(await instance.asyncSqrt(-1)).to.equals(undefined); + }); + + it('decorator error', async () => { + const instance = new (factory(threshold, timeout, { onError: 'ignoreAsync' })); + + const promises = repeat(() => instance.asyncSqrt(-1), threshold); + await Promise.all(promises); + + expect(await instance.asyncSqrt(-1)).to.equals(undefined); + }); + + }); + + }); + + describe('policy', () => { + const timeout = 10; + + describe('errors', () => { + const threshold = 2; + const options: CircuitOptions = { policy: 'errors' }; + + it('should block after threshold exceded', () => { + const instance = new (factory(threshold, timeout, options)); + + repeat( + () => { + try { instance.sqrt(-1); } catch { } + }, + threshold, + ); + + expect(() => instance.sqrt(-1)).to.throw('@circuit: method sqrt is blocked.'); + }); + + }); + + describe('rate', () => { + const threshold = 0.5; + const options: CircuitOptions = { policy: 'rate' }; + + it('should block after threshold exceded', () => { + const instance = new (factory(threshold, timeout, options)); + + try { instance.sqrt(); } catch { } + try { instance.sqrt(); } catch { } + try { instance.sqrt(-1); } catch { } + try { instance.sqrt(-1); } catch { } + + expect(() => instance.sqrt()).to.throw('@circuit: method sqrt is blocked.'); + }); + + }); + + }); + + describe('scope', () => { + const threshold = 1; + const timeout = 10; + + describe('class', () => { + const options: CircuitOptions = { scope: 'class' }; + + it('should block execution for every instance of class', () => { + const constructor = factory(threshold, timeout, options); + const firstInstance = new constructor(); + const secondInstance = new constructor(); + + try { firstInstance.sqrt(-1); } catch { } + + expect(() => firstInstance.sqrt()).to.throw('@circuit: method sqrt is blocked.'); + expect(() => secondInstance.sqrt()).to.throw('@circuit: method sqrt is blocked.'); + }); + + it('should return to open state for every instance of class', async () => { + const constructor = factory(0.1, timeout, { ...options, policy: 'rate' }); + const firstInstance = new constructor(); + const secondInstance = new constructor(); + + try { firstInstance.sqrt(-1); } catch { } + + await delay(timeout); + + expect(firstInstance.sqrt()).to.equals(42); + expect(secondInstance.sqrt()).to.equals(42); + }); + + }); + + describe('instance', () => { + const options: CircuitOptions = { scope: 'instance' }; + + it('should block only current instance of class', () => { + const constructor = factory(threshold, timeout, options); + const firstInstance = new constructor(); + const secondInstance = new constructor(); + + try { firstInstance.sqrt(-1); } catch { } + + expect(() => firstInstance.sqrt()).to.throw('@circuit: method sqrt is blocked.'); + expect(secondInstance.sqrt()).to.equals(42); + }); + + it('should return to open for blocked instance of class', async () => { + const constructor = factory(threshold, timeout, options); + const firstInstance = new constructor(); + const secondInstance = new constructor(); + + try { firstInstance.sqrt(-1); } catch { } + + await delay(timeout); + + try { secondInstance.sqrt(-1); } catch { } + + expect(firstInstance.sqrt()).to.equals(42); + expect(() => secondInstance.sqrt()).to.throw('@circuit: method sqrt is blocked.'); + }); + + }); + + describe('arguments hash', () => { + const options: CircuitOptions = { scope: 'args-hash' }; + + it('should block call by call arguments', () => { + const constructor = factory(threshold, timeout, options); + const firstInstance = new constructor(); + const secondInstance = new constructor(); + + try { firstInstance.sqrt(-1); } catch { } + + expect(() => firstInstance.sqrt(-1)).to.throw('@circuit: method sqrt is blocked.'); + expect(secondInstance.sqrt()).to.equals(42); + }); + + it('should return to open state for blocked arguemnts', async () => { + const constructor = factory(threshold, timeout, options); + const firstInstance = new constructor(); + const secondInstance = new constructor(); + + try { firstInstance.sqrt(-1); } catch { } + + await delay(timeout); + + try { secondInstance.sqrt(-2); } catch { } + + expect(() => firstInstance.sqrt(-1)) + .to.throw('Can not evaluate sqrt from an negative number'); + expect(() => secondInstance.sqrt(-2)).to.throw('@circuit: method sqrt is blocked.'); + }); + + }); + + }); + + }); + +}); diff --git a/test/indext.ts b/test/indext.ts index 6dfdc7f..7edf60a 100644 --- a/test/indext.ts +++ b/test/indext.ts @@ -1,4 +1,8 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; +import * as chaiSpies from 'chai-spies'; +import * as sinonChai from 'sinon-chai'; chai.use(chaiAsPromised); +chai.use(chaiSpies); +chai.use(sinonChai);