Tantangan Rate Limiting pada Next.js Serverless
Arsitektur serverless pada Next.js (Vercel, AWS Lambda, atau Docker container multi-instance) menyebabkan status in-memory tidak dapat dibagikan antar-instance. Algoritma fixed-window sederhana memiliki celah traffic burst di batas interval waktu, di mana klien dapat mengirimkan dua kali lipat kuota dalam hitungan detik.
Solusi standar industri untuk masalah ini adalah algoritma Sliding Window berbasis Redis Sorted Set (ZSET). Metode ini mencatat timestamp setiap request secara presisi, menghapus entri usang di luar window aktif, dan menghitung total request yang tersisa secara atomik.
1. Ekstraksi Client Identifier & Mitigasi IP Spoofing
Identifikasi klien yang salah merusak efektivitas rate limit. Mengandalkan header x-forwarded-for secara naif membuka celah IP spoofing, karena penyerang dapat menyuntikkan IP palsu pada request header.
Aturan Penentuan Identifier
- Authenticated Request: Gunakan User ID unik dari decoded JWT atau session database. Jangan gunakan IP untuk user terautentikasi agar pengguna di balik NAT publik (kantor/kampus) tidak saling memblokir.
- Unauthenticated Request: Ekstraksi IP dari header yang divalidasi oleh reverse proxy terpercaya (contoh:
cf-connecting-ippada Cloudflare, atau IP paling kanan sebelum proxy internal padax-forwarded-for).
import { NextRequest } from "next/server";
export function getClientIdentifier(req: NextRequest, userId?: string): string {
if (userId) return `usr:${userId}`;
// Cloudflare trusted header
const cfIp = req.headers.get("cf-connecting-ip");
if (cfIp) return `ip:${cfIp.trim()}`;
// Parse x-forwarded-for: Ambil IP paling kiri HANYA jika reverse proxy dikonfigurasi menimpa header
const xff = req.headers.get("x-forwarded-for");
if (xff) {
const ips = xff.split(",").map((ip) => ip.trim());
return `ip:${ips[0]}`;
}
return "ip:unknown";
}2. Algoritma Sliding Window via Redis Lua Script
Untuk menghindari race condition akibat banyak request paralel yang membaca dan menulis ke Redis secara bersamaan, seluruh operasi sliding window dieksekusi dalam satu transaksi atomik menggunakan Lua script.
Logika Eksekusi Lua
- Hapus record request dengan timestamp lebih kecil dari
(now - windowSize)menggunakanZREMRANGEBYSCORE. - Hitung jumlah elemen yang tersisa di set dengan
ZCARD. - Jika jumlah < limit, tambahkan timestamp saat ini via
ZADDdan set TTL viaEXPIRE. - Kembalikan status kuota, sisa request, dan waktu reset.
// lib/rate-limiter.ts
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
connectTimeout: 2000,
maxRetriesPerRequest: 1,
});
const SLIDING_WINDOW_LUA = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local clearBefore = now - window
redis.call('ZREMRANGEBYSCORE', key, 0, clearBefore)
local currentRequests = redis.call('ZCARD', key)
if currentRequests < limit then
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('EXPIRE', key, math.ceil(window / 1000))
return {1, limit - currentRequests - 1, math.ceil((now + window) / 1000)}
else
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local resetTime = math.ceil((tonumber(oldest[2]) + window) / 1000)
return {0, 0, resetTime}
end
`;
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetTimestamp: number;
}
export async function checkRateLimit(
identifier: string,
limit: number = 10,
windowMs: number = 60000
): Promise<RateLimitResult> {
const now = Date.now();
const key = `rl:${identifier}`;
const result = (await redis.eval(
SLIDING_WINDOW_LUA,
1,
key,
now,
windowMs,
limit
)) as [number, number, number];
return {
allowed: result[0] === 1,
remaining: result[1],
resetTimestamp: result[2],
};
}3. Implementasi pada Route Handler dengan Header Standar
RFC 6585 dan draft standar IETF mewajibkan penyertaan header rate limit pada respon agar klien API dapat melakukan backoff throttling secara presisi.
// app/api/protected/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getClientIdentifier } from "@/lib/client-ip";
import { checkRateLimit } from "@/lib/rate-limiter";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const identifier = getClientIdentifier(req);
const LIMIT = 5;
const WINDOW_MS = 60 * 1000; // 1 menit
try {
const { allowed, remaining, resetTimestamp } = await checkRateLimit(
identifier,
LIMIT,
WINDOW_MS
);
const retryAfter = Math.max(0, resetTimestamp - Math.floor(Date.now() / 1000));
const standardHeaders = {
"X-RateLimit-Limit": LIMIT.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": resetTimestamp.toString(),
};
if (!allowed) {
return NextResponse.json(
{ error: "Too Many Requests" },
{
status: 429,
headers: {
...standardHeaders,
"Retry-After": retryAfter.toString(),
},
}
);
}
// Proses bisnis route handler
return NextResponse.json(
{ message: "Data berhasil diproses" },
{
status: 200,
headers: standardHeaders,
}
);
} catch (error) {
return handleRateLimitFailure(error, req);
}
}4. Strategi Fail-Open vs Fail-Closed
Saat instance Redis mengalami down, packet drop, atau timeout, sistem harus memilih prioritas antara ketersediaan (availability) atau keamanan (security).
| Endpoint Type | Strategi | Alasan Teknis |
|---|---|---|
/api/auth/login, /api/auth/reset-password | Fail-Closed (Reject) | Mencegah serangan brute-force dan credential stuffing saat proteksi mati. Return HTTP 503 Service Unavailable. |
/api/products, /api/articles | Fail-Open (Allow) | Prioritaskan user experience dan ketersediaan aplikasi bisnis daripada proteksi over-fetching. Bypass verifikasi dan catat error log. |
function handleRateLimitFailure(error: unknown, req: NextRequest) {
console.error("Redis Rate Limiting Error:", error);
const isAuthEndpoint = req.nextUrl.pathname.startsWith("/api/auth");
// Fail-closed untuk endpoint kritis
if (isAuthEndpoint) {
return NextResponse.json(
{ error: "Layanan verifikasi sementara tidak tersedia. Coba lagi nanti." },
{ status: 503 }
);
}
// Fail-open untuk endpoint publik: Izinkan request lewat
return NextResponse.json({ message: "Fallback processing" }, { status: 200 });
}5. Analisis Overhead Latensi: Node.js vs Edge Runtime
Node.js Runtime
- Koneksi: Mendukung full TCP persistent connection pool via
ioredis. - Latensi RTT: Rata-rata 1–3 ms jika database Redis berada dalam region jaringan VPC yang sama.
- Kelemahan: Memory overhead lebih besar saat terjadi lonjakan cold-start container baru.
Edge Runtime
- Koneksi: Tidak mendukung connection pool TCP standar jangka panjang di lingkungan serverless pendek.
- Solusi: Memerlukan protokol Redis over HTTP/REST (seperti Upstash REST API) untuk menghindari socket hanging.
- Latensi RTT: Mengalami overhead HTTP/TLS handshaking sebesar 15–40 ms per request jika koneksi pipeline HTTP/1.1 tidak dapat di-reuse secara lokal.
Gunakan Node.js runtime jika Redis berada di private network yang sama dengan deployment Next.js Anda. Beralihlah ke HTTP-based Redis SDK hanya jika rute dijalankan secara global pada Edge Runtime.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!