Apollo-server: proposal: [apollo-datasource-rest] - bypass redis cache while redis host is not reachable

Created on 4 Nov 2019  路  8Comments  路  Source: apollographql/apollo-server

If Redis host is down, i.e. not reachable, the query response gets this error:

 "message": "Reached the max retries per request limit (which is 3). Refer to \"maxRetriesPerRequest\" option for details."

redis-down

However, I think that showing this kind of error should be avoided and the default response's flow should be granted.

In https://github.com/apollographql/apollo-server/blob/master/packages/apollo-datasource-rest/src/HTTPCache.ts line 38 will assign the ioredis' error to entry.

...
const entry = await this.keyValueCache.get(cacheKey);
    if (!entry) {
      const response = await this.httpFetch(request);
      ...

I guess entry should be null in that case so that const response = await this.httpFetch(request); can be executed if redis' host is down.

馃К data-sources

Most helpful comment

Hope this to be fixed, it will be better for production usage.

All 8 comments

I also think this is a better and more robust approach.
It is fine to get redis exceptions in case the host is down. However, the cache should be bypassed in that case.

@ecerroni did you open a PR or found a workaround for this issue?

@geraldstrobl Nope, no PR.

This issue is been waiting for a response from the team. Still nothing.

I guess it is not a high priority.

Hope this to be fixed, it will be better for production usage.

This happened in memcached cache, too.

Found a workround;
I end up implementing my own ApolloRedisCache using apollo-server-caching spec

const Redis = require('ioredis');

class MyApolloCache extends Redis {
  // set(key: string, value: V, options?: KeyValueCacheSetOptions): Promise<void>;
  async set(key, value, options = {}) {
    // check that redis is ready otherwise resolve with undefined to bypass
    if (this.status !== 'ready') {
      return undefined;
    }

    if (options.ttl) {
      return super.set(key, value, 'ex', options.ttl);
    }
    return super.set(key, value);
  }
  // get(key: string): Promise<V | undefined>;
  async get(key) {
    // check that redis is ready otherwise resolve with undefined to bypass
    if (this.status !== 'ready') {
      return undefined;
    }
    const value = await super.get(key);
    // ioredis returns null when not found however,
    // the spec `apollo-server-caching` states that we should return undefined
    if (value === null) {
      return undefined;
    }
    return value;
  }
}

module.exports = { MyApolloCache };

So install apollo-server-caching (mostly for the interfaces) and ioredis and create a
enhanced version of ioredis (which also you will need to install as a separate package).

Usage

new ApolloServer({
  cacheControl: {
    calculateHttpHeaders: true,
    defaultMaxAge: 30,
  },
  cache: new MyApolloCache({ host: 'your host'}) ,
  plugins: [responseCachePlugin()],
  schema,
});

You could also extend the RedisCache class from the apollo-server-cache-redis package and override some of its functions to bypass cache when redis server is unavailable.

_redis.js_

import { RedisCache } from 'apollo-server-cache-redis'

export default class SafeRedisCache extends RedisCache {
  isReady() {
    if (!this.client || !this.client.status) return false
    return this.client.status.toLowerCase() === 'ready'
  }

  async set(key, value, options) {
    if (!this.isReady()) return undefined
    await super.set(key, value, options)
  }

  async get(key) {
    if (!this.isReady()) return undefined
    return super.get(key)
  }

  async delete(key) {
    if (!this.isReady()) return undefined
    return super.delete(key)
  }
}

Then you can just import SafeRedisCache from './redis' instead and use it in your ApolloServer.
The only catch is that the cache doesn't fallback to the LRU in-memory cache which is used by default inapollo-datasource-rest and 'apollo-server-plugin-response-cache'. Thus, no requests will be cached at all.

UPDATE:

In order to have an in-memory cache as a fallback when using Redis, you will need to create a single new InMemoryLRUCache() instance in your server entry file and pass it to the ApolloServer request context. After that, you will be able to access it in apollo-datasource-rest and apollo-server-plugin-response-cache as part of the request context object and set it as your cache fallback. Mind that this implementation is intended to work with SafeRedisCache. Otherwise, you might need to change the code below.

Mind the ... in the code. Substitute it with your configs.

_app.js_

import { ApolloServer } from 'apollo-server
import { InMemoryLRUCache } from 'apollo-server-caching'
import SafeRedisCache from './redis' // Redis cache with bypass when unavailable
import plugin from './plugins' // In case you want to use patched responseCachePlugin
import Request from './request' // in case you want to use patched RESTDataSource

const redisConfig = { ... } // Your redis confing goes here
const Redis = new RedisCache(redisConfig)
// Create a fallback in-memory cache
const LRUCache = new InMemoryLRUCache() // Might be a good idea to set a maxsize

const server = new ApolloServer({
  cache: Redis, // Gateway to REST API request cache layer
  cacheControl: true, // Client to gateway query cache (with apollo-server-plugin-response-cache)
  plugins: plugins, // Includes patched apollo-server-plugin-response-cache
  dataSources: () => ({  request: new Request() }), // With apollo-datasource-rest
  context: ({ req }) => ({ req, LRUCache }), // Pass in-memory LRU cache to every request context
})

_plugins.js_ (https://www.apollographql.com/docs/apollo-server/performance/caching/#saving-full-responses-to-a-cache)

import responseCachePlugin from 'apollo-server-plugin-response-cache'

const qcpConfig = { ... } // Your responseCachePlugin config goes here
const queryCachePlugin = responseCachePlugin(qcpConfig)

// Hijack the requestDidStart function and use a proper cache instance
queryCachePlugin.requestDidStart = (requestDidStart => {
  return outerRequestContext => {
    if (typeof requestDidStart !== 'function') return undefined

    const cacheDefault = outerRequestContext.cache
    const cacheFallback = outerRequestContext.context.LRUCache
    if (!cacheFallback || typeof cacheDefault.isReady !== 'function') return requestDidStart(outerRequestContext)
    const cache = cacheDefault.isReady() ? cacheDefault : cacheFallback
    return requestDidStart({ ...outerRequestContext, cache })
  }
})(queryCachePlugin.requestDidStart)

export default [queryCachePlugin]

_request.js_ (https://www.apollographql.com/docs/apollo-server/data/data-sources/)

import { RESTDataSource, HTTPCache } from 'apollo-datasource-rest'

class Request extends RESTDataSource {
  initialize(config) {
    super.initialize(config)
    this.cache = config.cache
    if (!this.context.LRUCache) return
    // Save default and fallback httpCache references if default LRUCache is set
    this.httpCacheDefault = this.httpCache
    this.httpCacheFallback = new HTTPCache(this.context.LRUCache, super.httpFetch)
  }

  willSendRequest(request) {
    // Use a fallback cache (if any available) in case the main cache in not available
    if (!this.httpCacheFallback || typeof this.cache.isReady !== 'function') return
    this.httpCache = this.cache.isReady() ? this.httpCacheDefault : this.httpCacheFallback
  }
}

export default Request

You might have multiple implementations of RESTDataSource classes as it is described in 'apollo-datasource-rest' documentation. In this case, I would recommend extending all your RESTDataSource implementations using a Request class:

import { RESTDataSource } from 'apollo-datasource-rest'

class MoviesAPI extends RESTDataSource { ... }
export default MoviesAPI

would become

import Request from './request'

class MoviesAPI extends Request { ... }
export default MoviesAPI

I hope it helps.

In case you want to save yourself all of this overhead, here a shameless plug:
I built https://graphcdn.io, which takes care of the caching for you.
I promise you, that you won't hit these limitations there.
You won't need to host any Redis with that anymore.

Was this page helpful?
0 / 5 - 0 ratings