* Explicitly set the Redis database number used for rate limiting * Switch to using ioredis as the Redis client for rate limiting * Install ioredis-mock as a primary dependency * Create a Redis BasicAccessor class and tests * Switch rendered page caching to use Redis for storage * Add support for additional Redis SET options like TTLs * Remove currently unused methods * Rename redis-accessors/basic to redis-accessor and remove extra fluff * Change default behavior for cache setting to throw if an error occurs Add option allowSetFailures to facilitate graceful failures * Allow SET failures to fail gracefully for the rendered page cache * Remove as-yet unneeded serialization options from RedisAccessor * Move Redis client construction into RedisAccessor constructor, just pass in databaseNumber as option * Remove rendered-page-cache in favor of direct RedisAccessor use * Add tests for RedisAccessor constructor param validations * Eliminate one roundtrip to Redis for the cached HTML existence check Are we fast yet? * Set a rendered page cache TTL of 24 hours
124 lines
3.5 KiB
JavaScript
124 lines
3.5 KiB
JavaScript
const Redis = require('ioredis')
|
|
const InMemoryRedis = require('ioredis-mock')
|
|
|
|
const { CI, NODE_ENV, REDIS_URL, REDIS_MAX_DB } = process.env
|
|
|
|
// Do not use real a Redis client for CI, tests, or if the REDIS_URL is not provided
|
|
const useRealRedis = !CI && NODE_ENV !== 'test' && !!REDIS_URL
|
|
|
|
// By default, every Redis instance supports database numbers 0 - 15
|
|
const redisMaxDb = REDIS_MAX_DB || 15
|
|
|
|
// Enable better stack traces in non-production environments
|
|
const redisOptions = {
|
|
showFriendlyErrorStack: NODE_ENV !== 'production'
|
|
}
|
|
|
|
class RedisAccessor {
|
|
constructor ({ databaseNumber = 0, prefix = null, allowSetFailures = false } = {}) {
|
|
if (!Number.isInteger(databaseNumber) || databaseNumber < 0 || databaseNumber > redisMaxDb) {
|
|
throw new TypeError(
|
|
`Redis database number must be an integer between 0 and ${redisMaxDb} but was: ${JSON.stringify(databaseNumber)}`
|
|
)
|
|
}
|
|
|
|
const redisUrl = `${REDIS_URL}/${databaseNumber}`
|
|
const redisClient = useRealRedis ? new Redis(redisUrl, redisOptions) : new InMemoryRedis()
|
|
this._client = redisClient
|
|
|
|
this._prefix = prefix ? prefix.replace(/:+$/, '') + ':' : ''
|
|
|
|
// Allow for graceful failures if a Redis SET operation fails?
|
|
this._allowSetFailures = allowSetFailures === true
|
|
}
|
|
|
|
/** @private */
|
|
prefix (key) {
|
|
if (typeof key !== 'string' || !key) {
|
|
throw new TypeError(`Key must be a non-empty string but was: ${JSON.stringify(key)}`)
|
|
}
|
|
|
|
return this._prefix + key
|
|
}
|
|
|
|
static translateSetArguments (options = {}) {
|
|
const setArgs = []
|
|
|
|
const defaults = {
|
|
newOnly: false,
|
|
existingOnly: false,
|
|
expireIn: null, // No expiration
|
|
rollingExpiration: true
|
|
}
|
|
const opts = { ...defaults, ...options }
|
|
|
|
if (opts.newOnly === true) {
|
|
if (opts.existingOnly === true) {
|
|
throw new TypeError('Misconfiguration: entry cannot be both new and existing')
|
|
}
|
|
setArgs.push('NX')
|
|
} else if (opts.existingOnly === true) {
|
|
setArgs.push('XX')
|
|
}
|
|
|
|
if (Number.isFinite(opts.expireIn)) {
|
|
const ttl = Math.round(opts.expireIn)
|
|
if (ttl < 1) {
|
|
throw new TypeError('Misconfiguration: cannot set a TTL of less than 1 millisecond')
|
|
}
|
|
setArgs.push('PX')
|
|
setArgs.push(ttl)
|
|
}
|
|
// otherwise there is no expiration
|
|
|
|
if (opts.rollingExpiration === false) {
|
|
if (opts.newOnly === true) {
|
|
throw new TypeError('Misconfiguration: cannot keep an existing TTL on a new entry')
|
|
}
|
|
setArgs.push('KEEPTTL')
|
|
}
|
|
|
|
return setArgs
|
|
}
|
|
|
|
async set (key, value, options = {}) {
|
|
const fullKey = this.prefix(key)
|
|
|
|
if (typeof value !== 'string' || !value) {
|
|
throw new TypeError(`Value must be a non-empty string but was: ${JSON.stringify(value)}`)
|
|
}
|
|
|
|
// Handle optional arguments
|
|
const setArgs = this.constructor.translateSetArguments(options)
|
|
|
|
try {
|
|
const result = await this._client.set(fullKey, value, ...setArgs)
|
|
return result === 'OK'
|
|
} catch (err) {
|
|
const errorText = `Failed to set value in Redis.
|
|
Key: ${fullKey}
|
|
Error: ${err.message}`
|
|
|
|
if (this._allowSetFailures === true) {
|
|
// Allow for graceful failure
|
|
console.error(errorText)
|
|
return false
|
|
}
|
|
|
|
throw new Error(errorText)
|
|
}
|
|
}
|
|
|
|
async get (key) {
|
|
const value = await this._client.get(this.prefix(key))
|
|
return value
|
|
}
|
|
|
|
async exists (key) {
|
|
const result = await this._client.exists(this.prefix(key))
|
|
return result === 1
|
|
}
|
|
}
|
|
|
|
module.exports = RedisAccessor
|