PHPのHTTPクライアントライブラリであるGuzzleを用いて、リトライ処理をテスタブルに実装する方法を紹介します。
Proxyパターンを使ってコードをすっきりとまとめられました。

背景

外部APIとの通信において、一時的なエラーやレート制限(HTTPステータスコード429)に対処するためにリトライ処理は欠かせません。
しかし、リトライ処理を適切に実装し、かつテスト可能な状態に保つことは簡単ではありません。

たとえば、SlackのAPIはわりと簡単にレート制限が発生するので、リトライ処理の実装は必須になるケースが多いでしょう。

ソースコードはGitHubに公開しています。

https://github.com/jnkmtsd/php-playground/tree/v0.1.2

Proxyパターンの活用

Proxyパターンを使うことで、一般的に使われるクライアント\GuzzleHttp\Clientと利用側の間にリトライ機能が追加されたオブジェクトを透過的に割り込ませられます。
それにより、コードの再利用性とテスタビリティを向上させます。

クラス図

クラス図

具象クラスの依存はClientInterfaceのみなので、具象クラス間の関係を理解するのは難しいかもしれません。
後述のクライアントコードを見るとわかりますが、\Pp\RetryableClient\GuzzleHttp\Clientをラップしていて、\Pp\Clientがそれを使用するという関係です。

リトライ処理の実装

まずはRetryableClientから。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<?php

declare(strict_types=1);

namespace Pp;

use GuzzleHttp\ClientInterface;
use GuzzleHttp\Middleware;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class RetryableClient implements ClientInterface
{
    private ClientInterface $client;
    private const MAX_RETRY_COUNT = 3;

    public function __construct(ClientInterface $client)
    {
        $client->getConfig('handler')->push(Middleware::retry($this->decider(), $this->delay()));
        $this->client = $client;
    }

    private function decider(): \Closure
    {
        return function (int $retries, Request $request, Response $response) {
//            echo ($retries === 0 ? '初回実行' : "リトライ{$retries}回目") . PHP_EOL;
            if ($retries >= self::MAX_RETRY_COUNT) {
                return false;
            }
            $statusCode = $response->getStatusCode();
            if ($statusCode === 429) {
                return true;
            }
            return false;
        };
    }

    private function delay(): \Closure
    {
        return function (int $retries, Response $response): float {
            return 0;
        };
    }

    /**
     * @inheritDoc
     */
    public function send(RequestInterface $request, array $options = []): ResponseInterface
    {
        return $this->client->send($request, $options);
    }

    /**
     * @inheritDoc
     */
    public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
    {
        return $this->client->sendAsync($request, $options);
    }

    /**
     * @inheritDoc
     */
    public function request(string $method, $uri, array $options = []): ResponseInterface
    {
        return $this->client->request($method, $uri, $options);
    }

    /**
     * @inheritDoc
     */
    public function requestAsync(string $method, $uri, array $options = []): PromiseInterface
    {
        return $this->client->requestAsync($method, $uri, $options);
    }

    /**
     * @inheritDoc
     */
    public function getConfig(?string $option = null)
    {
        return $this->getConfig($option);
    }
}

重要な点は以下です。

  • ClientInterfaceで規定されたメソッドを拡張するのがProxyパターンの特徴
    • 規定されたメソッドを拡張せずそのまま委譲して、別メソッドを追加するのはDecoratorパターンなので、違いに注意
  • コンストラクタでリトライ処理を追加
  • send()などのリクエスト送信はリトライ処理が組み込まれた$clientが処理

次にClient

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php

declare(strict_types=1);

namespace Pp;

use GuzzleHttp\ClientInterface;

class Client
{
    private ClientInterface $client;

    public function __construct(ClientInterface $client)
    {
        $this->client = $client;
    }

    public function request(string $path): string
    {
        $url = "https://httpbin.org{$path}";
        $response = $this->client->request('get', $url);

        return $response->getBody()->getContents();
    }
}

重要な点は以下です。

  • コンストラクタの引数の型はClientInterfaceにしておいてDIコンテナなどでRetryableClientを注入するという形が基本
    • 型をRetryableClientにしてリトライ処理を強制するのも状況によってはありだと思う

最後にクライアントコード。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

$client = new \Pp\Client(new \Pp\RetryableClient(new \GuzzleHttp\Client()));

echo '/status/200 request' . PHP_EOL;
echo $client->request('/status/200') . PHP_EOL;

echo '/status/429 request' . PHP_EOL;

try {
    echo $client->request('/status/429') . PHP_EOL;
} catch (\Throwable $e) {
    echo $e->getMessage() . PHP_EOL;
}

\Pp\Clientインスタンスの作成は本来DIコンテナに任せるものなので、本インスタンスの生成方法はあくまで簡易的な例だと思ってください。

テストコード

以下のように、HandlerStackMockHandlerpush()することで任意のレスポンスを返すようにしてテストができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php

declare(strict_types=1);

namespace Pp\Tests;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Pp\RetryableClient;

class RetryableClientTest extends TestCase
{
    public function test_request_retry_success(): void
    {
        $client = (function () {
            $handlerStack = HandlerStack::create(new MockHandler(
                [
                    new Response(429, [], 'Too Many Requests'),
                    new Response(429, [], 'Too Many Requests'),
                    new Response(429, [], 'Too Many Requests'),
                    new Response(200, [], 'OK'), // RetryableClient::MAX_RETRY_COUNT
                ]
            ));
            return new Client(['handler' => $handlerStack]);
        })();
        $retryClient = new RetryableClient($client);
        $response = $retryClient->request('get', 'uri');

        $this->assertSame(200, $response->getStatusCode());
        $this->assertSame('OK', $response->getBody()->getContents());
    }

    public function test_request_retry_failure(): void
    {
        $client = (function () {
            $handlerStack = HandlerStack::create(new MockHandler(
                [
                    new Response(429, [], 'Too Many Requests'),
                    new Response(429, [], 'Too Many Requests'),
                    new Response(429, [], 'Too Many Requests'),
                    new Response(429, [], 'Too Many Requests'), // RetryableClient::MAX_RETRY_COUNT
                    new Response(200, [], 'OK'),
                ]
            ));
            return new Client(['handler' => $handlerStack]);
        })();
        $retryClient = new RetryableClient($client);

        $this->expectException(ClientException::class);
        $retryClient->request('get', 'uri');
    }
}

まとめ

Proxyパターンを活用することで、Guzzleのクライアントにリトライ機能を柔軟に追加し、テストもしやすい構成にすることができました。
このアプローチにより、コードの再利用性とメンテナンス性が向上し、より堅牢なAPIクライアントを実装できます。