diff options
author | Elizabeth Hunt <me@liz.coffee> | 2025-08-17 15:09:14 -0700 |
---|---|---|
committer | Elizabeth Hunt <me@liz.coffee> | 2025-08-17 15:32:01 -0700 |
commit | d9656076ec5c4138201080f0a250b57533357165 (patch) | |
tree | c23d32d95c532c8b25a112af4369d0aa0de55382 | |
parent | 3d61a9b0c29deb576ecd5d0e8de7b4426d5ab41c (diff) | |
download | pengueno-d9656076ec5c4138201080f0a250b57533357165.tar.gz pengueno-d9656076ec5c4138201080f0a250b57533357165.zip |
Add some retry behavior
-rw-r--r-- | lib/trace/util.ts | 1 | ||||
-rw-r--r-- | lib/types/fn/either.ts | 40 | ||||
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | tst/either.test.ts | 45 |
4 files changed, 86 insertions, 2 deletions
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<E, T> extends Tagged<IEitherTag> { readonly flatMapAsync: <_T>(mapper: Mapper<T, Promise<IEither<E, _T>>>) => Promise<IEither<E, _T>>; readonly moveRight: <_T>(t: _T) => IEither<E, _T>; + readonly swap: Supplier<IEither<T, E>>; readonly fold: <_T>(leftFolder: Mapper<E, _T>, rightFolder: Mapper<T, _T>) => _T; readonly joinRight: <O, _T>(other: IEither<E, O>, mapper: (a: O, b: T) => _T) => IEither<E, _T>; readonly joinRightAsync: <O, _T>( @@ -119,6 +120,11 @@ export class Either<E, T> extends _Tagged implements IEither<E, T> { }); } + public swap(): IEither<T, E> { + if (isRight(this.self)) return Either.left(this.self.ok); + return Either.right(this.self.err); + } + static left<E, T>(e: E): IEither<E, T> { return new Either({ err: e, _tag: ELeftTag }); } @@ -140,4 +146,38 @@ export class Either<E, T> extends _Tagged implements IEither<E, T> { .then((t: T) => Either.right<E, T>(t)) .catch((e: E) => Either.left<E, T>(e)); } + + static async retrying<E, T>( + s: Supplier<Promise<IEither<E, T>>>, + attempts: number = 3, + interval: Mapper<number, Promise<void>> = (attempt) => Either.attemptWait(attempt), + ): Promise<IEither<E, T>> { + const res: IEither<T, E> = await Array(attempts) + .fill(0) + .reduce( + async (_result, _i, attempt) => { + await interval(attempt); + const result: IEither<T, E> = await _result; + return result.joinRightAsync( + () => s().then((s) => s.swap()), + (res, _prevError) => res, + ); + }, + Promise.resolve(Either.right<T, E>(<E>new Error('No attempts made'))), + ); + return res.swap(); + } + + static attemptWait( + attempt: number, + backoffFactor: number = 500, + jitter: number = 300, + exponent: number = 1.3, + ): Promise<void> { + 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<string, string>('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<string, string>('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<string, string>('attempt 1 failed')) + .mockResolvedValueOnce(Either.left<string, string>('attempt 2 failed')) + .mockResolvedValueOnce(Either.right<string, string>('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); + }); +}); |