Caching requests with Guzzle 

Cache.. cash... Look, it was the closest I could get for a visual representation!

In my last web development blog I covered creating a piece of Guzzle middleware to log all requests made. This time I want to look at caching my external requests for a set amount of time.

As web developers we're not meant to like repeating the same tasks over and over without wanting to automate them, so why should we make the same requests over and over without just reading the responses from a local cache?

This version will be a simple setup that'll automatically cache any requests passed through it for a given length of time. There is some fun stuff we can do in future but we'll keep this version quite light.

Now all that's left is choosing between which PHP PSR standard we're going to use for our caching code...

Obligatory XKCD link

PSR-6 vs PSR-16

The latest versions of Symfony support both PSR-6 and PSR-16. In case you're some strange person (possibly alien or a WordPress developer) who doesn't have all the PSRs committed to memory, PSR-6 defines interfaces for caching, and PSR-16 defines an interface for simple caching. Clear? Good!

Pre-3.3, only PSR-6 was automatically supported by Symfony, but we now the choice of both. The code below is PSR-16 compatible, but that's simply because the code is slightly shorter, so makes a marginally easier-to-read blog post.

The messy bit

For our example, we're going to create use Redis to cache our responses from Guzzle. To start then, we're going to update our composer.json because we're going to need Predis.

"I thought you said this was supported by Symfony already?" I hear you think loudly. Well yes, ish. The adapters for the various cache solutions exist within Symfony, but for some of them you may need external packages. I promise this isn't going to turn into Node.js though.

Let's load in Predis, then:

composer require predis/predis:^1.1

Cache for questions

Let's create our middleware then. We want to return a cached version if we've already been hit before within x number of seconds. The code will follow the same layout for Guzzle middleware as before, with a request section, a valid response section, and an invalid response section, and a buttload of anonymous functions.

namespace Dittto\CachedRequestBundle\GuzzleMiddleware;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Promise\FulfilledPromise;
use GuzzleHttp\Promise\RejectedPromise;
use Psr\Http\Message\{
    RequestInterface, ResponseInterface
};
use Psr\SimpleCache\CacheInterface;

class CachedMiddleware {
    public const CACHE_TIME_IN_S = 'cache_time';
    private const DEFAULT_CACHE_TIME = 5;

    public function onRequest(CacheInterface $cache, int $defaultCacheTime = self::DEFAULT_CACHE_TIME) {
        return function (callable $handler) use ($cache, $defaultCacheTime) {
            return function (RequestInterface $request, array $options) use ($handler, $cache, $defaultCacheTime) {

                $cacheKey = sha1((string) $request->getUri());

                if ($cachedResponse = $cache->get($cacheKey)) {
                    return new FulfilledPromise($cachedResponse);
                }

                $cacheTime = $options[self::CACHE_TIME_IN_S] ?? $defaultCacheTime;

                return $handler($request, $options)->then(
                    function (ResponseInterface $response) use ($request, $cache, $cacheKey, $cacheTime) {

                        $cache->set($cacheKey, $response, $cacheTime);

                        return $response;
                    },
                    function (TransferException $e) {
                        return new RejectedPromise($e);
                    }
                );
            };
        };
    }
}

In this code block we create a cache key from the full url of the request we're going to make and sha1 it as Guzzle doesn't like special characters in it's keys.

Guzzle relies on promises so if we find our response, we'll return it wrapped up in a valid promise.

If it's an uncached but valid response, we get the cache time in seconds and pass this to the valid response function, so we know how long to cache for. Cache times are either set manually via services, or overridden on a per-request basis.

A Service industry

Speaking of services, let's show how we'd use this code. As with a lot of Symfony apps, we're going to use a lot of services to cut down on the amount of concrete code we need to do.

services:
    http_client:
        class: GuzzleHttp\Client
        arguments:
            - handler: '@http_client.handlerstack'
              connect_timeout: 5
              timeout: 5

    http_client.handlerstack:
        class: GuzzleHttp\HandlerStack
        factory: [ GuzzleHttp\HandlerStack, 'create' ]
        calls:
            - [ 'push', [ '@dittto.cached_request.middleware.request' ] ]

    dittto.cached_request.middleware.request:
        class: Closure
        factory: [ '@dittto.cached_request.middleware', 'onRequest' ]
        arguments: [ '@cache_adapter', 5 ]
        
    dittto.cached_request.middleware:
        class: Dittto\CachedRequestBundle\GuzzleMiddleware\CachedMiddleware
        arguments: [ ]

    redis_cache:
        class: Predis\Client
        arguments:
          - scheme: 'tcp'
            host:   'redis_box'
            port:   6379

    cache_adapter:
        class: Symfony\Component\Cache\Simple\RedisCache
        arguments: [ '@redis_cache' ]

We'll start with defining how our Guzzle client's going to work. If you've already got one set up, then just take the bits from this you need. If you don't, then we define our Guzzle client with some short timeouts, and override it's handler stack.

This handler stack has our middleware pushed on to it. Our middleware (the code above this) is allowed to take a generic CacheInterface, so we init Predis with settings for our Redis box and then pass it through Symfony's PSR-16 compliant simple RedisCacheadapter.

An example

The following shows how to use this above setup, overriding the cache time on the request. As with most Guzzle requests, don't forget your try/catch to capture those failed requests.

class DefaultController
{
    ...

    public function index(Request $request)
    {
        try {
            var_dump($this->client->request(
                'GET',
                $url,
                [
                    CachedMiddleware::CACHE_TIME_IN_S => 10
                ]
            ));
        } catch (TransferException $e) {
            var_dump($e);
            die();
        }
    }
}

This code forms the basic for my open-source plugin, https://github.com/dittto/symfony-cached-request.