🎄 科学と神々株式会社 アドベントカレンダー 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"
🌟 まとめ
レートリミット実装の要点:
-
アルゴリズム選択
- Sliding Window: 正確・メモリ効率的
- Fixed Window: シンプル
- Token Bucket: バースト対応
-
Nim実装
- SQLiteでシンプルな実装
- キャッシュでパフォーマンス最適化
- Redisでスケーラビリティ対応
-
レスポンスヘッダー
-
X-RateLimit-*でクライアントに情報提供 -
Retry-Afterでリトライ時刻指示
-
-
ビジネスモデル
- プラン別制限で収益化
- アップグレード誘導
前回: Day 12: データベーススキーマ設計
次回: Day 14: 階層型プランの設計
Happy Learning! 🎉