From d9656076ec5c4138201080f0a250b57533357165 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sun, 17 Aug 2025 15:09:14 -0700 Subject: Add some retry behavior --- lib/trace/util.ts | 1 - lib/types/fn/either.ts | 40 ++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- tst/either.test.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tst/either.test.ts diff --git a/lib/trace/util.ts b/lib/trace/util.ts index ec67571..95f8d25 100644 --- a/lib/trace/util.ts +++ b/lib/trace/util.ts @@ -1,7 +1,6 @@ import { IEither, IMetric, - isEither, ITraceable, ITraceWith, LogLevel, diff --git a/lib/types/fn/either.ts b/lib/types/fn/either.ts index 0f65859..ffe06d4 100644 --- a/lib/types/fn/either.ts +++ b/lib/types/fn/either.ts @@ -25,6 +25,7 @@ export interface IEither extends Tagged { readonly flatMapAsync: <_T>(mapper: Mapper>>) => Promise>; readonly moveRight: <_T>(t: _T) => IEither; + readonly swap: Supplier>; readonly fold: <_T>(leftFolder: Mapper, rightFolder: Mapper) => _T; readonly joinRight: (other: IEither, mapper: (a: O, b: T) => _T) => IEither; readonly joinRightAsync: ( @@ -119,6 +120,11 @@ export class Either extends _Tagged implements IEither { }); } + public swap(): IEither { + if (isRight(this.self)) return Either.left(this.self.ok); + return Either.right(this.self.err); + } + static left(e: E): IEither { return new Either({ err: e, _tag: ELeftTag }); } @@ -140,4 +146,38 @@ export class Either extends _Tagged implements IEither { .then((t: T) => Either.right(t)) .catch((e: E) => Either.left(e)); } + + static async retrying( + s: Supplier>>, + attempts: number = 3, + interval: Mapper> = (attempt) => Either.attemptWait(attempt), + ): Promise> { + const res: IEither = await Array(attempts) + .fill(0) + .reduce( + async (_result, _i, attempt) => { + await interval(attempt); + const result: IEither = await _result; + return result.joinRightAsync( + () => s().then((s) => s.swap()), + (res, _prevError) => res, + ); + }, + Promise.resolve(Either.right(new Error('No attempts made'))), + ); + return res.swap(); + } + + static attemptWait( + attempt: number, + backoffFactor: number = 500, + jitter: number = 300, + exponent: number = 1.3, + ): Promise { + if (attempt === 0) { + return Promise.resolve(); + } + const wait = Math.pow(exponent, attempt) * backoffFactor + jitter * Math.random() * Math.pow(exponent, attempt); + return new Promise((res) => setTimeout(res, wait)); + } } diff --git a/package.json b/package.json index a77b9ae..d4bb7e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@emprespresso/pengueno", - "version": "0.0.9", + "version": "0.0.11", "description": "emprespresso pengueno utils", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/tst/either.test.ts b/tst/either.test.ts new file mode 100644 index 0000000..790d392 --- /dev/null +++ b/tst/either.test.ts @@ -0,0 +1,45 @@ +import { Either } from '@emprespresso/pengueno'; + +describe('Either.retrying', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('succeeds on first attempt', async () => { + const supplier = jest.fn().mockResolvedValue(Either.right('success')); + const interval = jest.fn().mockResolvedValue(undefined); + + const result = await Either.retrying(supplier, 3, interval); + + expect(result.right().get()).toBe('success'); + expect(supplier).toHaveBeenCalledTimes(1); + }); + + test('never succeeds after all attempts', async () => { + const supplier = jest.fn().mockResolvedValue(Either.left('failed')); + const interval = jest.fn().mockResolvedValue(undefined); + + const result = await Either.retrying(supplier, 3, interval); + + expect(result.left().get()).toBe('failed'); + expect(supplier).toHaveBeenCalledTimes(3); + }); + + test('attempts correct number of times and calls interval with backoff', async () => { + const supplier = jest.fn() + .mockResolvedValueOnce(Either.left('attempt 1 failed')) + .mockResolvedValueOnce(Either.left('attempt 2 failed')) + .mockResolvedValueOnce(Either.right('attempt 3 success')); + + const interval = jest.fn().mockResolvedValue(undefined); + + const result = await Either.retrying(supplier, 3, interval); + + expect(result.right().get()).toBe('attempt 3 success'); + expect(supplier).toHaveBeenCalledTimes(3); + expect(interval).toHaveBeenCalledTimes(3); + expect(interval).toHaveBeenNthCalledWith(1, 0); + expect(interval).toHaveBeenNthCalledWith(2, 1); + expect(interval).toHaveBeenNthCalledWith(3, 2); + }); +}); -- cgit v1.2.3-70-g09d2