
Слишком частые запросы — это медленный сайт, внезапные счета за инфраструктуру и срывы SLA. Часто «шум» создают не только злоумышленники, но и вполне легитимные партнёрские интеграции, мобильные приложения с повторными попытками, скрипты клиентов. Правильно настроенный rate limiting:
Применение:
Ключи (по чём считать лимит):
Совет: для анонимного трафика начинайте с IP, для авторизованного — с user_id или api_key. Для административных задач — белые списки.
Рекомендация: для пользовательских интерфейсов — скользящее окно; для публичных API и тарифов — токен‑бакет с burst.
Варианты размещения лимитов:
Часто лучшая стратегия — комбинировать: грубый отсев на периметре + тонкая логика в приложении.
Ограничим попытки логина по IP до 5/мин с небольшим всплеском, и API по ключу до 120/мин.
# nginx.conf (фрагмент)
http {
# 10 МБ памяти ~ 160k ключей для limit_req_zone (примерно)
limit_req_zone $binary_remote_addr zone=login_zone:10m rate=5r/m;
# Ключ API в заголовке X-API-Key
map $http_x_api_key $api_key { default $http_x_api_key; }
limit_req_zone $api_key zone=api_zone:20m rate=120r/m;
server {
listen 80;
# Лимит логина по IP
location = /login {
limit_req zone=login_zone burst=10 nodelay;
add_header RateLimit-Policy "5;w=60" always;
try_files $uri @app;
}
# Лимит API по ключу
location /api/ {
limit_req zone=api_zone burst=60 nodelay;
add_header RateLimit-Policy "120;w=60" always;
try_files $uri @app;
}
location @app {
proxy_pass http://app_upstream;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
error_page 429 = @too_many;
location @too_many {
add_header Retry-After 30 always;
return 429 "Too Many Requests";
}
}
}
Преимущества: дешёво, быстро, без изменений в приложении. Недостатки: нет знаний о пользователях/тарифах, только по заголовку/IP.
Ниже — готовая реализация глобального токен‑бакета в Redis. Поддерживает:
Установка зависимостей:
python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn redis
Lua‑скрипт токен‑бакета (атомарно):
-- file: token_bucket.lua
-- KEYS[1] = bucket key
-- ARGV[1] = capacity (int)
-- ARGV[2] = refill_rate (tokens per second, float)
-- ARGV[3] = now_ms (int)
-- ARGV[4] = tokens_required (float)
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local need = tonumber(ARGV[4])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then tokens = capacity end
if ts == nil then ts = now_ms end
local elapsed = (now_ms - ts) / 1000.0
if elapsed < 0 then elapsed = 0 end
local new_tokens = tokens + elapsed * refill_rate
if new_tokens > capacity then new_tokens = capacity end
local allowed = 0
if new_tokens >= need then
new_tokens = new_tokens - need
allowed = 1
end
redis.call('HMSET', KEYS[1], 'tokens', new_tokens, 'ts', now_ms)
-- TTL равен времени полного восстановления ведра
local ttl_ms = math.ceil((capacity / refill_rate) * 1000)
redis.call('PEXPIRE', KEYS[1], ttl_ms)
return { allowed, new_tokens, ttl_ms }
Приложение FastAPI с middleware для лимитов:
# file: app.py
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
import time
from pathlib import Path
from redis.asyncio import Redis
import asyncio
app = FastAPI()
redis = Redis.from_url("redis://localhost:6379", encoding="utf-8", decode_responses=True)
# Загрузка Lua-скрипта и получение SHA
TOKEN_BUCKET_LUA = Path("token_bucket.lua").read_text(encoding="utf-8")
SCRIPT_SHA = None
# Конфигурация лимитов (пример тарифов)
RATE_CONFIG = {
"default": {"capacity": 120.0, "refill_per_sec": 2.0}, # 120/мин
"/heavy": {"weight": 10.0}, # дорогой эндпоинт
}
async def ensure_script_loaded():
global SCRIPT_SHA
if SCRIPT_SHA is None:
SCRIPT_SHA = await redis.script_load(TOKEN_BUCKET_LUA)
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
# Выбираем ключ лимита: API-ключ, иначе IP
api_key = request.headers.get("X-API-Key")
client_ip = request.headers.get("x-forwarded-for", request.client.host)
subject = api_key or client_ip
# Вычисляем вес: дорогие ручки потребляют больше токенов
path = request.url.path
weight = 1.0
if path.startswith("/heavy"):
weight = RATE_CONFIG.get("/heavy", {}).get("weight", 1.0)
# Выбираем ведро и параметры тарифа
bucket_key = f"rl:{subject}"
capacity = RATE_CONFIG["default"]["capacity"]
refill = RATE_CONFIG["default"]["refill_per_sec"]
await ensure_script_loaded()
now_ms = int(time.time() * 1000)
try:
allowed, tokens_left, ttl_ms = await redis.evalsha(
SCRIPT_SHA,
1,
bucket_key,
str(capacity),
str(refill),
str(now_ms),
str(weight),
)
except Exception:
# На крайний случай — fail-open, но логируем. В бою лучше fail-closed для критичных зон.
allowed, tokens_left, ttl_ms = 1, capacity, 1000
reset_sec = int(ttl_ms / 1000)
limit_header = f"{int(capacity)};w=60"
remaining_header = str(int(tokens_left))
if allowed == 1:
response: Response = await call_next(request)
response.headers["RateLimit-Limit"] = limit_header
response.headers["RateLimit-Remaining"] = remaining_header
response.headers["RateLimit-Reset"] = str(reset_sec)
return response
else:
retry_after = max(1, int(1 / RATE_CONFIG["default"]["refill_per_sec"]))
return JSONResponse(
status_code=429,
content={"detail": "Too Many Requests"},
headers={
"Retry-After": str(retry_after),
"RateLimit-Limit": limit_header,
"RateLimit-Remaining": remaining_header,
"RateLimit-Reset": str(reset_sec),
},
)
@app.get("/ping")
async def ping():
return {"ok": True}
@app.get("/heavy")
async def heavy():
# Эмуляция тяжёлой операции
await asyncio.sleep(0.1)
return {"report": "ready"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
Запуск:
redis-server --port 6379
python app.py
Проверьте заголовки RateLimit-* и 429 при превышении лимита.
Что собирать:
Алерты:
Логи:
Пример простого теста k6, который проверит 429 после превышения лимита:
// file: rate_limit_test.js
import http from 'k6/http';
import { sleep, check } from 'k6';
export const options = {
vus: 5,
duration: '30s',
};
export default function () {
const res = http.get('http://localhost:8000/heavy', { headers: { 'X-API-Key': 'test' } });
check(res, {
'status is 200 or 429': (r) => r.status === 200 || r.status === 429,
});
sleep(0.1);
}
Запуск:
k6 run rate_limit_test.js
Следите за долей 429 и заголовками RateLimit-*.
Подход:
Указатель: если у вас платные внешние вызовы (например, к ИИ), ставьте лимит не на запросы, а на «стоимость» запросов — через вес в токен‑бакете.
Итог: грамотный rate limiting делает трафик управляемым, защищает от злоупотреблений и превращает хаотичные всплески нагрузки в предсказуемые цифры. Это прямая экономия на инфраструктуре и меньше инцидентов — то, за что бизнес всегда благодарит.