Laravel api Throttle機能調べてみた

Laravelにデフォルトで用意されているThrottle機能について調べてみた。

Throttle機能では最初にアクセスした時刻を元に、決められた時間範囲内のアクセス可能数を制限できる。アクセス可能数を超えてアクセスしようとすると429エラーが返される。その後時間範囲が過ぎるとアクセス可能数がリセットされる。


## どこで読み込まれているか
throttle機能はデフォルトで全apiに適応される。これはKernelのapiミドルウェアグループにthrottle:apiが指定されているから。
routeMiddlewareにthrottle名としてThrottleRequests.classが指定されている。

```Kernel.php
    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            // \Illuminate\Session\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];

    protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
        'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
    ];
```

## ThrottleRequests
Illuminate\Routing\Middleware\ThrottleRequests.php について調べる。

コンストラクタでRateLimitterを受け取っている。

```ThrottleRequests.php
    public function __construct(RateLimiter $limiter)
    {
        $this->limiter = $limiter;
    }
```

handle関数では最大で3つの引数が指定できる。($requestや$nextはmiddleware実行時に指定される)
$maxAttemptsが文字列で、引数が3つで(ミドルウェア指定時にlimiterNameとして引数を１つ指定した場合)、limitterを取得できた場合はhandleRequestUsingNamedLimiter関数を実行する。
そうじゃない場合は、handleRequestを実行する。

```ThrottleRequests.php
   public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
    {
        if (is_string($maxAttempts)
            && func_num_args() === 3
            && ! is_null($limiter = $this->limiter->limiter($maxAttempts))) {
            return $this->handleRequestUsingNamedLimiter($request, $next, $maxAttempts, $limiter);
        }

        return $this->handleRequest(
            $request,
            $next,
            [
                (object) [
                    'key' => $prefix.$this->resolveRequestSignature($request),
                    'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts),
                    'decayMinutes' => $decayMinutes,
                    'responseCallback' => null,
                ],
            ]
        );
    }
```

## limiter
ThrottleRequests.phpで出てきたlimitterについて調べる。クラス名はRateLimiter

```ThrottleRequests.php
    public function __construct(RateLimiter $limiter)
    {
        $this->limiter = $limiter;
    }
```

RateLimiterのコンストラクタではCacheを取得している。

```RateLimiter.php
    public function __construct(Cache $cache)
    {
        $this->cache = $cache;
    }

```

for関数を使ってlimitterを登録でき、limiter関数で登録したlimitterを返せる。

```RateLimiter.php
    public function for(string $name, Closure $callback)
    {
        $this->limiters[$name] = $callback;

        return $this;
    }

    public function limiter(string $name)
    {
        return $this->limiters[$name] ?? null;
    }
```

limitterの登録はRouteServiceProviderで行われている。
apiという名前でlimitterを登録し、userIdもしくはrequestのipで1分間60回のリクエスト上限を設定しているようだ。
limitterの実態はRequestを受け取りLimitを返すClosure。

```RouteServiceProvider.php
    protected function configureRateLimiting()
    {
        RateLimiter::for('api', function (Request $request) {
            return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
        });
    }
```

### Limit
リミットに関する情報を持つクラス。
LimiterとLimitは違うので注意。
LimiterはCacheを持ち、Limitを管理する。Limitは上限に関するパラメータを持つ。

### ThrottleRequestのhandleRequestUsingNamedLimiter

ミドルウェア指定時に `throttle:api` と指定した場合は、apiというlimitter（Closure）を使ってLimit処理が行われるようだ。
`$limitterResponse = call_user_func($limiter, $request)` でレスポンスを取得し、それがResponse型なら次のミドルウェアに処理を移す前にレスポンスを返すことで処理を中断させている。
Unlimited型だった場合はそのまま次のミドルウェアに処理を移す。
それ以外の場合はhandleRequestに処理を移すようになっている。

```ThrottleRequest.php
    protected function handleRequestUsingNamedLimiter($request, Closure $next, $limiterName, Closure $limiter)
    {
        $limiterResponse = call_user_func($limiter, $request);

        if ($limiterResponse instanceof Response) {
            return $limiterResponse;
        } elseif ($limiterResponse instanceof Unlimited) {
            return $next($request);
        }

        return $this->handleRequest(
            $request,
            $next,
            collect(Arr::wrap($limiterResponse))->map(function ($limit) use ($limiterName) {
                return (object) [
                    'key' => md5($limiterName.$limit->key),
                    'maxAttempts' => $limit->maxAttempts,
                    'decayMinutes' => $limit->decayMinutes,
                    'responseCallback' => $limit->responseCallback,
                ];
            })->all()
        );
    }
```

### ThrottleRequestのhandleRequest
渡されたlimiter毎に上限チェックを行い、問題があればエラーを投げる。無ければ次のmiddlewareに処理を渡す。最後にヘッダーに上限情報を埋め込む。

```ThrottleRequest.php
    protected function handleRequest($request, Closure $next, array $limits)
    {
        foreach ($limits as $limit) {
            if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) {
                throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback);
            }

            $this->limiter->hit($limit->key, $limit->decayMinutes * 60);
        }

        $response = $next($request);

        foreach ($limits as $limit) {
            $response = $this->addHeaders(
                $response,
                $limit->maxAttempts,
                $this->calculateRemainingAttempts($limit->key, $limit->maxAttempts)
            );
        }

        return $response;
    }
```

### RateLimiterのtooManyAttempts
keyと上限値をもらい、上限を超えているかチェックする。
attempts関数でキャッシュから回数を取得し、上限と比較する。
上限を超えていて、key.:timerのキャッシュがあれば上限を超えているとしてtrueを返す。
key.:timerのキャッシュが無ければresetAttemptsを行い$keyに紐づくキャッシュを消す。
key.:timerの有無でストップをかけていることが分かる。

```RateLimiter.php
    public function tooManyAttempts($key, $maxAttempts)
    {
        if ($this->attempts($key) >= $maxAttempts) {
            if ($this->cache->has($key.':timer')) {
                return true;
            }

            $this->resetAttempts($key);
        }

        return false;
    }
```

### RateLimiterのhit
キャッシュにkey.':timer'がない場合は有効期限(decaySeconds)付きで追加する。
キャッシュにkeyがない場合は0を追加し、incrementする。
初回にkeyを追加した場合はkeyに有効期限(decaySeconds)を追加する

```RateLimiter.php
    public function hit($key, $decaySeconds = 60)
    {
        $this->cache->add(
            $key.':timer', $this->availableAt($decaySeconds), $decaySeconds
        );

        $added = $this->cache->add($key, 0, $decaySeconds);

        $hits = (int) $this->cache->increment($key);

        if (! $added && $hits == 1) {
            $this->cache->put($key, 1, $decaySeconds);
        }

        return $hits;
    }
```


## レスポンス
レスポンスを確認する。
curlに--dump-headerオプションをつけるとレスポンスヘッダーを表示してくれる(--dump-header - にすることで標準出力に出力してくれる)
X-RateLimit-LimitとX-RateLimit-RemainingがThrottle機能によって付与されたヘッダーです。
X-RateLimit-Limitはリミットを表し、X-RateLimit-Remainingは残り回数を意味しています。

```bash
 curl -H 'Accept: application/json' -H 'Authorization: Bearer WzLJB6Djg7HS9vDp8Ss8RqcIKYPYvs2pJBUl8CYMwsnVCHXK3FeIEdLBFq8Q' http://127.0.0.1:8000/api/user --dump-header -
HTTP/1.1 200 OK
Host: 127.0.0.1:8000
Date: Sat, 05 Dec 2020 03:24:20 GMT
Connection: close
X-Powered-By: PHP/7.4.1
Cache-Control: no-cache, private
Date: Sat, 05 Dec 2020 03:24:20 GMT
Content-Type: application/json
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
Access-Control-Allow-Origin: *

{"id":1,"name":"aaa","email":"hoge@co.jp","email_verified_at":null,"api_token":"WzLJB6Djg7HS9vDp8Ss8RqcIKYPYvs2pJBUl8CYMwsnVCHXK3FeIEdLBFq8Q","created_at":"2020-12-04T14:46:11.000000Z","updated_at":"2020-12-04T14:46:11.000000Z"}  
```

## 連続でアクセスしてみる
abコマンドを使って60回アクセスしてthrottleを使い切る

```
ab -n 60 -H 'Accept: application/json' -H 'Authorization: Bearer WzLJB6Djg7HS9vDp8Ss8RqcIKYPYvs2pJBUl8CYMwsnVCHXK3FeIEdLBFq8Q' http://127.0.0.1:8000/api/user
```

その後curlでアクセスすると、429 Too Many Requestsエラーが返ってくる。
レスポンスヘッダーにRetry-Afterがあり、後何秒後に解除されるかが記載されている。

```
$ curl -H 'Accept: application/json' -H 'Authorization: Bearer WzLJB6Djg7HS9vDp8Ss8RqcIKYPYvs2pJBUl8CYMwsnVCHXK3FeIEdLBFq8Q' http://127.0.0.1:8000/api/user --dump-header -
HTTP/1.1 429 Too Many Requests
Host: 127.0.0.1:8000
Date: Sat, 05 Dec 2020 03:45:30 GMT
Connection: close
X-Powered-By: PHP/7.4.1
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
Retry-After: 32
X-RateLimit-Reset: 1607139962
Cache-Control: no-cache, private
Date: Sat, 05 Dec 2020 03:45:30 GMT
Content-Type: application/json
Access-Control-Allow-Origin: *

{
    "message": "Too Many Attempts."
}%                     
```