diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 8a37dad418f..f16dff306c8 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getCircuitState` method to `ServicePolicy` ([#7164](https://github.com/MetaMask/core/pull/7164)) + - This can be used when working with a chain of services to know whether a service's underlying circuit is open or closed. +- Add `onAvailable` method to `ServicePolicy` ([#7164](https://github.com/MetaMask/core/pull/7164)) + - This can be used to listen for the initial successful execution of the service, or the first successful execution after the service becomes degraded or the circuit breaks. +- Add `reset` method to `ServicePolicy` ([#7164](https://github.com/MetaMask/core/pull/7164)) + - This can be used when working with a chain of services to reset the state of the circuit breaker policy (e.g. if a primary recovers and we want to reset the failovers). +- Export `CockatielEventEmitter` and `CockatielFailureReason` from Cockatiel ([#7164](https://github.com/MetaMask/core/pull/7164)) + - These can be used to further transform types for event emitters/listeners. + ## [11.15.0] ### Added diff --git a/packages/controller-utils/src/create-service-policy.test.ts b/packages/controller-utils/src/create-service-policy.test.ts index c42bea58b39..445ebb764cd 100644 --- a/packages/controller-utils/src/create-service-policy.test.ts +++ b/packages/controller-utils/src/create-service-policy.test.ts @@ -1,4 +1,4 @@ -import { handleWhen } from 'cockatiel'; +import { CircuitState, handleWhen } from 'cockatiel'; import { useFakeTimers } from 'sinon'; import type { SinonFakeTimers } from 'sinon'; @@ -32,7 +32,7 @@ describe('createServicePolicy', () => { }); it('only calls the service once before returning', async () => { - const mockService = jest.fn(() => ({ some: 'data' })); + const mockService = jest.fn(); const policy = createServicePolicy(); await policy.execute(mockService); @@ -40,10 +40,11 @@ describe('createServicePolicy', () => { expect(mockService).toHaveBeenCalledTimes(1); }); - it('does not call the listener passed to onBreak, since the circuit never opens', async () => { - const mockService = jest.fn(() => ({ some: 'data' })); + it('does not call onBreak listeners, since the circuit never opens', async () => { + const mockService = jest.fn(); const onBreakListener = jest.fn(); const policy = createServicePolicy(); + policy.onBreak(onBreakListener); await policy.execute(mockService); @@ -51,67 +52,79 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the listener passed to onDegraded if the service execution time is below the threshold', async () => { - const mockService = jest.fn(() => ({ some: 'data' })); - const onDegradedListener = jest.fn(); - const policy = createServicePolicy(); - policy.onDegraded(onDegradedListener); + describe.each([ + { + desc: `the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, + threshold: DEFAULT_DEGRADED_THRESHOLD, + options: {}, + }, + { + desc: 'a custom degraded threshold', + threshold: 2000, + options: { degradedThreshold: 2000 }, + }, + ])('using $desc', ({ threshold, options }) => { + describe('if the service execution time is below the threshold', () => { + it('does not call onDegraded listeners', async () => { + const mockService = jest.fn(); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(options); + policy.onDegraded(onDegradedListener); + + await policy.execute(mockService); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); - await policy.execute(mockService); + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const mockService = jest.fn(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy(options); + policy.onAvailable(onAvailableListener); - expect(onDegradedListener).not.toHaveBeenCalled(); - }); + await policy.execute(mockService); + await policy.execute(mockService); - it('calls the listener passed to onDegraded once if the service execution time is beyond the threshold', async () => { - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; - const mockService = jest.fn(() => { - return new Promise((resolve) => { - setTimeout(() => resolve({ some: 'data' }), delay); - }); + expect(onAvailableListener).toHaveBeenCalledTimes(1); }); - const onDegradedListener = jest.fn(); - const policy = createServicePolicy(); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - clock.tick(delay); - await promise; - - expect(onDegradedListener).toHaveBeenCalledTimes(1); }); - }); - describe('using a custom degraded threshold', () => { - it('does not call the listener passed to onDegraded if the service execution time below the threshold', async () => { - const degradedThreshold = 2000; - const mockService = jest.fn(() => ({ some: 'data' })); - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ degradedThreshold }); - policy.onDegraded(onDegradedListener); + describe('if the service execution time is beyond the threshold', () => { + it('calls onDegraded listeners once', async () => { + const delay = threshold + 1; + const mockService = jest.fn(() => { + return new Promise((resolve) => { + setTimeout(() => resolve(), delay); + }); + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(options); + policy.onDegraded(onDegradedListener); - await policy.execute(mockService); + const promise = policy.execute(mockService); + clock.tick(delay); + await promise; - expect(onDegradedListener).not.toHaveBeenCalled(); - }); + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); - it('calls the listener passed to onDegraded once if the service execution time beyond the threshold', async () => { - const degradedThreshold = 2000; - const delay = degradedThreshold + 1; - const mockService = jest.fn(() => { - return new Promise((resolve) => { - setTimeout(() => resolve({ some: 'data' }), delay); + it('does not call onAvailable listeners', async () => { + const delay = threshold + 1; + const mockService = jest.fn(() => { + return new Promise((resolve) => { + setTimeout(() => resolve(), delay); + }); }); - }); - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ degradedThreshold }); - policy.onDegraded(onDegradedListener); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy(options); + policy.onAvailable(onAvailableListener); - const promise = policy.execute(mockService); - clock.tick(delay); - await promise; + const promise = policy.execute(mockService); + clock.tick(delay); + await promise; - expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).not.toHaveBeenCalled(); + }); }); }); }); @@ -151,7 +164,7 @@ describe('createServicePolicy', () => { expect(mockService).toHaveBeenCalledTimes(1); }); - it('does not call the listener passed to onRetry', async () => { + it('does not call onRetry listeners', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; @@ -170,7 +183,7 @@ describe('createServicePolicy', () => { expect(onRetryListener).not.toHaveBeenCalled(); }); - it('does not call the listener passed to onBreak', async () => { + it('does not call onBreak listeners', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; @@ -181,6 +194,7 @@ describe('createServicePolicy', () => { (caughtError) => caughtError.message !== 'failure', ), }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -193,7 +207,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - it('does not call the listener passed to onDegraded', async () => { + it('does not call onDegraded listeners', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; @@ -215,6 +229,29 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); + + it('does not call onAvailable listeners', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise queue + // is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); }); describe('using the default retry filter policy (which retries all errors)', () => { @@ -245,7 +282,7 @@ describe('createServicePolicy', () => { expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); }); - it('calls the listener passed to onRetry once per retry', async () => { + it('calls onRetry listeners once per retry', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; @@ -281,13 +318,14 @@ describe('createServicePolicy', () => { await expect(promise).rejects.toThrow(error); }); - it('does not call the listener passed to onBreak, since the max number of consecutive failures is never reached', async () => { + it('does not call onBreak listeners, since the max number of consecutive failures is never reached', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); const onBreakListener = jest.fn(); const policy = createServicePolicy(); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -300,7 +338,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - it('calls the listener passed to onDegraded once, since the circuit is still closed', async () => { + it('calls onDegraded listeners once with the error, since the circuit is still closed', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; @@ -317,6 +355,26 @@ describe('createServicePolicy', () => { await ignoreRejection(promise); expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ error }); + }); + + it('does not call onAvailable listeners', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy(); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); }); }); @@ -341,7 +399,7 @@ describe('createServicePolicy', () => { await expect(promise).rejects.toThrow(error); }); - it('does not call the listener passed to onBreak', async () => { + it('does not call onBreak listeners', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -351,6 +409,7 @@ describe('createServicePolicy', () => { const policy = createServicePolicy({ maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -363,7 +422,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - it('calls the listener passed to onDegraded once', async () => { + it('calls onDegraded listeners once with the error', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -383,6 +442,29 @@ describe('createServicePolicy', () => { await ignoreRejection(promise); expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ error }); + }); + + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); }); }); @@ -406,7 +488,7 @@ describe('createServicePolicy', () => { await expect(promise).rejects.toThrow(error); }); - it('calls the listener passed to onBreak once with the error', async () => { + it('calls onBreak listeners once with the error', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -416,6 +498,7 @@ describe('createServicePolicy', () => { const policy = createServicePolicy({ maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -429,7 +512,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).toHaveBeenCalledWith({ error }); }); - it('never calls the listener passed to onDegraded, since the circuit is open', async () => { + it('never calls onDegraded listeners, since the circuit is open', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -451,6 +534,28 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + it('throws a BrokenCircuitError instead of whatever error the service produces if the service is executed again', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; const error = new Error('failure'); @@ -501,7 +606,7 @@ describe('createServicePolicy', () => { ); }); - it('calls the listener passed to onBreak once with the error', async () => { + it('calls onBreak listeners once with the error', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -511,6 +616,7 @@ describe('createServicePolicy', () => { const policy = createServicePolicy({ maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -524,7 +630,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).toHaveBeenCalledWith({ error }); }); - it('never calls the listener passed to onDegraded, since the circuit is open', async () => { + it('never calls onDegraded listeners, since the circuit is open', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -545,6 +651,28 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); + + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); }); }); }); @@ -579,7 +707,7 @@ describe('createServicePolicy', () => { expect(mockService).toHaveBeenCalledTimes(1 + maxRetries); }); - it('calls the onRetry callback once per retry', async () => { + it('calls onRetry listeners once per retry', async () => { const maxRetries = 5; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -620,7 +748,7 @@ describe('createServicePolicy', () => { await expect(promise).rejects.toThrow(error); }); - it('does not call the onBreak callback', async () => { + it('does not call onBreak listeners', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -628,6 +756,7 @@ describe('createServicePolicy', () => { }); const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -640,7 +769,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - it('calls the onDegraded callback once', async () => { + it('calls onDegraded listeners once with the error', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -658,6 +787,27 @@ describe('createServicePolicy', () => { await ignoreRejection(promise); expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ error }); + }); + + it('does not call onAvailable listeners', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); }); }); @@ -679,7 +829,7 @@ describe('createServicePolicy', () => { await expect(promise).rejects.toThrow(error); }); - it('calls the onBreak callback once with the error', async () => { + it('calls onBreak listeners once with the error', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -687,6 +837,7 @@ describe('createServicePolicy', () => { }); const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -700,7 +851,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).toHaveBeenCalledWith({ error }); }); - it('never calls the onDegraded callback, since the circuit is open', async () => { + it('never calls onDegraded listeners, since the circuit is open', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -720,6 +871,26 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); + it('does not call onAvailable listeners', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + it('throws a BrokenCircuitError instead of whatever error the service produces if the policy is executed again', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; const error = new Error('failure'); @@ -765,7 +936,7 @@ describe('createServicePolicy', () => { ); }); - it('calls the onBreak callback once with the error', async () => { + it('calls onBreak listeners once with the error', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -773,6 +944,7 @@ describe('createServicePolicy', () => { }); const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -786,7 +958,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).toHaveBeenCalledWith({ error }); }); - it('never calls the onDegraded callback, since the circuit is open', async () => { + it('never calls onDegraded listeners, since the circuit is open', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; const error = new Error('failure'); const mockService = jest.fn(() => { @@ -805,6 +977,26 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); + + it('does not call onAvailable listeners', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); }); }); @@ -831,7 +1023,7 @@ describe('createServicePolicy', () => { await expect(promise).rejects.toThrow(error); }); - it('does not call the onBreak callback', async () => { + it('does not call onBreak listeners', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures - 2; const error = new Error('failure'); @@ -843,6 +1035,7 @@ describe('createServicePolicy', () => { maxRetries, maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -855,7 +1048,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - it('calls the onDegraded callback once', async () => { + it('calls onDegraded listeners once with the error', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures - 2; const error = new Error('failure'); @@ -877,6 +1070,31 @@ describe('createServicePolicy', () => { await ignoreRejection(promise); expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ error }); + }); + + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); }); }); @@ -902,7 +1120,7 @@ describe('createServicePolicy', () => { await expect(promise).rejects.toThrow(error); }); - it('calls the onBreak callback once with the error', async () => { + it('calls onBreak listeners once with the error', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures - 1; const error = new Error('failure'); @@ -914,6 +1132,7 @@ describe('createServicePolicy', () => { maxRetries, maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -927,7 +1146,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).toHaveBeenCalledWith({ error }); }); - it('never calls the onDegraded callback, since the circuit is open', async () => { + it('never calls onDegraded listeners, since the circuit is open', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures - 1; const error = new Error('failure'); @@ -951,6 +1170,30 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); + it('never calls onAvailable listeners', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + it('throws a BrokenCircuitError instead of whatever error the service produces if the policy is executed again', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures - 1; @@ -1005,7 +1248,7 @@ describe('createServicePolicy', () => { ); }); - it('calls the onBreak callback once with the error', async () => { + it('calls onBreak listeners once with the error', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures; const error = new Error('failure'); @@ -1017,6 +1260,7 @@ describe('createServicePolicy', () => { maxRetries, maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -1030,7 +1274,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).toHaveBeenCalledWith({ error }); }); - it('never calls the onDegraded callback, since the circuit is open', async () => { + it('never calls onDegraded listeners, since the circuit is open', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures; const error = new Error('failure'); @@ -1053,6 +1297,30 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); + + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); }); }); }); @@ -1104,9 +1372,7 @@ describe('createServicePolicy', () => { } throw new Error('failure'); }; - const onBreakListener = jest.fn(); const policy = createServicePolicy(); - policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise queue @@ -1117,7 +1383,7 @@ describe('createServicePolicy', () => { expect(await promise).toStrictEqual({ some: 'data' }); }); - it('does not call the onBreak callback, since the max number of consecutive failures is never reached', async () => { + it('does not call onBreak listeners, since the max number of consecutive failures is never reached', async () => { let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -1128,6 +1394,7 @@ describe('createServicePolicy', () => { }; const onBreakListener = jest.fn(); const policy = createServicePolicy(); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -1140,173 +1407,20 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy(); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - let invocationCounter = 0; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); - } - }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy(); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).toHaveBeenCalledTimes(1); - }); - }); - - describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const degradedThreshold = 2000; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; - let invocationCounter = 0; - const delay = degradedThreshold + 1; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); - } - }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('using a custom max number of consecutive failures', () => { - describe('if the initial run + retries is less than the max number of consecutive failures', () => { - it('returns what the service returns', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onBreakListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - }); - policy.onBreak(onBreakListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - - expect(await promise).toStrictEqual({ some: 'data' }); - }); - - it('does not call the onBreak callback', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onBreakListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - }); - policy.onBreak(onBreakListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onBreakListener).not.toHaveBeenCalled(); - }); - - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + describe.each([ + { + desc: `the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, + threshold: DEFAULT_DEGRADED_THRESHOLD, + options: {}, + }, + { + desc: 'a custom degraded threshold', + threshold: 2000, + options: { degradedThreshold: 2000 }, + }, + ])('using $desc', ({ threshold, options }) => { + describe('if the service execution time is below the threshold', () => { + it('does not call onDegraded listeners', async () => { let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -1316,9 +1430,7 @@ describe('createServicePolicy', () => { throw new Error('failure'); }; const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - }); + const policy = createServicePolicy(options); policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); @@ -1331,54 +1443,55 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + it('calls onAvailable listeners once, even if the service is called more than once', async () => { let invocationCounter = 0; const mockService = () => { invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); - } - }); + if ( + invocationCounter > 0 && + invocationCounter % (DEFAULT_MAX_RETRIES + 1) === 0 + ) { + return { some: 'data' }; + } + throw new Error('failure'); }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - }); - policy.onDegraded(onDegradedListener); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy(options); + policy.onAvailable(onAvailableListener); - const promise = policy.execute(mockService); + const promise1 = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); - await promise; + await promise1; + const promise2 = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise2; - expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenCalledTimes(1); }); }); - describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const degradedThreshold = 2000; - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + describe('if the service execution time is beyond the threshold', () => { + it('calls onDegraded listeners once', async () => { let invocationCounter = 0; + const delay = threshold + 1; const mockService = () => { invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); }; const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - degradedThreshold, - }); + const policy = createServicePolicy(options); policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); @@ -1388,14 +1501,12 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onDegradedListener).not.toHaveBeenCalled(); + expect(onDegradedListener).toHaveBeenCalledTimes(1); }); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; - const delay = degradedThreshold + 1; + it('does not call onAvailable listeners', async () => { let invocationCounter = 0; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; const mockService = () => { invocationCounter += 1; return new Promise((resolve, reject) => { @@ -1406,12 +1517,9 @@ describe('createServicePolicy', () => { } }); }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy(options); + policy.onAvailable(onAvailableListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -1420,14 +1528,16 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).not.toHaveBeenCalled(); }); }); }); + }); - describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + describe('using a custom max number of consecutive failures', () => { + describe('if the initial run + retries is less than the max number of consecutive failures', () => { it('returns what the service returns', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -1436,11 +1546,9 @@ describe('createServicePolicy', () => { } throw new Error('failure'); }; - const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, }); - policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -1451,21 +1559,21 @@ describe('createServicePolicy', () => { expect(await promise).toStrictEqual({ some: 'data' }); }); - it('does not call the onBreak callback', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + it('does not call onBreak listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; let invocationCounter = 0; - const error = new Error('failure'); const mockService = () => { invocationCounter += 1; if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { return { some: 'data' }; } - throw error; + throw new Error('failure'); }; const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -1478,125 +1586,336 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - }); - policy.onDegraded(onDegradedListener); + describe.each([ + { + desc: `the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, + threshold: DEFAULT_DEGRADED_THRESHOLD, + options: {}, + }, + { + desc: 'a custom degraded threshold', + threshold: 2000, + options: { degradedThreshold: 2000 }, + }, + ])('using $desc', ({ threshold, options }) => { + describe('if the service execution time is below the threshold', () => { + it('does not call onDegraded listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, + }); + policy.onDegraded(onDegradedListener); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; - expect(onDegradedListener).not.toHaveBeenCalled(); - }); + expect(onDegradedListener).not.toHaveBeenCalled(); + }); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + if (invocationCounter >= DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; } + throw new Error('failure'); + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - }); - policy.onDegraded(onDegradedListener); + policy.onAvailable(onAvailableListener); + + const promise1 = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise1; + const promise2 = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise2; + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('if the service execution time is beyond the threshold', () => { + it('calls onDegraded listeners once', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const delay = threshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const delay = threshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, + }); + policy.onAvailable(onAvailableListener); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; - expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).not.toHaveBeenCalled(); + }); }); }); + }); - describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const degradedThreshold = 2000; - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('returns what the service returns', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }; + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); - expect(onDegradedListener).not.toHaveBeenCalled(); + expect(await promise).toStrictEqual({ some: 'data' }); + }); + + it('does not call onBreak listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, }); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const delay = degradedThreshold + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { + policy.onBreak(onBreakListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + describe.each([ + { + desc: `the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, + threshold: DEFAULT_DEGRADED_THRESHOLD, + options: {}, + }, + { + desc: 'a custom degraded threshold', + threshold: 2000, + options: { degradedThreshold: 2000 }, + }, + ])('using $desc', ({ threshold, options }) => { + describe('if the service execution time is below the threshold', () => { + it('does not call onDegraded listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + return { some: 'data' }; } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter >= DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, + }); + policy.onAvailable(onAvailableListener); + + const promise1 = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise1; + const promise2 = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise2; + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('if the service execution time is beyond the threshold', () => { + it('calls onDegraded listeners once', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const delay = threshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const delay = threshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, + }); + policy.onAvailable(onAvailableListener); - expect(onDegradedListener).toHaveBeenCalledTimes(1); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); }); }); }); @@ -1613,11 +1932,9 @@ describe('createServicePolicy', () => { } throw error; }; - const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, }); - policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -1631,7 +1948,7 @@ describe('createServicePolicy', () => { ); }); - it('calls the onBreak callback once with the error', async () => { + it('calls onBreak listeners once with the error', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; let invocationCounter = 0; const error = new Error('failure'); @@ -1646,6 +1963,7 @@ describe('createServicePolicy', () => { const policy = createServicePolicy({ maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -1659,7 +1977,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).toHaveBeenCalledWith({ error }); }); - it('does not call the onDegraded callback', async () => { + it('does not call onDegraded listeners', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; let invocationCounter = 0; const error = new Error('failure'); @@ -1686,64 +2004,107 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); - describe(`using the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, () => { - it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw error; - }; - const policy = createServicePolicy({ - maxConsecutiveFailures, - }); + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + policy.onAvailable(onAvailableListener); - const firstExecution = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(firstExecution); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - const result = await policy.execute(mockService); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); - expect(result).toStrictEqual({ some: 'data' }); - }); + expect(onAvailableListener).not.toHaveBeenCalled(); }); - describe('using a custom circuit break duration', () => { - it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { - // This has to be high enough to exceed the exponential backoff - const circuitBreakDuration = 5_000; - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw error; - }; - const policy = createServicePolicy({ - maxConsecutiveFailures, - circuitBreakDuration, - }); + describe('after the circuit break duration has elapsed', () => { + describe.each([ + { + desc: `the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, + duration: DEFAULT_CIRCUIT_BREAK_DURATION, + options: {}, + }, + { + desc: 'a custom circuit break duration', + duration: 5_000, + options: { + // This has to be high enough to exceed the exponential backoff + circuitBreakDuration: 5_000, + }, + }, + ])('using $desc', ({ duration, options }) => { + it('returns what the service returns', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, + }); - const firstExecution = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(firstExecution); - clock.tick(circuitBreakDuration); - const result = await policy.execute(mockService); + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(duration); + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter >= DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + ...options, + }); + policy.onAvailable(onAvailableListener); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(duration); + await policy.execute(mockService); + await policy.execute(mockService); - expect(result).toStrictEqual({ some: 'data' }); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); }); }); }); @@ -1809,7 +2170,7 @@ describe('createServicePolicy', () => { expect(await promise).toStrictEqual({ some: 'data' }); }); - it('does not call the onBreak callback', async () => { + it('does not call onBreak listeners', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; let invocationCounter = 0; const error = new Error('failure'); @@ -1822,6 +2183,7 @@ describe('createServicePolicy', () => { }; const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -1834,121 +2196,127 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ maxRetries }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + describe.each([ + { + desc: `the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, + threshold: DEFAULT_DEGRADED_THRESHOLD, + options: {}, + }, + { + desc: 'a custom degraded threshold', + threshold: 2000, + options: { degradedThreshold: 2000 }, + }, + ])('using $desc', ({ threshold, options }) => { + describe('if the service execution time is below the threshold', () => { + it('does not call onDegraded listeners', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; } - }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ maxRetries }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).toHaveBeenCalledTimes(1); - }); - }); - - describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const degradedThreshold = 2000; - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; - const delay = degradedThreshold + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ ...options, maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter >= maxRetries + 1) { + return { some: 'data' }; } - }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - degradedThreshold, + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ ...options, maxRetries }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + await policy.execute(mockService); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('if the service execution time is beyond the threshold', () => { + it('calls onDegraded listeners once', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const delay = threshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ ...options, maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not call onAvailable listeners', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const delay = threshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ ...options, maxRetries }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onAvailableListener).not.toHaveBeenCalled(); }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).toHaveBeenCalledTimes(1); }); }); }); @@ -1960,162 +2328,169 @@ describe('createServicePolicy', () => { const error = new Error('failure'); const mockService = () => { invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const policy = createServicePolicy({ maxRetries }); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - - expect(await promise).toStrictEqual({ some: 'data' }); - }); - - it('does not call the onBreak callback', async () => { - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const onBreakListener = jest.fn(); - const policy = createServicePolicy({ maxRetries }); - policy.onBreak(onBreakListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onBreakListener).not.toHaveBeenCalled(); - }); - - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ maxRetries }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); - } - }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ maxRetries }); - policy.onDegraded(onDegradedListener); + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ maxRetries }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); - expect(onDegradedListener).toHaveBeenCalledTimes(1); - }); + expect(await promise).toStrictEqual({ some: 'data' }); }); - describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const degradedThreshold = 2000; - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); + it('does not call onBreak listeners', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreakListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + policy.onBreak(onBreakListener); - expect(onDegradedListener).not.toHaveBeenCalled(); - }); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; - const delay = degradedThreshold + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + describe.each([ + { + desc: `the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, + threshold: DEFAULT_DEGRADED_THRESHOLD, + options: {}, + }, + { + desc: 'a custom degraded threshold', + threshold: 2000, + options: { degradedThreshold: 2000 }, + }, + ])('using $desc', () => { + describe('if the service execution time is below the threshold', () => { + it('does not call onDegraded listeners', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; } - }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - degradedThreshold, + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter >= maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + await policy.execute(mockService); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('if the service execution time is beyond the threshold', () => { + it('calls onDegraded listeners once', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not call onAvailable listeners', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onAvailableListener).not.toHaveBeenCalled(); }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).toHaveBeenCalledTimes(1); }); }); }); @@ -2147,7 +2522,7 @@ describe('createServicePolicy', () => { ); }); - it('calls the onBreak callback once with the error', async () => { + it('calls onBreak listeners once with the error', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; let invocationCounter = 0; const error = new Error('failure'); @@ -2160,6 +2535,7 @@ describe('createServicePolicy', () => { }; const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxRetries }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -2173,7 +2549,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).toHaveBeenCalledWith({ error }); }); - it('does not call the onDegraded callback', async () => { + it('does not call onDegraded listeners', async () => { const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; let invocationCounter = 0; const error = new Error('failure'); @@ -2198,66 +2574,99 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); - describe(`using the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, () => { - it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const policy = createServicePolicy({ maxRetries }); + it('does not call onAvailable listeners', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ maxRetries }); + policy.onAvailable(onAvailableListener); - const firstExecution = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(firstExecution); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - const result = await policy.execute(mockService); + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); - expect(result).toStrictEqual({ some: 'data' }); - }); + expect(onAvailableListener).not.toHaveBeenCalled(); }); - describe('using a custom circuit break duration', () => { - it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { - // This has to be high enough to exceed the exponential backoff - const circuitBreakDuration = 50_000; - const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const policy = createServicePolicy({ - maxRetries, - circuitBreakDuration, - }); + describe('after the circuit break duration has elapsed', () => { + describe.each([ + { + desc: `the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, + duration: DEFAULT_CIRCUIT_BREAK_DURATION, + options: {}, + }, + { + desc: 'a custom circuit break duration', + duration: 5_000, + options: { + // This has to be high enough to exceed the exponential backoff + circuitBreakDuration: 50_000, + }, + }, + ])('using $desc', ({ duration, options }) => { + it('returns what the service returns', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ maxRetries, ...options }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(duration); + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter >= maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ maxRetries, ...options }); + policy.onAvailable(onAvailableListener); - const firstExecution = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await expect(firstExecution).rejects.toThrow( - new Error( - 'Execution prevented because the circuit breaker is open', - ), - ); - clock.tick(circuitBreakDuration); - const result = await policy.execute(mockService); + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(duration); + await policy.execute(mockService); + await policy.execute(mockService); - expect(result).toStrictEqual({ some: 'data' }); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); }); }); }); @@ -2277,12 +2686,10 @@ describe('createServicePolicy', () => { } throw error; }; - const onBreakListener = jest.fn(); const policy = createServicePolicy({ maxRetries, maxConsecutiveFailures, }); - policy.onBreak(onBreakListener); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -2293,7 +2700,7 @@ describe('createServicePolicy', () => { expect(await promise).toStrictEqual({ some: 'data' }); }); - it('does not call the onBreak callback', async () => { + it('does not call onBreak listeners', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures - 2; let invocationCounter = 0; @@ -2310,6 +2717,7 @@ describe('createServicePolicy', () => { maxRetries, maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -2322,133 +2730,152 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures - 2; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures - 2; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + describe.each([ + { + desc: `the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, + threshold: DEFAULT_DEGRADED_THRESHOLD, + options: {}, + }, + { + desc: 'a custom degraded threshold', + threshold: 2000, + options: { degradedThreshold: 2000 }, + }, + ])('using $desc', ({ threshold, options }) => { + describe('if the service execution time is below the threshold', () => { + it('does not call onDegraded listeners', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).toHaveBeenCalledTimes(1); - }); - }); - - describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const degradedThreshold = 2000; - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures - 2; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures - 2; - const delay = degradedThreshold + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter >= maxRetries + 1) { + return { some: 'data' }; } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); + policy.onAvailable(onAvailableListener); + + const promise1 = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise1; + const promise2 = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise2; + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('if the service execution time is beyond the threshold', () => { + it('calls onDegraded listeners once', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const delay = threshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, + }); + policy.onAvailable(onAvailableListener); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; - expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).not.toHaveBeenCalled(); + }); }); }); }); @@ -2480,7 +2907,7 @@ describe('createServicePolicy', () => { expect(await promise).toStrictEqual({ some: 'data' }); }); - it('does not call the onBreak callback', async () => { + it('does not call onBreak listeners', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures - 1; let invocationCounter = 0; @@ -2497,6 +2924,7 @@ describe('createServicePolicy', () => { maxRetries, maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -2509,133 +2937,152 @@ describe('createServicePolicy', () => { expect(onBreakListener).not.toHaveBeenCalled(); }); - describe(`using the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures - 1; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures - 1; - const delay = DEFAULT_DEGRADED_THRESHOLD + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + describe.each([ + { + desc: `the default degraded threshold (${DEFAULT_DEGRADED_THRESHOLD})`, + threshold: DEFAULT_DEGRADED_THRESHOLD, + options: {}, + }, + { + desc: 'a custom degraded threshold', + threshold: 2000, + options: { degradedThreshold: 2000 }, + }, + ])('using $desc', ({ threshold, options }) => { + describe('if the service execution time is below the threshold', () => { + it('does not call onDegraded listeners', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; } + throw error; + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).toHaveBeenCalledTimes(1); - }); - }); - - describe('using a custom degraded threshold', () => { - it('does not call the onDegraded callback if the service execution time is below the threshold', async () => { - const degradedThreshold = 2000; - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures - 1; - let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { - return { some: 'data' }; - } - throw error; - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); - - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; - - expect(onDegradedListener).not.toHaveBeenCalled(); - }); - - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures - 1; - const delay = degradedThreshold + 1; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - return new Promise((resolve, reject) => { - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - setTimeout(() => resolve({ some: 'data' }), delay); - } else { - reject(new Error('failure')); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter % (maxRetries + 1) === 0) { + return { some: 'data' }; } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, }); - }; - const onDegradedListener = jest.fn(); - const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, - degradedThreshold, - }); - policy.onDegraded(onDegradedListener); + policy.onAvailable(onAvailableListener); + + const promise1 = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise1; + const promise2 = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise2; + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('if the service execution time is beyond the threshold', () => { + it('calls onDegraded listeners once', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const delay = threshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const delay = threshold + 1; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + return new Promise((resolve, reject) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, + }); + policy.onAvailable(onAvailableListener); - const promise = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await promise; + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await promise; - expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).not.toHaveBeenCalled(); + }); }); }); }); @@ -2672,7 +3119,7 @@ describe('createServicePolicy', () => { ); }); - it('calls the onBreak callback once with the error', async () => { + it('calls onBreak listeners once with the error', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures; let invocationCounter = 0; @@ -2689,6 +3136,7 @@ describe('createServicePolicy', () => { maxRetries, maxConsecutiveFailures, }); + policy.onBreak(onBreakListener); const promise = policy.execute(mockService); @@ -2702,7 +3150,7 @@ describe('createServicePolicy', () => { expect(onBreakListener).toHaveBeenCalledWith({ error }); }); - it('does not call the onDegraded callback', async () => { + it('does not call onDegraded listeners', async () => { const maxConsecutiveFailures = 5; const maxRetries = maxConsecutiveFailures; let invocationCounter = 0; @@ -2731,76 +3179,379 @@ describe('createServicePolicy', () => { expect(onDegradedListener).not.toHaveBeenCalled(); }); - describe(`using the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, () => { - it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures; + it('does not call onAvailable listeners', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(promise); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + describe('after the circuit break duration has elapsed', () => { + describe.each([ + { + desc: `the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, + duration: DEFAULT_CIRCUIT_BREAK_DURATION, + options: {}, + }, + { + desc: 'a custom circuit break duration', + duration: 5_000, + options: { + // This has to be high enough to exceed the exponential backoff + circuitBreakDuration: 5_000, + }, + }, + ])('using $desc', ({ duration, options }) => { + it('returns what the service returns', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, + }); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(duration); + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('calls onAvailable listeners once, even if the service is called more than once', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter >= maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + ...options, + }); + policy.onAvailable(onAvailableListener); + + const firstExecution = policy.execute(mockService); + // It's safe not to await this promise; adding it to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.runAllAsync(); + await ignoreRejection(firstExecution); + clock.tick(duration); + await policy.execute(mockService); + await policy.execute(mockService); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + }); + }); + }); + + describe('wrapping a service that succeeds at first and then fails enough to break the circuit', () => { + describe.each([ + { + desc: `the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, + maxConsecutiveFailures: DEFAULT_MAX_CONSECUTIVE_FAILURES, + optionsWithMaxConsecutiveFailures: {}, + }, + { + desc: 'a custom max number of consecutive failures', + maxConsecutiveFailures: DEFAULT_MAX_RETRIES + 1, + optionsWithMaxConsecutiveFailures: { + maxConsecutiveFailures: DEFAULT_MAX_RETRIES + 1, + }, + }, + ])( + 'using $desc', + ({ maxConsecutiveFailures, optionsWithMaxConsecutiveFailures }) => { + describe.each([ + { + desc: `the default circuit break duration (${DEFAULT_CIRCUIT_BREAK_DURATION})`, + circuitBreakDuration: DEFAULT_CIRCUIT_BREAK_DURATION, + optionsWithCircuitBreakDuration: {}, + }, + { + desc: 'a custom circuit break duration', + circuitBreakDuration: DEFAULT_CIRCUIT_BREAK_DURATION, + optionsWithCircuitBreakDuration: { + // This has to be high enough to exceed the exponential backoff + circuitBreakDuration: 5_000, + }, + }, + ])( + 'using $desc', + ({ circuitBreakDuration, optionsWithCircuitBreakDuration }) => { + it('calls onAvailable listeners if the service finally succeeds', async () => { let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { + const mockService = jest.fn(() => { invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { + if ( + invocationCounter === 1 || + invocationCounter === maxConsecutiveFailures + 2 + ) { return { some: 'data' }; } - throw error; - }; + throw new Error('failure'); + }); + const onAvailableListener = jest.fn(); const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, + ...optionsWithMaxConsecutiveFailures, + ...optionsWithCircuitBreakDuration, + }); + policy.onRetry(() => { + clock.next(); }); + policy.onAvailable(onAvailableListener); - const firstExecution = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await ignoreRejection(firstExecution); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - const result = await policy.execute(mockService); + // Execute the service successfully once + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual({ some: 'data' }); + // Execute and retry until we break the circuit + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + clock.tick(circuitBreakDuration); + + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(2); }); - }); - describe('using a custom circuit break duration', () => { - it('returns what the service returns if it is successfully called again after the circuit break duration has elapsed', async () => { - // This has to be high enough to exceed the exponential backoff - const circuitBreakDuration = 5_000; - const maxConsecutiveFailures = 5; - const maxRetries = maxConsecutiveFailures; + it('does not call onAvailable listeners if the service finally fails', async () => { let invocationCounter = 0; - const error = new Error('failure'); - const mockService = () => { + const mockService = jest.fn(() => { invocationCounter += 1; - if (invocationCounter === maxRetries + 1) { + if (invocationCounter === 1) { return { some: 'data' }; } - throw error; - }; + throw new Error('failure'); + }); + const onAvailableListener = jest.fn(); const policy = createServicePolicy({ - maxRetries, - maxConsecutiveFailures, - circuitBreakDuration, + ...optionsWithMaxConsecutiveFailures, + ...optionsWithCircuitBreakDuration, + }); + policy.onRetry(() => { + clock.next(); }); + policy.onAvailable(onAvailableListener); - const firstExecution = policy.execute(mockService); - // It's safe not to await this promise; adding it to the promise - // queue is enough to prevent this test from running indefinitely. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); - await expect(firstExecution).rejects.toThrow( - new Error( - 'Execution prevented because the circuit breaker is open', - ), - ); + // Execute the service successfully once + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Execute and retry until we break the circuit + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); clock.tick(circuitBreakDuration); - const result = await policy.execute(mockService); - expect(result).toStrictEqual({ some: 'data' }); + await ignoreRejection(policy.execute(mockService)); + expect(onAvailableListener).toHaveBeenCalledTimes(1); }); - }); - }); + }, + ); + }, + ); + }); + + describe('getRemainingCircuitOpenDuration', () => { + it('returns the number of milliseconds before the circuit will transition from open to half-open', async () => { + const mockService = () => { + throw new Error('failure'); + }; + const policy = createServicePolicy(); + policy.onRetry(() => { + clock.next(); + }); + // Retry until we break the circuit + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + clock.tick(1000); + + expect(policy.getRemainingCircuitOpenDuration()).toBe( + DEFAULT_CIRCUIT_BREAK_DURATION - 1000, + ); + }); + + it('returns null if the circuit is closed', () => { + const policy = createServicePolicy(); + + expect(policy.getRemainingCircuitOpenDuration()).toBeNull(); + }); + }); + + describe('getCircuitState', () => { + it('returns the state of the circuit', async () => { + const mockService = () => { + throw new Error('failure'); + }; + const policy = createServicePolicy(); + policy.onRetry(() => { + clock.next(); + }); + + expect(policy.getCircuitState()).toBe(CircuitState.Closed); + + // Retry until we break the circuit + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + expect(policy.getCircuitState()).toBe(CircuitState.Open); + + clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + const promise = ignoreRejection(policy.execute(mockService)); + expect(policy.getCircuitState()).toBe(CircuitState.HalfOpen); + await promise; + expect(policy.getCircuitState()).toBe(CircuitState.Open); + }); + }); + + describe('reset', () => { + it('resets the state of the circuit to "closed"', async () => { + let invocationCounter = 0; + const mockService = jest.fn(() => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_CONSECUTIVE_FAILURES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicy(); + policy.onRetry(() => { + clock.next(); + }); + // Retry until we break the circuit + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + expect(policy.getCircuitState()).toBe(CircuitState.Open); + + policy.reset(); + + expect(policy.getCircuitState()).toBe(CircuitState.Closed); + }); + + it('allows the service to be executed successfully again if its circuit has broken after resetting', async () => { + let invocationCounter = 0; + const mockService = jest.fn(() => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_CONSECUTIVE_FAILURES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicy(); + policy.onRetry(() => { + clock.next(); + }); + // Retry until we break the circuit + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + policy.reset(); + + expect(await policy.execute(mockService)).toStrictEqual({ some: 'data' }); + }); + + it('calls onAvailable listeners if the service was executed successfully, its circuit broke, it was reset, and executes again, successfully', async () => { + let invocationCounter = 0; + const mockService = jest.fn(() => { + invocationCounter += 1; + if ( + invocationCounter === 1 || + invocationCounter === DEFAULT_MAX_CONSECUTIVE_FAILURES + 2 + ) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy(); + policy.onRetry(() => { + clock.next(); + }); + policy.onAvailable(onAvailableListener); + + // Execute the service successfully once + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Execute and retry until we break the circuit + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + policy.reset(); + + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(2); + }); + + it('allows the service to be executed unsuccessfully again if its circuit has broken after resetting', async () => { + const mockService = jest.fn(() => { + throw new Error('failure'); }); + const policy = createServicePolicy(); + policy.onRetry(() => { + clock.next(); + }); + // Retry until we break the circuit + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + policy.reset(); + + await expect(policy.execute(mockService)).rejects.toThrow('failure'); }); }); }); diff --git a/packages/controller-utils/src/create-service-policy.ts b/packages/controller-utils/src/create-service-policy.ts index 3860caad532..cd28da0669e 100644 --- a/packages/controller-utils/src/create-service-policy.ts +++ b/packages/controller-utils/src/create-service-policy.ts @@ -23,6 +23,7 @@ import type { export { BrokenCircuitError, + CockatielEventEmitter, CircuitState, ConstantBackoff, ExponentialBackoff, @@ -30,7 +31,7 @@ export { handleWhen, }; -export type { CockatielEvent }; +export type { CockatielEvent, FailureReason as CockatielFailureReason }; /** * The options for `createServicePolicy`. @@ -85,12 +86,21 @@ export type ServicePolicy = IPolicy & { * maximum consecutive failures is reached. */ circuitBreakDuration: number; + /** + * @returns The state of the underlying circuit. + */ + getCircuitState: () => CircuitState; /** * If the circuit is open and ongoing requests are paused, returns the number * of milliseconds before the requests will be attempted again. If the circuit * is not open, returns null. */ getRemainingCircuitOpenDuration: () => number | null; + /** + * Resets the internal circuit breaker policy (if it is open, it will now be + * closed). + */ + reset: () => void; /** * The Cockatiel retry policy that the service policy uses internally. */ @@ -108,6 +118,12 @@ export type ServicePolicy = IPolicy & { * number of consecutive failures has been reached. */ onDegraded: CockatielEvent | void>; + /** + * A function which is called when the service succeeds for the first time, + * or when the service fails enough times to cause the circuit to break and + * then recovers. + */ + onAvailable: CockatielEvent; /** * A function which will be called by the retry policy each time the service * fails and the policy kicks off a timer to re-run the service. This is @@ -127,6 +143,26 @@ type InternalCircuitState = } | { state: Exclude }; +/** + * Availability statuses that the service can be in. + * + * Used to keep track of whether the `onAvailable` event should be fired. + */ +const AVAILABILITY_STATUSES = { + Available: 'available', + Degraded: 'degraded', + Unavailable: 'unavailable', + Unknown: 'unknown', +} as const; + +/** + * Availability statuses that the service can be in. + * + * Used to keep track of whether the `onAvailable` event should be fired. + */ +type AvailabilityStatus = + (typeof AVAILABILITY_STATUSES)[keyof typeof AVAILABILITY_STATUSES]; + /** * The maximum number of times that a failing service should be re-run before * giving up. @@ -249,6 +285,8 @@ export function createServicePolicy( backoff = new ExponentialBackoff(), } = options; + let availabilityStatus: AvailabilityStatus = AVAILABILITY_STATUSES.Unknown; + const retryPolicy = retry(retryFilterPolicy, { // Note that although the option here is called "max attempts", it's really // maximum number of *retries* (attempts past the initial attempt). @@ -259,6 +297,7 @@ export function createServicePolicy( }); const onRetry = retryPolicy.onRetry.bind(retryPolicy); + const consecutiveBreaker = new ConsecutiveBreaker(maxConsecutiveFailures); const circuitBreakerPolicy = circuitBreaker(handleWhen(isServiceFailure), { // While the circuit is open, any additional invocations of the service // passed to the policy (either via automatic retries or by manually @@ -267,7 +306,7 @@ export function createServicePolicy( // service will be allowed to run again. If the service succeeds, the // circuit will close, otherwise it will remain open. halfOpenAfter: circuitBreakDuration, - breaker: new ConsecutiveBreaker(maxConsecutiveFailures), + breaker: consecutiveBreaker, }); let internalCircuitState: InternalCircuitState = getInternalCircuitState( @@ -276,27 +315,58 @@ export function createServicePolicy( circuitBreakerPolicy.onStateChange((state) => { internalCircuitState = getInternalCircuitState(state); }); + + circuitBreakerPolicy.onBreak(() => { + availabilityStatus = AVAILABILITY_STATUSES.Unavailable; + }); const onBreak = circuitBreakerPolicy.onBreak.bind(circuitBreakerPolicy); const onDegradedEventEmitter = new CockatielEventEmitter | void>(); + const onDegraded = onDegradedEventEmitter.addListener; + + const onAvailableEventEmitter = new CockatielEventEmitter(); + const onAvailable = onAvailableEventEmitter.addListener; + retryPolicy.onGiveUp((data) => { if (circuitBreakerPolicy.state === CircuitState.Closed) { + availabilityStatus = AVAILABILITY_STATUSES.Degraded; onDegradedEventEmitter.emit(data); } }); retryPolicy.onSuccess(({ duration }) => { - if ( - circuitBreakerPolicy.state === CircuitState.Closed && - duration > degradedThreshold - ) { - onDegradedEventEmitter.emit(); + if (circuitBreakerPolicy.state === CircuitState.Closed) { + if (duration > degradedThreshold) { + availabilityStatus = AVAILABILITY_STATUSES.Degraded; + onDegradedEventEmitter.emit(); + } else if (availabilityStatus !== AVAILABILITY_STATUSES.Available) { + availabilityStatus = AVAILABILITY_STATUSES.Available; + onAvailableEventEmitter.emit(); + } } }); - const onDegraded = onDegradedEventEmitter.addListener; // Every time the retry policy makes an attempt, it executes the circuit // breaker policy, which executes the service. + // + // Calling: + // + // policy.execute(() => { + // // do what the service does + // }) + // + // is equivalent to: + // + // retryPolicy.execute(() => { + // circuitBreakerPolicy.execute(() => { + // // do what the service does + // }); + // }); + // + // So if the retry policy succeeds or fails, it is because the circuit breaker + // policy succeeded or failed. And if there are any event listeners registered + // on the retry policy, by the time they are called, the state of the circuit + // breaker will have already changed. const policy = wrap(retryPolicy, circuitBreakerPolicy); const getRemainingCircuitOpenDuration = () => { @@ -306,14 +376,35 @@ export function createServicePolicy( return null; }; + const getCircuitState = () => { + return circuitBreakerPolicy.state; + }; + + const reset = () => { + // Set the state of the policy to "isolated" regardless of its current state + const { dispose } = circuitBreakerPolicy.isolate(); + // Reset the state to "closed" + dispose(); + + // Reset the counter on the breaker as well + consecutiveBreaker.success(); + + // Re-initialize the availability status so that if the service is executed + // successfully, onAvailable listeners will be called again + availabilityStatus = AVAILABILITY_STATUSES.Unknown; + }; + return { ...policy, circuitBreakerPolicy, circuitBreakDuration, + getCircuitState, getRemainingCircuitOpenDuration, + reset, retryPolicy, onBreak, onDegraded, + onAvailable, onRetry, }; } diff --git a/packages/controller-utils/src/index.test.ts b/packages/controller-utils/src/index.test.ts index f22633a8b08..1167bdcdfad 100644 --- a/packages/controller-utils/src/index.test.ts +++ b/packages/controller-utils/src/index.test.ts @@ -6,6 +6,7 @@ describe('@metamask/controller-utils', () => { Array [ "BrokenCircuitError", "CircuitState", + "CockatielEventEmitter", "ConstantBackoff", "DEFAULT_CIRCUIT_BREAK_DURATION", "DEFAULT_DEGRADED_THRESHOLD", diff --git a/packages/controller-utils/src/index.ts b/packages/controller-utils/src/index.ts index f6de7c26f38..c07b7df7dfa 100644 --- a/packages/controller-utils/src/index.ts +++ b/packages/controller-utils/src/index.ts @@ -1,6 +1,7 @@ export { BrokenCircuitError, CircuitState, + CockatielEventEmitter, ConstantBackoff, DEFAULT_CIRCUIT_BREAK_DURATION, DEFAULT_DEGRADED_THRESHOLD, @@ -14,6 +15,7 @@ export { export type { CockatielEvent, CreateServicePolicyOptions, + CockatielFailureReason, ServicePolicy, } from './create-service-policy'; export {