APIレートリミティング実装ガイド2026 - トークンバケット、スライディングウィンドウ、Redis実装、Express/Hono対応
APIレートリミティング実装ガイド2026
APIレートリミティングは、サービスの安定性とセキュリティに不可欠です。本記事では、主要なアルゴリズムと実装方法を徹底解説します。
目次
- レートリミティングの基本
- アルゴリズム比較
- トークンバケット実装
- スライディングウィンドウ実装
- Redis実装
- Express対応
- Hono対応
- 分散環境での実装
- ベストプラクティス
レートリミティングの基本
なぜ必要か
// レートリミティングが必要な理由
// 1. DDoS攻撃の防止
// 2. APIの公平な利用
// 3. サーバーリソースの保護
// 4. コストの最適化
// 5. サービス品質の維持
レート制限の種類
// ユーザーごと
const USER_RATE_LIMIT = {
requests: 100,
window: '1m', // 1分間に100リクエスト
}
// IPアドレスごと
const IP_RATE_LIMIT = {
requests: 1000,
window: '1h', // 1時間に1000リクエスト
}
// APIキーごと
const API_KEY_RATE_LIMIT = {
free: { requests: 100, window: '1d' },
pro: { requests: 10000, window: '1d' },
enterprise: { requests: 1000000, window: '1d' },
}
レスポンスヘッダー
// 標準的なレートリミットヘッダー
const headers = {
'X-RateLimit-Limit': '100', // 制限値
'X-RateLimit-Remaining': '95', // 残り回数
'X-RateLimit-Reset': '1640995200', // リセット時刻(UNIX timestamp)
'Retry-After': '60', // 再試行までの秒数
}
アルゴリズム比較
固定ウィンドウ
// シンプルだが境界問題がある
class FixedWindowLimiter {
private counts = new Map<string, { count: number; resetAt: number }>()
isAllowed(key: string, limit: number, windowMs: number): boolean {
const now = Date.now()
const data = this.counts.get(key)
if (!data || now >= data.resetAt) {
// 新しいウィンドウ
this.counts.set(key, {
count: 1,
resetAt: now + windowMs,
})
return true
}
if (data.count < limit) {
data.count++
return true
}
return false
}
getInfo(key: string, limit: number) {
const data = this.counts.get(key)
if (!data) {
return {
remaining: limit,
resetAt: Date.now() + 60000,
}
}
return {
remaining: Math.max(0, limit - data.count),
resetAt: data.resetAt,
}
}
}
スライディングウィンドウ
// より正確だが計算コストが高い
class SlidingWindowLimiter {
private requests = new Map<string, number[]>()
isAllowed(key: string, limit: number, windowMs: number): boolean {
const now = Date.now()
const timestamps = this.requests.get(key) || []
// 古いタイムスタンプを削除
const validTimestamps = timestamps.filter(ts => now - ts < windowMs)
if (validTimestamps.length < limit) {
validTimestamps.push(now)
this.requests.set(key, validTimestamps)
return true
}
this.requests.set(key, validTimestamps)
return false
}
getInfo(key: string, limit: number, windowMs: number) {
const now = Date.now()
const timestamps = this.requests.get(key) || []
const validTimestamps = timestamps.filter(ts => now - ts < windowMs)
return {
remaining: Math.max(0, limit - validTimestamps.length),
resetAt: validTimestamps[0] ? validTimestamps[0] + windowMs : now + windowMs,
}
}
}
トークンバケット
// 柔軟でバースト対応可能
class TokenBucketLimiter {
private buckets = new Map<string, {
tokens: number
lastRefill: number
}>()
isAllowed(
key: string,
capacity: number,
refillRate: number, // tokens per second
cost: number = 1
): boolean {
const now = Date.now()
const bucket = this.buckets.get(key) || {
tokens: capacity,
lastRefill: now,
}
// トークンを補充
const timePassed = (now - bucket.lastRefill) / 1000
const tokensToAdd = timePassed * refillRate
bucket.tokens = Math.min(capacity, bucket.tokens + tokensToAdd)
bucket.lastRefill = now
if (bucket.tokens >= cost) {
bucket.tokens -= cost
this.buckets.set(key, bucket)
return true
}
this.buckets.set(key, bucket)
return false
}
getInfo(key: string, capacity: number, refillRate: number) {
const now = Date.now()
const bucket = this.buckets.get(key)
if (!bucket) {
return {
remaining: capacity,
resetAt: now,
}
}
const timePassed = (now - bucket.lastRefill) / 1000
const tokensToAdd = timePassed * refillRate
const currentTokens = Math.min(capacity, bucket.tokens + tokensToAdd)
return {
remaining: Math.floor(currentTokens),
resetAt: bucket.lastRefill + ((capacity - bucket.tokens) / refillRate) * 1000,
}
}
}
リーキーバケット
// 一定のレートで処理
class LeakyBucketLimiter {
private queues = new Map<string, {
queue: number[]
lastLeak: number
}>()
isAllowed(
key: string,
capacity: number,
leakRate: number // requests per second
): boolean {
const now = Date.now()
const data = this.queues.get(key) || {
queue: [],
lastLeak: now,
}
// リークを処理
const timePassed = (now - data.lastLeak) / 1000
const requestsToLeak = Math.floor(timePassed * leakRate)
if (requestsToLeak > 0) {
data.queue = data.queue.slice(requestsToLeak)
data.lastLeak = now
}
// キューに追加
if (data.queue.length < capacity) {
data.queue.push(now)
this.queues.set(key, data)
return true
}
this.queues.set(key, data)
return false
}
}
トークンバケット実装
基本実装
// token-bucket.ts
export class TokenBucket {
private tokens: number
private lastRefill: number
constructor(
private capacity: number,
private refillRate: number // tokens per second
) {
this.tokens = capacity
this.lastRefill = Date.now()
}
consume(tokens: number = 1): boolean {
this.refill()
if (this.tokens >= tokens) {
this.tokens -= tokens
return true
}
return false
}
private refill() {
const now = Date.now()
const timePassed = (now - this.lastRefill) / 1000
const tokensToAdd = timePassed * this.refillRate
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd)
this.lastRefill = now
}
getTokens(): number {
this.refill()
return Math.floor(this.tokens)
}
getWaitTime(tokens: number = 1): number {
this.refill()
if (this.tokens >= tokens) {
return 0
}
const deficit = tokens - this.tokens
return Math.ceil((deficit / this.refillRate) * 1000)
}
}
階層型レート制限
// tiered-rate-limiter.ts
type Tier = 'free' | 'pro' | 'enterprise'
const TIER_LIMITS = {
free: { capacity: 100, refillRate: 100 / 3600 }, // 100 per hour
pro: { capacity: 1000, refillRate: 1000 / 3600 }, // 1000 per hour
enterprise: { capacity: 10000, refillRate: 10000 / 3600 }, // 10000 per hour
}
export class TieredRateLimiter {
private buckets = new Map<string, TokenBucket>()
isAllowed(userId: string, tier: Tier, cost: number = 1): boolean {
const key = `${userId}:${tier}`
let bucket = this.buckets.get(key)
if (!bucket) {
const config = TIER_LIMITS[tier]
bucket = new TokenBucket(config.capacity, config.refillRate)
this.buckets.set(key, bucket)
}
return bucket.consume(cost)
}
getInfo(userId: string, tier: Tier) {
const key = `${userId}:${tier}`
const bucket = this.buckets.get(key)
const config = TIER_LIMITS[tier]
if (!bucket) {
return {
limit: config.capacity,
remaining: config.capacity,
resetAt: Date.now() + 3600000,
}
}
return {
limit: config.capacity,
remaining: bucket.getTokens(),
resetAt: Date.now() + bucket.getWaitTime(1),
}
}
}
スライディングウィンドウ実装
メモリベース
// sliding-window.ts
export class SlidingWindowLimiter {
private windows = new Map<string, number[]>()
isAllowed(key: string, limit: number, windowMs: number): boolean {
const now = Date.now()
const requests = this.windows.get(key) || []
// ウィンドウ外のリクエストを削除
const validRequests = requests.filter(timestamp => now - timestamp < windowMs)
if (validRequests.length < limit) {
validRequests.push(now)
this.windows.set(key, validRequests)
return true
}
this.windows.set(key, validRequests)
return false
}
getInfo(key: string, limit: number, windowMs: number) {
const now = Date.now()
const requests = this.windows.get(key) || []
const validRequests = requests.filter(timestamp => now - timestamp < windowMs)
const oldestRequest = validRequests[0] || now
const resetAt = oldestRequest + windowMs
return {
limit,
remaining: Math.max(0, limit - validRequests.length),
resetAt,
retryAfter: validRequests.length >= limit
? Math.ceil((resetAt - now) / 1000)
: 0,
}
}
// 定期的にクリーンアップ
cleanup(maxAge: number = 3600000) {
const now = Date.now()
for (const [key, requests] of this.windows.entries()) {
const validRequests = requests.filter(timestamp => now - timestamp < maxAge)
if (validRequests.length === 0) {
this.windows.delete(key)
} else {
this.windows.set(key, validRequests)
}
}
}
}
スライディングウィンドウログ
// sliding-window-log.ts
type RequestLog = {
timestamp: number
cost: number
}
export class SlidingWindowLog {
private logs = new Map<string, RequestLog[]>()
isAllowed(
key: string,
limit: number,
windowMs: number,
cost: number = 1
): boolean {
const now = Date.now()
const requests = this.logs.get(key) || []
// ウィンドウ外のリクエストを削除
const validRequests = requests.filter(
req => now - req.timestamp < windowMs
)
// コストの合計を計算
const totalCost = validRequests.reduce((sum, req) => sum + req.cost, 0)
if (totalCost + cost <= limit) {
validRequests.push({ timestamp: now, cost })
this.logs.set(key, validRequests)
return true
}
this.logs.set(key, validRequests)
return false
}
getInfo(key: string, limit: number, windowMs: number) {
const now = Date.now()
const requests = this.logs.get(key) || []
const validRequests = requests.filter(
req => now - req.timestamp < windowMs
)
const totalCost = validRequests.reduce((sum, req) => sum + req.cost, 0)
const oldestRequest = validRequests[0]?.timestamp || now
return {
limit,
used: totalCost,
remaining: Math.max(0, limit - totalCost),
resetAt: oldestRequest + windowMs,
}
}
}
Redis実装
Redisベーストークンバケット
// redis-token-bucket.ts
import { Redis } from 'ioredis'
export class RedisTokenBucket {
constructor(private redis: Redis) {}
async isAllowed(
key: string,
capacity: number,
refillRate: number, // tokens per second
cost: number = 1
): Promise<boolean> {
const now = Date.now()
const bucketKey = `rate:${key}`
// Luaスクリプトでアトミックに処理
const script = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
local tokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now
-- トークンを補充
local timePassed = (now - lastRefill) / 1000
local tokensToAdd = timePassed * refillRate
tokens = math.min(capacity, tokens + tokensToAdd)
-- トークンを消費
if tokens >= cost then
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
redis.call('EXPIRE', key, 3600)
return {1, tokens}
else
redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
redis.call('EXPIRE', key, 3600)
return {0, tokens}
end
`
const result = await this.redis.eval(
script,
1,
bucketKey,
capacity,
refillRate,
cost,
now
) as [number, number]
return result[0] === 1
}
async getInfo(key: string, capacity: number, refillRate: number) {
const now = Date.now()
const bucketKey = `rate:${key}`
const bucket = await this.redis.hmget(bucketKey, 'tokens', 'lastRefill')
const tokens = parseFloat(bucket[0] || capacity.toString())
const lastRefill = parseFloat(bucket[1] || now.toString())
// トークンを補充(読み取りのみ)
const timePassed = (now - lastRefill) / 1000
const tokensToAdd = timePassed * refillRate
const currentTokens = Math.min(capacity, tokens + tokensToAdd)
return {
limit: capacity,
remaining: Math.floor(currentTokens),
resetAt: lastRefill + ((capacity - tokens) / refillRate) * 1000,
}
}
}
Redisスライディングウィンドウ
// redis-sliding-window.ts
import { Redis } from 'ioredis'
export class RedisSlidingWindow {
constructor(private redis: Redis) {}
async isAllowed(
key: string,
limit: number,
windowMs: number
): Promise<boolean> {
const now = Date.now()
const windowKey = `rate:${key}`
const windowStart = now - windowMs
// Luaスクリプトでアトミックに処理
const script = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- 古いエントリを削除
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
-- 現在のカウントを取得
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, 3600)
return 1
else
return 0
end
`
const result = await this.redis.eval(
script,
1,
windowKey,
limit,
windowStart,
now
)
return result === 1
}
async getInfo(key: string, limit: number, windowMs: number) {
const now = Date.now()
const windowKey = `rate:${key}`
const windowStart = now - windowMs
// 古いエントリを削除
await this.redis.zremrangebyscore(windowKey, 0, windowStart)
// 現在のカウントを取得
const count = await this.redis.zcard(windowKey)
// 最も古いエントリのタイムスタンプを取得
const oldest = await this.redis.zrange(windowKey, 0, 0, 'WITHSCORES')
const oldestTimestamp = oldest[1] ? parseFloat(oldest[1]) : now
return {
limit,
remaining: Math.max(0, limit - count),
resetAt: oldestTimestamp + windowMs,
retryAfter: count >= limit
? Math.ceil((oldestTimestamp + windowMs - now) / 1000)
: 0,
}
}
}
Express対応
ミドルウェア実装
// express-rate-limit.ts
import { Request, Response, NextFunction } from 'express'
import { Redis } from 'ioredis'
import { RedisSlidingWindow } from './redis-sliding-window'
type RateLimitConfig = {
windowMs: number
max: number
message?: string
statusCode?: number
keyGenerator?: (req: Request) => string
skip?: (req: Request) => boolean
handler?: (req: Request, res: Response) => void
}
export function createRateLimiter(
redis: Redis,
config: RateLimitConfig
) {
const limiter = new RedisSlidingWindow(redis)
const {
windowMs,
max,
message = 'Too many requests',
statusCode = 429,
keyGenerator = (req) => req.ip,
skip = () => false,
handler,
} = config
return async (req: Request, res: Response, next: NextFunction) => {
if (skip(req)) {
return next()
}
const key = keyGenerator(req)
const allowed = await limiter.isAllowed(key, max, windowMs)
const info = await limiter.getInfo(key, max, windowMs)
// レートリミットヘッダーを設定
res.setHeader('X-RateLimit-Limit', info.limit)
res.setHeader('X-RateLimit-Remaining', info.remaining)
res.setHeader('X-RateLimit-Reset', Math.ceil(info.resetAt / 1000))
if (!allowed) {
res.setHeader('Retry-After', info.retryAfter)
if (handler) {
return handler(req, res)
}
return res.status(statusCode).json({
error: message,
retryAfter: info.retryAfter,
})
}
next()
}
}
使用例
// app.ts
import express from 'express'
import { Redis } from 'ioredis'
import { createRateLimiter } from './express-rate-limit'
const app = express()
const redis = new Redis()
// グローバルレート制限
app.use(createRateLimiter(redis, {
windowMs: 15 * 60 * 1000, // 15分
max: 100, // 100リクエスト
}))
// API固有のレート制限
const apiLimiter = createRateLimiter(redis, {
windowMs: 60 * 1000, // 1分
max: 10, // 10リクエスト
keyGenerator: (req) => req.user?.id || req.ip,
skip: (req) => req.user?.tier === 'enterprise',
})
app.use('/api/', apiLimiter)
// エンドポイント固有のレート制限
const strictLimiter = createRateLimiter(redis, {
windowMs: 60 * 1000, // 1分
max: 5, // 5リクエスト
message: 'Too many login attempts',
})
app.post('/api/login', strictLimiter, (req, res) => {
// ログイン処理
})
Hono対応
Honoミドルウェア
// hono-rate-limit.ts
import { Context, Next } from 'hono'
import { Redis } from 'ioredis'
import { RedisSlidingWindow } from './redis-sliding-window'
type HonoRateLimitConfig = {
windowMs: number
max: number
message?: string
statusCode?: number
keyGenerator?: (c: Context) => string
skip?: (c: Context) => boolean
}
export function rateLimiter(
redis: Redis,
config: HonoRateLimitConfig
) {
const limiter = new RedisSlidingWindow(redis)
const {
windowMs,
max,
message = 'Too many requests',
statusCode = 429,
keyGenerator = (c) => c.req.header('x-forwarded-for') || 'unknown',
skip = () => false,
} = config
return async (c: Context, next: Next) => {
if (skip(c)) {
return next()
}
const key = keyGenerator(c)
const allowed = await limiter.isAllowed(key, max, windowMs)
const info = await limiter.getInfo(key, max, windowMs)
// レートリミットヘッダーを設定
c.header('X-RateLimit-Limit', info.limit.toString())
c.header('X-RateLimit-Remaining', info.remaining.toString())
c.header('X-RateLimit-Reset', Math.ceil(info.resetAt / 1000).toString())
if (!allowed) {
c.header('Retry-After', info.retryAfter.toString())
return c.json({
error: message,
retryAfter: info.retryAfter,
}, statusCode)
}
await next()
}
}
Hono使用例
// app.ts
import { Hono } from 'hono'
import { Redis } from 'ioredis'
import { rateLimiter } from './hono-rate-limit'
const app = new Hono()
const redis = new Redis()
// グローバルレート制限
app.use('*', rateLimiter(redis, {
windowMs: 15 * 60 * 1000,
max: 100,
}))
// API固有のレート制限
app.use('/api/*', rateLimiter(redis, {
windowMs: 60 * 1000,
max: 10,
keyGenerator: (c) => c.get('userId') || c.req.header('x-forwarded-for') || 'unknown',
}))
// エンドポイント固有のレート制限
const strictLimiter = rateLimiter(redis, {
windowMs: 60 * 1000,
max: 5,
message: 'Too many login attempts',
})
app.post('/api/login', strictLimiter, async (c) => {
// ログイン処理
return c.json({ message: 'Login successful' })
})
export default app
分散環境での実装
Redisクラスター対応
// distributed-rate-limiter.ts
import { Cluster } from 'ioredis'
export class DistributedRateLimiter {
constructor(private cluster: Cluster) {}
async isAllowed(
key: string,
limit: number,
windowMs: number
): Promise<boolean> {
const now = Date.now()
const windowKey = `rate:${key}`
const windowStart = now - windowMs
// ハッシュタグを使用して同じスロットに配置
const slotKey = `{${key}}`
const script = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, 3600)
return 1
else
return 0
end
`
const result = await this.cluster.eval(
script,
1,
`${slotKey}:${windowKey}`,
limit,
windowStart,
now
)
return result === 1
}
}
マルチリージョン対応
// multi-region-limiter.ts
import { Redis } from 'ioredis'
export class MultiRegionLimiter {
constructor(
private primaryRedis: Redis,
private replicaRedis: Redis
) {}
async isAllowed(
key: string,
limit: number,
windowMs: number
): Promise<boolean> {
try {
// プライマリで書き込み
return await this.checkLimit(this.primaryRedis, key, limit, windowMs)
} catch (error) {
console.error('Primary Redis error:', error)
// フェイルオーバー
return await this.checkLimit(this.replicaRedis, key, limit, windowMs)
}
}
private async checkLimit(
redis: Redis,
key: string,
limit: number,
windowMs: number
): Promise<boolean> {
const now = Date.now()
const windowKey = `rate:${key}`
const windowStart = now - windowMs
const script = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, 3600)
return 1
else
return 0
end
`
const result = await redis.eval(
script,
1,
windowKey,
limit,
windowStart,
now
)
return result === 1
}
}
ベストプラクティス
レート制限の設計
// rate-limit-config.ts
export const RATE_LIMITS = {
// グローバル制限
global: {
windowMs: 15 * 60 * 1000, // 15分
max: 1000,
},
// 認証エンドポイント
auth: {
windowMs: 60 * 1000, // 1分
max: 5,
},
// API階層別
tiers: {
free: {
windowMs: 60 * 60 * 1000, // 1時間
max: 100,
},
pro: {
windowMs: 60 * 60 * 1000,
max: 1000,
},
enterprise: {
windowMs: 60 * 60 * 1000,
max: 10000,
},
},
// コストベース
costs: {
read: 1,
write: 5,
search: 10,
export: 50,
},
}
エラーハンドリング
// error-handling.ts
export function handleRateLimitError(
remaining: number,
resetAt: number,
retryAfter: number
) {
return {
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Rate limit exceeded',
details: {
limit: 'Request limit has been exceeded',
remaining,
resetAt: new Date(resetAt).toISOString(),
retryAfter,
},
},
}
}
モニタリング
// monitoring.ts
export async function trackRateLimit(
key: string,
allowed: boolean,
info: {
limit: number
remaining: number
resetAt: number
}
) {
// メトリクスを記録
await metrics.increment('rate_limit.requests', {
key,
allowed: allowed.toString(),
})
if (!allowed) {
await metrics.increment('rate_limit.exceeded', { key })
}
// 使用率を記録
const usage = ((info.limit - info.remaining) / info.limit) * 100
await metrics.gauge('rate_limit.usage', usage, { key })
}
まとめ
APIレートリミティングは、サービスの安定性とセキュリティに不可欠な機能です。
主要なアルゴリズム:
- 固定ウィンドウ: シンプルだが境界問題あり
- スライディングウィンドウ: 正確だが計算コスト高
- トークンバケット: 柔軟でバースト対応
- リーキーバケット: 一定レートで処理
2026年のベストプラクティス:
- Redisで分散環境対応
- 適切なアルゴリズム選択
- 階層型レート制限
- レスポンスヘッダーの設定
- モニタリングとアラート
適切なレートリミティングを実装して、安定したAPIサービスを提供しましょう。