🥤

Next.jsでシンプルかつ柔軟なレート制限を実装する

に公開

はじめに

geminiのエンドポイントに向けてリクエストを行う簡易AppをNext.jsで作っているのですが
API叩かれ過ぎるとなぁということでレート制限を実装してみました。

Next.jsにおけるAPI Routes内に差し込む形で使える感じです。

実際のコード

import { LRUCache } from 'lru-cache';
import { NextRequest } from 'next/server';

import { RATE_LIMIT } from './constant';
import { ApiError } from './error';

const defaultCache = new LRUCache({
  max: RATE_LIMIT.CACHE_MAX_SIZE,
  ttl: RATE_LIMIT.CACHE_TTL_MS,
});

export interface RateLimitOptions {
  maxRequests?: number;
  windowMs?: number;
  cache?: LRUCache<string, number>;
  identifierFn?: (req: NextRequest) => string;
}

export const getIdentifier = (req: NextRequest): string => {
  return req.headers.get('x-forwarded-for') || '127.0.0.1';
};

export const rateLimiter = async (
  req: NextRequest,
  options?: RateLimitOptions
) => {
  const cache = options?.cache ?? defaultCache;
  const maxRequests = options?.maxRequests ?? RATE_LIMIT.REQUESTS_PER_MINUTE;
  const windowMs = options?.windowMs ?? RATE_LIMIT.CACHE_TTL_MS;
  const getIdFn = options?.identifierFn ?? getIdentifier;

  const identifier = getIdFn(req);
  const currentCount = (cache.get(identifier) as number) || 0;

  if (currentCount >= maxRequests) {
    throw new ApiError(429, 'Rate limit exceeded. Please try again later.', {
      limitResetTime: new Date(Date.now() + windowMs).toISOString(),
      remaining: 0,
      limit: maxRequests,
    });
  }

  cache.set(identifier, currentCount + 1);
};

各部分の解説

キャッシュの設定

const defaultCache = new LRUCache({
  max: RATE_LIMIT.CACHE_MAX_SIZE,
  ttl: RATE_LIMIT.CACHE_TTL_MS,
});

lru-cacheパッケージを使って、IPアドレスごとのリクエスト数を追跡します。LRU(Least Recently Used)アルゴリズムにより、キャッシュが一杯になると最も長く使われていないエントリが自動的に削除されます。

設定オプション

export interface RateLimitOptions {
  maxRequests?: number;
  windowMs?: number;
  cache?: LRUCache<string, number>;
  identifierFn?: (req: NextRequest) => string;
}

オプションで設定をカスタマイズできるインターフェースを定義しています:

  • maxRequests: 制限時間内の最大リクエスト数
  • windowMs: 制限をリセットする時間枠(ミリ秒)
  • cache: カスタムキャッシュインスタンス
  • identifierFn: クライアントを識別するカスタム関数

クライアント識別部分

export const getIdentifier = (req: NextRequest): string => {
  return req.headers.get('x-forwarded-for') || '127.0.0.1';
};

デフォルトではIPアドレスでクライアントを識別しますが、identifierFnオプションで独自の識別方法に変更可能です。

メイン関数の実装について

フローとしては以下の流れになります。

オプションの解析とデフォルト値の適用

const cache = options?.cache ?? defaultCache;
const maxRequests = options?.maxRequests ?? RATE_LIMIT.REQUESTS_PER_MINUTE;
const windowMs = options?.windowMs ?? RATE_LIMIT.CACHE_TTL_MS;

クライアント識別子の取得

const getIdFn = options?.identifierFn ?? getIdentifier;
const identifier = getIdFn(req);

現在のリクエスト数のチェック

const currentCount = (cache.get(identifier) as number) || 0;

制限超過時のエラー処理

if (currentCount >= maxRequests) {
 throw new ApiError(429, 'Rate limit exceeded. Please try again later.', {
  limitResetTime: new Date(Date.now() + windowMs).toISOString(),
  remaining: 0,
  limit: maxRequests,
 });
}

リクエスト数のインクリメント

cache.set(identifier, currentCount + 1);

使用方法について

基本的な使い方

API Routesの中での呼び出し

// Route Handler (App Router)
import { NextRequest, NextResponse } from 'next/server';
import { rateLimiter } from '@/lib/rateLimiter';

export async function GET(req: NextRequest) {
  try {
    await rateLimiter(req);
    // 通常の処理...
    return NextResponse.json({ message: 'Success' });
  } catch (error) {
    if (error.status === 429) {
      return NextResponse.json(
        { error: error.message },
        { status: 429, headers: { 'Retry-After': '60' } }
      );
    }
    // その他のエラー処理...
  }
}

カスタム設定について

// 特定のエンドポイントで異なる制限を設定
await rateLimiter(req, { 
  maxRequests: 5,
  windowMs: 10000 // 10秒
});

// ユーザーIDベースの制限
await rateLimiter(req, {
  identifierFn: (req) => {
    const session = getSession(req); // セッション取得関数
    return session?.userId || req.headers.get('x-forwarded-for') || '127.0.0.1';
  }
});

メリット

外部サービス不要のメモリ内実装であり簡易的にやる分にはよい(デメリットとの表裏一旦)
また、依存はlru-cacheのみなので実装コストは高くない。

デメリット

分散環境では効果が薄く大量のクライアント識別子があるとメモリ使用量が増加してしまい、更にインスタンスでの管理の為、サーバー再起動時にすべてのカウンターがリセットしてしまう。

参考

https://zenn.dev/yui/articles/4ab2249cb39c4e

https://zenn.dev/catnose99/articles/9183c86d3558e5

参考にさせて頂きました。学び多かったです!
ありがとうございました🙇

まとめ

生成AIのエンドポイントいっぱい叩かれると困るなぁと思っていたので
簡易的ではありますがNext.jsだけで完結するものを処理として組み込めて満足です。

Discussion