
API живёт в непредсказуемой среде: интеграции, боты, периодические задачи клиентов, новые релизы партнёров. Один неудачный цикл повтора у клиента — и ваш бекенд получает шквал идентичных запросов. Без лимитов вы платите за пик в инфраструктуре, теряете SLA, а поддержка разбирает инциденты.
Ограничение частоты запросов (rate limit) решает три задачи:
При правильном дизайне лимиты почти не усложняют код, а прозрачная коммуникация (заголовки и документация) делает работу для клиентов предсказуемой.
Один уровень редко достаточно надёжен. Лучше лестница защиты, где каждый уровень дёшев и быстр:
Комбинация: грубые лимиты на периметре (защита от «залётов» по IP) + точные квоты в API (по ключу клиента, тарифу и типу операции).
Основные подходы:
Для продуктовых квот чаще подходит токенный бак: «5 запросов в секунду, с пиками до 20» звучит понятно и работает предсказуемо.
Ключ лимитирования
Параметры квоты
Контракт с клиентом в заголовках
Документация должна описывать лимиты, политику повтора (экспоненциальный бэкофф) и возможные «окна» по тарифам.
Локальная память инстанса
Redis/Memcached
База данных
Масштабирование Redis
Ниже — атомарный Lua‑скрипт для Redis. Он реализует токенный бак: накапливает «жетоны» с заданной скоростью, позволяет кратковременные всплески до ёмкости «ведра», возвращает, прошёл ли запрос, сколько жетонов осталось и через сколько миллисекунд можно повторить.
-- file: token_bucket.lua
-- KEYS[1] - ключ ведра (например, rate:{client_id}:{route})
-- ARGV[1] - now (мс)
-- ARGV[2] - refill_rate (жетонов в секунду)
-- ARGV[3] - capacity (максимум жетонов)
-- ARGV[4] - cost (стоимость запроса в жетонах)
-- ARGV[5] - ttl_sec (TTL ключа в секундах)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local ttl_sec = tonumber(ARGV[5])
local tokens = tonumber(redis.call('HGET', key, 'tokens'))
local ts = tonumber(redis.call('HGET', key, 'ts'))
if tokens == nil then tokens = capacity end
if ts == nil then ts = now end
local delta_ms = now - ts
if delta_ms < 0 then delta_ms = 0 end
-- Сколько жетонов добавилось со времени последнего запроса
local refill = (delta_ms / 1000.0) * refill_rate
if refill > 0 then
tokens = math.min(capacity, tokens + refill)
end
local allowed = 0
local retry_after_ms = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
allowed = 0
-- Сколько времени нужно, чтобы накопить недостающее
local need = cost - tokens
retry_after_ms = math.floor((need / refill_rate) * 1000.0)
end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, ttl_sec)
-- Возвращаем: allowed, оставшиеся жетоны (float), retry_after_ms
return { allowed, string.format('%.3f', tokens), retry_after_ms }
Пример middleware на Node.js (Express) с ioredis. Он загружает скрипт, вычисляет ключ по клиентскому токену или IP, проставляет заголовки и отдаёт 429 при превышении.
// file: rateLimit.js
// Node.js 18+, Express 4+, ioredis 5+
import fs from 'node:fs/promises';
import path from 'node:path';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
let scriptSha = null;
async function loadScript() {
const scriptPath = path.join(process.cwd(), 'token_bucket.lua');
const lua = await fs.readFile(scriptPath, 'utf8');
scriptSha = await redis.script('load', lua);
}
export async function initRateLimit() {
await loadScript();
// На случай перезапуска Redis перезагружаем скрипт по ошибке NOSCRIPT
redis.on('error', (e) => console.error('Redis error', e));
}
export function rateLimit({
capacity = 20, // ёмкость ведра (burst)
refillPerSec = 5, // скорость пополнения (RPS)
cost = 1, // стоимость запроса
ttlSec = 3600, // TTL ключа
keyBuilder, // (req) => string — ключ лимитирования
getNowMs = () => Date.now()
} = {}) {
if (!keyBuilder) {
keyBuilder = (req) => {
const clientKey = req.header('X-API-Key') || req.header('Authorization') || '';
const ip = req.ip || req.connection?.remoteAddress || 'unknown';
// Ключ учитывает маршрут, чтобы можно было задавать разные квоты по операциям
return `rate:${clientKey || ip}:${req.method}:${req.baseUrl || ''}${req.path}`;
};
}
return async function rateLimitMiddleware(req, res, next) {
try {
const key = keyBuilder(req);
const now = getNowMs();
const args = [ now, refillPerSec, capacity, cost, ttlSec ];
let result;
try {
result = await redis.evalsha(scriptSha, 1, key, ...args);
} catch (e) {
if (String(e.message || '').includes('NOSCRIPT')) {
await loadScript();
result = await redis.evalsha(scriptSha, 1, key, ...args);
} else {
throw e;
}
}
const allowed = Number(result[0]) === 1;
const tokensLeft = Math.max(0, Math.floor(Number(result[1])));
const retryAfterMs = Number(result[2]);
// Заголовки контракта
res.setHeader('X-RateLimit-Limit', String(capacity));
res.setHeader('X-RateLimit-Remaining', String(tokensLeft));
// Примерно когда ведро полностью восстановится
const resetSec = Math.ceil(now / 1000 + (capacity - Number(result[1])) / refillPerSec);
res.setHeader('X-RateLimit-Reset', String(resetSec));
if (!allowed) {
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
res.setHeader('Retry-After', String(retryAfterSec));
return res.status(429).json({
error: 'too_many_requests',
message: `Превышен лимит. Повторите через ${retryAfterSec} сек.`,
});
}
return next();
} catch (err) {
// В случае недоступности Redis лучше «пропустить», чем сломать все запросы,
// но залогировать и поднять алерт.
console.error('RateLimit failure', err);
return next();
}
};
}
Использование в Express:
// file: app.js
import express from 'express';
import { initRateLimit, rateLimit } from './rateLimit.js';
const app = express();
await initRateLimit();
// Глобальный лимит по ключу клиента
app.use(rateLimit({ capacity: 20, refillPerSec: 5 }));
// Более строгий лимит на «дорогой» маршрут
app.post('/v1/reports/generate', rateLimit({ capacity: 5, refillPerSec: 1 }));
app.get('/v1/ping', (req, res) => res.json({ ok: true }));
app.listen(3000, () => console.log('API on :3000'));
На периметре хорошо работает встроенный модуль limit_req. Он прост и почти ничего не стоит.
# 10Мб под зону — достаточно примерно для десятков тысяч уникальных ключей
# Ключ — по API‑ключу, если есть, иначе по IP
map $http_x_api_key $limit_key {
default $http_x_api_key;
'' $binary_remote_addr;
}
limit_req_zone $limit_key zone=per_client:10m rate=5r/s;
server {
listen 443 ssl;
server_name api.example.com;
location /v1/ {
# Разрешаем кратковременный всплеск до 20 без задержки
limit_req zone=per_client burst=20 nodelay;
proxy_pass http://backend;
# Опционально: на 429 отдаём заголовок Retry-After
error_page 429 = @rate_limited;
}
location @rate_limited {
add_header Retry-After 1 always;
return 429;
}
}
Важно: NGINX лимитирует по отдельному инстансу. Для точных глобальных квот нужен внешний стор (Redis) на уровне шлюза или приложения.
Что снимать в метриках:
Алерты:
Нагрузочное тестирование:
Лимитирование запросов — недорогой и надёжный способ защитить API и бюджет. Комбинируя быстрые лимиты на периметре и точные квоты в приложении/шлюзе, вы получаете предсказуемые SLA и честное распределение ресурсов между клиентами. Токенный бак на Redis с Lua даёт атомарность, прозрачные заголовки и гибкость под тарифы — без лишних серверов и сложных очередей.