Eksploitasi Asimetri Biaya Komputasi API

Model rate limiting standar umumnya menggunakan counter berbasis kuantitas absolut, misalnya 100 req/menit. Pendekatan ini rentan ketika diterapkan pada arsitektur modern yang menyajikan variasi endpoint dengan disparitas biaya komputasi ekstrem. Permintaan data statis via cache membutuhkan CPU cycle minimal, sedangkan permintaan kalkulasi agregasi analitik, inferensi model machine learning, atau export PDF memakan resource intensif.

Ketika insentif finansial pengguna bertabrakan dengan arsitektur flat-rate (fenomena capitalism gone wrong pada konsumsi resource publik atau tier gratis), aktor jahat akan mengeksploitasi asimetri tersebut. Dengan mengirimkan 100 request berat secara paralel, penyerang menghabiskan kapasitas worker pool tanpa melanggar batasan frekuensi request nominal. Solusinya adalah beralih ke rate limiting berbasis cost model, di mana kuota dikonsumsi berdasarkan bobot beban komputasi dan biaya aktual backend.

Arsitektur Dynamic Token Bucket

Dalam dynamic token bucket, kapasitas bucket dihitung dalam satuan kredit (token), bukan jumlah request. Parameter utama sistem meliputi:

  • Capacity ($C$): Batas maksimum kredit yang dapat ditampung bucket.
  • Refill Rate ($r$): Jumlah kredit yang ditambahkan kembali per detik.
  • Cost ($w$): Bobot token yang didebit untuk endpoint tertentu berdasarkan kompleksitas parameter, ukuran payload, atau durasi eksekusi historis.

Kondisi eksekusi mensyaratkan token tersedia ($T$) ≥ $w$. Jika valid, sistem mengurangi $T$ sebesar $w$ dan meloloskan request. Jika tidak mencukupi, sistem menolak request dan mengembalikan estimasi waktu tunggu.

Implementasi Atomic Token Bucket dengan Redis Lua Script

Pengambilan token, pengecekan waktu isi ulang, dan pengurangan saldo harus dieksekusi secara atomik untuk mencegah race condition pada sistem konkuren. Menggunakan Redis Lua script memastikan seluruh kalkulasi terjadi dalam satu thread Redis tanpa risiko lost updates.

-- KEYS[1]: Nama key identifier (contoh: rate_limit:{tenant_id})
-- ARGV[1]: Maksimum kapasitas token (Capacity)
-- ARGV[2]: Refill rate per detik
-- ARGV[3]: Bobot biaya request (Cost)
-- ARGV[4]: Timestamp presisi milidetik saat ini (Current Time)

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local now = tonumber(ARGV[4]) / 1000

local data = redis.call("HMGET", key, "tokens", "last_updated")
local tokens = tonumber(data[1])
local last_updated = tonumber(data[2])

if not tokens then
    tokens = capacity
    last_updated = now
else
    local delta = math.max(0, now - last_updated)
    tokens = math.min(capacity, tokens + delta * refill_rate)
    last_updated = now
end

if tokens < cost then
    local missing_tokens = cost - tokens
    local retry_after = math.ceil(missing_tokens / refill_rate)
    
    redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated)
    redis.call("EXPIRE", key, math.ceil(capacity / refill_rate))
    
    return {0, tostring(tokens), tostring(retry_after)}
else
    tokens = tokens - cost
    redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated)
    redis.call("EXPIRE", key, math.ceil(capacity / refill_rate))
    
    return {1, tostring(tokens), "0"}
end

Middleware Penentuan Bobot dan Penanganan HTTP 429

Middleware mengevaluasi metadata sesi dan payload untuk menetapkan nilai cost sebelum memanggil Redis. Jika request ditolak, middleware menyertakan header Retry-After sesuai standar RFC 6585.

// Contoh pseudocode middleware Node.js/Express
const redis = require('./redis-client');
const fs = require('fs');
const luaScript = fs.readFileSync('token_bucket.lua', 'utf8');

function calculateCost(req) {
  // Parsing payload untuk kalkulasi beban
  if (req.path.startsWith('/api/v1/export')) {
    const rowLimit = parseInt(req.query.limit, 10) || 1000;
    return Math.ceil(rowLimit / 500); // 1 token per 500 baris
  }
  if (req.path.startsWith('/api/v1/search')) {
    return req.query.deep_scan === 'true' ? 5 : 1;
  }
  return 1; // Default endpoint cost
}

async function costRateLimiter(req, res, next) {
  const tenantId = req.user ? req.user.id : req.ip;
  const key = `rate_limit:${tenantId}`;
  
  const capacity = 100;
  const refillRate = 10; // 10 token per detik
  const cost = calculateCost(req);
  const now = Date.now();

  try {
    const [allowed, remainingTokens, retryAfter] = await redis.eval(
      luaScript,
      1,
      key,
      capacity,
      refillRate,
      cost,
      now
    );

    res.setHeader('X-RateLimit-Limit', capacity);
    res.setHeader('X-RateLimit-Remaining', Math.floor(remainingTokens));

    if (allowed === 0) {
      res.setHeader('Retry-After', retryAfter);
      return res.status(429).json({
        error: 'Too Many Requests',
        message: 'Kredit komputasi tidak mencukupi untuk request ini.',
        cost_required: cost,
        retry_after_seconds: parseInt(retryAfter, 10)
      });
    }

    next();
  } catch (err) {
    // Log kegagalan Redis; bypass atau fail-closed sesuai kebutuhan SLA
    console.error('Rate limiting error:', err);
    next();
  }
}

Trade-offs dan Kesalahan Implementasi Umum

  • Fail-Open vs Fail-Closed: Saat node Redis tidak responsif, strategi fail-open menjamin ketersediaan API tetapi membuka celah abuse. Strategi fail-closed melindungi resource backend namun berisiko mematikan traffic sah. Untuk layanan berbiaya ekstrem, terapkan fallback in-memory rate limiter lokal per instance server jika Redis gagal.
  • Perhitungan Cost yang Terlalu Statis: Mematok bobot hanya dari nama endpoint sering meleset. Cost harus mencerminkan kompleksitas filter query, volume data input, atau kedalaman relasi pagination.
  • Presisi Timestamp: Gunakan unit waktu milidetik saat menghitung delta pada Redis Lua untuk mencegah clock quantization errors di lingkungan dengan throughput tinggi.