Token bucket algorithm দিয়ে Node.js-এ rate limiter বানান

Rate limiting আপনার API-কে অপব্যবহার থেকে বাঁচায়। ছোট, পরীক্ষিত token bucket limiter বানিয়ে burst ও refill কীভাবে কাজ করে দেখুন।

token-ভরা একটি bucket, যেখানে request token নেয় আর token ফিরে আসে

Rate limiter ঠিক করে একজন client নির্দিষ্ট সময়ে কয়টি request করতে পারবে। এটি login form-কে পাসওয়ার্ড অনুমান থেকে, API-কে অপব্যবহার থেকে এবং আপনার server-কে অনিচ্ছাকৃত চাপ থেকে রক্ষা করে। এই প্রজেক্টে token bucket algorithm দিয়ে সাধারণ Node.js-এ, কোনো package ছাড়া, একটি বানানো হয়েছে। কোড ও নিচের আউটপুট প্রকাশের আগে Node.js 22-এ চালানো হয়েছে।

Token bucket-এর ধারণা

ভাবুন প্রতিটি client-এর একটি bucket আছে, যাতে সর্বোচ্চ ৫টি token ধরে।

  • প্রতিটি request-কে একটি token নিতে হয়। token না থাকলে সেবা নেই।
  • Token স্থির হারে bucket-এ ফিরে আসে, যেমন প্রতি সেকেন্ডে ১টি।
  • Bucket কখনো তার ধারণক্ষমতার বেশি ধরে না।

ফল: client burst হিসেবে ৫টি দ্রুত request করতে পারে, কিন্তু দীর্ঘ সময়ে গড়ে প্রতি সেকেন্ডে ১টি। এটি কঠোর "প্রতি সেকেন্ডে ১টি" নিয়মের চেয়ে বাস্তব ব্যবহারের সঙ্গে ভালো মেলে, কারণ মানুষ কয়েকবার দ্রুত click করে তারপর থামে।

Limiter (limiter.mjs)

// Token bucket: each client has a bucket that holds up to `capacity` tokens
// and refills at `refillPerSecond`. A request costs one token.
export function createLimiter({ capacity = 5, refillPerSecond = 1, now = () => Date.now() } = {}) {
  const buckets = new Map();

  function take(key) {
    const t = now();
    const bucket = buckets.get(key) ?? { tokens: capacity, updated: t };
    const elapsed = (t - bucket.updated) / 1000;
    bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * refillPerSecond);
    bucket.updated = t;

    const allowed = bucket.tokens >= 1;
    if (allowed) bucket.tokens -= 1;
    buckets.set(key, bucket);

    const retryAfter = allowed ? 0 : Math.ceil((1 - bucket.tokens) / refillPerSecond);
    return { allowed, remaining: Math.floor(bucket.tokens), retryAfter };
  }

  // forget idle clients so the Map cannot grow forever
  function cleanup(maxIdleMs = 10 * 60 * 1000) {
    const t = now();
    for (const [key, bucket] of buckets) if (t - bucket.updated > maxIdleMs) buckets.delete(key);
  }

  return { take, cleanup, size: () => buckets.size };
}

মূল বিষয়:

  • কোনো timer নেই। আমরা refill-এর loop চালাই না। request আসার সময় অলসভাবে refill হিসাব করি: কেটে যাওয়া সময় গুণ হার। এটি সস্তা, আর লক্ষ লক্ষ নিষ্ক্রিয় client-এও কাজ করে।
  • now ইনজেক্ট করা যায়। ঘড়ি একটি প্যারামিটার, তাই test অপেক্ষা না করে সময় তাৎক্ষণিক এগিয়ে নিতে পারে।
  • retryAfter আটকে যাওয়া client-কে জানায় কত সেকেন্ড অপেক্ষা করবে, যা আমরা Retry-After header-এ পাঠাই।
  • cleanup() নিষ্ক্রিয় client মুছে দেয়, তাই Map চিরকাল বাড়তে পারে না। এটি না থাকলে অনেক ঠিকানা ব্যবহারকারী আক্রমণকারী ধীরে ধীরে আপনার মেমরি শেষ করতে পারত।

HTTP server-এ ব্যবহার (server.mjs)

import { createServer } from "node:http";
import { createLimiter } from "./limiter.mjs";

const limiter = createLimiter({ capacity: 5, refillPerSecond: 1 });
setInterval(() => limiter.cleanup(), 60_000).unref();

export const server = createServer((req, res) => {
  // Behind a proxy, use the address your proxy sets. Never trust X-Forwarded-For from the open internet.
  const key = req.socket.remoteAddress;
  const result = limiter.take(key);
  res.setHeader("X-RateLimit-Remaining", result.remaining);
  if (!result.allowed) {
    res.writeHead(429, { "Retry-After": result.retryAfter, "Content-Type": "application/json" });
    return res.end(JSON.stringify({ error: "Too many requests", retryAfter: result.retryAfter }));
  }
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ ok: true }));
});

if (process.argv[1] === new URL(import.meta.url).pathname) {
  const port = process.env.PORT ?? 3000;
  server.listen(port, () => console.log(`Listening on http://localhost:${port}`));
}

চালান

$ PORT=3921 node server.mjs
Listening on http://localhost:3921

$ for i in 1 2 3 4 5 6 7; do curl -si localhost:3921 | grep -E "^HTTP|RateLimit|Retry"; done
HTTP/1.1 200 OK        X-RateLimit-Remaining: 4
HTTP/1.1 200 OK        X-RateLimit-Remaining: 3
HTTP/1.1 200 OK        X-RateLimit-Remaining: 2
HTTP/1.1 200 OK        X-RateLimit-Remaining: 1
HTTP/1.1 200 OK        X-RateLimit-Remaining: 0
HTTP/1.1 429 Too Many Requests   X-RateLimit-Remaining: 0   Retry-After: 1
HTTP/1.1 429 Too Many Requests   X-RateLimit-Remaining: 0   Retry-After: 1

(wait 2 seconds)
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3921
200

প্রথম পাঁচটি request পাস করে (burst), পরের দুটি Retry-After header সহ 429 Too Many Requests পায়, আর কয়েক সেকেন্ড অপেক্ষার পর bucket আরেকটি request-এর মতো refill হয়ে যায়।

Test (limiter.test.mjs)

import test from "node:test";
import assert from "node:assert/strict";
import { createLimiter } from "./limiter.mjs";

function setup(options) {
  let time = 0;
  const limiter = createLimiter({ ...options, now: () => time });
  return { limiter, advance: (ms) => (time += ms) };
}

test("allows a burst up to capacity, then blocks", () => {
  const { limiter } = setup({ capacity: 3, refillPerSecond: 1 });
  assert.deepEqual([1, 2, 3].map(() => limiter.take("a").allowed), [true, true, true]);
  const blocked = limiter.take("a");
  assert.equal(blocked.allowed, false);
  assert.equal(blocked.retryAfter, 1);
});

test("refills over time", () => {
  const { limiter, advance } = setup({ capacity: 2, refillPerSecond: 1 });
  limiter.take("a"); limiter.take("a");
  assert.equal(limiter.take("a").allowed, false);
  advance(1000);
  assert.equal(limiter.take("a").allowed, true);
});

test("never refills beyond capacity", () => {
  const { limiter, advance } = setup({ capacity: 2, refillPerSecond: 1 });
  advance(60_000);
  assert.equal(limiter.take("a").remaining, 1);
});

test("clients are independent", () => {
  const { limiter } = setup({ capacity: 1, refillPerSecond: 1 });
  assert.equal(limiter.take("a").allowed, true);
  assert.equal(limiter.take("a").allowed, false);
  assert.equal(limiter.take("b").allowed, true);
});

test("cleanup removes idle clients", () => {
  const { limiter, advance } = setup({});
  limiter.take("a");
  advance(11 * 60 * 1000);
  limiter.cleanup();
  assert.equal(limiter.size(), 0);
});
$ node --test
# tests 5
# pass 5
# fail 0

সময় ইনজেক্ট করা হয় বলে "৬০ সেকেন্ড এগোও" আসলে কোনো সময় নেয় না, এবং আমরা প্রমাণ করতে পারি bucket ধারণক্ষমতার বেশি কখনো refill হয় না।

HTTP-র খুঁটিনাটি সঠিকভাবে

  • Status 429 Too Many Requests আদর্শ সাড়া।
  • Retry-After ভদ্র client-কে আপনাকে আঘাত না করে পিছিয়ে যেতে সাহায্য করে।
  • X-RateLimit-Remaining-এর মতো অবশিষ্ট-সংখ্যার header একটি প্রচলিত রীতি, সরকারি standard নয়, তাই আপনার API ব্যবহারকারীরা কী আশা করে দেখে নিন।
  • Login endpoint-এ প্রয়োজনের বেশি প্রকাশ করবেন না, এবং থাকা ও না-থাকা অ্যাকাউন্টের জন্য একই error বার্তা রাখুন।

Limit-এর key বাছাই

উদাহরণে client-এর IP ঠিকানা ব্যবহার হয়েছে। এতে ফাঁদ আছে:

  • Proxy বা CDN-এর পেছনে remoteAddress হলো proxy, তাই সবাই একটি bucket ভাগ করে। আপনার বিশ্বস্ত proxy-র দেওয়া client IP ব্যবহার করুন, এবং ইন্টারনেট থেকে সরাসরি আসা X-Forwarded-For কখনো বিশ্বাস করবেন না, কারণ client তা জাল করতে পারে।
  • অনেক ব্যবহারকারী একটি IP ভাগ করতে পারে (অফিস, মোবাইল নেটওয়ার্ক)। শুধু IP দিয়ে সীমা দিলে নির্দোষ মানুষ আটকাতে পারে। লগইন করা ব্যবহারকারীর জন্য বরং user id বা API key দিয়ে সীমা দিন।
  • Login সুরক্ষায় প্রতি-IP ও প্রতি-অ্যাকাউন্ট সীমা মেলান, কারণ আক্রমণকারী অনেক IP-তে অনুমান ছড়াতে পারে।

এই সংস্করণের সীমা (গুরুত্বপূর্ণ)

  • এটি একটি process-এর মেমরিতে থাকে। Load balancer-এর পেছনে দুটি server চালালে প্রতিটির নিজস্ব bucket, তাই আসল সীমা দ্বিগুণ হয়। Restart-এ রিসেটও হয়। একাধিক server-এর জন্য Redis-এর মতো ভাগ করা store-এ counter রাখুন, atomic operation দিয়ে (Lua script বা মেয়াদসহ INCR), যাতে দুটি request একই শেষ token নিতে না পারে।
  • এটি মৌলিক প্রতিরক্ষা, সম্পূর্ণ সুরক্ষা নয়। বড় আক্রমণে আপনার CDN বা web application firewall-এর সাহায্য লাগে, আর brute-force প্রতিরক্ষায় শক্ত পাসওয়ার্ড, lockout বা বিলম্ব এবং monitoring-ও লাগে।
  • আসল traffic থেকে সংখ্যা বাছুন। বেশি কঠোর হলে আসল ব্যবহারকারী আটকায়; বেশি ঢিলে হলে কিছুই হয় না। মেপে শুরু করুন।

অন্যান্য algorithm (তুলনার জন্য)

Algorithm আচরণ মন্তব্য
Fixed window ঘড়ির প্রতি মিনিটে N request সরল, কিন্তু সীমানা পেরিয়ে burst-এ 2N অনুমতি দেয়
Sliding window যেকোনো চলমান মিনিটে N request মসৃণ, বেশি মেমরি লাগে
Token bucket burst সহ স্থির হার আমাদের পছন্দ: সরল ও burst-বান্ধব
Leaky bucket আউটপুট ধ্রুব হারে মসৃণ করে স্থির সক্ষমতার backend রক্ষায় ভালো

চ্যালেঞ্জ

১. route অনুযায়ী আলাদা সীমা (/login-এ কঠোরতর)। ২. Authorization header থাকলে API key দিয়ে সীমা দিন। ৩. আপনার API-র নথিভুক্ত ফরম্যাটে RateLimit header ফেরত দিন। ৪. bucket Redis-এ সরান যাতে একাধিক server ভাগ করে। ৫. "শাস্তি" যোগ করুন: ১০টি আটকানো request-এর পর ১৫ মিনিট বন্ধ।

মূল কথা: Token bucket স্বল্প burst ও স্থির গড় অনুমতি দেয়, timer লাগে না, আর সময় ইনজেক্ট করা গেলে test করা সহজ। key সাবধানে বাছুন, 429 ও Retry-After দিয়ে সাড়া দিন, এবং মনে রাখুন একটি process-এর limiter শুধু প্রথম স্তর।


হাতে-কলমে প্রজেক্ট সিরিজ, পর্ব ২ (পুনর্ব্যবহারযোগ্য টুল): 3 নম্বর (মোট 5)। আগের লেখা: Python দিয়ে এলোমেলো CSV পরিষ্কার: নাম, তারিখ, ফোন ও ডুপ্লিকেট.

আরও পড়ুন

Python দিয়ে এলোমেলো CSV পরিষ্কার: নাম, তারিখ, ফোন ও ডুপ্লিকেট

আসল data এলোমেলো। standard library দিয়ে contacts পরিষ্কার করা, প্রতিটি বাতিল row ব্যাখ্যা করা ও test-সহ একটি script বানান।

প্রজেক্ট•৫ মিনিটে পড়া