Как не дать вашему 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('Сообщение