Skip to content

Commit

Permalink
Implement error resilient cache wrapper (#69)
Browse files Browse the repository at this point in the history
  • Loading branch information
Fryuni authored May 11, 2023
1 parent 9492ce7 commit ad6f100
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 1 deletion.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ This library ships with a few `CacheProvider` implementations, including:

- Standalone providers
- Adapters for key and value transformation
- Auto-caching strategy
- Auto-caching strategies
- Behavior strategies

### Standalone providers

Expand Down Expand Up @@ -151,6 +152,10 @@ of a loader function for the expiration period that you configure. Once the cach
still return the cached value while the loader function is being resolved in the background.
- [`SharedInFlightCache`](src/sharedInFlight.ts): A cache that ensures there is no concurrent get requests for a key to the underlying cache.

### Behavior strategies

- [`ErrorResilientCache`](src/errorResilient.ts): A cache wrapper that suppresses and logs errors from the underlying cache. Consumers can then assume that the cache never fails.

## Contributing

Contributions to the package are always welcome!
Expand Down
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"dependencies": {
"@croct-tech/time": "^0.7.0",
"@croct/json": "^2.0.0",
"@croct/logging": "^0.2.2",
"object-hash": "^3.0.0"
},
"devDependencies": {
Expand Down
58 changes: 58 additions & 0 deletions src/errorResilient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {extractErrorMessage, Log, Logger, LogLevel} from '@croct/logging';
import {CacheLoader, CacheProvider} from './cacheProvider';

type CacheErrorLog = Log<{
errorMessage: string,
errorStack?: string,
}>;

/**
* A cache wrapper that prevents any error from propagating to the caller.
*
* Errors retrieving values from the cache behave as a cache miss.
*/
export class ErrorResilientCache<K, V> implements CacheProvider<K, V> {
private readonly cache: CacheProvider<K, V>;

private readonly logger: Logger<CacheErrorLog>;

public constructor(cache: CacheProvider<K, V>, logger: Logger<CacheErrorLog>) {
this.cache = cache;
this.logger = logger;

this.logError = this.logError.bind(this);
}

public get(key: K, loader: CacheLoader<K, V>): Promise<V> {
return this.cache
.get(key, loader)
.catch(error => {
this.logError(error);

return loader(key);
});
}

public set(key: K, value: V): Promise<void> {
return this.cache
.set(key, value)
.catch(this.logError);
}

public delete(key: K): Promise<void> {
return this.cache
.delete(key)
.catch(this.logError);
}

private logError(error: unknown): void {
this.logger.log({
level: LogLevel.ERROR,
message: 'Suppressed error on cache operation',
details: {
errorMessage: extractErrorMessage(error),
errorStack: error instanceof Error ? error.stack : undefined,
},
});
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export * from './autoSave';
export * from './holdWhileRevalidate';
export * from './staleWhileRevalidate';
export * from './timestampedCacheEntry';
export * from './errorResilient';
export * from './sharedInFlight';
export {NoopCache} from './noop';
109 changes: 109 additions & 0 deletions test/errorResilient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {InMemoryLogger, Log, LogLevel} from '@croct/logging';
import {CacheProvider, ErrorResilientCache, InMemoryCache} from '../src';

class FailingCache implements CacheProvider<any, any> {
public delete(): Promise<void> {
return Promise.reject(new Error('Failing cache implementation.'));
}

public get(): Promise<any> {
return Promise.reject(new Error('Failing cache implementation.'));
}

public set(): Promise<void> {
return Promise.reject(new Error('Failing cache implementation.'));
}
}

describe('An error-resilient cache wrapper', () => {
it('should forward get calls', async () => {
const innerCache = new InMemoryCache();
const logger = new InMemoryLogger();
const cache = new ErrorResilientCache(innerCache, logger);

await innerCache.set('foo', 'bar');

await expect(cache.get('foo', () => Promise.resolve(null))).resolves.toBe('bar');
await expect(cache.get('baz', () => Promise.resolve(null))).resolves.toBeNull();

expect(logger.getLogs()).toStrictEqual([]);
});

it('should forward set calls', async () => {
const innerCache = new InMemoryCache();
const logger = new InMemoryLogger();
const cache = new ErrorResilientCache(innerCache, logger);

await cache.set('foo', 'bar');

await expect(cache.get('foo', () => Promise.resolve(null))).resolves.toBe('bar');
await expect(innerCache.get('foo', () => Promise.resolve(null))).resolves.toBe('bar');

expect(logger.getLogs()).toStrictEqual([]);
});

it('should forward delete calls', async () => {
const innerCache = new InMemoryCache();
const logger = new InMemoryLogger();
const cache = new ErrorResilientCache(innerCache, logger);

await innerCache.set('foo', 'bar');
await cache.delete('foo');

await expect(cache.get('foo', () => Promise.resolve(null))).resolves.toBeNull();
await expect(innerCache.get('foo', () => Promise.resolve(null))).resolves.toBeNull();

expect(logger.getLogs()).toStrictEqual([]);
});

it('should handle errors on get calls as a cache miss', async () => {
const innerCache = new FailingCache();
const logger = new InMemoryLogger();
const cache = new ErrorResilientCache(innerCache, logger);

await expect(cache.get('foo', () => Promise.resolve(null))).resolves.toBeNull();

expect(logger.getLogs()).toStrictEqual<Log[]>([{
level: LogLevel.ERROR,
message: 'Suppressed error on cache operation',
details: {
errorMessage: 'Failing cache implementation.',
errorStack: expect.stringContaining('at FailingCache.get'),
},
}]);
});

it('should suppress errors on set calls', async () => {
const innerCache = new FailingCache();
const logger = new InMemoryLogger();
const cache = new ErrorResilientCache(innerCache, logger);

await cache.set('foo', 'bar');

expect(logger.getLogs()).toStrictEqual<Log[]>([{
level: LogLevel.ERROR,
message: 'Suppressed error on cache operation',
details: {
errorMessage: 'Failing cache implementation.',
errorStack: expect.stringContaining('at FailingCache.set'),
},
}]);
});

it('should suppress errors on delete calls', async () => {
const innerCache = new FailingCache();
const logger = new InMemoryLogger();
const cache = new ErrorResilientCache(innerCache, logger);

await cache.delete('foo');

expect(logger.getLogs()).toStrictEqual<Log[]>([{
level: LogLevel.ERROR,
message: 'Suppressed error on cache operation',
details: {
errorMessage: 'Failing cache implementation.',
errorStack: expect.stringContaining('at FailingCache.delete'),
},
}]);
});
});

0 comments on commit ad6f100

Please sign in to comment.