RareJob Tech Blog

レアジョブテクノロジーズのエンジニア・デザイナーによる技術ブログです

Guzzleでリトライ処理を実装してみる

こんにちは。サービス開発チームのすずきです。

今回は「ネットワークは不安定なり、それを前提にリトライ処理を実装せよ」という天からの啓示があったので
PHPではどのような方法を用いて実現できそうか調べてみると Guzzle の Middleware 機構を利用できそうだったので、 Middleware を利用したリトライ方法について書いていきます。
HTTP クライアントとして Guzzle を利用していたこと、リトライ処理をアプリケーションのロジックと分けて Guzzle 側のみで完結できるのがいいと思ったのが採用した理由になります。

Middleware

Guzzle には Middleware という機構があってリトライを行う Middleware を備えています。
今回はそれを利用してリトライを行います。
RetryMiddleware: https://github.com/guzzle/guzzle/blob/master/src/RetryMiddleware.php

まずは公式のドキュメントを参考に Middleware の使い方を見てみましょう。

<?php

$stack = new HandlerStack();  // Middleware を追加するためのstackを生成する
$stack->setHandler(new CurlHandler());  // ベースとなるハンドラをセット

// この例では mapRequest という Middleware を stack に追加する
// $stack->push() をいくつも記載することで複数の Middleware を設定できる
$stack->push(Middleware::mapRequest(function (RequestInterface $request) {
    return $request->withHeader('X-Foo', 'bar');
}));

$client = new Client(['handler' => $stack]);

次にリトライの Middleware の使い方を見ていきます。
RetryMiddleware の生成ロジックを見ると以下のようになっています。

<?php

    /**
     * Middleware that retries requests based on the boolean result of
     * invoking the provided "decider" function.
     *
     * If no delay function is provided, a simple implementation of exponential
     * backoff will be utilized.
     *
     * @param callable $decider Function that accepts the number of retries,
     *                          a request, [response], and [exception] and
     *                          returns true if the request is to be retried.
     * @param callable $delay   Function that accepts the number of retries and
     *                          returns the number of milliseconds to delay.
     *
     * @return callable Returns a function that accepts the next handler.
     */
    public static function retry(callable $decider, callable $delay = null): callable
    {
        return static function (callable $handler) use ($decider, $delay): RetryMiddleware {
            return new RetryMiddleware($decider, $handler, $delay);
        };
    }

$decider にはリトライ条件を記述した bool を返す function 、$delay にはリトライ時にどれくらい待ってリトライするのかという待機時間をミリ秒で渡せそうです。

もう少しわかりやすいようにテストコードも見てみたいと思います。

<?php

    public function testRetriesWhenDeciderReturnsTrue()
    {
        $delayCalls = 0;
        $calls = [];
        $decider = static function (...$args) use (&$calls) {
            $calls[] = $args;
            return \count($calls) < 3;
        };
        $delay = static function ($retries, $response) use (&$delayCalls) {
            $delayCalls++;
            self::assertSame($retries, $delayCalls);
            self::assertInstanceOf(Response::class, $response);
            return 1;
        };
        $m = Middleware::retry($decider, $delay);

        // 以下略

確かに Middleware::retry() の第一引数 $decider には bool が返却されるfunctionが渡されていました。

リトライしてみる

ここまでで RetryMiddleware の使い方がわかりました。
今回は以下のような条件でリトライをしてみたいと思います。

  • 接続先ホストに接続できない場合にリトライする
  • リトライは3回まで行う

「接続先ホストに接続できない場合にリトライする」という条件はどのように判断できそうか見ていきます。
Guzzle の例外クラスは以下のような構造になっているようです。
https://docs.guzzlephp.org/en/stable/quickstart.html#exceptions

. \RuntimeException
└── TransferException (implements GuzzleException)
    ├── ConnectException (implements NetworkExceptionInterface)
    └── RequestException
        ├── BadResponseException
        │   ├── ServerException
        │   └── ClientException
        └── TooManyRedirectsException

A GuzzleHttp\Exception\ConnectException exception is thrown in the event of a networking error. This exception extends from GuzzleHttp\Exception\TransferException.

ドキュメントの中でこのような記載があるので、接続できないようなネットワークのエラーは ConnectException かどうかで判断できそうです。

ちなみに、http_errors オプションが true の場合はリクエスト送信時の HTTP ステータスが 4xx や 5xx の場合に例外をスローするようです。

A GuzzleHttp\Exception\ClientException is thrown for 400 level errors if the http_errors request option is set to true.

-> クライアントエラー時は ClientException がスローされる

A GuzzleHttp\Exception\ServerException is thrown for 500 level errors if the http_errors request option is set to true.

-> サーバエラー時は ServerException がスローされる

http_errors オプションはデフォルトが true なので例外を抑えたい時は false を設定するのが良さそうです。

<?php

$client->request('GET', '/status/500');
// Throws a GuzzleHttp\Exception\ServerException

$res = $client->request('GET', '/status/500', ['http_errors' => false]);
echo $res->getStatusCode();
// 500

改めて、今回の条件でリトライをする場合は以下のようなコードになります。

  • 接続先ホストに接続できない場合にリトライする
  • リトライは3回まで行う
<?php

$decider = function ($retries, $request, $response, $exception) {
    if ($retries >= 3) {  // 初回を含めると最大4回のリクエストが送信される
        return false;
    }
    if ($exception instanceof ConnectException) {
        return true;
    }
    return false;
};
$retry = Middleware::retry($decider);
$handler = HandlerStack::create(new CurlHandler());
$handler->push($retry);
$client = new Client(['handler' => $stack]);

テスト方法

リトライ処理の実装はできましたが、最後にテストをするにはどうしたらよいか見ていきます。

Guzzle には HTTP リクエストをモック化するためのハンドラを備えており、ハンドラを切り替えることで HTTP リクエストをモック化することができます。
ハンドラが設定されていない場合は、HandlerStack::create() 内で呼び出す Utils::chooseHandler() でデフォルトのハンドラが設定されている模様。

モック用のハンドラとして MockHandler があるので、これをハンドラとして設定すれば良さそうです。
改めて RetryMiddleware のテストコードを見てみると、
1回目のリクエストで HTTP ステータスコード 200 を返す場合は以下のように書けるようです。

<?php

    public function testDoesNotRetryWhenDeciderReturnsFalse()
    {
        $decider = static function () {
            return false;
        };
        $m = Middleware::retry($decider);
        $h = new MockHandler([new Response(200)]);
        $c = new Client(['handler' => $m($h)]);
        $p = $c->sendAsync(new Request('GET', 'http://test.com'), []);
        self::assertSame(200, $p->wait()->getStatusCode());
    }

では、3回目のリトライで HTTP ステータスコード 200 が返る場合の MockHandler は以下のように書けそうです。

<?php

$h = new MockHandler([
    new ConnectException('timeout', new Request('GET', 'test')),
    new ConnectException('timeout', new Request('GET', 'test')),
    new ConnectException('timeout', new Request('GET', 'test')),
    new Response(200)
]);

以上で Guzzle の RetryMiddleware 機構を使ったリトライ処理とモック用のハンドラを利用したテストコードの書き方がわかりました。

さいごに

Guzzle すごい