karawaci.kode

2026-08-26 · 10 min

Rate Limiting Token Bucket vs Sliding Window: Implementasi di Node dan Cloudflare Worker

Rate limiting adalah salah satu fitur yang setiap tim akhirnya butuh, tapi sering diimplementasi terlalu simpel sampai tidak benar-benar melindungi apapun. Di klien Jakarta yang saya tangani — sebuah platform B2B SaaS dengan endpoint public untuk webhook dan API mitra — kami mulai dengan fixed-window counter di Redis, dan satu hari klien mitra menemukan cara mengirim 2x limit dalam jendela 2 detik. Bukan bug, tapi karakteristik algoritma yang memang tidak tepat untuk kasus itu.

Artikel ini membahas dua algoritma yang paling relevan di production — token bucket dan sliding window — plus implementasi konkret di Node.js dengan Redis dan di Cloudflare Worker.

Kenapa Algoritma Rate Limiting Penting

Semua algoritma rate limiting melakukan hal yang sama secara konseptual: membatasi berapa banyak request dalam satuan waktu. Perbedaannya ada di bagaimana mereka mendefinisikan “dalam satuan waktu” dan apa yang mereka simpan sebagai state.

Empat algoritma yang umum:

AlgoritmaState yang DisimpanBurst HandlingKompleksitas
Fixed window1 counter per windowBisa double-burst di tepiO(1)
Sliding window logSorted set timestampSangat presisiO(n) per request
Sliding window counter2 counter + timestampCukup presisiO(1)
Token bucket1 counter + timestampToleran burstO(1)

Fixed window tidak akan saya bahas lebih lanjut — sudah saya jelaskan di FAQ kenapa ia rawan dieksploitasi. Fokus ke dua yang paling banyak berguna di production.

Token Bucket: Untuk API yang Perlu Toleran Burst

Token bucket bekerja seperti ember yang diisi air secara berkala. Setiap request mengambil satu token. Kalau token habis, request ditolak. Kalau user lama tidak request, token terakumulasi hingga kapasitas maksimum.

State yang disimpan per key: jumlah token saat ini + timestamp terakhir diisi.

Ini artinya user yang sudah lama tidak request bisa mengirim burst singkat tanpa kena block — perilaku yang justru diinginkan untuk API mitra yang pola penggunaannya tidak merata.

Implementasi di Node.js dengan Redis (Upstash-compatible, menggunakan ioredis):

// lib/rate-limit-token-bucket.ts
import { Redis } from 'ioredis';

interface TokenBucketOptions {
  capacity: number;      // maksimum token
  refillRate: number;    // token per detik
  keyPrefix?: string;
}

interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAfterMs: number;
}

// Lua script untuk atomicity — penting, jangan pakai GET lalu SET terpisah
const TOKEN_BUCKET_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_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]) or capacity
local last_refill = tonumber(data[2]) or now

-- Hitung token yang ditambahkan sejak terakhir refill
local elapsed = (now - last_refill) / 1000  -- konversi ms ke detik
local new_tokens = math.min(capacity, tokens + (elapsed * refill_rate))

if new_tokens < cost then
  -- Tidak cukup token
  local wait_ms = math.ceil((cost - new_tokens) / refill_rate * 1000)
  redis.call('HSET', key, 'tokens', new_tokens, 'last_refill', now)
  redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 60)
  return {0, math.floor(new_tokens), wait_ms}
end

new_tokens = new_tokens - cost
redis.call('HSET', key, 'tokens', new_tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 60)
return {1, math.floor(new_tokens), 0}
`;

export class TokenBucketLimiter {
  constructor(
    private redis: Redis,
    private options: TokenBucketOptions,
  ) {}

  async check(identifier: string, cost = 1): Promise<RateLimitResult> {
    const key = `${this.options.keyPrefix ?? 'rl:tb'}:${identifier}`;
    const now = Date.now();

    try {
      const result = await this.redis.eval(
        TOKEN_BUCKET_SCRIPT,
        1,
        key,
        this.options.capacity,
        this.options.refillRate,
        now,
        cost,
      ) as [number, number, number];

      return {
        allowed: result[0] === 1,
        remaining: result[1],
        resetAfterMs: result[2],
      };
    } catch {
      // Fail open: Redis mati → loloskan request, tapi log alert
      console.error('[rate-limit] Redis unavailable, failing open');
      return { allowed: true, remaining: -1, resetAfterMs: 0 };
    }
  }
}

Penggunaan di Express middleware:

// middleware/rate-limit.ts
import { Request, Response, NextFunction } from 'express';
import { TokenBucketLimiter } from '../lib/rate-limit-token-bucket';
import { redis } from '../lib/redis';

const apiLimiter = new TokenBucketLimiter(redis, {
  capacity: 100,       // burst maksimum 100 request
  refillRate: 10,      // 10 request/detik = 600/menit sustained
  keyPrefix: 'rl:api',
});

export function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) {
  // Key per API key, fallback ke IP
  const identifier = (req.headers['x-api-key'] as string) ?? req.ip ?? 'anonymous';

  apiLimiter.check(identifier).then((result) => {
    res.setHeader('X-RateLimit-Remaining', result.remaining);

    if (!result.allowed) {
      res.setHeader('Retry-After', Math.ceil(result.resetAfterMs / 1000));
      return res.status(429).json({
        error: 'rate_limit_exceeded',
        retryAfterMs: result.resetAfterMs,
      });
    }

    next();
  });
}

Detail kritis yang sering diabaikan: gunakan Lua script untuk atomicity. Kalau Anda melakukan GET lalu SET terpisah di Node.js, ada race condition — dua request yang datang bersamaan bisa keduanya membaca token yang sama dan keduanya lolos. Lua script di Redis berjalan atomik.

Sliding Window: Untuk Endpoint yang Perlu Presisi Tinggi

Token bucket toleran terhadap burst — berguna untuk API mitra. Tapi untuk endpoint yang sensitif seperti login, OTP, atau reset password, toleransi burst justru berbahaya. Di sini sliding window counter lebih tepat.

Sliding window counter mengombinasikan dua pendekatan: ia menyimpan counter untuk window saat ini dan window sebelumnya, lalu menghitung estimasi berapa request yang masuk dalam “window 60 detik terakhir yang sebenarnya” menggunakan interpolasi linear.

// lib/rate-limit-sliding-window.ts
import { Redis } from 'ioredis';

interface SlidingWindowOptions {
  windowMs: number;     // durasi window dalam ms
  maxRequests: number;  // maksimum request per window
  keyPrefix?: string;
}

const SLIDING_WINDOW_SCRIPT = `
local key_curr = KEYS[1]
local key_prev = KEYS[2]
local now = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local max_requests = tonumber(ARGV[3])

local curr_count = tonumber(redis.call('GET', key_curr) or '0')
local prev_count = tonumber(redis.call('GET', key_prev) or '0')

-- Hitung berapa jauh kita ke dalam window saat ini (0.0 - 1.0)
local window_start = math.floor(now / window_ms) * window_ms
local elapsed_in_window = now - window_start
local weight = 1 - (elapsed_in_window / window_ms)

-- Estimasi request yang masih relevan dari window sebelumnya
local estimated = curr_count + (prev_count * weight)

if estimated >= max_requests then
  local reset_ms = window_ms - elapsed_in_window
  return {0, math.max(0, math.floor(max_requests - estimated)), reset_ms}
end

-- Tambah counter window saat ini
redis.call('INCR', key_curr)
redis.call('PEXPIRE', key_curr, window_ms * 2)

local remaining = math.floor(max_requests - estimated - 1)
return {1, remaining, 0}
`;

export class SlidingWindowLimiter {
  constructor(
    private redis: Redis,
    private options: SlidingWindowOptions,
  ) {}

  async check(identifier: string): Promise<{ allowed: boolean; remaining: number; resetMs: number }> {
    const now = Date.now();
    const windowIndex = Math.floor(now / this.options.windowMs);
    const prefix = this.options.keyPrefix ?? 'rl:sw';

    const keyCurr = `${prefix}:${identifier}:${windowIndex}`;
    const keyPrev = `${prefix}:${identifier}:${windowIndex - 1}`;

    try {
      const result = await this.redis.eval(
        SLIDING_WINDOW_SCRIPT,
        2,
        keyCurr,
        keyPrev,
        now,
        this.options.windowMs,
        this.options.maxRequests,
      ) as [number, number, number];

      return {
        allowed: result[0] === 1,
        remaining: result[1],
        resetMs: result[2],
      };
    } catch {
      return { allowed: true, remaining: -1, resetMs: 0 };
    }
  }
}

// Contoh: limit login 5x per 15 menit
export const loginLimiter = new SlidingWindowLimiter(redis, {
  windowMs: 15 * 60 * 1000,
  maxRequests: 5,
  keyPrefix: 'rl:login',
});

Implementasi di Cloudflare Worker

Cloudflare Worker tidak punya persistent memory antar invocasi. Untuk rate limiting yang konsisten, ada dua jalur:

Jalur 1 — Cloudflare Rate Limiting bawaan (cukup untuk banyak kasus):

# wrangler.toml
[[rules]]
description = "Rate limit API endpoint"
expression = '(http.request.uri.path matches "^/api/")'
action = "block"
characteristics = ["cf.colo.id", "ip.src"]
period = 60
requests_per_period = 100
mitigation_timeout = 60

Ini tanpa kode tambahan. Cocok untuk rate limiting berbasis IP + path.

Jalur 2 — Upstash Redis dari Worker (untuk limit per user/API key):

// worker/src/rate-limit.ts
import { Redis } from '@upstash/redis/cloudflare';

const redis = new Redis({
  url: env.UPSTASH_REDIS_URL,
  token: env.UPSTASH_REDIS_TOKEN,
});

// Sliding window di Worker — algoritmanya sama, beda environment
export async function checkRateLimit(
  identifier: string,
  windowMs: number,
  maxRequests: number,
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Date.now();
  const windowIndex = Math.floor(now / windowMs);
  const keyCurr = `rl:worker:${identifier}:${windowIndex}`;
  const keyPrev = `rl:worker:${identifier}:${windowIndex - 1}`;

  const [curr, prev] = await redis.mget<number[]>(keyCurr, keyPrev);
  const currCount = curr ?? 0;
  const prevCount = prev ?? 0;

  const elapsed = now - Math.floor(now / windowMs) * windowMs;
  const weight = 1 - elapsed / windowMs;
  const estimated = currCount + prevCount * weight;

  if (estimated >= maxRequests) {
    return { allowed: false, remaining: 0 };
  }

  await redis.incr(keyCurr);
  // TTL 2x window untuk memberikan ruang key prev
  await redis.expire(keyCurr, Math.ceil((windowMs * 2) / 1000));

  return { allowed: true, remaining: Math.floor(maxRequests - estimated - 1) };
}

// Handler utama Worker
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const apiKey = request.headers.get('x-api-key') ?? 'anon';
    const { allowed, remaining } = await checkRateLimit(apiKey, 60_000, 100);

    if (!allowed) {
      return new Response(JSON.stringify({ error: 'rate_limit_exceeded' }), {
        status: 429,
        headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '0' },
      });
    }

    // lanjut ke handler asli
    return handleRequest(request, env, remaining);
  },
};

Catatan: @upstash/redis/cloudflare penting — ini bukan package Redis biasa. Upstash menyediakan varian yang kompatibel dengan runtime Worker (fetch-based, bukan TCP).

Trade-off Jujur

Token bucket toleran burst tapi bisa dieksploitasi kalau bucket capacity-nya besar dan user sengaja menabung token. Untuk endpoint sensitif, capacity kecil atau pakai sliding window.

Sliding window counter adalah aproksimasi: interpolasi linear prev_count * weight bukan hitungan persis. Untuk kasus normal ini cukup akurat. Kalau butuh presisi mutlak (audit log, billing per-request), pakai sliding window log dengan sorted set — tapi siap dengan O(n) dan ZREMRANGEBYSCORE cleanup setiap request.

State di Redis berarti kalau Redis mati, Anda harus pilih antara fail open atau fail closed. Di production kami, endpoint non-sensitif fail open, endpoint sensitif (login, OTP) fail closed dengan respons 503 yang jelas.

Satu hal yang sering dilewatkan: set header Retry-After dan X-RateLimit-Remaining yang benar. Client yang well-behaved akan membaca ini dan backoff secara otomatis — mengurangi noise di log dan tekanan ke server.

Verdict

Untuk API endpoint umum (CRUD, data retrieval): token bucket dengan capacity 10–20x sustained rate. Ini memberikan burst tolerance yang wajar tanpa membuka celah eksploitasi.

Untuk endpoint sensitif (auth, OTP, operasi finansial): sliding window counter dengan window 15–60 menit dan limit ketat. Presisi lebih penting dari burst tolerance di sini.

Untuk Cloudflare Worker: mulai dari Cloudflare Rate Limiting bawaan untuk filter kasar berbasis IP. Tambah layer Upstash Redis kalau butuh limit per user atau per API key dengan logika yang lebih halus.

Yang tidak perlu diperdebatkan: selalu gunakan Lua script di Redis untuk atomicity, selalu kembalikan header yang informatif, dan selalu putuskan lebih awal apa yang terjadi saat Redis tidak tersedia. Keputusan terakhir itu yang paling sering bikin tim panik saat Redis mati jam 2 pagi.

Ditulis oleh Reza Pradipta