Skip to content

Commit

Permalink
Ability to catch promise rejections from background refreshes (#637)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kauhsa authored Feb 17, 2024
1 parent 2f69cc9 commit 9f54a0c
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 4 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
});
```

Expand Down
13 changes: 10 additions & 3 deletions src/caching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type Config = {
ttl?: Milliseconds;
refreshThreshold?: Milliseconds;
isCacheable?: (val: unknown) => boolean;
onBackgroundRefreshError?: (error: unknown) => void;
};

export type Milliseconds = number;
Expand Down Expand Up @@ -114,9 +115,15 @@ export function createCache<S extends Store, C extends Config>(
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<T>(key, result, cacheTTL),
);
coalesceAsync(`+++${key}`, fn)
.then((result) => store.set<T>(key, result, cacheTTL))
.catch((error) => {
if (args?.onBackgroundRefreshError) {
args.onBackgroundRefreshError(error);
} else {
return Promise.reject(error);
}
});
}
}
return value;
Expand Down
68 changes: 67 additions & 1 deletion test/caching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions test/utils.ts
Original file line number Diff line number Diff line change
@@ -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),
);
};
};

0 comments on commit 9f54a0c

Please sign in to comment.