-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
e3bcac0
commit 9509b6f
Showing
7 changed files
with
407 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,24 +1,106 @@ | ||
import { expect, test } from 'vitest' | ||
import { assertType, describe, expect, it, vi } from 'vitest' | ||
|
||
import { core } from './core.js' | ||
import type { RetryPolicy, RetryState } from './core.js' | ||
import { core, join } from './core.js' | ||
|
||
test('spy function called two times', async () => { | ||
let calls = 0 | ||
describe('core', () => { | ||
it('should call the input function several times', async () => { | ||
let calls = 0 | ||
|
||
const wrapped = core( | ||
() => { | ||
calls += 1 | ||
throw new Error('failure') | ||
}, | ||
(state) => { | ||
if (state.attempt >= 3) { | ||
throw state.error | ||
const wrapped = core( | ||
() => { | ||
calls += 1 | ||
throw new Error('failure') | ||
}, | ||
(state) => { | ||
if (state.attempt >= 3) { | ||
throw state.error | ||
} | ||
|
||
return 0 | ||
}, | ||
) | ||
|
||
await expect(wrapped()).rejects.toThrow('failure') | ||
expect(calls).toBe(3) | ||
}) | ||
}) | ||
|
||
describe('join', () => { | ||
it('should combine multiple retry policies', () => { | ||
const policy1 = vi.fn<RetryPolicy>((state, next) => { | ||
if (state.attempt > 2) { | ||
throw new Error('Max attempts reached') | ||
} | ||
return next ? next(state) : 0 | ||
}) | ||
const policy2 = vi.fn<RetryPolicy>((state) => state.attempt * 1000) | ||
const combinedPolicy = join(policy1, policy2) | ||
|
||
const state = { | ||
attempt: 1, | ||
} as RetryState | ||
|
||
expect(combinedPolicy(state)).toBe(1000) | ||
expect(policy1).toHaveBeenCalledWith(state, policy2) | ||
expect(policy2).toHaveBeenCalledWith(state) | ||
|
||
state.attempt = 2 | ||
expect(combinedPolicy(state)).toBe(2000) | ||
|
||
state.attempt = 3 | ||
expect(() => combinedPolicy(state)).toThrow('Max attempts reached') | ||
}) | ||
|
||
it('should flatten nested arrays of policies', () => { | ||
const policy1: RetryPolicy = () => 100 | ||
const policy2: RetryPolicy = () => 200 | ||
const policy3: RetryPolicy = () => 300 | ||
|
||
let combinedPolicy = join([policy1, policy2], policy3) | ||
expect(combinedPolicy({} as RetryState)).toBe(100) | ||
|
||
combinedPolicy = join(policy1, [policy2, policy3]) | ||
expect(combinedPolicy({} as RetryState)).toBe(100) | ||
|
||
combinedPolicy = join(policy1, [policy2], policy3) | ||
expect(combinedPolicy({} as RetryState)).toBe(100) | ||
}) | ||
|
||
it('should force at least 2 policies at the type-level', () => { | ||
expect(() => { | ||
// @ts-expect-error 2345 must pass at least 2 policies | ||
join() | ||
// @ts-expect-error 2345 must pass at least 2 policies | ||
join(vi.fn()) | ||
// @ts-expect-error 2345 must pass at least 2 policies | ||
join([vi.fn()]) | ||
}).toThrow() | ||
|
||
// While all these should be fine. | ||
assertType<RetryPolicy>(join(vi.fn(), vi.fn())) | ||
assertType<RetryPolicy>(join(vi.fn(), [vi.fn()])) | ||
assertType<RetryPolicy>(join(vi.fn(), [vi.fn(), vi.fn()])) | ||
assertType<RetryPolicy>(join([vi.fn()], [vi.fn(), vi.fn()])) | ||
}) | ||
|
||
it('should provide inference to inline policies', () => { | ||
const policy = join( | ||
(state, next) => { | ||
assertType<RetryState>(state) | ||
assertType<RetryPolicy | undefined>(next) | ||
|
||
return next ? next(state) : 0 | ||
}, | ||
(state) => { | ||
assertType<RetryState>(state) | ||
|
||
return state.attempt * 1000 | ||
}, | ||
) | ||
|
||
return 0 | ||
}, | ||
) | ||
expect(policy({ attempt: 1 } as RetryState)).toBe(1000) | ||
|
||
await expect(wrapped()).rejects.toThrow('failure') | ||
expect(calls).toBe(3) | ||
assertType<RetryPolicy>(policy) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import { describe, it, expect, vi } from 'vitest' | ||
import { Backoff } from './Backoff.js' | ||
import type { RetryState } from '../core.js' | ||
|
||
describe('Backoff', () => { | ||
it('should use default values when no options are provided', () => { | ||
const backoff = Backoff() | ||
|
||
expect(backoff({ attempt: 1 } as RetryState)).toBe(150) | ||
expect(backoff({ attempt: 2 } as RetryState)).toBe(300) | ||
expect(backoff({ attempt: 3 } as RetryState)).toBe(600) | ||
}) | ||
|
||
it('should respect custom delay and exp options', () => { | ||
const backoff = Backoff({ delay: 100, exp: 3 }) | ||
|
||
expect(backoff({ attempt: 1 } as RetryState)).toBe(100) | ||
expect(backoff({ attempt: 2 } as RetryState)).toBe(300) | ||
expect(backoff({ attempt: 3 } as RetryState)).toBe(900) | ||
}) | ||
|
||
it('should respect the max option', () => { | ||
const backoff = Backoff({ delay: 1000, exp: 2, max: 3000 }) | ||
|
||
expect(backoff({ attempt: 1 } as RetryState)).toBe(1000) | ||
expect(backoff({ attempt: 2 } as RetryState)).toBe(2000) | ||
expect(backoff({ attempt: 3 } as RetryState)).toBe(3000) | ||
expect(backoff({ attempt: 3 } as RetryState)).toBe(3000) | ||
}) | ||
|
||
it('should throw TypeError for invalid delay', () => { | ||
expect(() => Backoff({ delay: -1 })).toThrow(TypeError) | ||
expect(() => Backoff({ delay: 0 })).toThrow(TypeError) | ||
}) | ||
|
||
it('should throw TypeError for invalid exp', () => { | ||
expect(() => Backoff({ exp: -1 })).toThrow(TypeError) | ||
expect(() => Backoff({ exp: 0 })).toThrow(TypeError) | ||
}) | ||
|
||
it('should call the next function if provided', () => { | ||
const backoff = Backoff() | ||
const next = vi.fn() | ||
|
||
backoff({ attempt: 1 } as RetryState, next) | ||
expect(next).toHaveBeenCalledWith({ attempt: 1 } as RetryState) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { describe, it, expect, vi } from 'vitest' | ||
import { BrandError, RetryyyError } from './BrandError.js' | ||
import type { RetryState } from '../core.js' | ||
|
||
describe('BrandError', () => { | ||
it('should throw RetryyyError when no next function is provided', () => { | ||
const policy = BrandError() | ||
const testError = new Error('Test error') | ||
const state = { | ||
error: testError, | ||
errors: [testError], | ||
} as RetryState | ||
|
||
expect(() => policy(state)).toThrow(RetryyyError) | ||
}) | ||
|
||
it('should call next function if provided', () => { | ||
const policy = BrandError() | ||
const next = vi.fn() | ||
const state = { error: null } as RetryState | ||
|
||
policy(state, next) | ||
expect(next).toHaveBeenCalledWith(state) | ||
}) | ||
|
||
it('should wrap errors in RetryyyError', () => { | ||
const policy = BrandError() | ||
const lastError = new Error('Last error') | ||
const prevError = new Error('Previous error') | ||
const state = { | ||
error: lastError, | ||
errors: [prevError, lastError], | ||
} as RetryState | ||
|
||
expect(() => policy(state)).toThrow(RetryyyError) | ||
try { | ||
policy(state) | ||
} catch (error) { | ||
expect(error).toBeInstanceOf(RetryyyError) | ||
expect(error).toBeInstanceOf(AggregateError) | ||
expect((error as RetryyyError).errors).toHaveLength(2) | ||
expect((error as RetryyyError).errors[0]).toEqual(prevError) | ||
expect((error as RetryyyError).errors[1]).toEqual(lastError) | ||
expect((error as RetryyyError).cause).toEqual(lastError) | ||
} | ||
}) | ||
|
||
it('should throw RetryyyError with correct message', () => { | ||
const policy = BrandError() | ||
const testError = new Error('Test error') | ||
const state = { error: testError, errors: [testError] } as RetryState | ||
|
||
expect(() => policy(state)).toThrow('RetryyyError') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { describe, it, expect } from 'vitest' | ||
import { | ||
DecorrelatedJitter, | ||
EqualJitter, | ||
FullJitter, | ||
Jitter, | ||
} from './Jitter.js' | ||
import type { RetryState } from '../core.js' | ||
|
||
describe('Jitter', () => { | ||
it('should randomize the delay between retries', () => { | ||
const jitter = Jitter() | ||
const state = { attempt: 1 } as RetryState | ||
const next = () => 100 | ||
|
||
const delay1 = jitter(state, next) | ||
const delay2 = jitter(state, next) | ||
|
||
expect(delay1).not.toBe(delay2) | ||
}) | ||
}) | ||
|
||
describe('FullJitter', () => { | ||
it('should randomize the delay between 0 and the delay', () => { | ||
const jitter = FullJitter() | ||
const state = { attempt: 1 } as RetryState | ||
const next = () => 100 | ||
|
||
const delay = jitter(state, next) | ||
|
||
expect(delay).toBeGreaterThanOrEqual(0) | ||
expect(delay).toBeLessThanOrEqual(100) | ||
}) | ||
}) | ||
|
||
describe('EqualJitter', () => { | ||
it('should randomize the delay between -50% and 50% of the delay', () => { | ||
const jitter = EqualJitter() | ||
const state = { attempt: 1 } as RetryState | ||
const next = () => 100 | ||
|
||
const delay = jitter(state, next) | ||
|
||
expect(delay).toBeGreaterThanOrEqual(50) | ||
expect(delay).toBeLessThanOrEqual(100) | ||
}) | ||
}) | ||
|
||
describe('DecorrelatedJitter', () => { | ||
it('should randomize the delay between -50% and 50% of the delay', () => { | ||
const jitter = DecorrelatedJitter({ initial: 50, max: 1500 }) | ||
const state = { attempt: 1, delay: 0 } as RetryState | ||
|
||
const first = jitter(state) | ||
expect(first).toBeGreaterThanOrEqual(50) | ||
expect(first).toBeLessThanOrEqual(150) | ||
|
||
state.attempt = 2 | ||
state.delay = 100 | ||
|
||
const second = jitter(state) | ||
expect(second).toBeGreaterThanOrEqual(50) | ||
expect(second).toBeLessThanOrEqual(300) | ||
}) | ||
}) |
Oops, something went wrong.