APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング

API バックエンド レート制限 429 インフラ
結論
  • レート制限はToken Bucket(バースト許容)かSliding Window Counter(公平な時間窓)が実務で多い。
  • 上限超過時は429を返し、Retry-AfterX-RateLimit-*ヘッダーでクライアントに待ち時間を伝える。
  • Cloudflare/Nginxで粗く、Redis + アプリで細かく制限する二段構えが堅牢。
  • クライアント側の429処理はレート制限ヘッダーと429対応を参照。

なぜAPIにレート制限が必要か

公開APIやSaaSのバックエンドは、スクレイピング・認証情報の総当たり・バグったクライアントの無限リトライ・バッチジョブの並列暴走など、意図しないトラフィック急増に常に晒される。レート制限(Rate Limiting)は、1クライアントあたりのリクエスト数を上限内に抑え、他ユーザーへの影響を防ぎ、DB接続プールや外部API課金の暴走も止める。

設計で決めるべきは4点だ。

  1. 誰を制限するか — IP / APIキー / ユーザーID / エンドポイント
  2. どの時間幅で数えるか — 1秒 / 1分 / 1時間
  3. どのアルゴリズムで数えるか — Fixed / Sliding / Token Bucket
  4. 超えたとき何を返すか — 通常は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:59100リクエスト送信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.716トークン溜まる
その直後に15件連続16トークン消費 → 残115件中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である理由

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件までキューに溜める
nodelayburst内は遅延なしで即処理(Token Bucket的)
limit_req_status 429超過時のステータス(デフォルト503)

Nginxのlimit_reqは厳密なToken BucketではなくLeaky Bucketに近い動作。ビジネスロジック(プラン別上限)には向かないが、DDoS手前の防波堤として有効。

Cloudflare Rate Limiting ルールの設定

Cloudflareダッシュボードまたは Terraform で、エッジ側にレート制限を設定できる。アプリに到達する前に異常トラフィックを遮断する。

ダッシュボード設定例

  1. Security → WAF → Rate limiting rules → Create rule
  2. 条件: (http.request.uri.path contains "/v1/")
  3. 期間: 60秒、リクエスト数: 100
  4. アクション: Block(または Managed Challenge)
  5. 特性: 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 単一プロセス内の軽量保護。開発環境・フォールバック向け

二段構えの推奨フロー:

  1. Cloudflare: 同一IPから 1000 req/min 超 → Block(明らかな攻撃)
  2. Nginx: パス /v1/auth/10 req/s に制限(ブルートフォース対策)
  3. アプリ(Redis): APIキーごとに Free=100/min, Pro=1000/min(課金連動)

実装の流れと運用

1

SLOとビジネス要件から上限値を決める(例: 認証API 10 req/min、検索API 60 req/min)

2

アルゴリズムを選定する(バースト許容→Token Bucket、公平性→Sliding Window Counter)

3

Redis Luaスクリプトでアトミックなカウンタ更新を実装する

4

ミドルウェアで認証後・DB前に判定し、超過時は429 + ヘッダー + JSONを返す

5

Cloudflare/Nginxでエッジ層の粗い制限を設定する

6

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-AfterX-RateLimit-* を必ず付け、JSONボディで error_coderetry_after を返す。クライアント側の待機・再試行ロジックはレート制限ヘッダーと429対応を参照。

Cloudflare/Nginxで粗く、Redis + アプリで細かく制限する二段構えが堅牢。上限値は最初から完璧を目指さず、429率とユーザー体験を見ながら調整するのが実務的な進め方だ。