TypeScript под капотом: Rate limiting и throttling без боли

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Как не дать вашему API упасть под шквалом запросов

Представьте: вы запустили крутой сервис, написали бэкенд на TypeScript, а тут — бац! — один «добрый» пользователь запускает скрипт, который долбит ваш эндпоинт тысячами запросов в секунду. Сервер падает, база данных захлёбывается, а честные юзеры видят 502 ошибку. Знакомо?

В мире веб-разработки rate limiting (ограничение частоты запросов) и throttling (дросселирование) — это не просто модные слова, а спасательный круг для вашего API. Сегодня мы разберём, как реализовать эти механизмы на TypeScript, не сломав голову. Поехали!

Rate limiting vs Throttling: в чём разница?

Прежде чем писать код, давайте чётко разделим понятия. Многие путают их, но разница принципиальна.

  • Rate limiting — это жёсткий лимит: «Не больше 100 запросов в минуту с одного IP». Превысил? Получаешь 429 Too Many Requests и блокировку до конца окна. Работает на уровне API-гейта.
  • Throttling — это плавное регулирование: «Ты можешь делать запросы, но не чаще одного в секунду». Остальные запросы либо ставятся в очередь, либо игнорируются, но пользователь не блокируется.

Простая аналогия: rate limiting — это турникет в метро (пропустил карту — прошёл, нет — стой), а throttling — это регулировщик на перекрёстке (машины едут, но с интервалом).

Когда это нужно? Реальные сценарии

Вы скажете: «У меня маленький сайт, зачем мне это?» А вот зачем:

  • Защита от DDoS и брутфорса — злоумышленники не смогут перебрать пароли или положить сервер.
  • Экономия ресурсов — платные API (например, OpenAI) берут деньги за каждый вызов. Ограничив клиентов, вы спасёте бюджет.
  • Честное распределение нагрузки — один «шумный» пользователь не задавит всех остальных.
  • Соблюдение SLA — если вы обещаете uptime 99.9%, без ограничений не обойтись.

Пишем rate limiting на TypeScript: простой старт

Начнём с базового примера — in-memory rate limiter на основе фиксированного окна (Fixed Window). Он хранит счётчики запросов прямо в памяти приложения.

// rateLimiter.ts
import { Request, Response, NextFunction } from 'express';

interface RateLimitEntry {
    count: number;
    resetTime: number;
}

class InMemoryRateLimiter {
    private store: Map<string, RateLimitEntry> = new Map();
    private windowMs: number;
    private maxRequests: number;

    constructor(windowMs: number, maxRequests: number) {
        this.windowMs = windowMs;
        this.maxRequests = maxRequests;
    }

    public middleware() {
        return (req: Request, res: Response, next: NextFunction): void => {
            const key = req.ip || 'unknown';
            const now = Date.now();
            let entry = this.store.get(key);

            if (!entry || now > entry.resetTime) {
                // Новое окно
                entry = { count: 0, resetTime: now + this.windowMs };
                this.store.set(key, entry);
            }

            entry.count++;

            if (entry.count > this.maxRequests) {
                res.status(429).json({
                    error: 'Too Many Requests',
                    retryAfter: Math.ceil((entry.resetTime - now) / 1000)
                });
                return;
            }

            // Передаём информацию о лимите в заголовках
            res.setHeader('X-RateLimit-Limit', this.maxRequests);
            res.setHeader('X-RateLimit-Remaining', this.maxRequests - entry.count);
            res.setHeader('X-RateLimit-Reset', Math.ceil(entry.resetTime / 1000));

            next();
        };
    }

    // Очистка старых записей (для предотвращения утечки памяти)
    public cleanup(): void {
        const now = Date.now();
        for (const [key, entry] of this.store.entries()) {
            if (now > entry.resetTime) {
                this.store.delete(key);
            }
        }
    }
}

// Использование в Express:
// const limiter = new InMemoryRateLimiter(60 * 1000, 10); // 10 запросов в минуту
// app.use('/api', limiter.middleware());

Минус этого подхода — память приложения не разделяется между несколькими инстансами (если у вас кластер). Для продакшена лучше использовать Redis, но об этом позже.

Улучшаем: Sliding Window Log

Fixed Window имеет недостаток: в конце окна лимит сбрасывается, и пользователь может сделать «взрывной» запрос (burst). Sliding Window решает эту проблему: мы храним метки времени каждого запроса и считаем количество за последние N секунд.

// slidingWindow.ts
class SlidingWindowLimiter {
    private timestamps: Map<string, number[]> = new Map();
    private windowMs: number;
    private maxRequests: number;

    constructor(windowMs: number, maxRequests: number) {
        this.windowMs = windowMs;
        this.maxRequests = maxRequests;
    }

    public allow(key: string): boolean {
        const now = Date.now();
        const windowStart = now - this.windowMs;

        let userTimestamps = this.timestamps.get(key) || [];

        // Удаляем старые записи
        userTimestamps = userTimestamps.filter(ts => ts > windowStart);

        if (userTimestamps.length >= this.maxRequests) {
            return false; // Лимит исчерпан
        }

        userTimestamps.push(now);
        this.timestamps.set(key, userTimestamps);
        return true;
    }
}

Этот алгоритм точнее, но потребляет больше памяти. Для высоконагруженных систем используйте Token Bucket — он быстрее и экономичнее.

Throttling в TypeScript: контроль вызовов функций

Throttling чаще применяется на клиенте, но и на сервере он полезен. Например, чтобы не дёргать базу данных на каждое нажатие клавиши в поиске.

Реализуем throttle-функцию с гарантией вызова не чаще заданного интервала:

// throttle.ts
export function throttle<T extends (...args: any[]) => any>(
    fn: T,
    intervalMs: number
): (...args: Parameters<T>) => void {
    let lastCall = 0;
    let timeoutId: ReturnType<typeof setTimeout> | null = null;

    return function (this: any, ...args: Parameters<T>) {
        const now = Date.now();
        const elapsed = now - lastCall;

        if (!lastCall || elapsed >= intervalMs) {
            // Если прошло достаточно времени — вызываем сразу
            lastCall = now;
            fn.apply(this, args);
        } else if (!timeoutId) {
            // Иначе ставим в очередь на оставшееся время
            const remaining = intervalMs - elapsed;
            timeoutId = setTimeout(() => {
                lastCall = Date.now();
                timeoutId = null;
                fn.apply(this, args);
            }, remaining);
        }
    };
}

// Пример: логирование событий не чаще раза в секунду
// const throttledLog = throttle(console.log, 1000);
// throttledLog('Сообщение                

Блоги

Рекомендаций по книгам