Advanced (更新: 2026/6/7)

APIレート制限の実装:トークンバケットとRedisで429を正しく返す

固定窓・スライディング窓・トークンバケットの違いから、Redisでの分散実装、429とRetry-Afterの返し方、IP/ユーザー/APIキー単位の設計まで、コピペで動くコード付きでまとめました。

APIレート制限の実装:トークンバケットとRedisで429を正しく返す

問い合わせフォームのAPIを試作していたとき、フロントの二重送信を軽く見ていました。送信ボタンを押すたびに裏でメールが飛ぶ作りで、開発中に自分でポチポチ叩いているうちに、メール送信枠を数百件分溶かしていたんです。バグじゃありません。「同じ人が連打したら止める」という当たり前の制御を、最初に入れていなかった。それだけでした。

レート制限は地味です。動いているAPIには見た目の変化がない。だから後回しにされて、ログイン総当たり・スクレイピング・AI生成APIの濫用・SMS費用の爆発、ぜんぶ起きてから慌てて入れることになります。順番が逆なんですね。

この記事では、僕が実際に手を動かして確かめた範囲で、アルゴリズムの選び方、Node.jsだけで動く最小実装、Redisで複数台に耐える実装、そして429の正しい返し方までを並べます。

この記事の要点

  • アルゴリズムは3つ覚えれば足りる。固定窓(境界でバーストが2倍漏れる)、スライディング窓(自然だが記録コスト高)、トークンバケット(短いバーストを許しつつ平均速度を抑える)。
  • キーはIPだけに頼らない。会社のNATは多人数が同一IPに見え、攻撃者はIPを簡単に変える。認証済みならユーザーIDやAPIキーと組み合わせる。
  • 上限超過は**HTTP 429 + Retry-After**で返す。Retry-Afterは「待つ秒数」かHTTP日付のどちらかを取れる。
  • サーバーが2台以上なら、カウントはRedisに寄せる。各サーバーのメモリに持つと残り回数がズレて破綻する。
  • Cloudflareは入口、アプリはユーザー単位。二段構えにする。

アルゴリズムは3つだけ覚える

「レート制限 実装」で検索すると名前がいくつも出てきて身構えますが、実務で使うのは3つです。挙動の違いだけ押さえておけば選べます。

アルゴリズム数え方長所弱点
固定窓(Fixed Window)毎分0秒など固定区切りで回数を数える実装が一番楽。カウンタ1個窓の境界で上限の最大2倍が漏れる
スライディング窓(Sliding Window)「直近60秒」を動く枠で数える自然で公平。境界バーストがないリクエスト1件ごとに記録が要る
トークンバケット(Token Bucket)バケツに一定速度でトークンを補充、1回で1枚使う短いバーストを許しつつ平均を抑えられるパラメータ(容量と補充速度)の設計が要る

固定窓の「境界で2倍漏れる」は具体例で見ると怖さがわかります。上限が「1分10回」だとして、攻撃者は12:00:59に10回、12:01:00に10回投げられます。わずか2秒で20回が通る。毎分0秒に一斉リセットされる仕組みの宿命です。

トークンバケットは、容量5・1秒に1枚補充なら、最初に5連打を許して、そのあとは毎秒1回ペースに落ち着きます。バースト許容と平均抑制を両立したいAPIに向いていて、僕の基本はこれです。スライディング窓は、ログインのように「直近◯分で◯回」を厳密に守りたいときに選びます。

何を守るかを先に決める

数字(1分に何回)から考え始めると、たいてい失敗します。先に守る対象を分けるのが先です。レート制限は速度調整ではなく、サーバー・外部API費用・在庫・メール送信枠・ログイン画面・営業リードの品質を守る制御だからです。

実務で多いのはこの4パターンです。

ユースケース制限のキー目安守りたいもの
ログイン・OTP・パスワードリセットIP + アカウントID5回/10分総当たり、SMS費用
検索・一覧APIユーザーID + path60回/分DB負荷、スクレイピング
AI生成・画像生成APIユーザーID + プラン10回/日からAPI料金、無料枠
Webhook受信送信元 + event id短い再送は許可二重処理、キュー詰まり

キー設計でいちばん大事なのは、IP単位を過信しないことです。会社や学校のNAT環境では何十人もが同じIPに見えるので、IPで縛ると善良な人が巻き添えになります。逆に攻撃者はIPを簡単に変えるので、IPだけでは防げません。認証済みAPIなら、ユーザーID・APIキー・組織ID・プラン・エンドポイントを組み合わせてキーを作るほうが現実的です。

flowchart LR
  A["リクエスト"] --> B["利用者を特定<br/>(IP / userId / APIキー)"]
  B --> C["ポリシーを確認"]
  C -->|許可| D["本処理を実行"]
  C -->|超過| E["429 + Retry-After を返す"]
  D --> F["回数とコストを記録"]

Node.jsだけで動く最小実装:トークンバケットで429を返す

説明より動かすのが早いです。依存ゼロ、Node.js 20以上で動く最小例を置きます。rate-limit-demo.mjs として保存してください。トークンバケットは、バケツに一定速度でチケットが補充され、1リクエストで1枚使う方式です。

import http from "node:http";

class TokenBucket {
  constructor({ capacity, refillPerSecond }) {
    this.capacity = capacity;            // バケツの最大容量(=許すバースト数)
    this.refillPerSecond = refillPerSecond; // 1秒あたりの補充トークン数
    this.tokens = capacity;              // 最初は満タン
    this.updatedAt = Date.now();
  }

  // now を引数で渡せるようにしておく(テストで時間を進められる)
  take(now = Date.now()) {
    const elapsed = (now - this.updatedAt) / 1000;
    // 経過時間ぶんだけ補充。ただし容量で頭打ち
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSecond);
    this.updatedAt = now;

    if (this.tokens >= 1) {
      this.tokens -= 1;
      return { allowed: true, remaining: Math.floor(this.tokens), retryAfter: 0 };
    }

    // 足りないぶんが補充されるまでの秒数を計算して返す
    const missing = 1 - this.tokens;
    const retryAfter = Math.ceil(missing / this.refillPerSecond);
    return { allowed: false, remaining: 0, retryAfter };
  }
}

const buckets = new Map();

// 利用者の特定:APIキー優先、なければIP
function clientKey(req) {
  return req.headers["x-api-key"] ?? req.socket.remoteAddress ?? "anonymous";
}

function checkLimit(req) {
  const key = clientKey(req);
  if (!buckets.has(key)) {
    // 容量5・毎秒1補充:最初に5連打を許し、その後は毎秒1回ペース
    buckets.set(key, new TokenBucket({ capacity: 5, refillPerSecond: 1 }));
  }
  return buckets.get(key).take();
}

const server = http.createServer((req, res) => {
  if (req.url !== "/api/demo") {
    res.writeHead(404, { "content-type": "application/json" });
    res.end(JSON.stringify({ error: "not_found" }));
    return;
  }

  const result = checkLimit(req);
  res.setHeader("X-RateLimit-Limit", "5");
  res.setHeader("X-RateLimit-Remaining", String(result.remaining));

  if (!result.allowed) {
    res.writeHead(429, {
      "content-type": "application/json",
      "Retry-After": String(result.retryAfter), // 何秒後に再試行できるか
    });
    res.end(JSON.stringify({ error: "rate_limited", retryAfter: result.retryAfter }));
    return;
  }

  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ ok: true, remaining: result.remaining }));
});

server.listen(3000, () => {
  console.log("Listening on http://localhost:3000/api/demo");
});

起動します。

node rate-limit-demo.mjs

別のターミナルで連打して試します。

for i in 1 2 3 4 5 6 7; do
  curl -i http://localhost:3000/api/demo
done

Windows PowerShellならこちらです。

1..7 | ForEach-Object { curl.exe -i http://localhost:3000/api/demo }

6回目以降で 429 Too Many Requests が返れば成功です。MDNの429 Too Many Requestsが説明しているとおり、429では「いつ再試行できるか」を Retry-After で返すのが作法です。ここで1つ覚えておくと得をします。Retry-After は**「待つ秒数」(例: 120)だけでなくHTTP日付(例: Wed, 07 Jun 2026 12:00:00 GMT)も取れます**。秒数のほうがクライアント側の実装が楽なので、APIなら秒数で統一しておくと扱いやすいです。

Redisで複数台構成に耐える実装にする

メモリ実装は学習には最高ですが、本番でサーバーが2台以上あると一瞬で破綻します。Aサーバーでは残り0、Bサーバーでは残り5、とカウントが分かれてしまうからです。ロードバランサが振り分けるたびに上限が実質倍々になる。そこでカウントをRedisに寄せます。

次の例はExpressとRedis Sorted Set(スコア付き集合)を使ったスライディング窓です。タイムスタンプをスコアにしてメンバーを積み、古いものを毎回掃除することで「直近◯ミリ秒に何件あるか」を数えます。複数のRedisコマンドを1つのLuaスクリプトにまとめてアトミックに実行するのがポイントで、これをやらないと同時アクセス時にカウントがすり抜けます。

npm init -y
npm i express ioredis
docker run --rm --name redis-rate-limit -p 6379:6379 redis:7-alpine

redis-rate-limit-server.mjs として保存します。

import express from "express";
import Redis from "ioredis";

const app = express();
const redis = new Redis(process.env.REDIS_URL ?? "redis://127.0.0.1:6379");

// 古い記録を消す → 件数を数える → 超過なら待ち時間を返す → OKなら追加、を一気に行う
const limitScript = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]

-- 窓の外(古い)リクエスト記録を削除
redis.call("ZREMRANGEBYSCORE", key, 0, now - window_ms)

local count = redis.call("ZCARD", key)
if count >= limit then
  -- いちばん古い記録が窓から外れるまでの待ち時間を計算
  local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")[2]
  local retry_ms = math.max(1, oldest + window_ms - now)
  return {0, 0, retry_ms}
end

redis.call("ZADD", key, now, member)
redis.call("PEXPIRE", key, window_ms) -- 放置キーが残らないよう自動失効
return {1, limit - count - 1, 0}
`;

async function rateLimit(req, res, next) {
  // 認証済みならユーザー、未認証ならIPでキーを作る
  const user = req.get("authorization")?.replace(/^Bearer\s+/i, "");
  const identity = user || req.ip || "anonymous";
  const key = `rl:${identity}:${req.path}`;
  const limit = Number(process.env.RATE_LIMIT_REQUESTS ?? 10);   // 環境変数で変更可
  const windowMs = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60000);
  const now = Date.now();
  const member = `${now}:${Math.random()}`; // 同一msの衝突を避ける一意キー

  const [allowed, remaining, retryMs] = await redis.eval(
    limitScript, 1, key, limit, windowMs, now, member,
  );

  res.setHeader("X-RateLimit-Limit", String(limit));
  res.setHeader("X-RateLimit-Remaining", String(remaining));

  if (allowed === 1) return next();

  const retryAfter = Math.ceil(Number(retryMs) / 1000);
  res.setHeader("Retry-After", String(retryAfter));
  res.status(429).json({ error: "rate_limited", retryAfter });
}

app.use(rateLimit);

app.get("/api/search", (req, res) => {
  res.json({ data: ["rate-limit", "redis"], at: new Date().toISOString() });
});

app.listen(3000, () => {
  console.log("API ready on http://localhost:3000/api/search");
});

起動して連打します。

node redis-rate-limit-server.mjs
for i in $(seq 1 12); do
  curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/search
done

この実装ならサーバーを何台に増やしても全員が同じRedisを見るので、上限が崩れません。ここで必ず決めておくべきなのが、Redis障害時にどちらへ倒すかです。Redisが落ちたとき「全部通す(fail-open)」か「全部止める(fail-closed)」か。問い合わせフォームなら一時的に通す、ログインや決済なら厳しく止める、のように業務リスクで分けます。これを決めずに本番に出すと、Redis障害がそのままサービス全停止か、無防備な大穴のどちらかになります。

クライアントは Retry-After を尊重する

サーバーが429を返しても、クライアントが即座に再送し続けたら意味がありません。SDK・バッチ・Webhook送信側では Retry-After を読んで、待ってから再試行します。これがバッチ処理で特に効きます。

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// 429なら Retry-After ぶん待ってから再試行する fetch ラッパー
async function fetchWithRateLimit(url, options = {}, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
    const res = await fetch(url, options);
    if (res.status !== 429) return res;

    // Retry-After を優先。無ければ指数バックオフにフォールバック
    const header = res.headers.get("retry-after");
    const fallback = Math.min(30, 2 ** attempt); // 1,2,4,8...(上限30秒)
    const waitSec = header ? Math.max(1, Number(header)) : fallback;
    console.log(`429 received. Waiting ${waitSec}s before retry.`);
    await sleep(waitSec * 1000);
  }
  throw new Error("Rate limit retry budget exhausted");
}

for (let i = 0; i < 8; i += 1) {
  const res = await fetchWithRateLimit("http://localhost:3000/api/demo");
  console.log(i + 1, res.status, await res.text());
}

外部API連携を書くときは「429なら指数バックオフ、Retry-After があればそれを優先、最大再試行を超えたら失敗として記録」をセットにしてください。無限リトライは、障害時に相手先と自分の両方を巻き添えにします。テスト自動化の観点はAPIテスト自動化ガイドも合わせてどうぞ。

Cloudflareで入口、アプリでユーザー単位を守る

CloudflareのRate Limiting Rulesは、エッジで大量アクセスを早く落とすのに向いています。公式のRate limiting rulesには、マッチング式・カウントの特性(characteristics)・期間(period)・期間内リクエスト数・緩和時間(mitigation timeout)・アクションといったパラメータが説明されています。ログイン画面・検索API・管理画面・AI生成APIの入口に置くと、アプリまで届く前に負荷を削れます。

ただしCloudflareだけで完結させるのは危険です。無料プランと有料プランの差、組織ごとの上限、ユーザーID単位のAI生成回数、返金濫用の検出は、アプリ側のデータを見ないと判断できません。実務ではこう分担します。

役割
Cloudflare/WAF明らかな連打、bot、国・ASN単位の異常を止める/api/login をIPごとに制限
アプリユーザー・組織・プラン・操作種別で制限無料ユーザーはAI生成10回/日
キュー/ワーカー重い処理を平準化するメール送信、画像生成、PDF作成
請求・監視異常費用を検知するSMS費用、LLM API料金のアラート

公式ドキュメントには「verified bots(検索エンジン等)にレート制限をかけるとSEOに影響しうる」という注意があります。公開サイトでは、検索エンジン・ヘルスチェック・社内監視・決済Webhookを別扱いにしてください。レート制限はDDoS対策だけでなく、不正利用・転売・無料枠の食い潰し・SMS費用の爆発を防ぐ収益防衛策でもあります。設計全体はClaude Codeで本番API開発、運用の安全側はClaude Codeセキュリティベストプラクティスにもまとめています。

僕がやらかした落とし穴4つ

正直に書きます。最初に書いたレート制限は穴だらけでした。

ひとつ目は、全APIを同じ上限にしたこと。プロフィール取得とパスワードリセットを同じ「60回/分」にしたら、片方は緩すぎ、もう片方は厳しすぎ。エンドポイントごとにコストとリスクは違うので、上限も分けるべきでした。

ふたつ目は、成功リクエストだけを数えたこと。ログイン失敗・存在しないメールへのパスワードリセットも、攻撃面とコストを持ちます。総当たり対策ではむしろ失敗のほうを強く数えるのが正解で、ここを逆にしていました。

みっつ目は、429のレスポンス形式を決めなかったこと。HTMLのエラーページを返していたら、フロントもSDKも扱いに困る。JSON形式・Retry-After・残り回数ヘッダーを固定したら、急に連携が楽になりました。

よっつ目は、カウンタのキーに個人情報を平文で入れたこと。メールアドレスや電話番号をRedisキーやログにそのまま残すと、レート制限が別の情報漏えい事故になります。必要ならハッシュ化して、保存期間も短くします。

よくある質問

Q. 固定窓・スライディング窓・トークンバケット、結局どれを使えばいい? 迷ったらトークンバケットです。短いバーストを許しつつ平均速度を抑えられて、多くのAPIに合います。ログインのように「直近◯分で厳密に◯回」を守りたいときはスライディング窓。固定窓は実装が一番楽ですが、窓の境界で上限の最大2倍が漏れる点を理解した上で使ってください。

Q. キーはIPとユーザーIDのどちらにすべき? 未認証のエンドポイント(ログイン前・公開フォーム)はIP、認証済みはユーザーIDやAPIキーが基本です。ログインのような重要な経路は「IP + アカウントID」の組み合わせにすると、IPを変える攻撃にも、1つのIPからの総当たりにも両方効きます。

Q. 429のとき Retry-After は必須? 必須ではありませんが、付けるべきです。無いとクライアントは「いつ再試行していいか」がわからず、無駄な連打を続けます。秒数(例: 60)かHTTP日付のどちらかで返せますが、APIなら実装が楽な秒数で統一するのがおすすめです。

Q. Redisが落ちたらどうなる? 何も決めていないと、リクエストごとにエラーで全停止します。設計時に「fail-open(通す)かfail-closed(止める)か」を業務リスクで決めてください。問い合わせは通す、決済やログインは止める、が目安です。

Q. レート制限のテストで毎回60秒待つのが遅い。 時間を引数で渡せる関数にして、テストでは now を進めます。上のトークンバケット例の take(now) がまさにそれで、実時間を待たずに窓の経過を再現します。CIが速くなります。

実際に試した結果

手元で動かしたところ、メモリ版(トークンバケット)は容量5なので6回目の連続アクセスで 429 が返り、Retry-After: 1 が付きました。Redis版(スライディング窓)は12回連打のうち、設定した10回を超えたところで Retry-After 付きの429に切り替わりました。クライアント例で待機を挟むと、無駄な再送がぴたりと止まります。

冒頭のメール枠を溶かした失敗以来、僕がレート制限で見るのは「入れたかどうか」ではなく、レスポンス形式・再試行・ログ・例外設計まで揃っているかになりました。カウンタを1個足すだけなら30分の仕事です。でも実務で効くのは、その周りの地味な作法のほうでした。まずは上のNode.js版を動かして、自分のAPIに429を1つ返せるようにするところから始めてみてください。

チームで上限設計まで詰めたい場合は、研修・導入相談で、既存のExpressやCloudflare Workers構成を前提に「どの操作を、誰単位で、何回まで許すか」を実コードとテストへ落とし込めます。

#レート制限 #API #Redis #トークンバケット #Node.js
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。