diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a6c3a..e9aef08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ... +## [3.3.0] - 2024-02-15 + +### Added + +- Functionality to auto-retry failed tasks. Opt-in, by using `retry` and `retryDelay` config options. + ## [3.2.1] - 2023-09-07 ### Fixed @@ -100,7 +106,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add emitter events for when storage get and set fails -[unreleased]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.2.1...HEAD +[unreleased]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.3.0...HEAD +[3.3.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.2.1...v3.3.0 [3.2.1]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.2.0...v3.2.1 [3.2.0]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.1.3...v3.2.0 [3.1.3]: https://github.com/jperasmus/stale-while-revalidate-cache/compare/v3.1.2...v3.1.3 diff --git a/README.md b/README.md index f5ea618..694eabe 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,26 @@ Default: `Infinity` Milliseconds until a cached value should be considered expired. If a cached value is expired, it will be discarded and the task function will always be invoked and waited for before returning, ie. no background revalidation. +#### retry + +Default: `false` (no retries) + +- `retry: true` will infinitely retry failing tasks. +- `retry: false` will disable retries. +- `retry: 5` will retry failing tasks 5 times before bubbling up the final error thrown by task function. +- `retry: (failureCount: number, error: unknown) => ...` allows for custom logic based on why the task failed. + +#### retryDelay + +Default: `(invocationCount: number) => Math.min(1000 * 2 ** invocationCount, 30000)` + +The default configuration is set to double (starting at 1000ms) for each invocation, but not exceed 30 seconds. + +This setting has no effect if `retry` is `false`. + +- `retryDelay: 1000` will always wait 1000 milliseconds before retrying the task +- `retryDelay: (invocationCount) => 1000 * 2 ** invocationCount` will infinitely double the retry delay time until the max number of retries is reached. + #### serialize If your storage mechanism can't directly persist the value returned from your task function, supply a `serialize` method that will be invoked with the result from the task function and this will be persisted to your storage. diff --git a/src/constants.ts b/src/constants.ts index 8af7e3a..3585e89 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,3 +19,8 @@ export const CacheResponseStatus = { EXPIRED: 'expired', MISS: 'miss', } as const + +export const DefaultRetryDelay = { + MIN_MS: 1000, + MAX_MS: 30000, +} diff --git a/src/helpers.ts b/src/helpers.ts index f009d1e..7634e10 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,4 +1,5 @@ -import type { Config, IncomingCacheKey } from '../types' +import type { Config, IncomingCacheKey, RetryDelayFn, RetryFn } from '../types' +import { DefaultRetryDelay } from './constants' type Fn = (...args: any[]) => any @@ -20,6 +21,15 @@ export const createTimeCacheKey = (cacheKey: string) => `${cacheKey}_time` export const passThrough = (value: unknown) => value +export const waitFor = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +const defaultRetryDelay: RetryDelayFn = (invocationCount) => + Math.min( + DefaultRetryDelay.MIN_MS * 2 ** invocationCount, + DefaultRetryDelay.MAX_MS + ) + export function parseConfig(config: Config) { if (!isPlainObject(config)) { throw new Error('Config is required') @@ -42,6 +52,32 @@ export function parseConfig(config: Config) { config.maxTimeToLive === Infinity ? Infinity : Math.min(config.maxTimeToLive ?? 0, Number.MAX_SAFE_INTEGER) || Infinity + + const retry: RetryFn = (failureCount, error) => { + if (!config.retry) return false + + if (typeof config.retry === 'number') { + return failureCount <= config.retry + } + + if (isFunction(config.retry)) { + return config.retry(failureCount, error) + } + + return !!config.retry + } + const retryDelay: RetryDelayFn = (invocationCount) => { + if (typeof config.retryDelay === 'number') { + return config.retryDelay + } + + if (isFunction(config.retryDelay)) { + return config.retryDelay(invocationCount) + } + + return defaultRetryDelay(invocationCount) + } + const serialize = isFunction(config.serialize) ? config.serialize : passThrough @@ -57,6 +93,8 @@ export function parseConfig(config: Config) { storage, minTimeToStale, maxTimeToLive, + retry, + retryDelay, serialize, deserialize, } diff --git a/src/index.test.ts b/src/index.test.ts index 85dbc71..3b04e1d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -321,6 +321,62 @@ describe('createStaleWhileRevalidateCache', () => { expect(fn1).toHaveBeenCalledTimes(1) expect(fn2).not.toHaveBeenCalled() }) + + describe('Retry', () => { + it('should allow retrying the request using a number of retries', async () => { + const swr = createStaleWhileRevalidateCache({ + ...validConfig, + minTimeToStale: 0, + maxTimeToLive: Infinity, + retry: 3, + retryDelay: 0, + }) + const key = 'retry-example' + const error = new Error('beep boop') + const fn = jest.fn(() => { + throw error + }) + + expect.assertions(2) + + try { + await swr(key, fn) + + fail('Expected swr to throw an error') + } catch (err) { + expect(err).toBe(error) + } finally { + expect(fn).toHaveBeenCalledTimes(4) // Initial invocation + 3 retries + } + }) + }) + + it('should allow retrying the request using a custom retry function', async () => { + const swr = createStaleWhileRevalidateCache({ + ...validConfig, + minTimeToStale: 0, + maxTimeToLive: Infinity, + retry: (failureCount, _error) => failureCount < 3, + retryDelay: () => 10, + }) + const key = 'retry-example' + const error = new Error('beep boop') + const fn = jest.fn(() => { + throw error + }) + + expect.assertions(2) + + try { + await swr(key, fn) + + fail('Expected swr to throw an error') + } catch (err) { + expect(err).toBe(error) + } finally { + expect(fn).toHaveBeenCalledTimes(3) // Initial invocation + 2 retries (testing failureCount < 3) + } + }) }) describe('EmitterEvents', () => { diff --git a/src/index.ts b/src/index.ts index 293a1ae..0e8691d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,13 @@ import { extendWithEmitterMethods, createEmitter, } from './event-emitter' -import { createTimeCacheKey, getCacheKey, isNil, parseConfig } from './helpers' +import { + createTimeCacheKey, + getCacheKey, + isNil, + parseConfig, + waitFor, +} from './helpers' export { EmitterEvents } @@ -81,15 +87,23 @@ export function createStaleWhileRevalidateCache( fn: () => CacheValue | Promise, configOverrides?: Partial ): Promise>> { - const { storage, minTimeToStale, maxTimeToLive, serialize, deserialize } = - configOverrides - ? parseConfig({ ...cacheConfig, ...configOverrides }) - : cacheConfig + const { + storage, + minTimeToStale, + maxTimeToLive, + serialize, + deserialize, + retry, + retryDelay, + } = configOverrides + ? parseConfig({ ...cacheConfig, ...configOverrides }) + : cacheConfig emitter.emit(EmitterEvents.invoke, { cacheKey, fn }) const key = getCacheKey(cacheKey) const timeKey = createTimeCacheKey(key) + let invocationCount = 0 let cacheStatus: CacheStatus = CacheResponseStatus.MISS type RetrieveCachedValueResponse = Promise<{ @@ -162,10 +176,28 @@ export function createStaleWhileRevalidateCache( async function revalidate({ cacheTime }: { cacheTime: number }) { try { - emitter.emit(EmitterEvents.revalidate, { cacheKey, fn }) - inFlightKeys.add(key) + if (invocationCount === 0) { + emitter.emit(EmitterEvents.revalidate, { cacheKey, fn }) + inFlightKeys.add(key) + } + + invocationCount++ + + let result: Awaited - const result = await fn() + try { + result = await fn() + } catch (error) { + if (!retry(invocationCount, error)) { + throw error + } + + const delay = retryDelay(invocationCount) + + await waitFor(delay) + + return revalidate({ cacheTime }) + } // Error handled in `persistValue` by emitting an event, so only need a no-op here await persistValue({ diff --git a/types/index.d.ts b/types/index.d.ts index 0a6195a..4632512 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -5,10 +5,17 @@ export interface Storage { [key: string]: any } +export type RetryFn = (failureCount: number, error?: unknown) => boolean +export type Retry = boolean | number | RetryFn +export type RetryDelayFn = (invocationCount: number) => number +export type RetryDelay = number | RetryDelayFn + export interface Config { minTimeToStale?: number maxTimeToLive?: number storage: Storage + retry?: Retry + retryDelay?: RetryDelay serialize?: (value: any) => any deserialize?: (value: any) => any }