microsoft/azure-storage-blob のリトライ機能を試す

Microsoft 公式の Azure Storage Blob 操作用ライブラリ `microsoft/azure-storage-blob` のリトライ機能を試してみたのでメモ。

microsoft/azure-storage-blob - Packagist 
https://packagist.org/packages/microsoft/azure-storage-blob

azure-storage-php/azure-storage-blob at master · Azure/azure-storage-php 
https://github.com/Azure/azure-storage-php/tree/master/azure-storage-blob

## 環境

試した環境は以下。

```sh
% php -v
PHP 7.4.5 (cli) (built: Jun  9 2020 14:02:33) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
    with Zend OPcache v7.4.5, Copyright (c), by Zend Technologies
```

## インストール

```sh
% mkdir azure-storage-ex && cd $_
% composer require microsoft/azure-storage-blob
```

検証時点(2021.03.05) では以下のバージョンが入った。

```sh
% composer show
guzzlehttp/guzzle              7.2.0 Guzzle is a PHP HTTP client library
guzzlehttp/promises            1.4.0 Guzzle promises library
guzzlehttp/psr7                1.7.0 PSR-7 message implementation that also provides common utility methods
microsoft/azure-storage-blob   1.5.2 This project provides a set of PHP client libraries that make it easy to access Microsoft Azure Storage Blob APIs.
microsoft/azure-storage-common 1.5.1 This project provides a set of common code shared by Azure Storage Blob, Table, Queue and File PHP client libraries.
psr/http-client                1.0.1 Common interface for HTTP clients
psr/http-message               1.0.1 Common interface for HTTP messages
ralouphie/getallheaders        3.0.3 A polyfill for getallheaders.
```

## 使ってみる

まずは普通に使うコードを書く。
コンテナの一覧を出力するだけのもの。

```php
<?php
require_once 'vendor/autoload.php';

use MicrosoftAzure\Storage\Blob\BlobRestProxy;

$connectionString = getenv("AZURE_STORAGE_CONNECTION_STRING");

$restProxy = BlobRestProxy::createBlobService($connectionString);

$containers = $restProxy->listContainers()->getContainers();
foreach($containers as $container) {
    echo "Container name: {$container->getName()} url:{$container->getUrl()}", PHP_EOL;
}
```

上記が正常に実行されることを確認したら、 `iptables` を使ってサーバとの通信をブロック。

```sh
% sudo iptables -A OUTPUT -p tcp -d XXXXX.blob.core.windows.net --dport 443 -j REJECT
```

後で削除するので追加したルールの番号を調べておく(num 列の値。ここでは 1 だった)。

```sh
sudo iptables -nL --line-numbers
・・・
Chain OUTPUT (policy ACCEPT)
num  target     prot opt source               destination         
1    REJECT     tcp  --  0.0.0.0/0            XXX.XXX.XXX.XXX       tcp dpt:443 reject-with icmp-port-unreachable
・・・
```

再度実行。
当然だが今度は `GuzzleHttp\Exception\ConnectException` が発生することを確認。


```sh
% php blob-test.php                                                                                 
PHP Fatal error:  Uncaught GuzzleHttp\Exception\ConnectException: cURL error 7: Failed to connect to XXXXX.blob.core.windows.net port 443: 接続を拒否されました (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://XXXXX.blob.core.windows.net/?comp=list in /home/akishin/src/php/php7/azure-storage-ex/vendor/guzzlehttp/guzzle/src/Handler/CurlFactory.php:210
Stack trace:
#0 /home/akishin/src/php/php7/azure-storage-ex/vendor/guzzlehttp/guzzle/src/Handler/CurlFactory.php(158): GuzzleHttp\Handler\CurlFactory::createRejection()
#1 /home/akishin/src/php/php7/azure-storage-ex/vendor/guzzlehttp/guzzle/src/Handler/CurlFactory.php(110): GuzzleHttp\Handler\CurlFactory::finishError()
#2 /home/akishin/src/php/php7/azure-storage-ex/vendor/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php(236): GuzzleHttp\Handler\CurlFactory::finish()
#3 /home/akishin/src/php/php7/azure-storage-ex/vendor/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php(168): GuzzleHttp\Handler\CurlMultiHandler->processMe in /home/akishin/src/php/php7/azure-storage-ex/vendor/guzzlehttp/guzzle/src/Handler/CurlFactory.php on line 210
```

## リトライ処理の追加

リトライ機能についてのドキュメントは以下にある。

Retrying failures 
https://github.com/Azure/azure-storage-php#retrying-failures

以下のような感じで `RetryMiddleware` を追加するだけ。
ちなみに Blob だけでなく、Storage Table や Storage Queue の場合でも同じ。

```php
<?php
require_once 'vendor/autoload.php';

use MicrosoftAzure\Storage\Blob\BlobRestProxy;
use MicrosoftAzure\Storage\Common\Middlewares\RetryMiddlewareFactory;

$connectionString = getenv("AZURE_STORAGE_CONNECTION_STRING");

$retryMiddleware = RetryMiddlewareFactory::create(
    RetryMiddlewareFactory::GENERAL_RETRY_TYPE,  # リトライのロジック
    5,    # リトライ回数
    1000, # リトライ間隔
    RetryMiddlewareFactory::EXPONENTIAL_INTERVAL_ACCUMULATION,  # リトライ間隔の増加方法
    true  # 接続エラー時も再試行する
);

$optionsWithMiddlewares = [
    'middlewares' => [
        $retryMiddleware,
    ],
];

$restProxy = BlobRestProxy::createBlobService($connectionString, $optionsWithMiddlewares);

try {
    $start = microtime(true);
    $containers = $restProxy->listContainers()->getContainers();
    foreach($containers as $container) {
        echo "Container name: {$container->getName()} url:{$container->getUrl()}", PHP_EOL;
    }
} finally {
    $elapsed = microtime(true) - $start;
    echo "elapsed: [{$elapsed}]", PHP_EOL;
}
```

## 2021.03.05 時点での不具合

これで実行するとリトライしてくれるかと思いきや何度試しても即エラーで終了。

デフォルトでは `GuzzleHttp\Exception\ConnectException` の場合はリトライしないようだが、 `RetryMiddlewareFactory::create` 時の `$retryConnect` オプションに `true` を指定しているのでリトライしてくれるはず。

プリントデバッグしたりして調べてみると、どうやら以下の if 文で常に 1 つ目の if に入ってしまい false が返っている模様。

https://github.com/Azure/azure-storage-php/blob/v1.5.2-blob/azure-storage-common/src/Common/Middlewares/RetryMiddlewareFactory.php#L168-L171

`instanceof` 演算子で `ConnectException` の場合に `true` が返ることを期待しているようだがその通りに動いていないっぽい。

GuzzleHttp の例外階層を調べると以下のようになっている。

https://docs.guzzlephp.org/en/stable/quickstart.html#exceptions

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

しかしコードの方を見てみると、 どうも guzzlehttp 7 で例外の継承階層が変わった模様。

6.5 
https://github.com/guzzle/guzzle/blob/6.5/src/Exception/ConnectException.php

7.0 
https://github.com/guzzle/guzzle/blob/7.0/src/Exception/ConnectException.php

仕方ないので、一旦 guzzlehttp を 6 系に落として試すことにする。
composer.json を以下のように変更。

```json
{
    "require": {
        "microsoft/azure-storage-blob": "^1.5",
        "guzzlehttp/guzzle": "^6.0"
    }
}
```

変更を保存したら update 実行。

```
% composer update
```

guzzlehttp のバージョンは 6.5.5 になった。

```sh
% composer show
guzzlehttp/guzzle                6.5.5   Guzzle is a PHP HTTP client library
guzzlehttp/promises              1.4.0   Guzzle promises library
guzzlehttp/psr7                  1.7.0   PSR-7 message implementation that also provides common utility methods
microsoft/azure-storage-blob     1.5.2   This project provides a set of PHP client libraries that make it easy to access Microsoft Azure Storage Blob APIs.
microsoft/azure-storage-common   1.5.1   This project provides a set of common code shared by Azure Storage Blob, Table, Queue and File PHP client libraries.
psr/http-message                 1.0.1   Common interface for HTTP messages
ralouphie/getallheaders          3.0.3   A polyfill for getallheaders.
symfony/polyfill-intl-idn        v1.22.1 Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions
symfony/polyfill-intl-normalizer v1.22.1 Symfony polyfill for intl's Normalizer class and related functions
symfony/polyfill-php72           v1.22.1 Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions
```

## 再度検証

再度先程のサンプルコードを実行すると、最初とは変わり結構時間かかってからエラーが返ってくるようになったのでリトライは実行されてるっぽい。

試しにリトライ中に別ターミナルから `iptables` のルールを削除してみる。

```sh
% sudo iptables -D OUTPUT 1
```

しばらく待っていると正常にレスポンスが返ってきた。
ちゃんとリトライしてくれてるっぽい。

Azure Storage は本番運用していると割と頻繁に接続エラーが起きる印象があるので、公式ライブラリでリトライ機能を簡単に使えるのは嬉しい。