2024-06-20

Laravelのスロットリングの仕組み

LaravelのThrottleRequestのコードリーディングメモ。

今回はこんな感じなコードで何が起きているかを見ます。

Route::middleware(['throttle:api'])->group(function () {
  // ...
});

/**
 * Bootstrap any application services.
 */
protected function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
}

throttle\Illuminate\Routing\Middleware\ThrottleRequests のエイリアスなのでそこから見ていきます。

throttle:api のコロンのあとは引数になるので、以下の $maxAttemptsapi が入ります。

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),
                'decaySeconds' => 60 * $decayMinutes,
                'responseCallback' => null,
            ],
        ]
    );
}

https://github.com/laravel/framework/blob/v11.0.7/src/Illuminate/Routing/Middleware/ThrottleRequests.php#L82-L102

throttle:api の場合は最初のif分岐に入ります。

handleRequestUsingNamedLimiter()$limiter() を呼び出し RateLimiter::for() で定義したClosureの戻り値を取得し、その設定を使ってリクエストの閾値チェックを行っています。

protected function handleRequestUsingNamedLimiter($request, Closure $next, $limiterName, Closure $limiter)
{
    $limiterResponse = $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' => self::$shouldHashKeys ? md5($limiterName.$limit->key) : $limiterName.':'.$limit->key,
                'maxAttempts' => $limit->maxAttempts,
                'decaySeconds' => $limit->decaySeconds,
                'responseCallback' => $limit->responseCallback,
            ];
        })->all()
    );
}

https://github.com/laravel/framework/blob/v11.0.7/src/Illuminate/Routing/Middleware/ThrottleRequests.php#L115-L137

今回の例だと以下のような設定値となっています。

RateLimiter::tooManyAttempts() でチェックを行い閾値を超えていたらエラーを投げます。閾値を超えていない場合は RateLimiter::hit() でリクエスト数を更新します。

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->decaySeconds);
    }

    $response = $next($request);

    // ...(省略)...

    return $response;
}

https://github.com/laravel/framework/blob/v11.0.7/src/Illuminate/Routing/Middleware/ThrottleRequests.php#L149-L170

RateLimiter::tooManyAttempts() ではキャッシュストレージ内にあるリクエスト数が閾値を超えていたらtrue、そうでなければキャッシュストレージ内のリクエスト数を0にしてfalseを返します。

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

        $this->resetAttempts($key);
    }

    return false;
}

https://github.com/laravel/framework/blob/v11.0.7/src/Illuminate/Cache/RateLimiter.php#L94-L105 https://github.com/laravel/framework/blob/v11.0.7/src/Illuminate/Cache/RateLimiter.php#L152-L157

RateLimiter::hit() は有効期限つきでリクエスト数をインクリメントします。

public function hit($key, $decaySeconds = 60)
{
    return $this->increment($key, $decaySeconds);
}

https://github.com/laravel/framework/blob/v11.0.7/src/Illuminate/Cache/RateLimiter.php#L114-L117

このエントリーをはてなブックマークに追加