Skip to content

ハウツー: スライディングウィンドウレートリミッター

概要

このガイドでは、NENE2 を使ったユーザーごと・エンドポイントごとのスライディングウィンドウレートリミッターの構築方法を説明します。リクエストはローリングタイムウィンドウ内でカウントされ、制限に達するとそれ以降のリクエストは 429 Too Many Requests で拒否されます。

参照実装: ../NENE2-FT/ratelog/


スキーマ設計

sql
CREATE TABLE IF NOT EXISTS rate_events (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    endpoint   TEXT    NOT NULL,
    created_at TEXT    NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_rate_events_user_endpoint
    ON rate_events (user_id, endpoint, created_at);

(user_id, endpoint, created_at) のインデックスにより、大規模でも COUNT クエリが高速になります。


ルートテーブル

メソッドパス認証説明
POST/rate/checkユーザーリクエストを記録する; 制限超過時は 429 を返す
GET/rate/statusユーザーユーザー/エンドポイントの現在の使用状況
DELETE/rate/reset/{userId}管理者ユーザーのカウンターをリセットする

コアアルゴリズム

php
private const int LIMIT = 10;
private const int WINDOW_SECONDS = 60;

public function check(int $userId, string $endpoint): string
{
    $since = $this->windowStart();   // now() - 60s
    $count = $this->countInWindow($userId, $endpoint, $since);

    if ($count >= self::LIMIT) {
        return 'rate_limited';
    }

    $this->recordEvent($userId, $endpoint);
    return 'ok';
}

スライディングウィンドウ: 各 check() は現在の時刻から正確に WINDOW_SECONDS さかのぼって確認するため、古いイベントは自然にスコープ外に落ちます。


フェイルクローズパターンによる管理者リセット

php
private function isAdmin(ServerRequestInterface $req): bool
{
    if ($this->adminKey === '') {
        return false;     // フェイルクローズ: 未設定キーはすべての管理者アクセスをブロック
    }
    return hash_equals($this->adminKey, $req->getHeaderLine('X-Admin-Key'));
}

ユーザーのすべてのカウンターをリセット(すべてのエンドポイント):

sql
DELETE FROM rate_events WHERE user_id = :uid

特定のエンドポイントのみリセット:

sql
DELETE FROM rate_events WHERE user_id = :uid AND endpoint = :ep

パスパラメーター抽出(Router::param() なし)

インストール済みバージョンで Router::param() が利用できない場合は、属性を直接使用してください:

php
/** @var array<string, string> $params */
$params = $req->getAttribute(Router::PARAMETERS_ATTRIBUTE, []);
$raw    = $params['userId'] ?? '';

バリデーション

  • endpoint: 空でない文字列、最大 128 文字
  • X-User-Id: ctype_digit() + 正の整数
  • パス userId: ctype_digit() + 正の整数(失敗 → 404)
  • 管理者キー: hash_equals() 比較(失敗 → 403)

HTTP ステータスコード

状況ステータス
リクエスト許可200
ステータス取得済み200
カウンターリセット済み200
X-User-Id なし400
ボディなし400
endpoint が空または欠如422
endpoint が長すぎる422
管理者キーなし403
誤った管理者キー403
パスの無効な userId404
レート制限超過429

カバーされた ATK 攻撃パターン

ATKパターン防御
ATK-01X-User-Id 欠如メッセージ付き 400
ATK-02空の endpoint 文字列422 バリデーション
ATK-03129 文字の endpoint(DoS)長さ制限 422
ATK-04endpoint への SQL インジェクションパラメーター化クエリ
ATK-05非管理者のリセット試行403 フェイルクローズ
ATK-06誤った管理者キー403 hash_equals()
ATK-07パスの負の userId404
ATK-08ゼロ userId404
ATK-09非数字 userId(abc404 ctype_digit
ATK-10endpoint パラメーターなしのステータス422
ATK-11ボディなしのチェック400
ATK-12endpoint キーのないボディ422

注意事項

  • 並行性: スライディングウィンドウには小さな TOCTOU ウィンドウがあります。高並行性の本番環境では、アトミックカウンター(Redis INCR + EXPIRE)またはデータベースレベルのロックを検討してください。
  • クロックスキュー: すべてのタイムスタンプは UTC を使用して DST やタイムゾーンの問題を避けてください。
  • ストレージの増大: 古いイベントが蓄積されます。定期的なクリーンアップジョブを追加してください: DELETE FROM rate_events WHERE created_at < :cutoff

MIT ライセンスの下で公開されています。