PHPでExponential Backoffを扱う

## 初めに
こちらの記事は[PHPカンファレンス福岡 2019](https://phpcon.fukuoka.jp/2019/)の発表内容のセルフフォローになります
会場での発表では踏み込んだ実装について説明が追い付かないので…
発表スライドはこちら ⇒ [クラウド環境下におけるAPIリトライ設計](https://www.slideshare.net/ssuser537eef/api-152529773)

## Exponential Backoffとは
[AWS でのエラーの再試行とエクスポネンシャルバックオフ - アマゾン ウェブ サービス](https://docs.aws.amazon.com/ja_jp/general/latest/gr/api-retries.html)を読むとだいたい分かる！

リンク先開くのが面倒な人に端的に説明するとリトライの仕組みの1つで、リトライ間隔を徐々に広げていくアルゴリズムです
もう少し具体的には初回のリトライ間隔に係数を掛け、指数関数的にリトライ間隔を増加させます
（1s⇒2s⇒4s⇒8s⇒…）

## なんでExponential Backoffを使う？
SaaSなどを使うとサービス都合による瞬断を起因としたエラーが発生する
そのようなエラーは基本的には即座にリトライすることで回復することが多い
しかし瞬断じゃない場合は短期間に何度もリトライしても回復する可能性は低い
つまり、最初のほうは短い間隔でリトライしたほうがいいけど、何度も繰り返す場合は間隔を長く取らないと無駄に試行回数が増えるという事情をいい具合に解消するための仕組みです


## 実装が面倒そう…
じゃあライブラリ使いましょう
PHPだと自分が調べたところ
[vkartaviy/retry: The library for repeatable and retryable operations](https://github.com/vkartaviy/retry)
というものがありました
後で識者に教えてもらいましたが、みんな大好きGoogleのGoogle Cloud Client Library for PHPにも実装がありました
が、こっちは自分は触っていないので、今回は扱いません

## 公式リファレンスは…
残念ながらありません！
サンプルとしてあるのは超簡単なREADMEにある以下のみ

```php
<?php

use Retry\RetryProxy;
use Retry\Policy\SimpleRetryPolicy;
use Retry\BackOff\ExponentialBackOffPolicy;

$retryPolicy = new SimpleRetryPolicy(3);
$backOffPolicy = new ExponentialBackOffPolicy();

$proxy = new RetryProxy($retryPolicy, $backOffPolicy);
$result = $proxy->call(function() {
    // call external service and return result
});
```

なので、自分はGithubで公開されているソース読みながら実装しました
結局のところ半端なリファレンスよりもソース読んだほうが早い

で終わるのも乱暴なので、自分が調査して社内に共有した内容を以下にまとめたいと思います

## オレオレリファレンス

### 試行回数を変更したい
```php
$retryPolicy = new SimpleRetryPolicy(5);
```
`SimpleRetryPolicy`のコンストラクタの第一引数で指定してください（省略時は3）
なお、ここでいう試行回数は最初の実行も含んでいるので、上記サンプルだと厳密な日本語で言うところのリトライ回数は4回になります

なお、無限にリトライさせたい場合はPolicyクラスそのものを変えれば対応できます
`AlwaysRetryPolicy`を使うと完全に成功するまで繰り返す無限リトライ
`NeverRetryPolicy`を使うとフラグを明示的に変更することで抜けれる半無限リトライ

### リトライの初期値を指定したい
```php
$backOffPolicy = new ExponentialBackOffPolicy(1000);
```
`ExponentialBackOffPolicy`のコンストラクタの第一引数でミリ秒で指定してください（省略時は100ms）
もしくは`ExponentialBackOffPolicy::setInitialInterval()`でも変更できる

### リトライの間隔の増加係数を指定したい
```php
$backOffPolicy = new ExponentialBackOffPolicy(1000, 3);
```
`ExponentialBackOffPolicy`のコンストラクタの第二引数で指定してください（省略時は2）
もしくは`ExponentialBackOffPolicy::setMultiplier()`でも変更できる
前回のリトライ待ち時間にこの数字を掛けたものが次のリトライ待ち時間になる
上記サンプルだと1s ⇒ 3s ⇒ 9s ⇒ 27sと上がっていく
係数なので1未満の値を指定すると徐々に間隔が短くなっていくルールにもできそうだけど、それはアルゴリズムのポリシーに反するので、1未満の値を指定すると1に丸められる

### リトライ間隔の最大値を指定したい
```php
$backOffPolicy = new ExponentialBackOffPolicy(1000, 3, 60000);
```
`ExponentialBackOffPolicy`のコンストラクタの第三引数で指定してください（省略時は30000、つまり30s）
もしくは`ExponentialBackOffPolicy::setMaxInterval()`でも変更できる
こちらも1未満の値を指定すると1で丸められる

### リトライ間隔にランダム値を用いたい
`ExponentialBackOffPolicy`の代わりに`ExponentialRandomBackOffPolicy`を使うと対応できます
係数部分が毎回ランダムで決まります（値の範囲は前回インターバル値以上、`ExponentialBackOffPolicy`で得られるはずだった値×指定係数+1以下っぽい）
なお、なぜリトライ間隔をランダムにしたいかと言うと、サービスダウン時に接続しているサービスが同じ間隔がリトライを続けると、復旧後のあるタイミングに同時にアクセスが集中し再度ダウンすることがあるからです
リトライ間隔にランダム性をもたせることでリトライ処理によるアクセス集中を避けることができます

### 条件によってはリトライさせたくない
デフォルトの`SimpleRetryPolicy`はあらゆる例外でリトライしますが、リトライする例外をコンストラクタの第二引数で指定できます
予めリトライ用の例外を定義しておき、`$proxy->call()`で実行する処理の中で条件を判断し、リトライさせたいときだけその例外を発行することで対応できます

### Exponential Backoffじゃないリトライもこのライブラリで実装したい
`ExponentialBackOffPolicy`の代わりに`FixedBackOffPolicy`や`LinearBackOffPolicy`を使えば対応できます
`FixedBackOffPolicy` ⇒ 固定間隔によるリトライ
`LinearBackOffPolicy` ⇒ 線形増加によるリトライ（毎回1000msずつ増加するみたいなこと）

## おまけ PHPでの乱数取得のコツ
ライブラリで実装されているランダム値を取得するソースの解説をつけておきます
旧来のPHPで乱数と言うと[`rand()`](https://www.php.net/manual/ja/function.rand.php)でしたが、これは乱数と呼ぶには偏りがひどく常々問題になってました
今は[`mt_rand()`](https://www.php.net/manual/ja/function.mt-rand.php)という新しい関数があり、こちらを使うべきです（とはいえPHP7系あたりから`rand()`は内部的に`mt_rand()`に置き換えられているそうです 知らなかった…）
ただ`mt_rand()`を使っても得られる乱数の範囲が大きいので、使い方にコツがあります
それを今回のソースから学んでみましょう

まずは乱数を使わない場合のリトライインターバルの決定処理

```php

public function getIntervalAndIncrement()
{
    $interval = $this->interval;
    if ($interval > $this->max) {
        $interval = $this->max;
    } else {
        $this->interval = $this->getNextInterval();
    }
    return $interval;
}

public function getNextInterval()
{
    return $this->interval * $this->multiplier;
}
```
ソース的には単純です
まず前回のインターバル値を取得し、最大に達していれば最大値を、そうでなければ前回の値に係数をかけています
ん？このソースバグってる気がしますね
これだと前回値が最大値を超えてない、かつ係数をかけると最大値を超えるケースでは最大値を超えたインターバル値になるような…
まぁ本題からずれるので見なかったことにしますかｗ

次に乱数を使った場合のリトライインターバルの決定処理

```php
public function getIntervalAndIncrement()
{
    $random     = mt_rand(0, mt_getrandmax()) / mt_getrandmax();
    $multiplier = $this->getMultiplier();
    $interval = parent::getIntervalAndIncrement();
    $interval = $interval * (1 + $random * ($multiplier - 1));
    return $interval;
}
```

で、最初の1行が今回の趣旨です

```php
$random     = mt_rand(0, mt_getrandmax()) / mt_getrandmax();
```
`mt_getrandmax()`は処理系によって異なる乱数の最大値を取得します（具体的にはCPUのbitなどによって異なる値が返ってくると思われます）
一度0～最大値の中から乱数を取得し、それを最大値で割ることで0～1の間で乱数が得られるということです（もはや資料が消失しているのでわかりませんが、昔の`rand()`の仕様がそうだったような）

その後は設定している係数と、通常の計算で得られるインターバル値とで計算してます

```php
$interval = $interval * (1 + $random * ($multiplier - 1));
```
係数から1を引き、乱数とかける
乱数は0から1なので、これにより係数がいったん0～（係数-1）の範囲に収まります
その後1を足し直しているので係数が1～係数の範囲になるという意味です
いきなり係数×乱数すると値の範囲が0～係数になっちゃって、インターバル値が前回より下がるからだめだってことです


この一度1を引いてあとで戻すというのは係数の処理あるあるなので、数字が苦手な人はなんとなくで覚えててもいいと思います