Claude CodeでRedisのレート制限を実装する — スライディングウィンドウの原子化を負荷テストで確かめる
GETしてINCRする素朴な制限は、並行200リクエストで上限10を17〜28件通しました。Luaで原子化し、429とRetry-Afterまで返す実装をClaude Codeに書かせる手順です。
自社APIのレート制限をRedisで作るとき、いちばん多い失敗は「数える処理と判定する処理が別々のコマンドになっている」実装です。Claude Codeに任せても、書かせ方と確かめ方を決めていなければ同じ穴に落ちます。
手元のRedisで試すと、上限10件のところへ200件を並行で投げた結果は次のとおりでした。GETして上限未満ならINCRする素朴な実装は17〜28件を通し、Luaスクリプトで1回の呼び出しにまとめた実装は4回とも10件ちょうどで止まりました。
この記事では、その差を生む仕組みと、Claude Codeに「レース条件込みで」書かせて検証させる流れをまとめます。自社APIに入れる制限が対象で、Claude API側の制限は別の話です。確認手順はClaude APIの429が出てレート制限が低すぎると感じたときの確認手順にあります。
なぜINCRとEXPIREの組み合わせでは足りないのか
RedisのINCRのページには、レート制限のパターンが2つ載っています。どちらも「1秒あたりIPごとに10リクエストまで」という例です。
1つ目は、IPと現在の秒数をキーにしてINCRとEXPIREをMULTIとEXECで包む方法です。2つ目は、IPだけをキーにした単一のカウンターで、最初のINCRで値が1のときだけEXPIREする方法です。
2つ目のほうが後続処理は軽くなりますが、INCRのページ自身が競合を認めています。INCRを実行したあとクライアントがEXPIREを送れなかった場合、同じIPが再び現れるまでキーが残り続けます。解決策として挙げられているのが、INCRと条件付きEXPIREをLuaスクリプトにしてEVALで送る形です。
競合は2種類あります。区別して覚えておくと、Claude Codeへのレビュー指示が具体的になります。
レート制限に潜む競合の 2 種類
キーが消えない競合
INCRは成功したのにEXPIREが届かず、TTLの無いカウンターが残ります。ドキュメントが指摘しているのはこちらです。
判定と加算の隙間
GETで上限未満と判断してからINCRするまでの間に、別のリクエストが同じ判断を済ませます。次の実測で出た超過はこちらが原因です。
並行200リクエストで素朴な実装を壊してみる
実験の条件は次のとおりです。
- Redis 6.0.9をローカルで起動し、
redis-cliから操作する - 同一ユーザーのキーに対して200リクエストを、同時実行数50の
xargs -P 50で投げる - 上限は60秒あたり10件。許可された件数を数える
素朴な実装は、GETで現在値を読み、10未満ならINCRする2段階のシェルスクリプトにしました。
#!/bin/bash
# naive.sh: GET して上限未満なら INCR する(非原子)
cur=$(redis-cli -p 6391 GET naive:u1); cur=${cur:-0}
if [ "$cur" -lt 10 ]; then
n=$(redis-cli -p 6391 INCR naive:u1)
[ "$n" = 1 ] && redis-cli -p 6391 EXPIRE naive:u1 60 >/dev/null
echo allowed
else
echo denied
fiseq 1 200 | xargs -P 50 -I{} ./naive.sh | sort | uniq -c4回実行した許可件数は22、22、17、28でした。上限は10なので、毎回上限の2倍前後を通しています。実行ごとに数字が揺れるのは、プロセスの起動順で隙間に入り込むリクエスト数が変わるからです。
上限 10 に 200 件を並行で投げた許可件数
GET して INCR
17〜28 件
4 回の実測は 22・22・17・28
Lua で 1 呼び出し
10 件
4 回とも 10 件ちょうど
実運用のアプリケーションは、リクエストごとにプロセスを起こすことはありません。それでも、複数のアプリケーションサーバーや非同期処理が同じキーを読む構成では、同じ隙間が開きます。再現が簡単なので、検証用の負荷テストとして残す価値があります。
スライディングウィンドウをLuaで原子化する
固定ウィンドウは時間をひと続きの区画に区切って数えるので、区画の境目をまたぐと短時間に上限の倍近いリクエストが通る余地があります。スライディングウィンドウは「いまから遡って60秒」で数えるので、その余地がありません。
ここでは、受理したリクエストの時刻をソート済みセットに記録する方式を使います。仕組みは4つの命令でできています。
ZREMRANGEBYSCOREで、窓より古い記録を消すZCARDで、窓の中にある件数を数える- 上限未満なら
ZADDで今回の時刻を足し、PEXPIREでキー全体の寿命を窓の長さに揃える - 上限に達していたら、いちばん古い記録から「あと何ミリ秒待てば1枠空くか」を返す
-- sliding.lua KEYS[1]=制限キー ARGV[1]=上限 ARGV[2]=窓(ms) ARGV[3]=リクエスト ID
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, ARGV[3])
redis.call('PEXPIRE', KEYS[1], window)
return {1, limit - count - 1, 0}
end
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return {0, 0, oldest[2] + window - now}戻り値は「許可フラグ、残り枠、待つべきミリ秒」の3つ組です。これを同じ条件の負荷テストにかけます。
seq 1 200 | xargs -P 50 -I{} ./lua.sh {} | sort | uniq -clua.sh は redis-cli --eval sliding.lua lua:u1 , 10 60000 "req-$1-$RANDOM" を呼んで先頭の値を見るだけのスクリプトです(上の naive.sh と同じく、許可なら allowed を出力します)。scripts/hit.sh も同様に、ミドルウェアを通る1リクエストを送って結果を出力する小さなスクリプトを想定しています。結果は4回とも 10 allowed / 190 denied でした。
Luaにすると競合が消える理由
EVALのドキュメントによれば、スクリプトはRedisサーバー側で実行され、実行中はほかのサーバー活動が止まります。つまりZCARDで数えてからZADDで足すまでの間に、ほかのリクエストは割り込めません。
書くときに守る規則が2つあります。
- スクリプトが触るキーは、すべて
KEYSとして引数で渡す。キー名をスクリプト内で組み立てたり、データの中身から導いたりしない。クラスター構成でも正しく動かすための条件になる - 変わる値はスクリプトに埋め込まず
ARGVで渡す。値を埋め込むと呼び出しごとに別のスクリプトになり、キャッシュのメモリを消費し続ける
時刻を redis.call('TIME') でRedisに聞いているのも設計上の選択です。アプリケーションサーバーごとの時計のずれが判定に混ざりません。TIMEのような非決定的な命令は、スクリプトの副作用だけを複製する方式がRedis 5.0以降の既定になっているため、そのまま書けます。
本番ではスクリプトキャッシュの消失に備える
毎回EVALでスクリプト全文を送ると無駄が出るので、アプリケーションは SCRIPT LOAD で登録して EVALSHA を呼ぶのが定石です。ただしスクリプトキャッシュは、Redisの再起動やフェイルオーバーで失われることがあります。EVALSHAがエラーを返したら、SCRIPT LOAD をやり直してもう一度呼ぶ処理を入れます。
ZADDのメンバーは必ず一意にする
ZADDは、同じメンバーが既にあると追加でなくスコアの更新になります。リクエストIDの代わりに時刻だけをメンバーにすると、同じミリ秒に届いた2件が1件として数えられ、上限を超えて通します。IDにはリクエストごとに一意な文字列を渡します。
429とRetry-Afterを返す
制限に達したら429を返します。RFC 6585は、429を「一定時間内のリクエストが多すぎる」状態を示すステータスと定義し、応答に詳細を含めるべきこと、Retry-After ヘッダーを付けてもよいことを書いています。誰を数えるか、どう数えるかは仕様の対象外で、実装側が決めます。
Retry-Afterの値はRFC 9110で、HTTP日付か秒数のどちらかです。秒数は0以上の整数でなければならないので、Luaが返したミリ秒は切り上げて秒にします。
Luaの戻り値を使うミドルウェアの例です。
// Express + ioredis の例(Lua は前節の sliding.lua を文字列で読み込む)
const LIMIT = 10;
const WINDOW_MS = 60_000;
export async function rateLimit(req, res, next) {
const key = `rl:${req.user?.id ?? req.ip}`;
const [ok, remaining, retryMs] = await redis.eval(
SLIDING_LUA, 1, key, LIMIT, WINDOW_MS, crypto.randomUUID()
);
res.set("RateLimit-Remaining", String(remaining));
if (ok === 1) return next();
res.set("Retry-After", String(Math.ceil(retryMs / 1000)));
res.status(429).json({ error: "too_many_requests" });
}制限の窓を3秒、上限を3件にして0.4秒おきに4回叩くと、4回目の戻り値は 0 0 1755 でした。最初の受理から3秒後に1枠空くので、4回目の時点であと約1.75秒です。Math.ceil で Retry-After: 2 になります。
Claude APIが返す側のヘッダーを扱う側の読み方はClaude APIレート制限ヘッダー13種の読み方と分岐ロジックにあり、自社APIの返し方を決めるときの比較材料になります。
Claude Codeに書かせるときの指示と確認
ここからがClaude Codeの出番です。公式のベストプラクティスは、曖昧な依頼を避けて「症状・場所・直ったときの状態」を先に伝えることを勧めています。ログインの不具合なら「失敗するテストを書いて再現してから直す」といった形です。レート制限にも同じ型が使えます。
src/middleware/ にスライディングウィンドウ方式のレート制限を作って。
- Redis を使い、判定と加算は Lua スクリプト 1 回に収める(GET→INCR の 2 段階は不可)
- 制限キーは KEYS で渡し、スクリプト内で組み立てない
- ZADD のメンバーはリクエストごとに一意にする
- 超過時は 429 と整数秒の Retry-After を返す
- 先に scripts/ratelimit-check.sh を満たす失敗テストを置き、通るまで直す最後の1行が肝です。ベストプラクティスは「合否が返ってくるチェックをClaudeに持たせると、自分で直して再実行できる」と述べています。負荷テストを合否つきのスクリプトにして渡します。
#!/bin/bash
# scripts/ratelimit-check.sh: 200 件並行で上限 10 を超えたら失敗
redis-cli -p 6391 DEL rl:check >/dev/null
allowed=$(seq 1 200 | xargs -P 50 -I{} ./scripts/hit.sh {} | grep -c allowed)
echo "allowed=$allowed (limit=10)"
[ "$allowed" -le 10 ] || { echo "FAIL: 上限超過" >&2; exit 1; }CLAUDE.mdにはこのコマンドを1行だけ書きます。公式の指針では、CLAUDE.mdに置くのは「このリポジトリで作業の合否を確かめる正確なコマンド」のように、コードから読み取れないものに絞ります。
# Commands
- レート制限の検証: bash scripts/ratelimit-check.sh(要 Redis、REDIS_URL を設定)守らせたいのが「例外のない規則」なら、CLAUDE.mdの文章よりStopフックが確実です。ベストプラクティスは、Stopフックがコマンドの終了コード2でClaudeの終了を止め、標準エラー出力を理由としてClaudeに返す仕組みだと説明しています。
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "bash scripts/ratelimit-check.sh 1>&2 || exit 2" }
]
}
]
}
}公式のサンプルは pnpm tsc --noEmit ですが、判定を負荷テストに差し替えても形は同じです。ただし、連続8回ブロックされても通らないとClaude Codeが次のブロックを無効化して終了させます。上限は環境変数 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で変えられます。
Claudeが書いた差分で見る4点
テストが通っても、差分には目を通します。次の4点はテストで見えにくい穴です。
| 見る場所 | 危険な形 | 望ましい形 |
|---|---|---|
| 判定と加算 | 危険な形GET のあとで別コマンドの INCR | 望ましい形1つの EVAL か EVALSHA |
| キーの渡し方 | 危険な形スクリプト内で文字列連結してキーを作る | 望ましい形KEYS[1] で受け取る |
| ZADDのメンバー | 危険な形時刻のみ | 望ましい形時刻とは別の一意なID |
| 寿命 | 危険な形PEXPIRE の呼び忘れ | 望ましい形受理のたびに窓の長さで更新 |
Redisへの接続まわりをClaude Codeに見てもらう方法はClaude CodeにRedisプラグインを入れて、設計とクエリのレビューを任せるで、キー操作を会話から行う場合はRedis MCPサーバーの使い方が出発点になります。ミドルウェアを載せる側の骨格づくりはClaude CodeでFastAPIバックエンドを構築するの流れに沿わせられます。
方式ごとの違いと選び方
| 方式 | 原子化の手段 | 窓の境目の挙動 | Redisに持つもの |
|---|---|---|---|
| 固定ウィンドウ(INCR + EXPIRE) | 原子化の手段MULTI/EXECかLua | 窓の境目の挙動境目をまたぐと短時間に倍近く通る余地がある | Redisに持つもの1キーに整数1つ |
| スライディングウィンドウ(ZSET) | 原子化の手段Lua | 窓の境目の挙動常に直近の窓で数える | Redisに持つもの受理した件数ぶんのメンバー |
固定ウィンドウで足りるのは、境目の突出を許容できる内部APIです。課金や認証の試行回数のように「直近N秒で上限」が仕様になるものは、ZSETの方式が合います。ZSETはメンバーが上限件数まで増えるため、上限が数万件のような制限には向きません。
Luaスクリプトの実行中はサーバーがほかの処理を受け付けません。上限が小さい制限なら一瞬ですが、数万件をZREMRANGEBYSCOREで一度に消す設計にすると、その間は全クライアントが待つことになります。窓を短く、上限を小さく保つのが、この方式を安全に使う前提です。
まとめ
素朴なGETとINCRの組み合わせは、負荷をかけた瞬間に上限を破ります。判定と加算を1回のLua呼び出しに収めれば、同じ負荷でも件数は上限ちょうどで止まります。
Claude Codeに書かせるときは、実装の指示より先に「並行200件で上限を超えたら失敗する」チェックを渡すのが近道です。通ったかどうかをフックに判定させれば、Claudeが途中で止まっても確認の手間は増えません。残る確認は、差分の4点とスクリプトキャッシュの再登録処理です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。