0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

License System Day 13: レートリミットの実装

Last updated at Posted at 2025-12-12

🎄 科学と神々株式会社 アドベントカレンダー 2025

License System Day 13: レートリミットの実装


📖 今日のテーマ

今日は、APIの安定性を守る**レートリミット(Rate Limiting)**を学びます。

レートリミットは、ユーザーのプラン別に適切なリソース制限を行い、システム全体の健全性を保ちます。


🎯 レートリミットの目的

1. システム保護

目的:
✅ DoS攻撃からの防御
✅ リソース枯渇の防止
✅ サービス品質の維持
✅ 公平なリソース分配

2. ビジネスモデルの実現

Freeプラン:    10リクエスト/時間   → お試し利用
Premiumプラン: 1,000リクエスト/時間 → 通常利用
Enterpriseプラン: 無制限            → 大規模利用

3. 収益化の仕組み

Free → Premium への誘導
└─ レート制限に達したユーザーがアップグレード

🔧 実装アルゴリズム

1. Sliding Window (スライディングウィンドウ)

概念:

時間軸: ──────────────────────────►
        [  1時間のウィンドウ  ]
              ↓移動
           [  1時間のウィンドウ  ]
                 ↓移動
              [  1時間のウィンドウ  ]

メリット:

  • 正確なレート制限
  • 時間境界での急激な増加を防ぐ
  • メモリ効率が良い

デメリット:

  • 実装がやや複雑

2. Fixed Window (固定ウィンドウ)

概念:

時間軸: ──────────────────────────►
        [0:00-1:00][1:00-2:00][2:00-3:00]
        10リクエスト 10リクエスト 10リクエスト

メリット:

  • シンプルな実装
  • 高速

デメリット:

  • 境界での急増問題(1:00直前と直後で20リクエスト可能)

3. Token Bucket (トークンバケット)

概念:

バケット容量: 10トークン
補充速度: 1トークン/6分 (10/時間)

リクエスト → トークン消費
時間経過 → トークン補充

メリット:

  • バースト対応(一時的な急増を許容)
  • 柔軟な制限設定

デメリット:

  • 状態管理が複雑

💻 Nim実装: Sliding Window方式

データベーステーブル

Day 12で定義したrate_limitsテーブルを使用:

CREATE TABLE rate_limits (
  user_id TEXT NOT NULL,
  endpoint TEXT NOT NULL,
  request_count INTEGER DEFAULT 0,
  window_start TEXT NOT NULL,

  PRIMARY KEY (user_id, endpoint),
  INDEX idx_rate_limits_window (window_start)
);

プラン定義

# shared/types.nim
type
  PlanLimits* = object
    maxRequestsPerHour*: int
    maxMessageLength*: int
    features*: set[Feature]

  Feature* = enum
    fBasicEcho
    fAdvancedEcho
    fBulkOperations
    fPrioritySupport

const
  FreeLimits* = PlanLimits(
    maxRequestsPerHour: 10,
    maxMessageLength: 100,
    features: {fBasicEcho}
  )

  PremiumLimits* = PlanLimits(
    maxRequestsPerHour: 1000,
    maxMessageLength: 10_000,
    features: {fBasicEcho, fAdvancedEcho, fBulkOperations}
  )

  EnterpriseLimits* = PlanLimits(
    maxRequestsPerHour: int.high,  # 無制限
    maxMessageLength: 1_000_000,
    features: {fBasicEcho, fAdvancedEcho, fBulkOperations, fPrioritySupport}
  )

proc getLimits*(planType: PlanType): PlanLimits =
  case planType
  of ptFree: FreeLimits
  of ptPremium: PremiumLimits
  of ptEnterprise: EnterpriseLimits

レートリミットチェック実装

# server/src/database.nim
import std/[times, strutils, db_sqlite]
import ../../shared/types

proc checkRateLimit*(self: Database, userId, endpoint: string, limit: int): bool =
  ## Sliding Window方式のレートリミットチェック
  ##
  ## Returns:
  ##   true: リクエスト許可
  ##   false: レート制限超過

  let now = now()
  let windowStart = now - initDuration(hours = 1)  # 1時間前

  # 現在のレート情報を取得
  let row = self.db.getRow(sql"""
    SELECT request_count, window_start
    FROM rate_limits
    WHERE user_id = ? AND endpoint = ?
  """, userId, endpoint)

  if row[0] == "":
    # 初回リクエスト: 新規レコード作成
    self.db.exec(sql"""
      INSERT INTO rate_limits (user_id, endpoint, request_count, window_start)
      VALUES (?, ?, 1, ?)
    """, userId, endpoint, now.format("yyyy-MM-dd HH:mm:ss"))
    return true

  # 既存レコードの情報を取得
  let count = parseInt(row[0])
  let storedStart = parse(row[1], "yyyy-MM-dd HH:mm:ss")

  # ウィンドウが期限切れか確認
  if storedStart < windowStart:
    # 期限切れ: リセットして新規カウント開始
    self.db.exec(sql"""
      UPDATE rate_limits
      SET request_count = 1, window_start = ?
      WHERE user_id = ? AND endpoint = ?
    """, now.format("yyyy-MM-dd HH:mm:ss"), userId, endpoint)
    return true

  # リミットチェック
  if count >= limit:
    # レート制限超過
    return false

  # カウント増加
  self.db.exec(sql"""
    UPDATE rate_limits
    SET request_count = request_count + 1
    WHERE user_id = ? AND endpoint = ?
  """, userId, endpoint)

  return true

proc getRateLimitStatus*(self: Database, userId, endpoint: string, limit: int): tuple[remaining: int, resetAt: DateTime] =
  ## レート制限の残り回数とリセット時刻を取得
  let now = now()
  let windowStart = now - initDuration(hours = 1)

  let row = self.db.getRow(sql"""
    SELECT request_count, window_start
    FROM rate_limits
    WHERE user_id = ? AND endpoint = ?
  """, userId, endpoint)

  if row[0] == "":
    # レコード未作成
    return (remaining: limit, resetAt: now + initDuration(hours = 1))

  let count = parseInt(row[0])
  let storedStart = parse(row[1], "yyyy-MM-dd HH:mm:ss")

  if storedStart < windowStart:
    # 期限切れ
    return (remaining: limit, resetAt: now + initDuration(hours = 1))

  # 残り回数とリセット時刻を計算
  let remaining = max(0, limit - count)
  let resetAt = storedStart + initDuration(hours = 1)

  return (remaining: remaining, resetAt: resetAt)

APIエンドポイントでの使用

# server/src/main.nim
import jester
import std/json
import ./database
import ./crypto
import ../../shared/types

routes:
  post "/api/v1/service/echo":
    ## エコーサービス - レートリミット適用

    var userId = ""
    var planType = ptFree
    var limits = FreeLimits

    # JWT検証(任意)
    if request.headers.hasKey("X-Activation-Key"):
      let token = request.headers["X-Activation-Key"]
      let claims = cryptoService.verifyJWT(token)

      if claims.isSome:
        userId = claims.get["user_id"].getStr()
        planType = parseEnum[PlanType](claims.get["plan_type"].getStr())
        limits = getLimits(planType)
      else:
        # トークン無効: Freeプラン扱い
        userId = "anonymous"
    else:
      # トークンなし: Freeプラン扱い
      userId = "anonymous"

    # レートリミットチェック
    if not db.checkRateLimit(userId, "echo", limits.maxRequestsPerHour):
      # レート制限超過
      let status = db.getRateLimitStatus(userId, "echo", limits.maxRequestsPerHour)

      resp Http429, %*{
        "error": "Rate limit exceeded",
        "message": "Too many requests. Please upgrade your plan or try again later.",
        "rate_limit": {
          "limit": limits.maxRequestsPerHour,
          "remaining": status.remaining,
          "reset_at": status.resetAt.format("yyyy-MM-dd HH:mm:ss")
        },
        "upgrade_url": "https://example.com/upgrade"
      }

    # リクエスト処理
    let body = parseJson(request.body)
    let message = body["message"].getStr()

    # メッセージ長チェック
    if message.len > limits.maxMessageLength:
      resp Http413, %*{
        "error": "Message too long",
        "message": "Message exceeds plan limit",
        "limit": limits.maxMessageLength,
        "received": message.len
      }

    # エコー処理
    var echoResult = message
    if fAdvancedEcho in limits.features:
      echoResult = "[PREMIUM] " & message.toUpperAscii()
    else:
      echoResult = "[FREE] " & message

    # レート制限情報をレスポンスヘッダーに追加
    let status = db.getRateLimitStatus(userId, "echo", limits.maxRequestsPerHour)
    resp Http200, [
      ("X-RateLimit-Limit", $limits.maxRequestsPerHour),
      ("X-RateLimit-Remaining", $status.remaining),
      ("X-RateLimit-Reset", status.resetAt.format("yyyy-MM-dd'T'HH:mm:ss'Z'"))
    ], %*{
      "echo": echoResult,
      "plan": $planType,
      "rate_limit": {
        "limit": limits.maxRequestsPerHour,
        "remaining": status.remaining,
        "reset_at": status.resetAt.format("yyyy-MM-dd HH:mm:ss")
      }
    }

🔍 レスポンスヘッダーの標準

RateLimit Headers (IETF Draft)

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 2025-11-20T15:00:00Z
Retry-After: 3600

説明:

  • X-RateLimit-Limit: 時間枠内の最大リクエスト数
  • X-RateLimit-Remaining: 残りリクエスト数
  • X-RateLimit-Reset: リセット時刻(ISO 8601形式)
  • Retry-After: リトライまでの秒数(制限超過時)

クライアント側での処理

# client-wasm/src/license_client.nim
proc callEchoService*(self: LicenseClient, message: string): Future[JsonNode] {.async.} =
  when defined(js):
    let options = newFetchOptions(
      `method`: "POST",
      headers: newHeaders({
        "Content-Type": "application/json",
        "X-Activation-Key": self.activationKey
      }),
      body: $(%*{"message": message})
    )

    let response = await fetch(cstring(self.serverUrl & "/api/v1/service/echo"), options)

    # レートリミット情報の取得
    let limit = response.headers.get("X-RateLimit-Limit")
    let remaining = response.headers.get("X-RateLimit-Remaining")
    let resetAt = response.headers.get("X-RateLimit-Reset")

    if response.status == 429:
      # レート制限超過
      let retryAfter = response.headers.get("Retry-After")
      raise newException(RateLimitError,
        "Rate limit exceeded. Retry after " & retryAfter & " seconds")

    let data = await response.text()
    result = parseJson($data)

    # レート情報をログ出力
    echo "Rate Limit: ", remaining, "/", limit, " (resets at ", resetAt, ")"

📊 パフォーマンス最適化

1. インメモリキャッシュ

高頻度アクセスではDBクエリがボトルネックになるため、インメモリキャッシュを使用:

# server/src/rate_limiter.nim
import std/[tables, times]

type
  RateLimitCache* = ref object
    cache: Table[string, CacheEntry]
    db: Database

  CacheEntry = object
    count: int
    windowStart: DateTime
    lastAccess: DateTime

proc newRateLimitCache*(db: Database): RateLimitCache =
  result = RateLimitCache(
    cache: initTable[string, CacheEntry](),
    db: db
  )

proc checkRateLimitCached*(self: RateLimitCache, userId, endpoint: string, limit: int): bool =
  let key = userId & ":" & endpoint
  let now = now()
  let windowStart = now - initDuration(hours = 1)

  # キャッシュチェック
  if self.cache.hasKey(key):
    var entry = self.cache[key]

    # ウィンドウ期限切れ
    if entry.windowStart < windowStart:
      entry.count = 1
      entry.windowStart = now
      entry.lastAccess = now
      self.cache[key] = entry

      # DBへ非同期書き込み(バックグラウンド)
      asyncCheck self.db.resetRateLimit(userId, endpoint, now)
      return true

    # リミットチェック
    if entry.count >= limit:
      return false

    # カウント増加
    entry.count += 1
    entry.lastAccess = now
    self.cache[key] = entry

    # 定期的にDBへ同期
    if entry.count mod 10 == 0:
      asyncCheck self.db.updateRateLimit(userId, endpoint, entry.count)

    return true

  # キャッシュミス: DBから取得
  let allowed = self.db.checkRateLimit(userId, endpoint, limit)

  # キャッシュに追加
  self.cache[key] = CacheEntry(
    count: 1,
    windowStart: now,
    lastAccess: now
  )

  return allowed

proc cleanupCache*(self: RateLimitCache) =
  ## 古いエントリを削除(定期的に実行)
  let now = now()
  let threshold = now - initDuration(hours = 2)

  var keysToDelete: seq[string] = @[]
  for key, entry in self.cache.pairs:
    if entry.lastAccess < threshold:
      keysToDelete.add(key)

  for key in keysToDelete:
    self.cache.del(key)

2. Redis統合(スケーラビリティ)

複数サーバー環境ではRedisを使用:

# server/src/redis_rate_limiter.nim
import std/asyncdispatch
import redis

proc checkRateLimitRedis*(redis: AsyncRedis, userId, endpoint: string, limit: int): Future[bool] {.async.} =
  let key = "ratelimit:" & userId & ":" & endpoint
  let now = epochTime().int
  let windowStart = now - 3600  # 1時間前

  # Lua script for atomic operation
  let script = """
    local key = KEYS[1]
    local limit = tonumber(ARGV[1])
    local now = tonumber(ARGV[2])
    local window_start = tonumber(ARGV[3])

    local count = redis.call('GET', key)
    if not count then
      redis.call('SET', key, 1)
      redis.call('EXPIRE', key, 3600)
      return 1
    end

    count = tonumber(count)
    if count >= limit then
      return 0
    end

    redis.call('INCR', key)
    return 1
  """

  let result = await redis.eval(script, 1, key, $limit, $now, $windowStart)
  return result == "1"

🌟 まとめ

レートリミット実装の要点:

  1. アルゴリズム選択

    • Sliding Window: 正確・メモリ効率的
    • Fixed Window: シンプル
    • Token Bucket: バースト対応
  2. Nim実装

    • SQLiteでシンプルな実装
    • キャッシュでパフォーマンス最適化
    • Redisでスケーラビリティ対応
  3. レスポンスヘッダー

    • X-RateLimit-* でクライアントに情報提供
    • Retry-After でリトライ時刻指示
  4. ビジネスモデル

    • プラン別制限で収益化
    • アップグレード誘導

前回: Day 12: データベーススキーマ設計
次回: Day 14: 階層型プランの設計

Happy Learning! 🎉

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?