Sunday 20 November 2016

PHP: decorating async GuzzleHttp calls - handling exceptions

G'day:
This is a continuation from my earlier article - "PHP: decorating async GuzzleHttp calls". Hopefully it stands on its own merit, but perhaps y/day's one (ie: Thursday 17/11/2016) will offer some more context than I perhaps will repeat here. I do refer back to some of the code in that article. Go read it.

Speaking of context though, we're in the process of re-decorating (in the Decorator Pattern sense, not the shopping-at-Ikea sense) our GuzzleHttp calls, specifically when we're making async calls.  Yesterday I looked at how to do request & response logging on async calls. The next mission is to deal with exceptions in our own way.

GuzzleHttp calls will raise a ClientException when an HTTP response has a 400-series status code, and a ServerException when it's in the 500 range. See "Guzzle 6 docs / Quickstart / Exceptions". That's all cool as a default, but we need to be able to map response codes to different exceptions in our adapters for one reason or another. So I need to work out how to intercept those, handle them, and throw our own exceptions. I was fairly confident it was gonna be the same as with the logging example, except using rejection handlers instead of resolution ones.

For testing I set up another quick CFML-based endpoint, which would just let me return a specific HTTP response code:

<cfheader statusCode="#URL.statusCode#">
<cfoutput>The request resulted in a #URL.statusCode# status code</cfoutput>


So whatever code I pass on the URL, it uses that as the status code for the response, and also confirms that in the body.

I figured adapter on the PHP end of things would be along these lines:
namespace me\adamcameron\testApp;

class StatusToExceptionAdapter {

    private $adapter;
    private $exceptionMap;

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

    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
                );
            });
    }
}

Points:

  • Again this adapter just decorates the adapter that does the actual "guzzling" (code in previous article); 
  • the decoration being a passed-in exception map.
  • Where in the previous logging example I was using a resolution handler (a then call), here I'm using a rejection handler, via otherwise.
  • So if the call to get results in a rejected promise (ie: an error), I do the following:
    • check if the exception passed to the handler was one I have a mapping for
    • if not, just rethrow the original exception
    • otherwise I throw a new exception of the remapped exception type
    • and pass the original exception as its previous exception.
That's it. And... um... this all... works.

Here's a test rig:


use \me\adamcameron\testApp\GuzzleAdapter;
use \me\adamcameron\testApp\StatusToExceptionAdapterOld;

require_once __DIR__ . "/../../vendor/autoload.php";

$endPoint  = "http://cf2016.local:8516/cfml/misc/guzzleTestEndpoints/returnStatusCode.cfm?statusCode=";

$guzzleAdapter = new GuzzleAdapter($endPoint);

$exceptionMap = [
    400 => '\me\adamcameron\testApp\exception\BadRequestException',
    503 => '\me\adamcameron\testApp\exception\ServerException'
];

$adapter = new StatusToExceptionAdapter($guzzleAdapter, $exceptionMap);

$tests = [
    'non-error' => 200,
    'mapped client error' => 400,
    'mapped server error' => 503,
    'un-mapped client error' => 404,
    'un-mapped server error' => 500
];

foreach($tests as $test=>$value) {
    printf("%s (%s)%s", $test, $value, PHP_EOL . PHP_EOL);

    $response = $adapter->get($value);

    try {
        $resolvedResponse = $response->wait();

        printf(
            "Successful request returned: %s%s ",
            (string) $resolvedResponse->getBody()
        );
    } catch (Exception $e){
        printf("Request errored with %s%s", $e->getCode(), PHP_EOL);
        printf("Class: %s%s", get_class($e), PHP_EOL);
        printf("Message %s%s", $e->getMessage(), PHP_EOL);

        $previous = $e->getPrevious();
        if (is_null($previous)){
            continue;
        }

        printf("Previous class %s%s", get_class($previous), PHP_EOL);
        printf("Previous code %s%s", $previous->getCode(), PHP_EOL);
        printf("Previous message %s%s", $previous->getMessage(), PHP_EOL);
    }
    finally {
        echo PHP_EOL . "====================================================" . PHP_EOL . PHP_EOL;
    }
}


That looks like a lot of code, but it's simple:

  • initialise the Guzzle adapter
  • decorate it with the exception mapping one
  • I'm mapping for just a couple of response codes
  • I loop over a bunch of possibilities, including:
    • a 200-OK response (which is not impacted by any of this work, and should just filter through);
    • both exceptions I'm remapping;
    • another coupla "error" response codes I'm not remapping
  • I call the web service for each of these;
  • and output the results.
  • Which might or might not have a wrapped "previous" exception.
And the output of that test run shows it all working A-OK:


 C:\>php testExceptionMap.php

 non-error (200)

 Successful request returned: The request resulted in a 200 status code


 ====================================================

 mapped client error (400)

 Request errored with 400
 Class: me\adamcameron\testApp\exception\BadRequestException
 Message A response of 400 was received from the service
 Previous class GuzzleHttp\Exception\ClientException
 Previous code 400
 Previous message Client error: `GET http://cf2016.local:8516/cfml/misc/guzzleTestEndpoints/returnStatusCode.cfm?statusCode=400` resulted in a `400 Bad Request` response: The request resulted in a 400 status code

 ====================================================


 mapped server error (503)

 Request errored with 503
 Class: me\adamcameron\testApp\exception\ServerException
 Message A response of 503 was received from the service
 Previous class GuzzleHttp\Exception\ServerException
 Previous code 503
 Previous message Server error: `GET http://cf2016.local:8516/cfml/misc/guzzleTestEndpoints/returnStatusCode.cfm?statusCode=503` resulted in a `503 Service Unavailable` response: The request resulted in a 503 status code

 ====================================================

 un-mapped client error (404)

 Request errored with 404
 Class: GuzzleHttp\Exception\ClientException
 Message Client error: `GET http://cf2016.local:8516/cfml/misc/guzzleTestEndpoints/returnStatusCode.cfm?statusCode=404` resulted in a `404 Not Found` response: The request resulted in a 404 status code

 ====================================================

 un-mapped server error (500)

 Request errored with 500
 Class: GuzzleHttp\Exception\ServerException
 Message Server error: `GET http://cf2016.local:8516/cfml/misc/guzzleTestEndpoints/returnStatusCode.cfm?statusCode=500` resulted in a `500 Internal Server Error` response: The request resulted in a 500 status code

 ====================================================

 C:\>

So here we see, again:
  • the 200-OK response;
  • both exceptions I'm remapping;
  • A 404 and a 500 which I'm not remapping, so retain their Guzzle-specific exceptions

All good. But why did I sound hesitant before when I said "And... um... this all... works"? Because when I first wrote this code on Friday it didn't bloody work. No matter what I did could I stop the original Guzzle exceptions bubbling back. I started writing this up today, and hastily rewrote the code I was using on Fri for the article, and... now it bloody works! This is excellent as it's really easy, but I spent all Fri evening and Saturday working around not being able to get this working the way I wanted. Sigh. And now I can't - for the life of me - work out where I was going wrong before. Grrr.

One positive side of this though is that I did find another way of dealing with this, and it might be slightly more "semantically Guzzle", so I'll write that up tomorrow. -ish.

Anyway, I need to scarf this Guinness and go find my gate; which will hopefully have an aircraft at it, to take me back to London.

Righto.

--
Adam