From 32234612ae1a2643a1ef9d087027e6d359f0ff4c Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:06:22 -0400 Subject: [PATCH] RedisCacheAdapter put, del and clear reject on a Redis outage --- spec/RedisCacheAdapter.spec.js | 65 +++++++++++++++++++++++++ src/Adapters/Cache/RedisCacheAdapter.js | 42 ++++++++++------ 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/spec/RedisCacheAdapter.spec.js b/spec/RedisCacheAdapter.spec.js index 9b88e857c4..a159bdbf18 100644 --- a/spec/RedisCacheAdapter.spec.js +++ b/spec/RedisCacheAdapter.spec.js @@ -182,3 +182,68 @@ describe_only(() => { expect(client.isOpen).toBeTrue(); }); }); + +// These run without a Redis server: the client is replaced with one that always +// rejects, which is what a Redis outage looks like to the adapter. +describe('RedisCacheAdapter error handling', () => { + const KEY = 'hello'; + const VALUE = 'world'; + const failure = new Error('Redis is unavailable'); + + let cache; + let loggerErrorSpy; + + beforeEach(() => { + cache = new RedisCacheAdapter(null, 100); + cache.client = { + get: () => Promise.reject(failure), + set: () => Promise.reject(failure), + del: () => Promise.reject(failure), + sendCommand: () => Promise.reject(failure), + }; + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callFake(() => {}); + }); + + it('resolves and logs when get fails', async () => { + await expectAsync(cache.get(KEY)).toBeResolved(); + expect(loggerErrorSpy.calls.mostRecent().args[0]).toBe('RedisCacheAdapter error on get'); + }); + + it('resolves and logs when put fails', async () => { + await expectAsync(cache.put(KEY, VALUE)).toBeResolved(); + expect(loggerErrorSpy.calls.mostRecent().args[0]).toBe('RedisCacheAdapter error on put'); + }); + + it('resolves and logs when put with an infinite ttl fails', async () => { + await expectAsync(cache.put(KEY, VALUE, Infinity)).toBeResolved(); + expect(loggerErrorSpy.calls.mostRecent().args[0]).toBe('RedisCacheAdapter error on put'); + }); + + it('resolves and logs when del fails', async () => { + await expectAsync(cache.del(KEY)).toBeResolved(); + expect(loggerErrorSpy.calls.mostRecent().args[0]).toBe('RedisCacheAdapter error on del'); + }); + + it('resolves and logs when clear fails', async () => { + await expectAsync(cache.clear()).toBeResolved(); + expect(loggerErrorSpy.calls.mostRecent().args[0]).toBe('RedisCacheAdapter error on clear'); + }); + + it('does not reject when a caller does not await the write', async () => { + // The call sites in Auth and RestWrite are deliberately not awaited, so a + // rejection here would surface as an unhandled rejection. + const rejections = []; + const onUnhandled = reason => rejections.push(reason); + process.on('unhandledRejection', onUnhandled); + + cache.put(KEY, VALUE); + cache.del(KEY); + cache.clear(); + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); + + process.removeListener('unhandledRejection', onUnhandled); + expect(rejections).toEqual([]); + }); +}); diff --git a/src/Adapters/Cache/RedisCacheAdapter.js b/src/Adapters/Cache/RedisCacheAdapter.js index 7acab7fecf..ba9869c6ad 100644 --- a/src/Adapters/Cache/RedisCacheAdapter.js +++ b/src/Adapters/Cache/RedisCacheAdapter.js @@ -58,32 +58,44 @@ export class RedisCacheAdapter { async put(key, value, ttl = this.ttl) { value = JSON.stringify(value); debug('put', { key, value, ttl }); - await this.queue.enqueue(key); - if (ttl === 0) { - // ttl of zero is a logical no-op, but redis cannot set expire time of zero - return; - } + try { + await this.queue.enqueue(key); + if (ttl === 0) { + // ttl of zero is a logical no-op, but redis cannot set expire time of zero + return; + } - if (ttl === Infinity) { - return this.client.set(key, value); - } + if (ttl === Infinity) { + return await this.client.set(key, value); + } - if (!isValidTTL(ttl)) { - ttl = this.ttl; + if (!isValidTTL(ttl)) { + ttl = this.ttl; + } + return await this.client.set(key, value, { PX: ttl }); + } catch (err) { + logger.error('RedisCacheAdapter error on put', { error: err }); } - return this.client.set(key, value, { PX: ttl }); } async del(key) { debug('del', { key }); - await this.queue.enqueue(key); - return this.client.del(key); + try { + await this.queue.enqueue(key); + return await this.client.del(key); + } catch (err) { + logger.error('RedisCacheAdapter error on del', { error: err }); + } } async clear() { debug('clear'); - await this.queue.enqueue(FLUSH_DB_KEY); - return this.client.sendCommand(['FLUSHDB']); + try { + await this.queue.enqueue(FLUSH_DB_KEY); + return await this.client.sendCommand(['FLUSHDB']); + } catch (err) { + logger.error('RedisCacheAdapter error on clear', { error: err }); + } } // Used for testing