UNPKG

umbot

Version:

Мультиплатформенный фреймворк для создания голосовых навыков и чат-ботов с единой бизнес-логикой. Встроенная поддержка ВКонтакте, Telegram, Viber, MAX, Яндекс Алисы, Маруси и Сбера SmartApp. Архитектура на адаптерах позволяет подключать любые другие платф

181 lines (180 loc) 10.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.rateLimiter = rateLimiter; async function processQueue(st, limit, appContext) { try { while (st.queue.length > 0) { const now = Date.now(); const timePassed = now - st.lastReset; // Если текущая секунда ещё не закончилась, ждём её окончания if (timePassed < 1000) { await new Promise((r) => setTimeout(r, 1000 - timePassed).unref()); // eslint-disable-next-line require-atomic-updates st.count = 0; // eslint-disable-next-line require-atomic-updates st.lastReset = Date.now(); } // Определяем, сколько задач можно выполнить в этом цикле const canRun = Math.max(0, limit - st.count); if (canRun === 0) continue; // защита от лишних итераций // Берём из очереди ровно столько, сколько можем выполнить const batch = st.queue.splice(0, Math.min(canRun, st.queue.length)); for (const item of batch) { st.count++; try { await item.task(); } catch (err) { // Ошибка уже обработана в reject, но логируем на всякий случай appContext.logError(`rateLimited - произошла ошибки при обработке. Ошибка: ${err.message}`, { err }); } // Обновляем время активности после выполнения задачи // eslint-disable-next-line require-atomic-updates st.lastActivity = Date.now(); // Равномерная задержка между запросами внутри пачки if (st.queue.length > 0) { // -1 для подстраховки await new Promise((r) => setTimeout(r, Math.ceil(1000 / limit) - 1).unref()); } } } } finally { // Гарантированно сбрасываем флаг обработки, даже если произошла ошибка st.processing = false; // Обновляем lastActivity после завершения обработки очереди st.lastActivity = Date.now(); } } /** * Создаёт middleware для ограничения частоты входящих запросов (rate limiting) на уровне платформы. * * 🎯 **Для чего используется:** * Некоторые платформы (например, Max, Telegram, VK, Viber) имеют ограничение на количество * отправляемых запросов в секунду (обычно 30). Данный middleware защищает от превышения * этого лимита, автоматически задерживая запросы, если они поступают слишком часто. * * ⚙️ **Как это работает:** * - Лимит берётся из свойства `limit` адаптера платформы (`platformAdapter.limit`). * Если свойство не задано или равно 0, ограничение не применяется. * - Для каждой комбинации `{platform}:{userId}` ведётся отдельная очередь и счётчик запросов. * - Счётчик сбрасывается каждую секунду, что позволяет точно соблюдать лимит в скользящем окне. * - Если лимит исчерпан, запрос помещается в очередь и будет выполнен, когда появится свободное «окно». * - Очередь имеет максимальный размер (`maxQueueSize`); при переполнении выбрасывается ошибка. * - Запросы в очереди выполняются с равномерной задержкой (⌈1000/limit⌉ мс), чтобы не превышать лимит. * - Неактивные записи (без запросов дольше `inactivityTimeout`) автоматически удаляются из памяти. * * 🧠 **Важные особенности:** * - Middleware применяется **только к входящим запросам** (webhook). Для исходящих уведомлений * ограничение нужно реализовывать непосредственно в адаптерах платформ. * - Функцию необходимо **вызвать** при подключении: `bot.use(rateLimiter())`. * - Все внутренние таймеры используют `unref()`, поэтому не блокируют завершение процесса. * * @param maxQueueSize - Максимальное количество ожидающих запросов в очереди для одного ключа (по умолчанию 100). * При превышении очередь перестаёт принимать новые запросы и выбрасывается исключение. * @param inactivityTimeout - Время в миллисекундах, после которого запись (очередь + счётчик) удаляется, * если не было активности. По умолчанию 60000 (1 минута). * @returns Middleware-функция для использования в `bot.use()`. * * @example * ```ts * import { rateLimiter } from 'umbot/middleware'; * * // Подключаем middleware с параметрами по умолчанию * bot.use(rateLimiter()); * * // Или с кастомными настройками * bot.use(rateLimiter(200, 120000)); * ``` * * @remarks * Чтобы лимит заработал для вашей платформы, добавьте в соответствующий адаптер публичное поле `limit`: * ```ts * export class TelegramAdapter extends BasePlatform { * public limit = 30; * // ... * } * ``` */ function rateLimiter(maxQueueSize = 100, inactivityTimeout = 60000) { const stateMap = new Map(); let cleanupInterval = null; const startCleanup = () => { if (!cleanupInterval) { cleanupInterval = setInterval(() => { const now = Date.now(); for (const [key, st] of stateMap.entries()) { // Удаляем запись, если она неактивна дольше порога и очередь пуста if (now - st.lastActivity > inactivityTimeout && st.queue.length === 0) { stateMap.delete(key); } } }, Math.min(inactivityTimeout / 2, 30000)).unref(); } }; startCleanup(); return async (ctx, next) => { const platform = ctx.appType; const userId = ctx.userId || 'um_unknown'; if (!platform) { return next(); } // Получаем лимит из адаптера платформы const limit = ctx.appContext.platforms[platform]?.limit; if (!limit) { return next(); // лимит не задан – пропускаем } const key = `${platform}:${userId}`; // Получаем или создаём состояние для этого ключа let st = stateMap.get(key); if (!st) { st = { queue: [], processing: false, lastReset: Date.now(), count: 0, lastActivity: Date.now(), }; stateMap.set(key, st); } const now = (st.lastActivity = Date.now()); // Сброс счётчика каждую секунду if (now - st.lastReset >= 1000) { st.count = 0; st.lastReset = now; } // Если влезаем в лимит – выполняем сразу if (st.count < limit) { st.count++; return next(); } // Превышен лимит – ставим в очередь, если есть место if (st.queue.length >= maxQueueSize) { throw new Error(`rateLimit - Превышено ограничение на размер очереди. Убедитесь что значение указанно корректно, текущее значение - ${maxQueueSize}.`); } // Возвращаем промис, который будет разрешён после выполнения задачи return new Promise((resolve, reject) => { st.queue.push({ resolve, reject, task: async () => { try { await next(); resolve(); } catch (err) { reject(err instanceof Error ? err : new Error(String(err))); } }, }); // Запускаем обработчик очереди, если он ещё не запущен if (!st.processing) { st.processing = true; processQueue(st, limit, ctx.appContext).catch((e) => { ctx.appContext.logError(`rateLimiter: Произошла ошибка при обработке очереди: ${e.message}`, { error: e }); }); } }); }; }