@yaebal/throttle
priority outbound scheduler with Telegram-shaped buckets, shared storage, cancellation and metrics.
install
pnpm add @yaebal/throttleusage
install it on the API layer. every outgoing request acquires all relevant buckets before it reaches Telegram.
import { Bot } from "@yaebal/core";
import { autoRetry } from "@yaebal/again";
import { throttle } from "@yaebal/throttle";
const limiter = throttle(bot.api, {
globalPerSec: 30,
perChatPerSec: 1,
perGroupPerMin: 20,
perMethod: {
sendVideo: { privateChat: { limit: 1, windowMs: 5_000 }, priority: -5 },
answerCallbackQuery: { privateChat: false, group: false, priority: 20 },
},
onEvent: (event) => console.log(event.type),
});
autoRetry(bot.api, { retryAfterPaddingMs: 250 });
console.log(limiter.metrics.pending);installable plugin form is available when you want a handle before the bot is started:
const transport = throttle({ globalPerSec: 30 });
const bot = new Bot(token)
.install(transport)
.on("message:text", (ctx) => ctx.reply("hello!"));
transport.handle.metrics.acquired;buckets
| bucket | default | what it protects |
|---|---|---|
global | 30/s | bot-wide Telegram soft cap |
private:<chat_id> | 1/s | one private chat |
group:<chat_id> | 20/min | one group or supergroup |
method:<method>:... | custom | isolated per-method buckets from limit overrides |
getMe, getUpdates, getWebhookInfo, logOut and close bypass throttling by default. override with excludedMethods or excludeMethods.
priority, abort, cancel
request-level control is attached with a symbol, so it is not serialized into Telegram params.
import { withThrottle } from "@yaebal/throttle";
await bot.api.sendMessage(
withThrottle({ chat_id, text: "urgent" }, { priority: 100 }),
);
const controller = new AbortController();
const queued = bot.api.sendMessage(
withThrottle({ chat_id, text: "cancel me" }, { signal: controller.signal }),
);
controller.abort();
await queued;limiter.cancel({ method: "sendMessage" });
limiter.cancel({ bucket: `private:${chatId}` });shared storage
the default storage is in-memory and process-local. pass a storage adapter to coordinate workers,
containers or regions. take() must atomically check all buckets and record the hit only
when every bucket fits.
import type { ThrottleStorage } from "@yaebal/throttle";
const redisStorage: ThrottleStorage = {
async take(buckets, now) {
// atomically check every bucket in Redis
// record hits only when every bucket has room
return { ok: true, waitMs: 0 };
},
async freeze(bucketKey, until) {
// store a retry_after freeze shared by every worker
},
};
throttle(bot.api, { storage: redisStorage });metrics and events
| metric | meaning |
|---|---|
pending | currently queued calls in this process |
acquired | calls that received a slot |
delayed | calls that waited at least once |
rejected | calls rejected by overflow mode or storage errors |
cancelled | calls cancelled by signal or handle.cancel() |
retryAfterLearned | structured Telegram flood-waits learned from errors |
totalWaitMs | aggregate local queue wait time |
api
| export | signature | description |
|---|---|---|
throttle | (options?) => ThrottlePlugin(api, options?) => ThrottleHandle | install the scheduler on a bot or API |
createThrottleHandle | (options?) => ThrottleHandle | create a standalone scheduler handle |
memoryThrottleStorage | () => ThrottleStorage | process-local sliding-window storage |
withThrottle | (params, control) => params | attach priority, skip or abort signal to one request |
reserve | (now, next, interval) => { at, next } | legacy pure helper kept for tests and compatibility |
compatibility mode
minIntervalMs still works for old code. it becomes a single global bucket with limit: 1 and the provided window.
import { reserve } from "@yaebal/throttle";
reserve(1000, 0, 34);
// => { at: 1000, next: 1034 }response_parameters.retry_after, @yaebal/core exposes it on TelegramError.parameters.retry_after. throttle freezes
the affected buckets, while @yaebal/again performs the awaited retry.