Пошук уроків, статей та іншого контенту
Обмежите частоту запитів користувачів, налаштуєте ліміти та повертатимете коректні відповіді під час перевищення квоти.
Обмеження частоти запитів, або rate limiting, визначає, скільки запитів клієнт може виконати за певний проміжок часу.
Це допомагає:
захищати API від випадкових пікових навантажень;
зменшувати вплив brute-force атак;
обмежувати зловживання дорогими операціями;
розподіляти ресурси між користувачами;
робити поведінку API передбачуваною.
Коли клієнт перевищує квоту, сервер має повернути статус 429 Too Many Requests.
Відповідь повинна пояснювати клієнту:
що квоту перевищено;
коли можна повторити запит;
скільки запитів ще доступно, якщо це можливо визначити.
Один із практичних алгоритмів — token bucket, або «кошик токенів».
Для кожного клієнта створюється кошик:
capacity — максимальна кількість токенів;
refillRate — швидкість додавання токенів;
один запит витрачає один токен;
якщо токенів немає, запит відхиляється;
невикористані токени накопичуються, але не перевищують місткість кошика.
Наприклад, для конфігурації:
capacity = 5
refillRate = 1 токен за секундуклієнт може одразу виконати до п’яти запитів. Після цього він отримуватиме приблизно один новий дозвіл щосекунди.
На відміну від простого фіксованого вікна, token bucket краще контролює різкі сплески запитів. Клієнт не може нескінченно виконувати запити на межі двох часових вікон.
Перед обмеженням потрібно визначити, до кого застосовувати квоту. Поширені варіанти:
IP-адреса;
ідентифікатор автентифікованого користувача;
API-ключ;
комбінація користувача та конкретного маршруту.
Для публічного API часто використовують API-ключ або ідентифікатор користувача. IP-адреса є запасним варіантом, але має недоліки:
кілька користувачів можуть працювати через одну NAT-адресу;
адреса може бути спільною для всієї організації;
мобільний клієнт може часто змінювати адресу.
Якщо застосунок працює за reverse proxy, req.socket.remoteAddress може містити адресу самого проксі. Заголовок X-Forwarded-For не можна безумовно вважати надійним: його слід використовувати лише після коректного налаштування списку довірених проксі.
Нижче наведено повний приклад HTTP-сервера без додаткових бібліотек. Він:
обмежує маршрут /api/hello;
застосовує окремий кошик до кожної IP-адреси;
повертає 429 Too Many Requests;
додає заголовки квоти;
встановлює Retry-After;
періодично видаляє неактивні записи з пам’яті.
'use strict';
const http = require('node:http');
function positiveNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0
? number
: fallback;
}
const config = {
port: positiveNumber(process.env.PORT, 3000),
capacity: positiveNumber(process.env.RATE_LIMIT_CAPACITY, 5),
refillPerSecond: positiveNumber(process.env.RATE_LIMIT_REFILL_PER_SECOND, 1),
stateTtlMs: 5 * 60 * 1000,
cleanupIntervalMs: 60 * 1000,
};
const buckets = new Map();
function getClientKey(request) {
// У production-оточенні ідентифікатором краще зробити API-ключ або user ID.
return request.socket.remoteAddress || 'unknown-client';
}
function consumeToken(key) {
const now = Date.now();
let bucket = buckets.get(key);
if (!bucket) {
bucket = {
tokens: config.capacity,
lastRefillAt: now,
lastSeenAt: now,
};
}
const elapsedSeconds = (now - bucket.lastRefillAt) / 1000;
const refilledTokens = elapsedSeconds * config.refillPerSecond;
bucket.tokens = Math.min(
config.capacity,
bucket.tokens + refilledTokens,
);
bucket.lastRefillAt = now;
bucket.lastSeenAt = now;
let allowed = false;
let retryAfterSeconds = 0;
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
allowed = true;
} else {
const missingTokens = 1 - bucket.tokens;
retryAfterSeconds = Math.max(
1,
Math.ceil(missingTokens / config.refillPerSecond),
);
}
buckets.set(key, bucket);
return {
allowed,
remaining: Math.max(0, Math.floor(bucket.tokens)),
retryAfterSeconds,
};
}
function sendJson(response, statusCode, payload, headers = {}) {
const body = JSON.stringify(payload);
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
...headers,
});
response.end(body);
}
function handleApiRequest(request, response) {
const clientKey = getClientKey(request);
const result = consumeToken(clientKey);
const quotaHeaders = {
'RateLimit-Limit': String(config.capacity),
'RateLimit-Remaining': String(result.remaining),
};
if (!result.allowed) {
return sendJson(
response,
429,
{
error: 'rate_limit_exceeded',
message: 'Перевищено частоту запитів.',
retryAfterSeconds: result.retryAfterSeconds,
},
{
...quotaHeaders,
'Retry-After': String(result.retryAfterSeconds),
},
);
}
if (request.method !== 'GET') {
return sendJson(
response,
405,
{
error: 'method_not_allowed',
message: 'Підтримується лише метод GET.',
},
{
...quotaHeaders,
Allow: 'GET',
},
);
}
return sendJson(
response,
200,
{
message: 'Запит дозволено.',
client: clientKey,
},
quotaHeaders,
);
}
const server = http.createServer((request, response) => {
const url = new URL(
request.url || '/',
`http://${request.headers.host || 'localhost'}`,
);
if (request.method === 'GET' && url.pathname === '/health') {
return sendJson(response, 200, { status: 'ok' });
}
if (url.pathname === '/api/hello') {
return handleApiRequest(request, response);
}
return sendJson(response, 404, {
error: 'not_found',
message: 'Маршрут не знайдено.',
});
});
const cleanupTimer = setInterval(() => {
const expirationTime = Date.now() - config.stateTtlMs;
for (const [key, bucket] of buckets) {
if (bucket.lastSeenAt < expirationTime) {
buckets.delete(key);
}
}
}, config.cleanupIntervalMs);
// Таймер не повинен утримувати процес активним під час завершення сервера.
cleanupTimer.unref();
server.listen(config.port, () => {
console.log(`Сервер запущено на порту ${config.port}`);
console.log(
`Ліміт: ${config.capacity} запитів у кошику, ` +
`${config.refillPerSecond} токенів за секунду`,
);
});Збережіть код у файл server.js і запустіть:
node server.jsПісля запуску можна виконати кілька запитів:
curl -i http://localhost:3000/api/helloПісля вичерпання токенів сервер поверне відповідь на кшталт:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
RateLimit-Limit: 5
RateLimit-Remaining: 0
Retry-After: 1Тіло відповіді:
{
"error": "rate_limit_exceeded",
"message": "Перевищено частоту запитів.",
"retryAfterSeconds": 1
}У прикладі параметри можна змінити через змінні середовища:
RATE_LIMIT_CAPACITY=20 \
RATE_LIMIT_REFILL_PER_SECOND=2 \
PORT=8080 \
node server.jsУ цьому випадку:
кошик вміщує до 20 токенів;
щосекунди додаються 2 токени;
сервер працює на порту 8080.
Значення потрібно підбирати за характеристиками конкретного маршруту. Дорогі операції, наприклад генерація звіту або надсилання коду підтвердження, зазвичай повинні мати суворіші ліміти, ніж простий запит читання.
429Для перевищення ліміту використовується саме статус:
429 Too Many RequestsНе слід повертати 400, оскільки проблема не у форматі запиту. Також 503 Service Unavailable описує недоступність сервера, а не перевищення квоти конкретним клієнтом.
Retry-AfterЗаголовок Retry-After повідомляє, через скільки секунд клієнт може повторити запит:
Retry-After: 3Для token bucket це приблизний час до появи наступного токена. Сервер не повинен обіцяти, що після цього часу клієнт отримає необмежену кількість запитів.
У прикладі використовуються:
RateLimit-Limit: 5
RateLimit-Remaining: 2Вони дозволяють клієнту бачити:
максимальну кількість токенів;
приблизну кількість доступних запитів.
Значення RateLimit-Remaining може бути приблизним для алгоритмів, де квота постійно поповнюється. Клієнт не повинен будувати логіку, яка залежить від ідеальної точності цього числа.
Єдиний ліміт для всього API часто недостатній. Маршрути можуть мати різну вартість.
Наприклад:
const routePolicies = {
'/api/search': {
capacity: 30,
refillPerSecond: 5,
},
'/api/send-code': {
capacity: 3,
refillPerSecond: 3 / 60,
},
};Для такої конфігурації потрібно, щоб стан кошика зберігав не лише клієнта, а й політику маршруту. Ключ можна побудувати так:
const bucketKey = `${clientKey}:${url.pathname}`;Тоді запити до /api/search не витрачатимуть квоту /api/send-code.
Ключ повинен містити всі виміри, за якими потрібно розділити квоту:
клієнта;
маршрут;
тип операції;
тарифний план користувача.
Map у прикладі підходить для:
локальної розробки;
одного процесу Node.js;
тимчасового захисту невеликого сервісу.
У production така реалізація має обмеження.
Якщо запустити кілька worker-процесів або кілька контейнерів, кожен матиме власну Map. Клієнт зможе отримувати окрему квоту в кожному процесі.
Наприклад, при чотирьох процесах фактичний ліміт може стати приблизно в чотири рази більшим за очікуваний.
Після перезапуску процесу всі кошики буде втрачено. Клієнти тимчасово отримають нову повну квоту.
Якщо ідентифікатори клієнтів створюються без обмеження, Map може зростати. У прикладі старі записи видаляються таймером, але це не вирішує всіх проблем для великої кількості унікальних клієнтів.
Для кількох екземплярів застосунку стан ліміту потрібно зберігати у спільному сховищі, яке підтримує атомарне оновлення. Критично важливо, щоб операції:
прочитати поточний стан;
поповнити кошик;
витратити токен;
записати новий стан
виконувалися атомарно. Інакше паралельні запити можуть одночасно побачити один і той самий токен і перевищити ліміт.
Для автентифікованих запитів краще використовувати стабільний ідентифікатор користувача:
const clientKey = request.user.id;Для неавтентифікованих запитів можна використовувати IP-адресу, але варто враховувати проксі та балансувальники.
Не слід довіряти довільному заголовку:
request.headers['x-forwarded-for']Якщо клієнт може сам встановити цей заголовок, він легко підмінить IP і обійде обмеження.
Безпечний підхід:
визначити відомі довірені проксі;
налаштувати обробку forwarded-заголовків лише для них;
для автентифікованих користувачів використовувати user ID або API-ключ;
за потреби поєднувати user ID та IP.
429Повернення 200 або 400 після перевищення квоти ускладнює роботу клієнтів і моніторинг.
Retry-AfterБез цього заголовка клієнт не знає, коли повторювати запит, і може продовжити надсилати його без паузи.
Кожен процес матиме власний лічильник, тому загальна квота не буде спільною.
Дешевий GET і дорога операція запису або надсилання повідомлення можуть вимагати різних політик.
Неактивні записи потрібно видаляти або використовувати сховище з автоматичним часом життя ключів.
X-Forwarded-ForЦе дозволяє клієнту підміняти ідентифікатор, якщо запит не проходить через довірений проксі.
Якщо ліміт перевіряється лише після виконання дорогої операції, зловмисник уже встигне навантажити систему. Перевірку потрібно виконувати до такої операції.
Клієнт не повинен одразу повторювати відхилений запит у циклі. Потрібно враховувати Retry-After і застосовувати паузу між повтореннями.
Rate limiting обмежує кількість запитів від одного клієнта.
Token bucket дозволяє контролювати як середню швидкість, так і короткочасні сплески.
Для перевищення квоти потрібно повертати 429 Too Many Requests.
Заголовок Retry-After повідомляє, коли варто повторити запит.
Ліміти можна налаштовувати окремо для маршрутів і типів клієнтів.
In-memory стан підходить лише для одного процесу та має обмеження.
У розподіленому production-застосунку ліміти повинні використовувати спільне сховище й атомарні операції.
Ідентифікатор клієнта потрібно обирати з урахуванням автентифікації, проксі та можливості підміни заголовків.