@yaebal/throttle

priority outbound scheduler with Telegram-shaped buckets, shared storage, cancellation and metrics.

install

terminal
pnpm add @yaebal/throttle

usage

install it on the API layer. every outgoing request acquires all relevant buckets before it reaches Telegram.

bot.ts
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:

plugin.ts
const transport = throttle({ globalPerSec: 30 });

const bot = new Bot(token)
  .install(transport)
  .on("message:text", (ctx) => ctx.reply("hello!"));

transport.handle.metrics.acquired;

buckets

bucketdefaultwhat it protects
global30/sbot-wide Telegram soft cap
private:<chat_id>1/sone private chat
group:<chat_id>20/minone group or supergroup
method:<method>:...customisolated 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.

priority.ts
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;
cancel.ts
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.

redis-storage.ts
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

metricmeaning
pendingcurrently queued calls in this process
acquiredcalls that received a slot
delayedcalls that waited at least once
rejectedcalls rejected by overflow mode or storage errors
cancelledcalls cancelled by signal or handle.cancel()
retryAfterLearnedstructured Telegram flood-waits learned from errors
totalWaitMsaggregate local queue wait time

api

exportsignaturedescription
throttle(options?) => ThrottlePlugin
(api, options?) => ThrottleHandle
install the scheduler on a bot or API
createThrottleHandle(options?) => ThrottleHandlecreate a standalone scheduler handle
memoryThrottleStorage() => ThrottleStorageprocess-local sliding-window storage
withThrottle(params, control) => paramsattach 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.

legacy.ts
import { reserve } from "@yaebal/throttle";

reserve(1000, 0, 34);
// => { at: 1000, next: 1034 }
pair with again. when Telegram returns 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.