diff --git a/README.md b/README.md index 9aa42707..f43dfd17 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,10 @@ const memoryCache = await caching('memory', { max: 100, ttl: 10 * 1000 /*milliseconds*/, refreshThreshold: 3 * 1000 /*milliseconds*/, + + /* optional, but if not set, background refresh error will be an unhandled + * promise rejection, which might crash your node process */ + onBackgroundRefreshError: (error) => { /* log or otherwise handle error */ } }); ``` diff --git a/src/caching.ts b/src/caching.ts index 7bdd5366..1e9f178d 100644 --- a/src/caching.ts +++ b/src/caching.ts @@ -5,6 +5,7 @@ export type Config = { ttl?: Milliseconds; refreshThreshold?: Milliseconds; isCacheable?: (val: unknown) => boolean; + onBackgroundRefreshError?: (error: unknown) => void; }; export type Milliseconds = number; @@ -114,9 +115,15 @@ export function createCache( const cacheTTL = typeof ttl === 'function' ? ttl(value) : ttl; const remainingTtl = await store.ttl(key); if (remainingTtl !== -1 && remainingTtl < refreshThresholdConfig) { - coalesceAsync(`+++${key}`, fn).then((result) => - store.set(key, result, cacheTTL), - ); + coalesceAsync(`+++${key}`, fn) + .then((result) => store.set(key, result, cacheTTL)) + .catch((error) => { + if (args?.onBackgroundRefreshError) { + args.onBackgroundRefreshError(error); + } else { + return Promise.reject(error); + } + }); } } return value; diff --git a/test/caching.test.ts b/test/caching.test.ts index fd682b6b..4e6d89af 100644 --- a/test/caching.test.ts +++ b/test/caching.test.ts @@ -3,7 +3,9 @@ import promiseCoalesce from 'promise-coalesce'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { caching, Cache, MemoryConfig, memoryStore, createCache } from '../src'; -import { sleep } from './utils'; +import { sleep, disableExistingExceptionListeners } from './utils'; +import { afterEach } from 'node:test'; +import process from 'node:process'; // Allow the module to be mocked so we can assert // the old and new behavior for issue #417 @@ -417,6 +419,70 @@ describe('caching', () => { expect(callCount).toEqual(2); }); + describe('if onBackgroundRefreshError is not set', async () => { + const rejectionHandler = vi.fn(); + let restoreListeners: () => void; + + beforeEach(() => { + restoreListeners = disableExistingExceptionListeners(); + process.on('uncaughtException', rejectionHandler); + }); + + afterEach(() => { + process.off('uncaughtException', rejectionHandler); + restoreListeners(); + }); + + it('failed background cache refresh calls uncaughtException', async () => { + key = faker.string.alpha(20); + + cache = await caching('memory', { + ttl: 1000, + refreshThreshold: 500, + }); + + value = await cache.wrap(key, () => Promise.resolve('ok')); + expect(value).toEqual('ok'); + expect(rejectionHandler).not.toHaveBeenCalled(); + + await sleep(600); + + value = await cache.wrap(key, () => Promise.reject(new Error('failed'))); + + expect(value).toEqual('ok'); // previous successful value returned + await vi.waitUntil(() => rejectionHandler.mock.calls.length > 0); + expect(rejectionHandler).toHaveBeenCalledTimes(1); + expect(rejectionHandler).toHaveBeenCalledWith( + new Error('failed'), + 'unhandledRejection', + ); + }); + }); + + it('if onBackgroundRefreshError if set, failed background cache refresh calls it', async () => { + key = faker.string.alpha(20); + const onBackgroundRefreshError = vi.fn(); + + cache = await caching('memory', { + ttl: 1000, + refreshThreshold: 500, + onBackgroundRefreshError, + }); + + value = await cache.wrap(key, () => Promise.resolve('ok')); + expect(value).toEqual('ok'); + expect(onBackgroundRefreshError).not.toHaveBeenCalled(); + + await sleep(600); + + value = await cache.wrap(key, () => Promise.reject(new Error('failed'))); + + expect(value).toEqual('ok'); // previous successful value returned + await vi.waitUntil(() => onBackgroundRefreshError.mock.calls.length > 0); + expect(onBackgroundRefreshError).toBeCalledTimes(1); + expect(onBackgroundRefreshError).toHaveBeenCalledWith(new Error('failed')); + }); + it('should allow dynamic refreshThreshold on wrap function', async () => { cache = await caching('memory', { ttl: 2 * 1000, diff --git a/test/utils.ts b/test/utils.ts index 2bab3c83..8c9700a2 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1 +1,25 @@ +import process from 'node:process'; + export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export const disableExistingExceptionListeners = () => { + const uncaughtExceptionListeners = process.rawListeners( + 'uncaughtException', + ) as NodeJS.UncaughtExceptionListener[]; + const unhandledRejectionListeners = process.rawListeners( + 'unhandledRejection', + ) as NodeJS.UnhandledRejectionListener[]; + + process.removeAllListeners('uncaughtException'); + process.removeAllListeners('unhandledRejection'); + + /* restore listeners */ + return () => { + uncaughtExceptionListeners.forEach((listener) => + process.addListener('uncaughtException', listener), + ); + unhandledRejectionListeners.forEach((listener) => + process.addListener('unhandledRejection', listener), + ); + }; +};