Skip to content

Commit

Permalink
Add auto-retry functionality with retry and retryDelay config options (
Browse files Browse the repository at this point in the history
  • Loading branch information
jperasmus committed Feb 15, 2024
1 parent 09c5b26 commit 24e0583
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 10 deletions.
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ export const CacheResponseStatus = {
EXPIRED: 'expired',
MISS: 'miss',
} as const

export const DefaultRetryDelay = {
MIN_MS: 1000,
MAX_MS: 30000,
}
40 changes: 39 additions & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 4 in src/helpers.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 18

Unexpected any. Specify a different type

Check warning on line 4 in src/helpers.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 18

Unexpected any. Specify a different type

Check warning on line 4 in src/helpers.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 16

Unexpected any. Specify a different type

Check warning on line 4 in src/helpers.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 16

Unexpected any. Specify a different type

Check warning on line 4 in src/helpers.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 14

Unexpected any. Specify a different type

Check warning on line 4 in src/helpers.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 14

Unexpected any. Specify a different type

Expand All @@ -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')
Expand All @@ -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
Expand All @@ -57,6 +93,8 @@ export function parseConfig(config: Config) {
storage,
minTimeToStale,
maxTimeToLive,
retry,
retryDelay,
serialize,
deserialize,
}
Expand Down
56 changes: 56 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Check warning on line 359 in src/index.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 18

'_error' is defined but never used

Check warning on line 359 in src/index.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 16

'_error' is defined but never used

Check warning on line 359 in src/index.test.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 14

'_error' is defined but never used
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', () => {
Expand Down
48 changes: 40 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -81,15 +87,23 @@ export function createStaleWhileRevalidateCache(
fn: () => CacheValue | Promise<CacheValue>,
configOverrides?: Partial<Config>
): Promise<ResponseEnvelope<Awaited<CacheValue>>> {
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<{
Expand Down Expand Up @@ -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<CacheValue>

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({
Expand Down
7 changes: 7 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ export interface Storage {
[key: string]: any

Check warning on line 5 in types/index.d.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 18

Unexpected any. Specify a different type

Check warning on line 5 in types/index.d.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 16

Unexpected any. Specify a different type

Check warning on line 5 in types/index.d.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test on Node.js 14

Unexpected any. Specify a different type
}

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
}
Expand Down

0 comments on commit 24e0583

Please sign in to comment.