Monday 21 November 2016

PHP: decorating async GuzzleHttp calls - handling exceptions a different way

G'day:
The previous article ("PHP: decorating async GuzzleHttp calls - handling exceptions") detailed how I've used a simple decorator to intercept 4xx & 5xx HTTP responses from my web service and remap Guzzle's default exceptions and use my own ones.  It was basically just this:

public function get($status){
    return $this->adapter
        ->get($status)
        ->otherwise(function($exception){
            $code = $exception->getCode();

            if (!array_key_exists($code, $this->exceptionMap)) {
                throw $exception;
            }
            throw new $this->exceptionMap[$code](
                "A response of $code was received from the service",
                $code,
                $exception
            );
        });
}


Notes:
  • get is a wrapper for a call to Guzzle, and it returns a GuzzleHttp\Promise
  • if the promise rejects (which it will on a 4xx or 5xx response), then I have a handler to check if it's a status that I'm interested in, and if so return (well: throw) a different exception.
  • If not, I just rethrow the original exception.
Simple enough and that works nicely. Read the article though, for more detail on this.

Along the way to that solution I ballsed something up in a way I still don't quite understand and I cannot replicate now, so abandoned that easy solution and approached things a different way. SO I've now got two ways of doing the same thing, and that seems liek a cheap win for me as far as blog content goes, so yer hearing about both of 'em ;-)

If I could not work out how to intercept Guzzle's own exception handling, I decided to replace it.

Reading through the docs I found one could switch off Guzzle's exception handling via the http_errors option. That was no use in itself (as it turns out), but looking at the matches for http_errors in the docs, I got down to the Middleware section, which detailed how the error handler (the one that pays attention to the http_errors setting) is one of the default middelware that Guzzle configures if no bespoke middleware is specified. It also details how one can set one's own middleware. Ergo: one can set one's own error handling middleware.

First things first, I took their own bespoke middleware example and modified it to handle my error remapping code (as per the previous article). As I was only really copying and pasting still into their example, I'll just present the final job:

private function handleHttpErrors($exceptionMap)
{
    return function (callable $handler) use ($exceptionMap) {
        return function ($request, array $options) use ($handler, $exceptionMap) {
            return $handler($request, $options)->then(
                function (ResponseInterface $response) use ($request, $handler, $exceptionMap) {
                    $code = $response->getStatusCode();

                    if ($code < 400) {
                        return $response;
                    }

                    if (array_key_exists($code, $exceptionMap)){
                        throw new $exceptionMap[$code](
                            (string)$response->getBody(),
                            $code
                        );
                    }
                    throw RequestException::create($request, $response);
                }
            );
        };
    };
}

Notes:
  • most of this is copy and paste from the docs and I have no idea why it is the way it is, so... erm... well there you go.
  • The rest just checks the response for an "error" situation;
  • if it's not just return it... the next middleware will continue with it.
  • If it is an exceptional situation: consult the exception map for how to handle it (which exception to throw);
  • or if there's no remapping consideration, just throw the same exception one would normally throw (this was pinched from a different part of the Guzzle source code: Middleware.php).
That's all lovely, but I still need to get it into the middleware stack. Remember this is just a decorator, and it doesn't itself have its own Guzzle client... that's hidden away in the base Guzzle Adapter. But I need to have access to its middleware stack.

Here I cheat slightly and add a method to the base GuzzleAdapter:

namespace me\adamcameron\testApp;

use GuzzleHttp\Client;

class GuzzleAdapter {

    private $client;
    private $endPoint;

    public function __construct($endPoint){
        $this->endPoint = $endPoint;
        $this->client = new Client();
    }

    public function get($id){
        $response = $this->client->requestAsync(
            "get",
            $this->endPoint . $id,
            ["http_errors" => true]
        );

        return $response;
    }

    public function getHandlerStack()
    {
        return $this->client->getConfig('handler');
    }

}

This means the ExceptionHandler decorator can call that method, and get a reference to the handler stack. One caveat with this is that all decorators now need a proxy for this method too. This kinda suggests to me I need to have a GuzzleAdapter interface which specifies which methods decorators need to implement, but I've not got around to that yet.

Now from the ExceptionHandler decorator, I can horse around slightly, replacing the default exception handling with my own version:

public function __construct($adapter, $exceptionMap) {
    $this->adapter = $adapter;
    $this->exceptionMap = $exceptionMap;

    $this->replaceClientErrorHandler($exceptionMap);
}

// ...

private function replaceClientErrorHandler($exceptionMap)
{
    $stack = $this->adapter->getHandlerStack();

    $stack->remove('http_errors');

    $stack->unshift($this->handleHttpErrors($exceptionMap));
}

The handler stack doesn't have a "set" method that works on a named handler like $stack->set('http_errors', $this->handleHttpErrors($exceptionMap)), hence having to remove the default and then stick my own back in. But that's OK I guess.

And that all works.

I have the same test code as I had in the previous article, and it outputs much the same results.

My question is now... which of these approaches is better?

  • The previous article's code is simpler, and easier to follow as one needs to know less about the inner workings of Guzzle.
  • However letting Guzzle handle exceptions in one way, then intercepting that and going "actually now I'll do it meself thanks" seems like doubling up on work a bit.

I think from the perspective of code maintenance, the previous approach is probably "better" in the long run. But I'll just give both options to the chief, and see what he reckons. As my lunch break is about to be over, I think he'll be reckoning "get back to work, Cameron", so I better do that.

Righto.

--
Adam