🦉

Guzzleと少し仲良くなる

2024/12/16に公開

この記事は カオナビ Advent Calendar 2024 16日目です。

はじめに

はいさい。しまぶ@shimabox です。ぎりぎり間に合いましたね。セーフセーフ。

というわけで、今回はGuzzleと少し仲良くなりたいと思います。
ん、これ前もなんだか聞いたことあるな?と思ったかた、するどい!そうなのですこれは去年のPHPカンファレンス福岡2024で発表した「並行処理を学びGuzzleと仲良くなる」というスライドの補足?になります。

なぜならば、その時の発表は並列/並行処理の違いで頭がパンクしてしまい肝心のGuzzleについての発表が間に合わなかった記憶がありましたので、それのリベンジです。

ちなみにスライドはこれです

今回の内容

基本的にスライドに書いていたサンプルコードはGitHubにあげたので小さなサンプルはそちらで見ていただいて、少し大きめのサンプル(複数のHTTPリクエストを実行するものや、レートリミット対策用ミドルウェア)について書いていきたいと思います。

サンプルコードを書いた

というわけでスライドに書いていたサンプルコードを、きちんと動くように書きました。

使い方はREADME.mdを見ていただければ大丈夫だと思いますが、

cloneして

git clone git@github.com:shimabox/guzzle-sample.git

setupして

cd guzzle-sample
make setup # これでどーんと立ち上がると思う

なにかを適当に実行してみればOKです。

make client # コンテナに入って
php index.php

※ Makefileにいろいろコマンドを用意しています

構成は、client側とapi側に分かれています。

  • client
    • PHP 8.3.14, Nginx
    • Guzzleでapiを叩くクライアント側
  • api
    • PHP 8.3.14, Apache, Laravel 11.35.1
    • APIです

ブラウザからもアクセス可能です。

これでclient側からGuzzleを使ってapi側を呼び出す形でコードを動かせます。
なぜこうしたのかは、こうしたかったからです。

サンプル

今回題材にするのは、ClientInterfacePoolを使って複数のHTTPリクエストを実行するものです。渡されたパラメーターを元にエンドポイントを叩き、結果を格納したクラスを返却します。

ディレクトリ構造

src/Sample/
├── ClientPoolFactoryInterface.php
├── FulfilledHandlerInterface.php
├── GuzzleSample.php // 処理を実行するクラス
├── Handler
│   ├── FulfillHandler.php  // リクエスト成功時の結果処理ハンドラ
│   └── RejectedHandler.php // リクエスト失敗時の結果処理ハンドラ
├── Middleware
│   └── RateLimitMiddleware.php // レートリミット対策用ミドルウェア
├── Pool
│   └── ClientPoolFactory.php // Poolクラスを生成するファクトリ
├── RejectedHandlerInterface.php
├── Result.php // 結果格納クラス
├── index.php // GuzzleSampleを使うサンプル
└── throttle_error.php // GuzzleSampleを使うサンプル(レートリミットでエラーになりやすい)

はい。こだわりは特にありません。

GuzzleSample

<?php

namespace App\Sample;

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

use GuzzleHttp\ClientInterface;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Client\ClientExceptionInterface;

readonly class GuzzleSample
{
    public function __construct(
        private ClientInterface $client,
        private ClientPoolFactoryInterface $poolFactory,
        private FulfilledHandlerInterface $fulfilledHandler,
        private RejectedHandlerInterface $rejectedHandler,
        private array $params,
        private int $concurrency = 10
    ) {}

    public function call(): Result
    {
        // リクエストを生成
        // $paramsを元に、'GET /sample_test/{id}/{name}' の非同期リクエストをyieldするジェネレータを返す
        $requests = function ($params) {
            foreach ($params as $key => $param) {
                yield $key => fn() => $this->client->requestAsync('GET', "/sample_test/{$param['id']}/{$param['name']}");
            }
        };

        // Poolを生成
        // factoryメソッドにクライアント、リクエストジェネレータ、そして各種オプションを渡す
        // 'concurrency'で同時リクエスト数を制御
        // 'fulfilled'コールバックでは成功時のレスポンスとキー($key)をFulfilledHandlerに渡す
        // 'rejected'コールバックでは失敗時の例外(reason)とキー($key)をRejectedHandlerに渡す
        $pool = $this->poolFactory->factory($this->client, $requests($this->params),
            [
                'concurrency' => $this->concurrency,
                // リクエストが成功したらFulfilledHandlerで処理
                'fulfilled' => fn(Response $res, $key) => $this->fulfilledHandler->handle($res, $key),
                // リクエストが失敗したらRejectedHandlerで処理
                'rejected' => fn(ClientExceptionInterface $reason, $key) => $this->rejectedHandler->handle($reason, $key),
            ]
        );

        // promise()を取得し、wait()で全ての非同期リクエストが完了するまで待機
        $promise = $pool->promise();
        $promise->wait();

        // 全リクエストが完了した時点でFulfilledHandlerやRejectedHandlerには結果が格納済み
        // ResultオブジェクトにFulfilledHandler/RejectedHandlerを渡し、結果をまとめる
        return new Result($this->fulfilledHandler, $this->rejectedHandler);
    }
}

ポイント

自分としては、リクエストを生成するときにyieldを使うのがポイントだと思っています。

// リクエストを生成
// $paramsを元に、'GET /sample_test/{id}/{name}' の非同期リクエストをyieldするジェネレータを返す
$requests = function ($params) {
    foreach ($params as $key => $param) {
        yield $key => fn() => $this->client->requestAsync('GET', "/sample_test/{$param['id']}/{$param['name']}");
    }
};

非同期でリクエストを投げるということは、レスポンスもバラバラで返ってきます。
リクエストで投げたパラメータと、返却されたてきたレスポンスで何か辻褄を合わせたい場合に非常に困ります。
なので、yieldを使って識別できる値を渡すと捗ると思っています。

// こんな感じでyieldを使って渡した値はコールバックで渡ってくるので捗る場面があるかも
'fulfilled' => fn(Response $res, $key) => $this->fulfilledHandler->handle($res, $key),

ちなみに、ClientPoolFactoryGuzzleHttp\Poolを生成するだけのラッパーです。

ClientPoolFactory
<?php

namespace App\Sample\Pool;

use App\Sample\ClientPoolFactoryInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Pool;

class ClientPoolFactory implements ClientPoolFactoryInterface
{
    public function factory(
        ClientInterface $client,
        $requests,
        array $config = []
    ): Pool {
        return new Pool($client, $requests, $config);
    }
}

なぜラップするかというとテストしづらくなるからですね。テストのためにラッパーを用意するのが良いか悪いかは置いておいて、基本的にはライブラリ(外部依存するもの)はinterfaceなどを用意して直接具象に依存しないのがベストだと思います。腐敗防止層ってやつだ!

※ 他のクラスの説明は割愛します

サンプルのテスト

では、テストを書いてみます。
テストでは以下の点をチェックします。

  • エンドポイントに正しくパラメータを渡せていること
  • FulfillHandlerが正しく成功レスポンスを処理できること
  • RejectedHandlerが正しくエラーレスポンスを処理できること
  • Poolのconcurrencyやfulfilled/rejectedコールバックが想定通り動作すること
  • MockHandlerを使って、レスポンスや例外を自由にシミュレート可能なこと
  • API側のテストはAPI側でやっているよな!!?
<?php

namespace Tests\Sample;

use App\Sample\ClientPoolFactoryInterface;
use App\Sample\Handler\FulfillHandler;
use App\Sample\GuzzleSample;
use App\Sample\Handler\RejectedHandler;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;

class GuzzleSampleTest extends TestCase
{
    /**
     * @var array ClientPoolFactoryへの設定
     */
    private array $capturedClientPoolFactoryConfig = [];

    public function test_すべて成功()
    {
        /*
         |---------------------
         | 準備
         |---------------------
         */
        $params = [
            'foo' => ['id' => 1, 'name' => 'foo'],
            'bar' => ['id' => 2, 'name' => 'bar'],
        ];

        $queue = [
            new Response(200, [], json_encode($params['foo'])),
            new Response(200, [], json_encode($params['bar'])),
        ];

        // MockHandlerとHistoryMiddlewareでリクエスト履歴をとる
        $historyContainer = [];
        $clientMock = $this->createMockClient($queue, $historyContainer);

        // 期待する同時リクエスト数
        $expectedConcurrency = 2;

        $factoryMock = $this->createMock(ClientPoolFactoryInterface::class);
        $factoryMock->expects($this->once())
            ->method('factory')
            ->willReturnCallback(function($client, $requests, $config) {
                // factory呼び出し時にClientPoolFactoryへの設定をキャプチャ
                $this->capturedClientPoolFactoryConfig = $config;
                return new Pool($client, $requests, $config);
            });

        /*
         |---------------------
         | 実行
         |---------------------
         */
        $sut = new GuzzleSample(
            client: $clientMock,
            poolFactory: $factoryMock,
            fulfilledHandler: new FulfillHandler(),
            rejectedHandler: new RejectedHandler(),
            params: $params,
            concurrency: $expectedConcurrency
        );
        $result = $sut->call()->getResult();
        $actual = json_decode($result, true);

        /*
         |---------------------
         | 検証
         |---------------------
         */
        $expectedBody = [
            'result' => [
                'foo' => $params['foo'],
                'bar' => $params['bar'],
            ],
            'error' => [],
        ];

        // レスポンスの中身が正しいか
        $this->assertSame($expectedBody, $actual);

        // 1回目のリクエストURI確認
        $this->assertSame('GET', $historyContainer[0]['request']->getMethod());
        $this->assertSame('/sample_test/1/foo', $historyContainer[0]['request']->getUri()->getPath());
        // 2回目のリクエストURI確認
        $this->assertSame('GET', $historyContainer[1]['request']->getMethod());
        $this->assertSame('/sample_test/2/bar', $historyContainer[1]['request']->getUri()->getPath());

        // factoryに渡されたconfig(concurrency)をチェック
        $this->assertSame($expectedConcurrency, $this->capturedClientPoolFactoryConfig['concurrency']);
    }

    public function test_2件中1件成功()
    {
        /*
         |---------------------
         | 準備
         |---------------------
         */
        $params = [
            'foo' => ['id' => 1, 'name' => 'foo'],
            'bar' => ['id' => 2, 'name' => 'bar'],
        ];

        $queue = [
            new Response(200, [], json_encode($params['foo'])),
            new Response(500, [], json_encode($params['bar'])),
        ];

        // MockHandlerとHistoryMiddlewareでリクエスト履歴をとる
        $historyContainer = [];
        $clientMock = $this->createMockClient($queue, $historyContainer);

        // 期待する同時リクエスト数
        $expectedConcurrency = 2;

        $factoryMock = $this->createMock(ClientPoolFactoryInterface::class);
        $factoryMock->expects($this->once())
            ->method('factory')
            ->willReturnCallback(function($client, $requests, $config) {
                // factory呼び出し時にClientPoolFactoryへの設定をキャプチャ
                $this->capturedClientPoolFactoryConfig = $config;
                return new Pool($client, $requests, $config);
            });

        /*
         |---------------------
         | 実行
         |---------------------
         */
        $sut = new GuzzleSample(
            client: $clientMock,
            poolFactory: $factoryMock,
            fulfilledHandler: new FulfillHandler(),
            rejectedHandler: new RejectedHandler(),
            params: $params,
            concurrency: $expectedConcurrency
        );
        $result = $sut->call()->getResult();
        $actual = json_decode($result, true);

        /*
         |---------------------
         | 検証
         |---------------------
         */
        $errorMessage = <<<EOF
Server error: `GET /sample_test/2/bar` resulted in a `500 Internal Server Error` response:
{"id":2,"name":"bar"}

EOF;
        $expectedBody = [
            'result' => [
                'foo' => $params['foo'],
            ],
            'error' => [
                'bar' => [
                    'error_code' => 500,
                    'error_message' => $errorMessage,
                ],
            ],
        ];

        // レスポンスの中身が正しいか
        $this->assertSame($expectedBody, $actual);

        // 1回目のリクエストURI確認
        $this->assertSame('GET', $historyContainer[0]['request']->getMethod());
        $this->assertSame('/sample_test/1/foo', $historyContainer[0]['request']->getUri()->getPath());
        // 2回目のリクエストURI確認
        $this->assertSame('GET', $historyContainer[1]['request']->getMethod());
        $this->assertSame('/sample_test/2/bar', $historyContainer[1]['request']->getUri()->getPath());

        // factoryに渡されたconfig(concurrency)をチェック
        $this->assertSame($expectedConcurrency, $this->capturedClientPoolFactoryConfig['concurrency']);
    }

    /**
     * 以下のClientを作成
     * - queue配列に入れたResponseやExceptionを順番に返す
     * - MockHandlerとHistory Middlewareでリクエスト履歴をとる
     *
     * @param array $queue
     * @param array $historyContainer
     * @return Client
     * @see https://docs.guzzlephp.org/en/stable/testing.html#mock-handler
     * @see https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
     */
    private function createMockClient(array $queue, array &$historyContainer): Client
    {
        $mock = new MockHandler($queue);
        $handlerStack = HandlerStack::create($mock);
        $handlerStack->push(Middleware::history($historyContainer));
        return new Client(['handler' => $handlerStack]);
    }
}

MockHandlerを使えば、queue配列にResponseやExceptionを登録するだけで、1回目は成功レスポンス、2回目は404エラー、といったシナリオが簡単に実現できます。
HandlerStackMiddleware::history()を差し込めば、実際にどんなURIにリクエストが飛んだか後で確認できます。

気になる方はこの辺を見てみましょう。

ミドルウェアを使ってみる(簡易なレートリミット対策)

Guzzleのミドルウェア

Guzzleのミドルウェアは、リクエスト処理前後に割り込み、処理を拡張する仕組みです。
HandlerStackpush()することで、ログ取得、リトライ、など様々なカスタム処理を挿入できます。
Laravelのミドルウェアと一緒だな?

https://docs.guzzlephp.org/en/stable/handlers-and-middleware.html#middleware

サンプルではAPI側のroutes(api/app/routes/web.php)で以下のようにレートリミット(throttle)をかけています。

// Guzzle サンプルテスト
Route::middleware(['throttle:sample_test'])->get('/sample_test/{id}/{name}', function (int $id, string $name) {
    return response()->json([
        'id' => $id,
        'name' => $name,
    ]);
});

サービスプロバイダー(api/app/app/Providers/AppServiceProvider.php)で、秒間3回までのレートリミットをかけている。

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    RateLimiter::for('sample_test', function (Request $request) {
        return Limit::perSecond(3, 1)->by($request->ip());
    });
}

このルートに対して、ふつうにアクセスしてしまうと

client/src/Sample/throttle_error.php
<?php

namespace App\Sample;

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

use App\Sample\Handler\FulfillHandler;
use App\Sample\Handler\RejectedHandler;
use App\Sample\Pool\ClientPoolFactory;
use GuzzleHttp\Client;

$baseUrl = $_ENV['API_BASE_URL'] ?? 'http://api';
$client = new Client([
    'base_uri' => $baseUrl,
]);

$params = [
    'foo' => ['id' => 1, 'name' => 'foo'],
    'bar' => ['id' => 2, 'name' => 'bar'],
    'baz' => ['id' => 3, 'name' => 'baz'],
    'hoge' => ['id' => 4, 'name' => 'hoge'],
    'fuga' => ['id' => 5, 'name' => 'fuga'],
    'piyo' => ['id' => 6, 'name' => 'piyo'],
];

$guzzleSample = new GuzzleSample(
    $client,
    new ClientPoolFactory(),
    new FulfillHandler(),
    new RejectedHandler(),
    $params,
    3
);
$result = $guzzleSample->call();

echo json_encode($result->getResult());

以下のように、429 Too Many Requestsが返ってきてしまいます。

root@177506328a5d:/var/www/html# php src/Sample/throttle_error.php
"{\"result\":{\"bar\":{\"id\":2,\"name\":\"bar\"},\"foo\":{\"id\":1,\"name\":\"foo\"},\"baz\":{\"id\":3,\"name\":\"baz\"},\"hoge\":{\"id\":4,\"name\":\"hoge\"},\"fuga\":{\"id\":5,\"name\":\"fuga\"}},\"error\":{\"piyo\":{\"error_code\":429,\"error_message\":\"Client error: `GET http:\\\/\\\/api\\\/sample_test\\\/6\\\/piyo` resulted in a `429 Too Many Requests` response:\\n<!DOCTYPE html>\\n<html lang=\\\"en\\\">\\n    <head>\\n        <meta charset=\\\"utf-8\\\">\\n        <meta name=\\\"viewport\\\" content=\\\"width= (truncated...)\\n\"}}}"

API側にレートリミットがあるのは、まれによくあると思いますのでお行儀よくAPIを実行したいものです。
そんなときに使えるのがミドルウェアです。以下は簡易なレートリミット対策を施したミドルウェアを使った例です。

client/src/Sample/index.php
<?php

namespace App\Sample;

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

use App\Sample\Handler\FulfillHandler;
use App\Sample\Handler\RejectedHandler;
use App\Sample\Middleware\RateLimitMiddleware;
use App\Sample\Pool\ClientPoolFactory;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;

$stack = HandlerStack::create();
// 秒間3リクエストまでの制限にする
$stack->push(new RateLimitMiddleware(3, 1.0));

$baseUrl = $_ENV['API_BASE_URL'] ?? 'http://api';
$client = new Client([
    'base_uri' => $baseUrl,
    'handler' => $stack,
]);

$params = [
    'foo' => ['id' => 1, 'name' => 'foo'],
    'bar' => ['id' => 2, 'name' => 'bar'],
    'baz' => ['id' => 3, 'name' => 'baz'],
    'hoge' => ['id' => 4, 'name' => 'hoge'],
    'fuga' => ['id' => 5, 'name' => 'fuga'],
    'piyo' => ['id' => 6, 'name' => 'piyo'],
];

$guzzleSample = new GuzzleSample(
    $client,
    new ClientPoolFactory(),
    new FulfillHandler(),
    new RejectedHandler(),
    $params,
  3
);
$result = $guzzleSample->call();

echo json_encode($result->getResult());

これを実行してみると、こんな感じでエラー無く実行できます。
※ 下記にも書いてありますが、まれに429エラーになります😇

root@177506328a5d:/var/www/html# php src/Sample/index.php
"{\"result\":{\"baz\":{\"id\":3,\"name\":\"baz\"},\"foo\":{\"id\":1,\"name\":\"foo\"},\"bar\":{\"id\":2,\"name\":\"bar\"},\"hoge\":{\"id\":4,\"name\":\"hoge\"},\"fuga\":{\"id\":5,\"name\":\"fuga\"},\"piyo\":{\"id\":6,\"name\":\"piyo\"}},\"error\":[]}"

以下のようにHandlerStackにミドルウェアをpush()するだけです。

$stack = HandlerStack::create();
// 秒間3リクエストまでの制限にする
$stack->push(new RateLimitMiddleware(3, 1.0));

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

ミドルウェアサンプル

では、RateLimitMiddlewareを見てみます。

RateLimitMiddlewareは、指定秒数(interval)あたりのリクエスト回数(limit)を超えた場合に自動的にusleep()で待機するミドルウェアです。

<?php

namespace App\Sample\Middleware;

use Psr\Http\Message\RequestInterface;
use GuzzleHttp\Promise\PromiseInterface;

/**
 * レートリミット(スロットリング)を行うGuzzle用ミドルウェア
 *
 * 指定した秒数(interval)あたりのリクエスト回数(limit)を超えないようにするため、
 * 必要に応じて送信前に待機(usleep)を行う。
 *
 * たとえば、limit=3, interval=1.0 の場合、「1秒間に3回までリクエスト可能」という制限になる。
 *
 * @see https://docs.guzzlephp.org/en/latest/handlers-and-middleware.html
 */
class RateLimitMiddleware
{
    /** @var float[] 過去に送ったリクエストのタイムスタンプ(秒数)を保持 */
    private array $timestamps = [];

    /**
     * コンストラクタ
     *
     * @param int   $limit    interval秒間に送れるリクエスト回数の上限
     * @param float $interval リクエスト回数をカウントする間隔(秒)
     *
     * 例: $limit=3, $interval=1.0なら「1秒間に3回まで」
     */
    public function __construct(private int $limit, private float $interval) {}

    /**
     * ミドルウェア本体
     *
     * Guzzleのミドルウェアとして呼び出されるクロージャを返します。
     * リクエスト送信前に、直近interval秒以内のリクエスト回数をチェックし、limitを超えていればusleepで待機します。
     *
     * @param callable $handler Guzzleの次のハンドラ
     * @return callable
     */
    public function __invoke(callable $handler): callable
    {
        return function (RequestInterface $request, array $options) use ($handler): PromiseInterface {
            $now = $this->getTime();

            // 現在時刻からinterval秒以上前のリクエストはカウント対象外なので除外
            $this->timestamps = array_filter($this->timestamps, fn($t) => $t > $now - $this->interval);

            // interval秒以内に送ったリクエストの数をチェックする
            // もしlimit回以上(例:4回以上)を既に送っているなら、すぐに新しいリクエストを送ると上限を超えてしまうので待機する
            if (count($this->timestamps) >= $this->limit) {
                // interval秒以内に送った中で、一番古いリクエストのタイムスタンプを取得する
                $oldestTimestamp = min($this->timestamps);

                // 一番古いリクエストのタイムスタンプから、次のリクエストを送るまでの待ち時間を計算
                $waitTime = ($oldestTimestamp + $this->interval) - $now;

                // $waitTimeが0より大きい場合は待機する
                if ($waitTime > 0) {
                    // $waitTime(秒)をマイクロ秒に変換して待機
                    // 0.2秒待ちたいなら0.2*1000000=200000マイクロ秒待つ(もうちょっと係数を増やしてもいいのかもしれない)
                    // floatをintに暗黙的に変換すると、精度ロスで非推奨(Deprecated)になるので、キャストしている
                    $this->sleep((int)($waitTime * 1000000));
                }
            }

            // リクエスト時刻を記録
            $this->timestamps[] = $this->getTime();

            return $handler($request, $options);
        };
    }

    /**
     * 現在時刻を返す
     *
     * @return float
     */
    protected function getTime(): float
    {
        return microtime(true);
    }

    /**
     * 待機する
     *
     * @param int $microseconds 待機時間(マイクロ秒)
     * @return void
     */
    protected function sleep(int $microseconds): void
    {
        usleep($microseconds);
    }
}

これで$client->request(), requestAsync()などを短時間に連打しても、上限超過でusleep()がかかり、APIに優しいリクエストができるようになります。

Guzzleのリクエスト送信直前に直近X秒以内にY回以上リクエストを送っているか?を調べて、超えていれば自動的に待機するだけなので、シンプルな制限付き処理をしたい場合に有効かもしれません。

ミドルウェアのテスト

RateLimitMiddlewareのテストは以下のようになっています。

<?php

namespace Tests\Sample\Middleware;

use App\Sample\Middleware\RateLimitMiddleware;
use GuzzleHttp\Promise\PromiseInterface;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use RuntimeException;

/**
 * このテストでは、RateLimitMiddlewareがレートリミットを超えた場合にsleep()で待機するか、
 * そうでない場合はすぐにリクエストを処理するかを検証します。
 */
class RateLimitMiddlewareTest extends TestCase
{
    public function test_レートリミットを超えなければ待機しない()
    {
        /*
         |---------------------
         | 準備
         |---------------------
         */
        // limit=3, interval=1.0で1秒間に3回まで
        $middleware = new class(3, 1.0) extends RateLimitMiddleware {
            private float $time = 100.0; // 現在時刻を100秒付近からスタート

            protected function getTime(): float
            {
                // このクラスではgetTime()で固定の$timeを返す
                return $this->time;
            }

            protected function sleep(int $microseconds): void
            {
                // sleepは実行されない
                throw new RuntimeException("sleep() が実行された");
            }


            public function advanceTime(float $sec): void
            {
                // テスト中に時間を経過させる
                $this->time += $sec;
            }
        };

        /*
         |---------------------
         | 実行
         |---------------------
         */
        $handler = function (RequestInterface $request, array $options): PromiseInterface {
            $this->assertTrue(true); // ハンドラーが呼ばれたことを確認
            // 適当なFulfilled Promiseを返す
            return $this->createAnonymousPromiseInterface();
        };

        // ミドルウェアを取得
        $callable = $middleware($handler);

        // Request Mock
        $request = $this->createMock(RequestInterface::class);

        /*
         |---------------------
         | 検証
         |---------------------
         */
        // 3回連続で呼んでもlimit=3以内ならsleepしない
        for ($i = 0; $i < 3; $i++) {
            $callable($request, []);
            $middleware->advanceTime(0.1); // 0.1秒進める
        }

        $this->assertTrue(true, "sleep() は実行されていない");
    }

    public function test_レートリミットを超えたら待機する()
    {
        /*
         |---------------------
         | 準備
         |---------------------
         */
        // limit=2, interval=1.0で1秒間に2回まで
        $middleware = new class(2, 1.0) extends RateLimitMiddleware {
            private float $time = 200.0;
            public int $sleepCalled = 0;
            public ?int $lastSleepTime = null;

            protected function getTime(): float
            {
                return $this->time;
            }

            protected function sleep(int $microseconds): void
            {
                // limit超過時はsleepが呼ばれるはず
                $this->sleepCalled++;
                $this->lastSleepTime = $microseconds;
            }

            public function advanceTime(float $sec): void
            {
                $this->time += $sec;
            }
        };

        /*
         |---------------------
         | 実行
         |---------------------
         */
        $handler = function (RequestInterface $request, array $options): PromiseInterface {
            return $this->createAnonymousPromiseInterface();
        };
        $callable = $middleware($handler);

        $request = $this->createMock(RequestInterface::class);

        /*
         |---------------------
         | 検証
         |---------------------
         */
        // 1回目のリクエスト(200.0s)
        $callable($request, []);
        $middleware->advanceTime(0.2); // 200.2s

        // 2回目のリクエスト(1秒以内に2回目なのでOK)
        $callable($request, []);
        $middleware->advanceTime(0.2); // 200.4s

        // ここで3回目をすぐ送るとlimit=2を超えるため待機するはず
        // interval=1秒以内に既に2回送っているので超過
        $callable($request, []);
        $this->assertSame(1, $middleware->sleepCalled);
        $this->assertNotNull($middleware->lastSleepTime);
        $this->assertGreaterThan(0, $middleware->lastSleepTime);
    }

    /**
     * PromiseInterfaceを実装して、Guzzleが期待する返り値を返す
     *
     * @return PromiseInterface
     */
    private function createAnonymousPromiseInterface(): PromiseInterface
    {
        return new class implements PromiseInterface {
            public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface
            {
                return $this;
            }

            public function otherwise(callable $onRejected): PromiseInterface
            {
                return $this;
            }

            public function wait($unwrap = true) {}

            public function getState(): string
            {
                return 'fulfilled';
            }

            public function resolve($value): void {}

            public function reject($reason): void {}

            public function cancel(): void {}
        };
    }
}

RateLimitMiddlewareは内部でmicrotime(true)usleep()を呼んでいるので、そのままテストを実行すると安定しない場合があります。
そのため、テスト時にはgetTime()sleep()をオーバーライドしてモック化しています。
これによって、「0.2秒後に3回目のリクエストを送ったら上限を超えるからsleep()が呼ばれるはずに違いない。」というシナリオでテストが可能になっています。

テストは通るぞ

はい。

root@177506328a5d:/var/www/html# vendor/bin/phpunit tests/
PHPUnit 11.5.1 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.14
Configuration: /var/www/html/phpunit.xml

....                                                        4 / 4 (100%)

Time: 00:00.054, Memory: 10.00 MB

OK (4 tests, 21 assertions)

カバレッジ。はい。

おわりに

以上、Guzzleと少し仲良くなるためのサンプルコードやテスト方法、ミドルウェアを使った簡易的なレートリミット対策方法について紹介しました。
みんなGuzzleと仲良くなれましたね??
(昨今、jsでapiは呼ぶもんだろと思うかもしれませんが、ほらあれですよバッチ処理とかでもあるかもしれないですよ)

Discussion