UNPKG

@bbc/http-transport-cache

Version:
707 lines (551 loc) 20.5 kB
'use strict'; const assert = require('chai').assert; const Catbox = require('@hapi/catbox'); const Memory = require('@hapi/catbox-memory').Engine; const nock = require('nock'); const bluebird = require('bluebird'); const sinon = require('sinon'); const httpTransport = require('@bbc/http-transport'); const toError = require('@bbc/http-transport-to-error'); const cache = require('../'); const events = require('../').events; const VERSION = require('../config').cache.version; const api = nock('http://www.example.com'); const sandbox = sinon.createSandbox(); const defaultHeaders = { 'cache-control': 'max-age=60,stale-if-error=7200' }; const defaultResponse = { body: 'I am a string!', url: 'http://www.example.com/', statusCode: 200, elapsedTime: 40, headers: defaultHeaders }; const bodySegment = { segment: `http-transport:${VERSION}:stale`, id: 'GET:http://www.example.com/' }; nock.disableNetConnect(); function createCache() { return new Catbox.Client(new Memory()); } function createCacheClient(catbox, opts, existingCacheMiddleware) { const cacheMiddlware = existingCacheMiddleware || cache.staleIfError(catbox, opts); return httpTransport.createClient() .use(cacheMiddlware); } function requestWithClient(client) { return client .get('http://www.example.com/') .use(toError()) .asResponse(); } async function requestWithCache(catbox, opts, cacheMiddlware) { return requestWithClient(createCacheClient(catbox, opts, cacheMiddlware)); } describe('Stale-If-Error', () => { afterEach(() => { nock.cleanAll(); sandbox.restore(); }); it('starts the cache if it\'s not already started', async () => { const cache = createCache(); sandbox.spy(cache, 'start'); api.get('/').reply(200, defaultResponse.body, defaultHeaders); await requestWithCache(cache); sandbox.assert.called(cache.start); }); it('times out a request if cache does not start', async () => { const cache = createCache(); sandbox.stub(cache, 'start').callsFake(async () => { await bluebird.delay(100); throw new Error('We should never get this error'); }); const connectionTimeout = 10; try { await requestWithCache(cache, { ignoreCacheErrors: false, connectionTimeout }); } catch (error) { assert.equal(error.message, 'Starting cache timed out after 10'); } }); it('throws the error that starting the cache throws', async () => { api.get('/').thrice().reply(200, defaultResponse.body, defaultHeaders); const cache = createCache(); const expectedErrorMessage = 'Error starting da cache'; const startError = new Error(expectedErrorMessage); sandbox.stub(cache, 'start').rejects(startError); try { await requestWithCache(cache, { ignoreCacheErrors: false }); throw new Error('this should not pass'); } catch (error) { assert.equal(error.message, expectedErrorMessage); return; } }); it('does not throw the error that starting the cache throws and continues to next middleware when ignoreCacheErrors is true', async () => { const catbox = createCache(); const startError = new Error('Error starting da cache'); sandbox.stub(catbox, 'start').rejects(startError); api.get('/').thrice().reply(200, defaultResponse.body, defaultHeaders); function requestWithCacheAndNextMiddleware() { return httpTransport .createClient() .use(cache.staleIfError(catbox, { ignoreCacheErrors: true })) .use((ctx, next) => { return next(); }) .get('http://www.example.com/') .asResponse(); } await requestWithCacheAndNextMiddleware(); }); it('stores cached values for the stale-if-error value', async () => { const cache = createCache(); await cache.start(); api.get('/').reply(200, defaultResponse.body, defaultHeaders); const maxAge = 60000; const staleIfError = 7200000; const expiry = Date.now() + maxAge + staleIfError; await requestWithCache(cache); const cached = await cache.get(bodySegment); const actualExpiry = cached.ttl + cached.stored; const differenceInExpires = actualExpiry - expiry; assert.deepEqual(cached.item.body, defaultResponse.body); assert(differenceInExpires < 1000 && differenceInExpires >= 0); }); it('stores cached values for the default TTL value if no "stale-if-error" specified', async () => { const cache = createCache(); await cache.start(); api.get('/').reply(200, defaultResponse.body, {}); const maxAge = 90000; const expiry = Date.now() + maxAge; await requestWithCache(cache, { defaultTTL: 90 }); const cached = await cache.get(bodySegment); const actualExpiry = cached.ttl + cached.stored; const differenceInExpires = actualExpiry - expiry; assert.deepEqual(cached.item.body, defaultResponse.body); assert(differenceInExpires < 1000 && differenceInExpires >= 0); }); it('only caches for "stale-if-error" when no other directives are specified', async () => { const catbox = new Catbox.Client(new Memory()); sandbox.stub(catbox, 'get').resolves(); sandbox.stub(catbox, 'set').resolves(); const headers = { 'cache-control': 'stale-if-error=7200' }; const response = { body: 'I am a string!', url: 'http://www.example.com/', statusCode: 200, elapsedTime: 40, headers }; api.get('/').reply(200, response.body, headers); await httpTransport .createClient() .use(cache.maxAge(catbox)) .use(cache.staleIfError(catbox)) .get('http://www.example.com/') .asResponse(); sinon.assert.calledWith(catbox.set, bodySegment); sinon.assert.callCount(catbox.set, 1); }); it('does not create cache entries for critical errors', async () => { const catbox = createCache(); api.get('/').reply(500, defaultResponse.body, defaultHeaders); await httpTransport .createClient() .use(cache.staleIfError(catbox)) .get('http://www.example.com/') .asResponse(); const cached = await catbox.get(bodySegment); assert.isNull(cached); }); it('does create cache entries for client errors', async () => { const catbox = createCache(); api.get('/').reply(404, defaultResponse.body, defaultHeaders); await httpTransport .createClient() .use(cache.staleIfError(catbox)) .get('http://www.example.com/') .asResponse(); const cached = await catbox.get(bodySegment); assert.deepEqual(cached.item.body, defaultResponse.body); }); it('does not create cache entries for items fetched from another cache', async () => { const nearCache = createCache(); const farCache = createCache(); api.get('/').reply(200, defaultResponse.body, defaultHeaders); const client = httpTransport.createClient(); // populate the far-away cache first await client .use(cache.staleIfError(farCache)) .get('http://www.example.com/') .asResponse(); // response will originate from the far-away cache await client .use(cache.staleIfError(nearCache)) .use(cache.staleIfError(farCache)) .get('http://www.example.com/') .asResponse(); const cachedItem = await nearCache.get(bodySegment); assert.isNull(cachedItem); }); it('does not store if no cache-control', async () => { const cache = createCache(); api.get('/').reply(200, defaultResponse); await requestWithCache(cache); const cached = await cache.get(bodySegment); assert(!cached); }); it('does not store if stale-if-error=0', async () => { const cache = createCache(); api.get('/').reply(200, defaultResponse, { 'cache-control': 'stale-if-error=0' }); await requestWithCache(cache); const cached = await cache.get(bodySegment); assert.isNull(cached); }); it('does not store if no-store', async () => { const cache = createCache(); api.get('/').reply(200, defaultResponse, { 'cache-control': 'no-store' }); await requestWithCache(cache); const cached = await cache.get(bodySegment); assert.isNull(cached); }); it('does not store if private', async () => { const cache = createCache(); api.get('/').reply(200, defaultResponse, { 'cache-control': 'private' }); await requestWithCache(cache); const cached = await cache.get(bodySegment); assert.isNull(cached); }); it('stores even if no max-age', async () => { const cache = createCache(); api.get('/').reply(200, defaultResponse, { 'cache-control': 'stale-if-error=7200' }); await requestWithCache(cache); const cached = await cache.get(bodySegment); assert(cached); }); it('does not store if cache control headers are non numbers', async () => { const cache = createCache(); api.get('/').reply(200, defaultResponse.body, { 'cache-control': 'stale-if-error =NAN' }); await requestWithCache(cache); const cached = await cache.get(bodySegment); assert(!cached); }); it('returns cached response if available when error response is returned', async () => { const cachedResponse = { body: 'http-transport', headers: defaultHeaders, elapsedTime: 40, url: 'http://www.example.com/', statusCode: 200 }; const cache = createCache(); api.get('/').reply(500, defaultResponse.body, {}); await cache.start(); await cache.set(bodySegment, cachedResponse, 7200); const res = await requestWithCache(cache); assert.equal(res.body, cachedResponse.body); assert.deepEqual(res.headers, cachedResponse.headers); assert.equal(res.elapsedTime, cachedResponse.elapsedTime); assert.equal(res.url, cachedResponse.url); assert.equal(res.statusCode, cachedResponse.statusCode); return cache.drop(bodySegment); }); it('returns the original error if nothing in cache', async () => { const cache = createCache(); api.get('/').reply(500, defaultResponse, {}); try { await requestWithCache(cache); } catch (err) { return assert.equal(err.message, 'Received HTTP code 500 for GET http://www.example.com/'); } assert.fail('Expected to throw'); }); it('returns an error if the cache lookup fails', async () => { const cache = createCache(); sandbox.stub(cache, 'get').rejects(new Error('cache lookup error')); try { await requestWithCache(cache); } catch (err) { return assert.equal(err.message, 'cache lookup error'); } assert.fail('Expected to throw'); }); it('does not write to cache if the cache lookup fails', async () => { const cache = createCache(); sandbox.stub(cache, 'set'); sandbox.stub(cache, 'get').rejects(new Error('cache lookup error')); try { await requestWithCache(cache); } catch (err) { sinon.assert.notCalled(cache.set); return; } assert.fail('Expected to throw'); }); it('does not write to cache if the cache lookup fails when ignoring cache errors', async () => { const cache = createCache(); sandbox.stub(cache, 'set'); sandbox.stub(cache, 'get').rejects(new Error('cache lookup error')); try { await requestWithCache(cache, { ignoreCacheErrors: true }); } catch (err) { sinon.assert.notCalled(cache.set); return; } assert.fail('Expected to throw'); }); it('returns the original error if "ignoreCacheErrors" is true', async () => { const cache = createCache(); api.get('/').reply(500, defaultResponse, {}); sandbox.stub(cache, 'get').rejects(new Error('cache lookup error')); try { await requestWithCache(cache, { ignoreCacheErrors: true }); } catch (err) { return assert.equal(err.message, 'Received HTTP code 500 for GET http://www.example.com/'); } assert.fail('Expected to throw'); }); it('continues to the next middleware when there\'s an error and no error handler', async () => { const catbox = createCache(); api.get('/').reply(500, defaultResponse.body, defaultHeaders); let called = false; await httpTransport .createClient() .use(cache.staleIfError(catbox)) .use((ctx, next) => { called = true; return next(); }) .get('http://www.example.com/') .asResponse(); assert.ok(called); }); describe('Events', () => { const cachedResponse = { body: 'http-transport', headers: defaultHeaders, elapsedTime: 40, url: 'http://www.example.com/', statusCode: 200 }; it('emits a connection_error event with error when cache.start fails', async () => { api.get('/').reply(200, 'ok'); let cacheConnectionError = null; events.on('cache.connection_error', (ctx, err) => { cacheConnectionError = err; }); const catboxCache = createCache(); const connectionTimeout = 10; const opts = { ignoreCacheErrors: true, connectionTimeout }; const middleware = cache.staleIfError(catboxCache, opts); sandbox.stub(catboxCache, 'start').callsFake(async () => { throw new Error('fake error'); }); sandbox.stub(catboxCache, 'isReady').returns(false); await requestWithCache(catboxCache, opts, middleware); assert(cacheConnectionError instanceof Error, 'expected error to have been emitted'); assert.strictEqual(cacheConnectionError.message, 'fake error'); }); it('emits a stale cache event when returning stale', async () => { let cacheStale = false; events.on('cache.stale', () => { cacheStale = true; }); const cache = createCache(); api.get('/').reply(500, defaultResponse.body, {}); await cache.start(); await cache.set(bodySegment, cachedResponse, 7200); await requestWithCache(cache); assert.ok(cacheStale); }); it('emits a stale cache event with cache name when present', async () => { const opts = { name: 'ceych' }; let cacheStale = false; events.on('cache.ceych.stale', () => { cacheStale = true; }); const cache = createCache(); api.get('/').reply(500, defaultResponse.body, {}); await cache.start(); await cache.set(bodySegment, cachedResponse, 7200); await requestWithCache(cache, opts); assert.ok(cacheStale); }); it('emits a stale cache event with the correct context', async () => { const opts = { name: 'ceych' }; let context; events.on('cache.ceych.stale', (ctx) => { context = ctx; }); const cache = createCache(); api.get('/').reply(500, defaultResponse.body, {}); await cache.start(); await cache.set(bodySegment, cachedResponse, 7200); await requestWithCache(cache, opts); assert.instanceOf(context, httpTransport.context); }); it('emits a timeout cache event with the correct context', async () => { const cache = createCache(); api.get('/').reply(500, defaultResponse, defaultHeaders); sandbox.stub(cache, 'get').callsFake(async () => { await bluebird.delay(100); }); let context; events.on('cache.timeout', (ctx) => { context = ctx; }); await cache.start(); await cache.set(bodySegment, cachedResponse, 7200); try { await requestWithCache(cache, { timeout: 10 }); } catch (err) { return assert.instanceOf(context, httpTransport.context); } assert.fail('Expected to throw'); }); it('emits a cache error event with the correct context', async () => { const cache = createCache(); api.get('/').reply(500, defaultResponse, defaultHeaders); sandbox.stub(cache, 'get').rejects(new Error('error')); let context; events.on('cache.error', (ctx) => { context = ctx; }); await cache.start(); await cache.set(bodySegment, cachedResponse, 7200); try { await requestWithCache(cache); } catch (err) { return assert.instanceOf(context, httpTransport.context); } assert.fail('Expected to throw'); }); }); describe('Cache keys', () => { it('keys cache entries by method and url', async () => { const cache = createCache(); api.get('/').reply(200, defaultResponse.body, defaultHeaders); const maxAge = 60000; const staleIfError = 7200000; const expiry = Date.now() + maxAge + staleIfError; await createCacheClient(cache) .get('http://www.example.com/') .asResponse(); const cached = await cache.get({ segment: `http-transport:${VERSION}:stale`, id: 'GET:http://www.example.com/' }); const actualExpiry = cached.ttl + cached.stored; const differenceInExpires = actualExpiry - expiry; assert.deepEqual(cached.item.body, defaultResponse.body); assert(differenceInExpires < 1000); }); it('keys cache entries by url including query strings in request url', async () => { const cache = createCache(); api.get('/?q=about').reply(200, defaultResponse.body, defaultHeaders); const maxAge = 60000; const staleIfError = 7200000; const expiry = Date.now() + maxAge + staleIfError; await createCacheClient(cache) .get('http://www.example.com/?q=about') .asResponse(); const cached = await cache.get({ segment: `http-transport:${VERSION}:stale`, id: 'GET:http://www.example.com/?q=about' }); const actualExpiry = cached.ttl + cached.stored; const differenceInExpires = actualExpiry - expiry; assert.deepEqual(cached.item.body, defaultResponse.body); assert(differenceInExpires < 1000); }); it('keys cache entries by method and url with the additional varyOn keys and values if matched with the request headers', async () => { const headers = { 'cache-control': 'max-age=60,stale-if-error=7200', 'accept-language': 'en', accept: 'application/json' }; const cache = createCache(); api.get('/some-cacheable-path').reply(200, defaultResponse.body, headers); const opts = { varyOn: [ 'accept-language', 'accept' ] }; const maxAge = 60000; const staleIfError = 7200000; const expiry = Date.now() + maxAge + staleIfError; await createCacheClient(cache, opts) .headers(headers) .get('http://www.example.com/some-cacheable-path') .asResponse(); const cached = await cache.get({ segment: `http-transport:${VERSION}:stale`, id: 'GET:http://www.example.com/some-cacheable-path:accept-language=en,accept=application/json' }); const actualExpiry = cached.ttl + cached.stored; const differenceInExpires = actualExpiry - expiry; assert.deepEqual(cached.item.body, defaultResponse.body); assert(differenceInExpires < 1000); }); it('keys cache entries by method and url with the additional varyOn keys and empty values if not matched with the request headers', async () => { const headers = { 'cache-control': 'max-age=60,stale-if-error=7200', 'accept-language': 'en', accept: 'application/json' }; const cache = createCache(); api.get('/some-cacheable-path').reply(200, defaultResponse.body, headers); const opts = { varyOn: [ 'some-rand-header-a', 'some-rand-header-b' ] }; const maxAge = 60000; const staleIfError = 7200000; const expiry = Date.now() + maxAge + staleIfError; await createCacheClient(cache, opts) .headers(headers) .get('http://www.example.com/some-cacheable-path') .asResponse(); const cached = await cache.get({ segment: `http-transport:${VERSION}:stale`, id: 'GET:http://www.example.com/some-cacheable-path:some-rand-header-a=,some-rand-header-b=' }); const actualExpiry = cached.ttl + cached.stored; const differenceInExpires = actualExpiry - expiry; assert.deepEqual(cached.item.body, defaultResponse.body); assert(differenceInExpires < 1000); }); }); });