@yaebal/ratelimiter

drop incoming updates from users who exceed a configurable rate — a sliding fixed-window counter, keyed per user by default.

install

terminal
pnpm add @yaebal/ratelimiter

usage

call bot.install(ratelimiter()) once, before any handlers you want protected. updates that exceed the limit are silently dropped (or handled by onLimit); updates within the limit pass through to the next middleware.

bot.ts
import { Bot } from "@yaebal/core";
import { ratelimiter } from "@yaebal/ratelimiter";

const bot = new Bot(process.env.BOT_TOKEN!);

// drop updates from users sending more than 5 per second
bot.install(ratelimiter());

bot.on("message:text", (ctx) => ctx.reply("hello!"));
bot.start();

options

bot.ts
bot.install(ratelimiter({
  limit: 2,           // max 2 updates per window
  windowMs: 10_000,   // per 10-second window
  onLimit: async (ctx) => {
    await ctx.reply("slow down!");
  },
}));

custom key

the default key is ctx.from?.id (per-user). supply getKey to change the unit of limiting. returning undefined from getKey bypasses the limiter for that update entirely.

bot.ts
// rate-limit per chat instead of per user
bot.install(ratelimiter({
  getKey: (ctx) => ctx.chat?.id?.toString(),
}));

api

exportsignaturedescription
ratelimiter(options?: RateLimiterOptions) => Plugin<Context, Record<never, never>>returns a plugin to pass to bot.install()
decide(rec, now, limit, windowMs) => \{ allowed: boolean; window: Window \}pure window-decision function — exported for testing
RateLimiterOptionsinterfaceoptions bag passed to ratelimiter()

RateLimiterOptions

fieldtypedefaultdescription
limitnumber5max updates allowed per window
windowMsnumber1000window length in milliseconds
onLimit(ctx: Context) => unknownundefinedcalled when an update is dropped; use to send a warning reply
getKey(ctx: Context) => string | undefinedper-user (ctx.from?.id)return undefined to bypass the limiter for that update

decide()

the core logic is a pure function with no I/O — exported so you can unit-test custom policies without a live bot. it takes the existing window record (or undefined for the first call), the current timestamp, the limit, and the window length.

decide.ts
import { decide } from "@yaebal/ratelimiter";

// first two calls allowed, third is blocked
const a = decide(undefined, 1000, 2, 1000);
// => { allowed: true,  window: { count: 1, resetAt: 2000 } }

const b = decide(a.window, 1000, 2, 1000);
// => { allowed: true,  window: { count: 2, resetAt: 2000 } }

const c = decide(b.window, 1000, 2, 1000);
// => { allowed: false, window: { count: 3, resetAt: 2000 } }

// after the window elapses the counter resets
const over = { count: 9, resetAt: 1500 };
decide(over, 2000, 2, 1000);
// => { allowed: true, window: { count: 1, resetAt: 3000 } }
bot.install(), not bot.use(). the ratelimiter returns a plugin and must be registered with bot.install(ratelimiter()).

install before your handlers. the limiter is middleware — updates are dropped before they reach any handlers registered after it. installing it last protects nothing.

the window map is never evicted. one entry per distinct key is held in memory for the lifetime of the process. for bots with a bounded and known user set this is fine; for bots with unbounded growth you may want to add a periodic sweep.

updates without a key are always allowed. if getKey returns undefined (e.g. updates with no from field, or channel posts with a custom key function), the limiter skips them — they are not counted or blocked.