APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング
- レート制限はToken Bucket(バースト許容)かSliding Window Counter(公平な時間窓)が実務で多い。
- 上限超過時は429を返し、
Retry-AfterとX-RateLimit-*ヘッダーでクライアントに待ち時間を伝える。 - Cloudflare/Nginxで粗く、Redis + アプリで細かく制限する二段構えが堅牢。
- クライアント側の429処理はレート制限ヘッダーと429対応を参照。
なぜAPIにレート制限が必要か
公開APIやSaaSのバックエンドは、スクレイピング・認証情報の総当たり・バグったクライアントの無限リトライ・バッチジョブの並列暴走など、意図しないトラフィック急増に常に晒される。レート制限(Rate Limiting)は、1クライアントあたりのリクエスト数を上限内に抑え、他ユーザーへの影響を防ぎ、DB接続プールや外部API課金の暴走も止める。
設計で決めるべきは4点だ。
- 誰を制限するか — IP / APIキー / ユーザーID / エンドポイント
- どの時間幅で数えるか — 1秒 / 1分 / 1時間
- どのアルゴリズムで数えるか — Fixed / Sliding / Token Bucket
- 超えたとき何を返すか — 通常は429 + ヘッダー + JSONボディ
制限チェックは認証後・DBアクセス前に置く。重い処理のあとで429を返しても、サーバー側のコストは既に消費されている。
Fixed Window:最もシンプルだが境界バーストに弱い
Fixed Window(固定窓)は、カレンダー上の区切り(毎分0秒、毎時0分など)ごとにカウンタをリセットする方式。実装が最も軽く、RedisのINCR + EXPIREだけで足りることも多い。
数式
時間窓 W 秒、上限 N リクエストのとき、窓の開始時刻 t₀ = floor(t / W) × W に対し:
count(t) = Σ (req in [t₀, t₀ + W)) 1
許可条件は count(t) < N。Redisキーは rate:{key}:{t0} のように窓開始時刻を含める。
境界バースト問題(具体例)
「1分100リクエスト」のFixed Windowを考える。
| 時刻 | 動作 | 窓内カウント |
|---|---|---|
| 12:00:59 | 100リクエスト送信 | 100(窓 12:00:00–12:00:59) |
| 12:01:00 | カウンタリセット | 0(新窓 12:01:00–12:01:59) |
| 12:01:01 | さらに100リクエスト送信 | 100 |
2秒間で200リクエストが通る。平均レートは「100/min」を守っているように見えても、瞬間的には2倍の負荷がDBに到達する。内部管理APIや厳密さより速度優先の場面では許容されるが、認証・決済APIでは問題になりやすい。
Redis実装(Fixed Window)
-- KEYS[1] = rate:api_key:123:1700000060 (窓開始のUnix秒)
-- ARGV[1] = limit (100)
-- ARGV[2] = window_sec (60)
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if count <= tonumber(ARGV[1]) then
return {1, tonumber(ARGV[1]) - count} -- 許可, remaining
else
return {0, 0} -- 拒否
end
Sliding Window:公平な時間窓と数式
Sliding Window(スライディング窓)は、直近 W 秒のリクエスト数を常に数える方式。Fixed Windowの境界問題を解消し、時間的に均等な制限がかかる。
方式A:タイムスタンプログ
各リクエストのタイムスタンプをSorted Setに保存し、t - W より古いエントリを削除してから件数を数える。正確だが、高トラフィックAPIではメモリ・CPUコストが高い。
方式B:Sliding Window Counter(実務で多い)
直前の窓と現在の窓を重み付き合成する近似方式。Redis LabsやCloudflareの内部実装でも使われる。
count = count_prev × (W - (t mod W)) / W + count_curr
具体例: W = 60秒、上限 N = 100、前の窓で80件、現在の窓(開始から15秒経過)で40件。
count = 80 × (60 - 15) / 60 + 40 = 80 × 0.75 + 40 = 100
ちょうど上限に達する。16秒目に1件来れば101となり拒否される。Fixed Windowの「2秒で200件」は起きない。
| Sliding Window方式 | 特性 |
|---|---|
| タイムスタンプログ | 正確。メモリ・ZREMコスト大。低〜中トラフィック向き |
| Sliding Window Counter | 近似だが軽量。Redis 1キー2カウンタで実装可能。実務定番 |
| Fixed Window | 最軽量。境界バーストあり。内部API・管理画面向き |
Token Bucket:バースト許容の数学
Token Bucket(トークンバケット)は、容量 C のバケットに、レート r(トークン/秒)でトークンを補充し、リクエスト1件につき1トークンを消費する方式。
補充と消費の数式
最終更新時刻 t_last、現在時刻 t におけるトークン数:
tokens(t) = min(C, tokens(t_last) + r × (t - t_last))
リクエスト到着時、tokens >= 1 なら許可して1消費、否则拒否。
具体例
- 平均レート: r = 100/60 ≈ 1.67 トークン/秒(100 req/min)
- バケット容量: C = 20
| 状況 | 計算 | 結果 |
|---|---|---|
| 10秒間リクエストなし | 0 + 1.67 × 10 = 16.7 | 16トークン溜まる |
| その直後に15件連続 | 16トークン消費 → 残1 | 15件中16件まで許可 |
| さらに5件即送信 | トークン不足 | 拒否 → 429 |
平均100 req/minを守りつつ、最大20件のバーストを許容できる。モバイルSDKのオフライン同期や、Webhook再送の集中など「たまにまとめて送る」UXに向く。
Leaky Bucketとの違い
Leaky Bucketは出力を一定レートに平滑化するキューイング方式。レート制限そのものより、下流処理の整形用途。Token Bucketは「溜めてから一気に使う」バースト許容が本質的な違い。
| アルゴリズム | 特性・向き不向き |
|---|---|
| Fixed Window | 実装最簡単。境界バーストに弱い。内部管理API向き |
| Sliding Window Counter | 公平な時間窓。認証・課金API向き |
| Token Bucket | バースト許容。公開API・モバイルSDK向き |
| Leaky Bucket | 出力平滑化。キューイング付き処理向き |
429 Too Many Requests とレスポンス設計
制限を超えたリクエストには HTTP 429 Too Many Requests を返す。503(Service Unavailable)と混同しやすいが、429は「サーバーは生きているが、このクライアントが上限を超えた」という意味。クライアント側はリトライ間隔を空けるべきシグナルとして扱う。
付与すべきレスポンスヘッダー
| ヘッダー | 役割 |
|---|---|
Retry-After | 再試行まで待つ秒数(またはHTTP-date)。429時に最優先 |
X-RateLimit-Limit | 時間窓あたりの上限値 |
X-RateLimit-Remaining | 残りリクエスト数 |
X-RateLimit-Reset | カウンタがリセットされるUnix時刻 |
ヘッダーの読み方・クライアント側の指数バックオフ実装は、APIレート制限と429対応|X-RateLimit・Retry-Afterで詳述している。
429 JSONボディの例
RFC 9457(Problem Details)に沿った形式が望ましい。
{
"type": "https://api.example.com/errors/rate_limit_exceeded",
"title": "Too Many Requests",
"status": 429,
"detail": "1分あたり100リクエストの上限に達しました。42秒後に再試行してください。",
"error_code": "rate_limit_exceeded",
"retry_after": 42,
"limit": 100,
"remaining": 0,
"reset": 1718863260
}
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 42
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1718863260
{"type":"https://api.example.com/errors/rate_limit_exceeded", ...}
429はクライアント側の送信ペースが原因なので4xx Client Errorに分類される。503のようにサーバー全体の問題ではない。クライアントSDKは429専用の分岐を入れ、Retry-Afterを尊重する(固定1秒リトライは禁物)。
Redis + Lua によるアトミックな Token Bucket
複数アプリインスタンスから同時にカウンタを更新すると、Read-Modify-Writeの競合で上限がすり抜ける。LuaスクリプトでGET→計算→SETをアトミックに実行するのが定番。
-- KEYS[1] = rate:token:api_key:abc123
-- ARGV[1] = capacity (20)
-- ARGV[2] = refill_rate_per_sec (1.6666667)
-- ARGV[3] = now_ms (Redis TIME * 1000)
-- ARGV[4] = cost (1)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1])
local last_refill = tonumber(data[2])
if tokens == nil then
tokens = capacity
last_refill = now
end
local elapsed = (now - last_refill) / 1000.0
local refilled = math.min(capacity, tokens + elapsed * rate)
if refilled >= cost then
refilled = refilled - cost
redis.call('HMSET', key, 'tokens', refilled, 'last_refill', now)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000)
local retry_after = 0
return {1, math.floor(refilled), retry_after}
else
local deficit = cost - refilled
local retry_after = math.ceil(deficit / rate)
return {0, 0, retry_after}
end
Goから呼び出す例:
// eval.go — Redis EVAL で Token Bucket を実行
result, err := rdb.Eval(ctx, tokenBucketScript, []string{
fmt.Sprintf("rate:token:%s", apiKey),
}, 20, 100.0/60.0, time.Now().UnixMilli(), 1).Int64Slice()
// result[0]=allowed(1/0), result[1]=remaining, result[2]=retry_after_sec
INCR + EXPIRE だけのFixed WindowよりLuaの方が1ラウンドトリップ多いが、アトミック性とToken Bucketの精度を両立できる。高QPS APIではRedis Clusterのスロット設計(キーを {api_key} でハッシュタグ化)も検討する。
Go: golang.org/x/time/rate の実装例
単一プロセス内の制限なら、標準ライブラリに近い golang.org/x/time/rate が手軽。内部はToken Bucket。
package middleware
import (
"fmt"
"net/http"
"strconv"
"sync"
"time"
"golang.org/x/time/rate"
)
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
var (
visitors = make(map[string]*visitor)
mu sync.Mutex
)
// 100 req/min, burst 20
func getLimiter(key string) *rate.Limiter {
mu.Lock()
defer mu.Unlock()
v, exists := visitors[key]
if !exists {
// rate.Every(time.Minute/100) ≒ 600ms/req
v = &visitor{
limiter: rate.NewLimiter(rate.Every(time.Minute/100), 20),
lastSeen: time.Now(),
}
visitors[key] = v
}
v.lastSeen = time.Now()
return v.limiter
}
func RateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" {
key = r.RemoteAddr
}
limiter := getLimiter(key)
if !limiter.Allow() {
retryAfter := int(time.Minute / 100 / time.Second) // 概算
if retryAfter < 1 {
retryAfter = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.Header().Set("X-RateLimit-Limit", "100")
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"error_code":"rate_limit_exceeded","retry_after":%d}`, retryAfter)
return
}
w.Header().Set("X-RateLimit-Limit", "100")
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(int(limiter.Tokens())))
next.ServeHTTP(w, r)
})
}
注意: この実装はプロセス内メモリに状態を持つため、複数Pod/VMでは上限がインスタンス数倍になる。本番のAPIキー単位制限はRedis Lua版に切り替える。rate.Limiterは単一サービス内のエンドポイント保護や、Redis障害時のフォールバック(fail-open / fail-closed)に使う。
Nginx limit_req_zone によるプロキシ層の制限
アプリケーションに到達する前に、NginxでIP・パス単位の粗い制限をかける。
# /etc/nginx/nginx.conf (http ブロック)
http {
# 10MB の共有メモリゾーン。キー=IP、レート=10req/s(burst未使用時)
limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=10r/s;
# APIキーヘッダー単位(ヘッダーが無い場合はIPにフォールバック)
map $http_x_api_key $limit_key {
default $binary_remote_addr;
"~." $http_x_api_key;
}
limit_req_zone $limit_key zone=api_per_key:10m rate=100r/m;
server {
listen 443 ssl;
server_name api.example.com;
location /v1/auth/ {
# 認証APIは厳しめ: 平均10r/s、burst 5、超過分は即503ではなくdelay
limit_req zone=api_per_ip burst=5 nodelay;
proxy_pass http://backend;
}
location /v1/ {
# 100 req/min ≒ 1.67r/s。burst=20 でToken Bucket的な許容
limit_req zone=api_per_key burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
}
}
| ディレクティブ | 意味 |
|---|---|
rate=10r/s | 平均每秒10リクエスト(漏れ桶/Leaky Bucket的動作) |
burst=20 | バースト20件までキューに溜める |
nodelay | burst内は遅延なしで即処理(Token Bucket的) |
limit_req_status 429 | 超過時のステータス(デフォルト503) |
Nginxのlimit_reqは厳密なToken BucketではなくLeaky Bucketに近い動作。ビジネスロジック(プラン別上限)には向かないが、DDoS手前の防波堤として有効。
Cloudflare Rate Limiting ルールの設定
Cloudflareダッシュボードまたは Terraform で、エッジ側にレート制限を設定できる。アプリに到達する前に異常トラフィックを遮断する。
ダッシュボード設定例
- Security → WAF → Rate limiting rules → Create rule
- 条件:
(http.request.uri.path contains "/v1/") - 期間: 60秒、リクエスト数: 100
- アクション: Block(または Managed Challenge)
- 特性:
cf.colo.id+ip.srcでカウント(データセンター単位の分散も考慮)
Terraform(cloudflare_ruleset)例
resource "cloudflare_ruleset" "api_rate_limit" {
zone_id = var.zone_id
name = "API Rate Limit"
kind = "zone"
phase = "http_ratelimit"
rules {
action = "block"
ratelimit {
characteristics = ["ip.src", "http.request.headers[\"x-api-key\"][0]"]
period = 60
requests_per_period = 100
mitigation_timeout = 60
}
expression = "(http.request.uri.path contains \"/v1/\")"
description = "Block IPs/keys exceeding 100 req/min on /v1/"
enabled = true
}
}
Cloudflareはエッジで完結するためRedis不要だが、プラン別の動的上限(Free=100、Pro=1000)はWorkers KV + Workersで補完するか、アプリ層に委ねる。429レスポンスのカスタムボディはWorkers on http_response フェーズで上書き可能。
CloudflareのIP制限はNAT・モバイルキャリア共有IPで正常ユーザーを巻き込む。APIキー単位の制限と組み合わせ、誤ブロック時のサポート導線(Challenge Pass、ホワイトリスト)を用意する。
制限キー設計と二段構えアーキテクチャ
| キー | メリット | デメリット |
|---|---|---|
| IPアドレス | 認証不要で即適用 | NAT共有、VPN回避容易 |
| APIキー | プラン別制限が自然 | キー漏洩で第三者が枠消費 |
| ユーザーID | 課金単位として正確 | 認証済みリクエスト限定 |
| エンドポイント | 重いAPIだけ厳しく | キー設計が複雑化 |
公開APIではAPIキーを第一キー、IPをフォールバックが多い。ログイン必須の内部APIならユーザーIDベース。
| 実装場所 | 役割・例 |
|---|---|
| Cloudflare Rate Limiting | IP/パス単位の粗い制限。DDoS手前。設定のみで運用可能 |
| Nginx limit_req_zone | リバースプロキシでのIP・ヘッダー単位制限。自前インフラ向け |
| アプリ + Redis Lua | APIキー・プラン・エンドポイント別の動的制限。ビジネスロジックの本丸 |
| Go rate.Limiter | 単一プロセス内の軽量保護。開発環境・フォールバック向け |
二段構えの推奨フロー:
- Cloudflare: 同一IPから 1000 req/min 超 → Block(明らかな攻撃)
- Nginx: パス
/v1/auth/を 10 req/s に制限(ブルートフォース対策) - アプリ(Redis): APIキーごとに Free=100/min, Pro=1000/min(課金連動)
実装の流れと運用
SLOとビジネス要件から上限値を決める(例: 認証API 10 req/min、検索API 60 req/min)
アルゴリズムを選定する(バースト許容→Token Bucket、公平性→Sliding Window Counter)
Redis Luaスクリプトでアトミックなカウンタ更新を実装する
ミドルウェアで認証後・DB前に判定し、超過時は429 + ヘッダー + JSONを返す
Cloudflare/Nginxでエッジ層の粗い制限を設定する
429発生率・Remaining分布・p99レイテンシをダッシュボード化し、上限値を調整する
監視すべきメトリクス
- 429率 — 全体の何%が制限ヒットか。高すぎれば上限が厳しすぎ、0に近ければ攻撃に弱い
- Remaining の分布 — 正常ユーザーが常にRemaining=0なら上限見直し
- Redisレイテンシ — レート制限チェック自体がボトルネックになっていないか
- Retry-After 遵守率 — クライアントが即リトライしていないか(ログの同一キー連続429)
fail-open vs fail-closed
Redis障害時に制限をスキップ(fail-open)するか全拒否(fail-closed)するかはプロダクト判断。決済・認証はfail-closed、読み取り専用APIはfail-openが多い。をフォールバックに使うパターンもある。
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのエラーハンドリング設計 | Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
| Redisキャッシュスタンピード防止ガイド | Redisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効 |
まとめ
レート制限は「アルゴリズム選定 × 429レスポンス設計 × 層の使い分け」の3点セットで設計する。
- Fixed Window — 実装最簡単。境界バーストに注意
- Sliding Window Counter — 公平な時間窓。認証・課金API向き
- Token Bucket — バースト許容。公開API・モバイルSDK向き
429には Retry-After と X-RateLimit-* を必ず付け、JSONボディで error_code と retry_after を返す。クライアント側の待機・再試行ロジックはレート制限ヘッダーと429対応を参照。
Cloudflare/Nginxで粗く、Redis + アプリで細かく制限する二段構えが堅牢。上限値は最初から完璧を目指さず、429率とユーザー体験を見ながら調整するのが実務的な進め方だ。