diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 92cc78a3f7..2f19fd61af 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -10,7 +10,7 @@ "import-x/no-named-as-default": 1, "import-x/no-named-as-default-member": 8, "import-x/order": 205, - "jest/no-conditional-in-test": 129, + "jest/no-conditional-in-test": 104, "jest/prefer-lowercase-title": 2, "jest/prefer-strict-equal": 2, "jsdoc/check-tag-names": 375, @@ -19,7 +19,7 @@ "n/no-unsupported-features/node-builtins": 18, "n/prefer-global/text-encoder": 4, "n/prefer-global/text-decoder": 4, - "prettier/prettier": 115, + "prettier/prettier": 110, "promise/always-return": 3, "promise/catch-or-return": 2, "promise/param-names": 8, diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 8d5452c149..8ef13e469f 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `createServicePolicy` function to assist with reducing boilerplate for service classes ([#5154](https://github.com/MetaMask/core/pull/5154), [#5143](https://github.com/MetaMask/core/pull/5143)) +- Add `createServicePolicy` function to assist with reducing boilerplate for service classes ([#5154](https://github.com/MetaMask/core/pull/5154), [#5143](https://github.com/MetaMask/core/pull/5143), [#5149](https://github.com/MetaMask/core/pull/5149)) + - Also add `DEFAULT_CIRCUIT_BREAK_DURATION`, `DEFAULT_DEGRADED_THRESHOLD`, `DEFAULT_MAX_CONSECUTIVE_FAILURES`, and `DEFAULT_MAX_RETRIES` constants and `IServicePolicy` type ## [11.4.5] diff --git a/packages/controller-utils/src/create-service-policy.test.ts b/packages/controller-utils/src/create-service-policy.test.ts index 800023b5e2..0474647cea 100644 --- a/packages/controller-utils/src/create-service-policy.test.ts +++ b/packages/controller-utils/src/create-service-policy.test.ts @@ -1,3 +1,7 @@ +// We use conditions exclusively in this file. +/* eslint-disable jest/no-conditional-in-test */ + +import { handleWhen } from 'cockatiel'; import { useFakeTimers } from 'sinon'; import type { SinonFakeTimers } from 'sinon'; @@ -111,67 +115,71 @@ describe('createServicePolicy', () => { }); describe('wrapping a service that always fails', () => { - it(`calls the service a total of ${ - 1 + DEFAULT_MAX_RETRIES - } times, delaying each retry using a backoff formula`, async () => { - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const policy = createServicePolicy(); - - 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); + describe('if a custom retry filter policy is given and the retry filter policy filters out the thrown error', () => { + it('throws what the service throws', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }); - expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); - }); + const promise = policy.execute(mockService); - it('calls the onRetry callback once per retry', async () => { - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; + await expect(promise).rejects.toThrow(error); }); - const onRetry = jest.fn(); - const policy = createServicePolicy({ onRetry }); - 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); + it('calls the service once and only once', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }); + + const promise = policy.execute(mockService); + await ignoreRejection(promise); - expect(onRetry).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES); - }); + expect(mockService).toHaveBeenCalledTimes(1); + }); - describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { - it('throws what the service throws', async () => { + it('does not call the onRetry callback', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const policy = createServicePolicy(); + const onRetry = jest.fn(); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + onRetry, + }); 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); - await expect(promise).rejects.toThrow(error); + expect(onRetry).not.toHaveBeenCalled(); }); - it('does not call the onBreak callback, since the max number of consecutive failures is never reached', async () => { + it('does not call the onBreak callback', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); const onBreak = jest.fn(); - const policy = createServicePolicy({ onBreak }); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + onBreak, + }); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise queue @@ -183,13 +191,18 @@ describe('createServicePolicy', () => { expect(onBreak).not.toHaveBeenCalled(); }); - it('calls the onDegraded callback once, since the circuit is still closed', async () => { + it('does not call the onDegraded callback', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); const onDegraded = jest.fn(); - const policy = createServicePolicy({ onDegraded }); + const policy = createServicePolicy({ + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + onDegraded, + }); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise queue @@ -198,444 +211,883 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await ignoreRejection(promise); - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onDegraded).not.toHaveBeenCalled(); }); }); - 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('throws what the service throws', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + describe('using the default retry filter policy (which retries all errors)', () => { + describe(`using the default max retries (${DEFAULT_MAX_RETRIES})`, () => { + it(`calls the service a total of ${ + 1 + DEFAULT_MAX_RETRIES + } times, delaying each retry using a backoff formula`, async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const policy = createServicePolicy(); + // Each retry delay is randomized using a decorrelated jitter formula, + // so we need to prevent that + jest.spyOn(Math, 'random').mockReturnValue(0); 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. + // It's safe not to await these promises; adding them to the promise + // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); + clock.tickAsync(0); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(176.27932892814937); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(186.8886145345685); + await ignoreRejection(promise); - await expect(promise).rejects.toThrow(error); + expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); }); - it('does not call the onBreak callback', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + it('calls the onRetry callback once per retry', async () => { const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const onRetry = jest.fn(); + const policy = createServicePolicy({ onRetry }); 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. + // 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(onBreak).not.toHaveBeenCalled(); + expect(onRetry).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES); }); - it('calls the onDegraded callback once', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, + describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { + it('throws what the service throws', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy(); + + 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 expect(promise).rejects.toThrow(error); }); - 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); + it('does not call the onBreak callback, since the max number of consecutive failures is never reached', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ onBreak }); - expect(onDegraded).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 ignoreRejection(promise); - describe('if the initial run + retries is equal to the max number of consecutive failures', () => { - it('throws what the service throws', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const policy = createServicePolicy({ - maxConsecutiveFailures, + expect(onBreak).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(); + it('calls the onDegraded callback once, since the circuit is still closed', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ onDegraded }); - await expect(promise).rejects.toThrow(error); - }); + 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); - it('calls the onBreak callback once with the error', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, + expect(onDegraded).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 ignoreRejection(promise); + 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('throws what the service throws', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onBreak, + }); - expect(onBreak).toHaveBeenCalledTimes(1); - expect(onBreak).toHaveBeenCalledWith({ error }); - }); + 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(); - it('never calls the onDegraded callback, since the circuit is open', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; - }); - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, - }); + await expect(promise).rejects.toThrow(error); + }); - 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); + it('does not call the onBreak callback', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onBreak, + }); - expect(onDegraded).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 ignoreRejection(promise); - 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'); - const mockService = jest.fn(() => { - throw error; - }); - const policy = createServicePolicy({ - maxConsecutiveFailures, - }); + expect(onBreak).not.toHaveBeenCalled(); + }); - 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); - - const secondExecution = policy.execute(mockService); - await expect(secondExecution).rejects.toThrow( - new Error( - 'Execution prevented because the circuit breaker is open', - ), - ); - }); - }); + it('calls the onDegraded callback once', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + }); - describe('if the initial run + retries is greater than the max number of consecutive failures', () => { - it('throws a BrokenCircuitError instead of whatever error the service produces', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; - const error = new Error('failure'); - const mockService = jest.fn(() => { - throw error; + 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(onDegraded).toHaveBeenCalledTimes(1); + }); }); - const policy = createServicePolicy({ - maxConsecutiveFailures, + + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + 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 expect(promise).rejects.toThrow(error); + }); + + it('calls the onBreak callback once with the error', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onBreak, + }); + + 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(onBreak).toHaveBeenCalledTimes(1); + expect(onBreak).toHaveBeenCalledWith({ error }); + }); + + it('never calls the onDegraded callback, since the circuit is open', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxConsecutiveFailures, + }); + + 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); + + const secondExecution = policy.execute(mockService); + await expect(secondExecution).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); }); - 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(); + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError instead of whatever error the service produces', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + 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 expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + + it('calls the onBreak callback once with the error', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onBreak, + }); - await expect(promise).rejects.toThrow( - new Error( - 'Execution prevented because the circuit breaker is open', - ), - ); + 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(onBreak).toHaveBeenCalledTimes(1); + expect(onBreak).toHaveBeenCalledWith({ error }); + }); + + it('never calls the onDegraded callback, since the circuit is open', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).not.toHaveBeenCalled(); + }); + }); }); + }); - it('calls the onBreak callback once with the error', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + describe('using a custom max number of retries', () => { + it(`calls the service a total of 1 + times, delaying each retry using a backoff formula`, async () => { + const maxRetries = 5; const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const policy = createServicePolicy({ maxRetries }); + // Each retry delay is randomized using a decorrelated jitter formula, + // so we need to prevent that + jest.spyOn(Math, 'random').mockReturnValue(0); 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. + // It's safe not to await these promises; adding them to the promise + // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.runAllAsync(); + clock.tickAsync(0); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(176.27932892814937); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(186.8886145345685); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(366.8287823691078); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(731.8792783578953); await ignoreRejection(promise); - expect(onBreak).toHaveBeenCalledTimes(1); - expect(onBreak).toHaveBeenCalledWith({ error }); + expect(mockService).toHaveBeenCalledTimes(1 + maxRetries); }); - it('never calls the onDegraded callback, since the circuit is open', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; + it('calls the onRetry callback once per retry', async () => { + const maxRetries = 5; const error = new Error('failure'); const mockService = jest.fn(() => { throw error; }); - const onDegraded = jest.fn(); + const onRetry = jest.fn(); const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, + maxRetries, + onRetry, }); 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. + // 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(onDegraded).not.toHaveBeenCalled(); + expect(onRetry).toHaveBeenCalledTimes(maxRetries); }); - }); - }); - }); - describe('wrapping a service that fails continuously and then succeeds on the final try', () => { - it(`calls the service a total of ${ - 1 + DEFAULT_MAX_RETRIES - } times, delaying each retry using a backoff formula`, async () => { - let invocationCounter = 0; - const mockService = jest.fn(() => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }); - const policy = createServicePolicy(); + describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { + describe('if the initial run + retries is less than the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + 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(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); - }); + await expect(promise).rejects.toThrow(error); + }); - it('calls the onRetry callback once per retry', async () => { - let invocationCounter = 0; - const mockService = jest.fn(() => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }); - const onRetry = jest.fn(); - const policy = createServicePolicy({ onRetry }); + it('does not call the onBreak callback', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ maxRetries, onBreak }); - 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 ignoreRejection(promise); - expect(onRetry).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES); - }); + expect(onBreak).not.toHaveBeenCalled(); + }); - describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { - it('returns what the service returns', async () => { - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ onBreak }); + it('calls the onDegraded callback once', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ maxRetries, onDegraded }); - 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(); + 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(await promise).toStrictEqual({ some: 'data' }); - }); + expect(onDegraded).toHaveBeenCalledTimes(1); + }); + }); - it('does not call the onBreak callback, since the max number of consecutive failures is never reached', async () => { - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onBreak = jest.fn(); - const policy = createServicePolicy({ onBreak }); + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + 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(onBreak).not.toHaveBeenCalled(); - }); + await expect(promise).rejects.toThrow(error); + }); - 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 onDegraded = jest.fn(); - const policy = createServicePolicy({ onDegraded }); + it('calls the onBreak callback once with the error', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ maxRetries, onBreak }); + + 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(onBreak).toHaveBeenCalledTimes(1); + expect(onBreak).toHaveBeenCalledWith({ error }); + }); - 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('never calls the onDegraded callback, since the circuit is open', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ maxRetries, onDegraded }); - expect(onDegraded).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 ignoreRejection(promise); - 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')); - } + expect(onDegraded).not.toHaveBeenCalled(); }); - }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ onDegraded }); - 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('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'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ maxRetries, onDegraded }); + + 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); + + const secondExecution = policy.execute(mockService); + await expect(secondExecution).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + }); + + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError instead of whatever error the service produces', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + const mockService = jest.fn(() => { + throw new Error('failure'); + }); + 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 expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); - expect(onDegraded).toHaveBeenCalledTimes(1); + it('calls the onBreak callback once with the error', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ maxRetries, onBreak }); + + 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(onBreak).toHaveBeenCalledTimes(1); + expect(onBreak).toHaveBeenCalledWith({ error }); + }); + + it('never calls the onDegraded callback, since the circuit is open', async () => { + const maxRetries = DEFAULT_MAX_CONSECUTIVE_FAILURES; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ maxRetries, onDegraded }); + + 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(onDegraded).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; - let invocationCounter = 0; - const mockService = () => { - invocationCounter += 1; - if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { - return { some: 'data' }; - } - throw new Error('failure'); - }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - onDegraded, - degradedThreshold, + 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('throws what the service throws', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxRetries, + 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 expect(promise).rejects.toThrow(error); + }); + + it('does not call the onBreak callback', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onBreak, + }); + + 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(onBreak).not.toHaveBeenCalled(); + }); + + it('calls the onDegraded callback once', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 2; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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; + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('throws what the service throws', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); - expect(onDegraded).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(); - 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')); - } + await expect(promise).rejects.toThrow(error); + }); + + it('calls the onBreak callback once with the error', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onBreak, + }); + + 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(onBreak).toHaveBeenCalledTimes(1); + expect(onBreak).toHaveBeenCalledWith({ error }); + }); + + it('never calls the onDegraded callback, since the circuit is open', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures - 1; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + }); + + 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); + + const secondExecution = policy.execute(mockService); + await expect(secondExecution).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); }); - }; - const onDegraded = jest.fn(); - const policy = createServicePolicy({ - onDegraded, - degradedThreshold, }); - 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; + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError instead of whatever error the service produces', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicy({ + maxRetries, + 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 expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + + it('calls the onBreak callback once with the error', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onBreak = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onBreak, + }); + + 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(onBreak).toHaveBeenCalledTimes(1); + expect(onBreak).toHaveBeenCalledWith({ error }); + }); + + it('never calls the onDegraded callback, since the circuit is open', async () => { + const maxConsecutiveFailures = 5; + const maxRetries = maxConsecutiveFailures; + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).toHaveBeenCalledTimes(1); + expect(onDegraded).not.toHaveBeenCalled(); + }); + }); }); }); }); + }); + + describe('wrapping a service that fails continuously and then succeeds on the final try', () => { + // NOTE: Using a custom retry filter policy is not tested here since the + // same thing would happen as above if the error is filtered out + + describe(`using the default max retries (${DEFAULT_MAX_RETRIES})`, () => { + it(`calls the service a total of ${ + 1 + DEFAULT_MAX_RETRIES + } times, delaying each retry using a backoff formula`, async () => { + let invocationCounter = 0; + const mockService = jest.fn(() => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicy(); + // Each retry delay is randomized using a decorrelated jitter formula, + // so we need to prevent that + jest.spyOn(Math, 'random').mockReturnValue(0); + + const promise = policy.execute(mockService); + // It's safe not to await these promises; adding them to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(0); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(176.27932892814937); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(186.8886145345685); + await promise; + + expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); + }); - describe('using a custom max number of consecutive failures', () => { - describe('if the initial run + retries is less than the max number of consecutive failures', () => { + describe(`using the default max number of consecutive failures (${DEFAULT_MAX_CONSECUTIVE_FAILURES})`, () => { it('returns what the service returns', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -645,10 +1097,7 @@ describe('createServicePolicy', () => { throw new Error('failure'); }; const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const policy = createServicePolicy({ onBreak }); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise queue @@ -659,8 +1108,7 @@ describe('createServicePolicy', () => { expect(await promise).toStrictEqual({ some: 'data' }); }); - it('does not call the onBreak callback', async () => { - const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; + it('does not call the onBreak callback, since the max number of consecutive failures is never reached', async () => { let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -670,10 +1118,7 @@ describe('createServicePolicy', () => { throw new Error('failure'); }; const onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + const policy = createServicePolicy({ onBreak }); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise queue @@ -687,7 +1132,6 @@ describe('createServicePolicy', () => { 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; let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -697,10 +1141,7 @@ describe('createServicePolicy', () => { throw new Error('failure'); }; const onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, - }); + const policy = createServicePolicy({ onDegraded }); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -713,9 +1154,8 @@ describe('createServicePolicy', () => { }); 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; let invocationCounter = 0; + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; const mockService = () => { invocationCounter += 1; return new Promise((resolve, reject) => { @@ -727,10 +1167,7 @@ describe('createServicePolicy', () => { }); }; const onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, - }); + const policy = createServicePolicy({ onDegraded }); const promise = policy.execute(mockService); // It's safe not to await this promise; adding it to the promise @@ -746,7 +1183,6 @@ describe('createServicePolicy', () => { 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; let invocationCounter = 0; const mockService = () => { invocationCounter += 1; @@ -757,7 +1193,6 @@ describe('createServicePolicy', () => { }; const onDegraded = jest.fn(); const policy = createServicePolicy({ - maxConsecutiveFailures, onDegraded, degradedThreshold, }); @@ -774,9 +1209,8 @@ describe('createServicePolicy', () => { 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; let invocationCounter = 0; + const delay = degradedThreshold + 1; const mockService = () => { invocationCounter += 1; return new Promise((resolve, reject) => { @@ -789,7 +1223,6 @@ describe('createServicePolicy', () => { }; const onDegraded = jest.fn(); const policy = createServicePolicy({ - maxConsecutiveFailures, onDegraded, degradedThreshold, }); @@ -806,75 +1239,22 @@ describe('createServicePolicy', () => { }); }); - 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 onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); - - 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 + 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 onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); - - 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(onBreak).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; + 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 error = new Error('failure'); const mockService = () => { invocationCounter += 1; if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { return { some: 'data' }; } - throw error; + throw new Error('failure'); }; - const onDegraded = jest.fn(); + const onBreak = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, - onDegraded, + onBreak, }); const promise = policy.execute(mockService); @@ -882,29 +1262,24 @@ describe('createServicePolicy', () => { // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); - await promise; - expect(onDegraded).not.toHaveBeenCalled(); + expect(await promise).toStrictEqual({ some: 'data' }); }); - 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; + it('does not call the onBreak callback', async () => { + const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 2; 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 === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); }; - const onDegraded = jest.fn(); + const onBreak = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, - onDegraded, + onBreak, }); const promise = policy.execute(mockService); @@ -914,28 +1289,145 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onBreak).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; + 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; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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; + 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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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; + let invocationCounter = 0; + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).not.toHaveBeenCalled(); + }); + + 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; + 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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).toHaveBeenCalledTimes(1); + }); + }); + }); + + 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 error; + throw new Error('failure'); }; - const onDegraded = jest.fn(); + const onBreak = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, - onDegraded, - degradedThreshold, + onBreak, }); const promise = policy.execute(mockService); @@ -943,31 +1435,25 @@ describe('createServicePolicy', () => { // queue is enough to prevent this test from running indefinitely. // eslint-disable-next-line @typescript-eslint/no-floating-promises clock.runAllAsync(); - await promise; - expect(onDegraded).not.toHaveBeenCalled(); + expect(await promise).toStrictEqual({ some: 'data' }); }); - it('calls the onDegraded callback once if the service execution time is beyond the threshold', async () => { - const degradedThreshold = 2000; + it('does not call the onBreak callback', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES + 1; - const delay = degradedThreshold + 1; let invocationCounter = 0; + const error = new Error('failure'); 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 === DEFAULT_MAX_RETRIES + 1) { + return { some: 'data' }; + } + throw error; }; - const onDegraded = jest.fn(); + const onBreak = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, - onDegraded, - degradedThreshold, + onBreak, }); const promise = policy.execute(mockService); @@ -977,98 +1463,163 @@ describe('createServicePolicy', () => { clock.runAllAsync(); await promise; - expect(onDegraded).toHaveBeenCalledTimes(1); + expect(onBreak).not.toHaveBeenCalled(); }); - }); - }); - describe('if the initial run + retries is greater than the max number of consecutive failures', () => { - it('throws a BrokenCircuitError before the service can succeed', 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 onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + 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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + }); - 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 expect(promise).rejects.toThrow( - new Error( - 'Execution prevented because the circuit breaker is open', - ), - ); - }); + 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 onBreak callback once with the error', 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 onBreak = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onBreak, - }); + expect(onDegraded).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 ignoreRejection(promise); + 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')); + } + }); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + }); - expect(onBreak).toHaveBeenCalledTimes(1); - expect(onBreak).toHaveBeenCalledWith({ error }); - }); + 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('does not call the onDegraded callback', 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 onDegraded = jest.fn(); - const policy = createServicePolicy({ - maxConsecutiveFailures, - onDegraded, + expect(onDegraded).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 ignoreRejection(promise); + 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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).not.toHaveBeenCalled(); + }); - expect(onDegraded).not.toHaveBeenCalled(); + 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) => { + if (invocationCounter === DEFAULT_MAX_RETRIES + 1) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).toHaveBeenCalledTimes(1); + }); + }); }); - 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 () => { + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError before the service can succeed', 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 onBreak = jest.fn(); + const policy = createServicePolicy({ + maxConsecutiveFailures, + onBreak, + }); + + 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 expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + + it('calls the onBreak callback once with the error', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; let invocationCounter = 0; const error = new Error('failure'); @@ -1079,27 +1630,24 @@ describe('createServicePolicy', () => { } throw error; }; + const onBreak = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, + onBreak, }); - const firstExecution = 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(firstExecution); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); - const result = await policy.execute(mockService); + await ignoreRejection(promise); - expect(result).toStrictEqual({ some: 'data' }); + expect(onBreak).toHaveBeenCalledTimes(1); + expect(onBreak).toHaveBeenCalledWith({ error }); }); - }); - 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; + it('does not call the onDegraded callback', async () => { const maxConsecutiveFailures = DEFAULT_MAX_RETRIES; let invocationCounter = 0; const error = new Error('failure'); @@ -1110,21 +1658,1126 @@ describe('createServicePolicy', () => { } throw error; }; + const onDegraded = jest.fn(); const policy = createServicePolicy({ maxConsecutiveFailures, - circuitBreakDuration, + onDegraded, + }); + + 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(onDegraded).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, + }); + + 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); + + expect(result).toStrictEqual({ some: 'data' }); + }); + }); + + 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, + }); + + 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); + + expect(result).toStrictEqual({ some: 'data' }); }); + }); + }); + }); + }); + + describe('using a custom max number of retries', () => { + it(`calls the service a total of 1 + times, delaying each retry using a backoff formula`, async () => { + const maxRetries = 5; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = jest.fn(() => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }); + const policy = createServicePolicy({ maxRetries }); + // Each retry delay is randomized using a decorrelated jitter formula, + // so we need to prevent that + jest.spyOn(Math, 'random').mockReturnValue(0); + + const promise = policy.execute(mockService); + // It's safe not to await these promises; adding them to the promise + // queue is enough to prevent this test from running indefinitely. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(0); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(176.27932892814937); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(186.8886145345685); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(366.8287823691078); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.tickAsync(731.8792783578953); + await promise; + + expect(mockService).toHaveBeenCalledTimes(1 + maxRetries); + }); + + describe(`using the default max number of consecutive failures (${DEFAULT_MAX_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 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 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 - 2; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onBreak = jest.fn(); + const policy = createServicePolicy({ maxRetries, onBreak }); - const firstExecution = 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(firstExecution); - clock.tick(circuitBreakDuration); - const result = await policy.execute(mockService); + await promise; + + expect(onBreak).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 onDegraded = jest.fn(); + const policy = createServicePolicy({ maxRetries, onDegraded }); + + 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(onDegraded).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')); + } + }); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ maxRetries, onDegraded }); + + 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(onDegraded).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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + onDegraded, + degradedThreshold, + }); - expect(result).toStrictEqual({ some: 'data' }); + 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(onDegraded).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')); + } + }); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('returns what the service returns', 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 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 onBreak = jest.fn(); + const policy = createServicePolicy({ maxRetries, onBreak }); + + 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(onBreak).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 onDegraded = jest.fn(); + const policy = createServicePolicy({ maxRetries, onDegraded }); + + 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(onDegraded).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 onDegraded = jest.fn(); + const policy = createServicePolicy({ maxRetries, onDegraded }); + + 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(onDegraded).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 - 1; + let invocationCounter = 0; + const error = new Error('failure'); + const mockService = () => { + invocationCounter += 1; + if (invocationCounter === maxRetries + 1) { + return { some: 'data' }; + } + throw error; + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).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 - 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')); + } + }); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError before the service can succeed', 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 }); + + 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 expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + + it('calls the onBreak callback once with the error', 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 onBreak = jest.fn(); + const policy = createServicePolicy({ maxRetries, onBreak }); + + 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(onBreak).toHaveBeenCalledTimes(1); + expect(onBreak).toHaveBeenCalledWith({ error }); + }); + + it('does not call the onDegraded callback', 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 onDegraded = jest.fn(); + const policy = createServicePolicy({ maxRetries, onDegraded }); + + 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(onDegraded).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 }); + + 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); + + expect(result).toStrictEqual({ some: 'data' }); + }); + }); + + 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, + }); + + 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); + + expect(result).toStrictEqual({ some: 'data' }); + }); + }); + }); + }); + + 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 = 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 onBreak = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onBreak, + }); + + 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 = 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 onBreak = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onBreak, + }); + + 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(onBreak).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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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')); + } + }); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).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')); + } + }); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('if the initial run + retries is equal to the max number of consecutive failures', () => { + it('returns what the service returns', 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 policy = createServicePolicy({ + maxRetries, + 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(); + + expect(await promise).toStrictEqual({ some: 'data' }); + }); + + it('does not call the onBreak callback', 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 onBreak = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onBreak, + }); + + 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(onBreak).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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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')); + } + }); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).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')); + } + }); + }; + const onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + degradedThreshold, + }); + + 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(onDegraded).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('if the initial run + retries is greater than the max number of consecutive failures', () => { + it('throws a BrokenCircuitError before the service can succeed', 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, + }); + + 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); + + await expect(promise).rejects.toThrow( + new Error( + 'Execution prevented because the circuit breaker is open', + ), + ); + }); + + it('calls the onBreak callback once with the error', 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 onBreak = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onBreak, + }); + + 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(onBreak).toHaveBeenCalledTimes(1); + expect(onBreak).toHaveBeenCalledWith({ error }); + }); + + it('does not call the onDegraded callback', 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 onDegraded = jest.fn(); + const policy = createServicePolicy({ + maxRetries, + maxConsecutiveFailures, + onDegraded, + }); + + 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(onDegraded).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; + 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, + }); + + 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); + + expect(result).toStrictEqual({ some: 'data' }); + }); + }); + + 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; + 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, + circuitBreakDuration, + }); + + 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); + + expect(result).toStrictEqual({ some: 'data' }); + }); }); }); }); diff --git a/packages/controller-utils/src/create-service-policy.ts b/packages/controller-utils/src/create-service-policy.ts index c985dba9e2..f60ed0f9cb 100644 --- a/packages/controller-utils/src/create-service-policy.ts +++ b/packages/controller-utils/src/create-service-policy.ts @@ -7,7 +7,7 @@ import { wrap, CircuitState, } from 'cockatiel'; -import type { IPolicy } from 'cockatiel'; +import type { IPolicy, Policy } from 'cockatiel'; export type { IPolicy as IServicePolicy }; @@ -51,6 +51,12 @@ export const DEFAULT_DEGRADED_THRESHOLD = 5_000; * there for more. * * @param options - The options to this function. + * @param options.maxRetries - The maximum number of times that a failing + * service should be re-invoked before giving up. Defaults to 3. + * @param options.retryFilterPolicy - The policy used to control when the + * service should be retried based on either the result of the servce or an + * error that it throws. For instance, you could use this to retry only certain + * errors. See `handleWhen` and friends from Cockatiel for more. * @param options.maxConsecutiveFailures - The maximum number of times that the * service is allowed to fail before pausing further retries. Defaults to 12. * @param options.circuitBreakDuration - The length of time (in milliseconds) to @@ -75,6 +81,10 @@ export const DEFAULT_DEGRADED_THRESHOLD = 5_000; * class Service { * constructor() { * this.#policy = createServicePolicy({ + * maxRetries: 3, + * retryFilterPolicy: handleWhen((error) => { + * return error.message.includes('oops'); + * }), * maxConsecutiveFailures: 3, * circuitBreakDuration: 5000, * degradedThreshold: 2000, @@ -97,6 +107,8 @@ export const DEFAULT_DEGRADED_THRESHOLD = 5_000; * ``` */ export function createServicePolicy({ + maxRetries = DEFAULT_MAX_RETRIES, + retryFilterPolicy = handleAll, maxConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES, circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, degradedThreshold = DEFAULT_DEGRADED_THRESHOLD, @@ -110,6 +122,8 @@ export function createServicePolicy({ // do nothing }, }: { + maxRetries?: number; + retryFilterPolicy?: Policy; maxConsecutiveFailures?: number; circuitBreakDuration?: number; degradedThreshold?: number; @@ -117,10 +131,10 @@ export function createServicePolicy({ onDegraded?: () => void; onRetry?: () => void; } = {}): IPolicy { - const retryPolicy = retry(handleAll, { + 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). - maxAttempts: DEFAULT_MAX_RETRIES, + maxAttempts: maxRetries, // Retries of the service will be executed following ever increasing delays, // determined by a backoff formula. backoff: new ExponentialBackoff(), diff --git a/packages/controller-utils/src/index.test.ts b/packages/controller-utils/src/index.test.ts index 61ef841826..5573baf512 100644 --- a/packages/controller-utils/src/index.test.ts +++ b/packages/controller-utils/src/index.test.ts @@ -4,6 +4,10 @@ describe('@metamask/controller-utils', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ + "DEFAULT_CIRCUIT_BREAK_DURATION", + "DEFAULT_DEGRADED_THRESHOLD", + "DEFAULT_MAX_CONSECUTIVE_FAILURES", + "DEFAULT_MAX_RETRIES", "createServicePolicy", "BNToHex", "convertHexToDecimal", diff --git a/packages/controller-utils/src/index.ts b/packages/controller-utils/src/index.ts index b3bd8821e1..446b9ce382 100644 --- a/packages/controller-utils/src/index.ts +++ b/packages/controller-utils/src/index.ts @@ -1,4 +1,11 @@ -export { createServicePolicy } from './create-service-policy'; +export { + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, + createServicePolicy, +} from './create-service-policy'; +export type { IServicePolicy } from './create-service-policy'; export * from './constants'; export type { NonEmptyArray } from './util'; export {