Save Cash with Cache: Minimizing API Calls with Redis

API Call Expenses

With the amount of public and pseudo-public APIs out in the world today, there is no shortage of databases for developers to use for their own application. And the API providers know this too. Very few of the most popular APIs allow everyone and anyone to hit their databases at will, even when only allowing GET requests.

Most of the time, the API provider will issue a developer a unique API key to send along as a header in their requests. The API provider then has quite a few options to limit access. They may set a daily or monthly limit on the developer's API calls. If they are really creative, they could throttle requests to spread out services. And, of course, they could take some credit card information and charge the developer a rate for each request. No matter which limitation option the API developer chooses, the independent developer has an interest in limiting their requests to the foreign API.

There are also the other expenses of an API call: time and allocations. If we as developers avoid making superfluous calls to the external API, our app will inevitably run faster as we will cut out the request-response cycle. We will tax our server less and deliver data to our frontend views faster.

Caching is the obvious solution here. If we store previous data responses from the external API on our own server, we can access them when people call on our views or API. In this blog, I will provide an example of this simple coding pattern using a Rails controller file running alongside a Redis server.

Guide to Setting Up Redis Locally

Before you can get started with caching, you will need to set up Redis on your machine. If you have not already, download and install the latest stable version of Redis from the project's downloads page.

You should then gain access to the cli commands redis-server and redis-cli, which will boot the Redis server and allow you to edit data through the command line respectively. Play around with it a bit to make sure it is working. If you are having difficulty, check out the quickstart documentation provided by Redis.

I assume here that you have already run "rails new" and created a simple object-oriented application. With Redis installed, we now have to integrate it into our development environment in Rails. We need to tell Rails that we will a) be using a cache store and b) Redis will act as that cache store. In order to tell Rails that a cache store will be used in this app, we need to create a temporary file, for which we have a few simple command line options:

touch tmp/caching-dev.txt

or:

rails dev:cache

Running the command rails dev:cache a second time will toggle caching off and remove the temp file.

Now that Rails knows we intend to cache data, we need to set it up to use Redis. In our development environment file, we need to change a variable. In the caching section, our environment file should include:

Most of this snippet is already provided, but we want to change our cache_store option to :redis_cache_store. I also changed the maximum age of the cache to 365 days, but you can choose what ever you would like when setting up your store. If you provide that field with an integer, Rails will assume you are providing the number of seconds.

We also need to add a few gems to our gemfile to get Redis working. Our gemfile should now include:

Then, we run bundle install to install the gems.

Lastly, we need to initialize an instance of Redis for our application. There are a number of options, including using a global variable or not initializing an instance at all and just calling Rails.cache.redis every time you need to access the Redis cache. I, however, prefer to create a new initializer file:

I use the Figaro gem to store environment variables in my projects and therefore use a application.yml file, but there are other, more common, Rails-centric ways to store environment variables.

The password option should only be included if you set a password to access your Redis server (requirepass myRedisPassword). You can also pass it through at the end of your initializer file with Redis.current.auth(ENV["REDIS_PASSWORD"])

And lastly, we will need to run redis-server from our command line to boot up our server.

With our cache store set up for use in Rails and Redis running, we are ready to manipulate our controller files to minimize our API calls.

Dr. Redis

or: How I Learned to Stop Worrying and Love the JSON

I first implemented this caching pattern in a Rails API on a transit app I created that requests data from WMATA's public API. In the age of Covid-19, the app is not getting too much use at the moment, but WMATA does keep track of how many calls are made to their API endpoints. The default (free) tier is limited to 10 calls per second and 50,000 calls per day. If we ever start using public transit regularly again and my app becomes popular, I need to limit the number of calls to their endpoints. Redis allows me to cut the number of calls significantly.

Our strategy is to cache responses on the first call and set the length of time the data will survive in our cache. My full transit app has seven different routes that connect (through a controller) to a unique endpoint of WMATA's API. And each of those endpoints has different considerations for how long we need to cache their response. For the endpoint that provides bus prediction data, we probably only want to cache the data for less than a minute, since that data will quickly become stale. For the endpoint that delivers us a list of different rail line names, we could keep that data cached until the Purple line opens in late 2022.

I will use the API call to the full list of bus routes as the first example. WMATA makes changes to their bus route list a little more often than the train lines list, so we probably want to make sure we get this refresh this data at least once per week. In our controller:

So, first we check to see if our Redis instance has already stored data associated with our key ('allBuses'). If not, we complete the API call as normal, which returns a JSON object. We then set the JSON object as the value for our 'allBuses' key and set an expiration time of one week (604,800 seconds).

Redis defaults to storing string values, but it can also manage to store hashes, lists, and sets with only slightly different commands. It may be tempting to convert our JSON response to a hash or list to store in Redis, then. But let's take a moment to consider what that would require our method to do. First we would need to parse our JSON object and convert it into an array. Then we would need to iterate through that array of bus routes, create something (an integer?) to use as a namespaced key, that will point to another series of key-value pairs that hold the data fields we got back for each bus route, where the key becomes a second namespace and our first key suddenly looks something like 'allBuses:0' to pass in different keys (RouteId, etc) to come up with a value of '10A'. That's awfully complicated. Key-value databases like Redis are not designed to store nested hashes. Plus, our entire goal with using a cache is to save time on our subsequent calls. Adding the time complexity of loops to pack or unpack our Redis hash and parse or construct our JSON objects is going to cut deeply into, if not completely undo, whatever time we saved with our cache store.

But... what exactly does Ruby think that JSON response's datatype is? A string! We don't need to convert our JSON into something Ruby can iterate over. Unpacking that data is our frontend's problem. Our Redis instance can store the ugly JSON string as the value for the 'allBuses' key.

What kind of time does this save? Let's call the bus_route_list method twice and find out.


# call method with Redis key not yet existing
  Processing by MetroController#bus_route_list as */*
  Completed 200 OK in 150ms (Views: 0.2ms | ActiveRecord: 0.0ms | Allocations: 1288)

# call method again finding the Redis key and returning the cached data
  Processing by MetroController#bus_route_list as */*
  Completed 200 OK in 2ms (Views: 0.1ms | ActiveRecord: 0.0ms | Allocations: 145)

We went from 150ms to get a 200 response down to just 2ms, and from 1288 memory allocations to 145! We have saved fairly significant time and space with our cache in addition to saving 1 request on our monthly and per second API call limits.

The bus_route_list method is similar to an index method in a more RESTful Rails API controller. It delivers an array and does not need to read any parameters to deliver that array. For methods that require us to send along a parameter, we can store that parameter as part of the key. In the same controller class, we can call the WMATA API endpoint that provides us a list of upcoming bus arrivals when provided with a bus stop's id:

By using the parameter for the bus stop ID as part of the key, we can potentially store as many as keys as WMATA has unique bus stops.

Because of how frequently the WMATA API updates bus arrivals, we set the key-value pair to expire after only 20 seconds. While the expiration time was used in both of the examples above, it is not required that we use this option. If we do not set an expiration time limit, the key will not expire until either a) Redis runs out of space and your eviction policy removes the key or b) you manually delete it.