Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Rate limiting (ограничение запросов) в API и веб‑приложениях: как защитить сервис от ботов и сократить расходы на 30–60%

Разработка и технологии20 декабря 2025 г.
Если пользователи, боты или партнёры шлют слишком много запросов, любая система начинает тормозить и дорогеть. Рассказываю, как внедрить честные лимиты запросов: выбрать алгоритм, настроить Nginx или приложение, не сломать UX и прозрачным образом монетизировать нагрузку. В статье есть рабочие примеры для Nginx и FastAPI+Redis+Lua, метрики, алерты и чек‑лист.
Rate limiting (ограничение запросов) в API и веб‑приложениях: как защитить сервис от ботов и сократить расходы на 30–60%

Оглавление

  • Зачем бизнесу ограничение частоты запросов
  • Где применять и какие ключи брать для лимитов
  • Алгоритмы: фиксированное окно, скользящее окно, токен‑бакет, протекающее ведро
  • Архитектура внедрения: периметр, приложение, Redis
  • Пример 1: Nginx — простой периметровый лимит логинов и API
  • Пример 2: FastAPI + Redis + Lua — глобальный токен‑бакет с весами запросов
  • Пользовательский опыт: 429, Retry‑After, тарифы и справедливость
  • Наблюдаемость: метрики, логи, дашборды и алерты
  • Нагрузочное тестирование лимитов (k6)
  • Экономика: как посчитать выгоду и выбрать числа
  • Частые ошибки и как их избежать
  • Чек‑лист внедрения

Зачем бизнесу ограничение частоты запросов

Слишком частые запросы — это медленный сайт, внезапные счета за инфраструктуру и срывы SLA. Часто «шум» создают не только злоумышленники, но и вполне легитимные партнёрские интеграции, мобильные приложения с повторными попытками, скрипты клиентов. Правильно настроенный rate limiting:

  • снижает инфраструктурные расходы на 30–60% за счёт сглаживания пиков и отсечения лишнего трафика;
  • защищает вход (login, пароль) от брутфорса и накрутки;
  • делает API предсказуемым: честные лимиты вместо деградации для всех;
  • помогает монетизировать нагрузку: тарифы, квоты, понятные лимиты;
  • уменьшает инциденты, а значит — время и деньги поддержки.

Где применять и какие ключи брать для лимитов

Применение:

  • вход и восстановление пароля (анти‑брутфорс);
  • дорогие операции: генерация отчётов, экспорт, поиск, интеграции с внешними системами;
  • вебхуки и приём событий от партнёров;
  • API с тарификацией (например, генеративный ИИ или платные справочники);
  • массовые формы (анти‑спам, анти‑боты).

Ключи (по чём считать лимит):

  • IP-адрес — просто и быстро на периметре, но не всегда честно (NAT, прокси);
  • идентификатор пользователя (user_id) — честно для авторизованных;
  • ключ API (api_key) или организация (org_id) — удобно для тарифов;
  • комбинация: org_id+endpoint, чтобы разные ручки имели разные лимиты;
  • отдельные ключи на логин/смс/почту — чтобы не блокировать весь аккаунт.

Совет: для анонимного трафика начинайте с IP, для авторизованного — с user_id или api_key. Для административных задач — белые списки.

Алгоритмы: фиксированное окно, скользящее окно, токен‑бакет, протекающее ведро

  • Фиксированное окно (fixed window): X запросов в минуту. Плюсы — простота. Минусы — всплеск на границах минут.
  • Скользящее окно (sliding window): считает за последние N секунд. Честнее, но чуть сложнее.
  • Токен‑бакет (token bucket): выдаёт «жетоны» с заданной скоростью, позволяет кратковременные всплески (burst). Это золотой стандарт для внешних API.
  • Протекающее ведро (leaky bucket): сглаживает до постоянной скорости, очередит лишнее; полезно для интеграций, где важна ровная подача.

Рекомендация: для пользовательских интерфейсов — скользящее окно; для публичных API и тарифов — токен‑бакет с burst.

Архитектура внедрения: периметр, приложение, Redis

Варианты размещения лимитов:

  • Периметр: CDN/WAF или Nginx/HAProxy/Envoy. Плюсы — защищает до приложения, дешёво, быстро. Минусы — ограничено по контексту (обычно IP, URL, заголовок).
  • В приложении: тонкие правила, веса запросов, тарифы, white/black‑lists. Минусы — нагрузка доходит до приложения.
  • В Redis/общем хранилище: единый источник истины для кластера и нескольких инстансов. Обязательно атомарные операции (Lua‑скрипты), иначе будут гонки.

Часто лучшая стратегия — комбинировать: грубый отсев на периметре + тонкая логика в приложении.

Пример 1: Nginx — простой периметровый лимит логинов и API

Ограничим попытки логина по 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.

Пример 2: FastAPI + Redis + Lua — глобальный токен‑бакет с весами запросов

Ниже — готовая реализация глобального токен‑бакета в Redis. Поддерживает:

  • вес запросов (например, отчёт стоит 10 токенов, а пинг — 1);
  • заголовки RateLimit‑Limit/Remaining/Reset согласно RFC;
  • 429 с Retry‑After;
  • ключ лимита: API‑ключ, иначе IP.

Установка зависимостей:

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 при превышении лимита.

Пользовательский опыт: 429, Retry‑After, тарифы и справедливость

  • Возвращайте 429 Too Many Requests и заголовок Retry‑After (секунды до следующей попытки).
  • Добавляйте RateLimit‑Limit/Remaining/Reset — это стандартно и прозрачно.
  • Делайте «burst» для UX: токен‑бакет позволяет быстро сделать 10–20 действий подряд, а потом восстановиться.
  • В тарифах не стесняйтесь обозначать лимиты: «120 запросов в минуту», «10 отчётов/час». Для enterprise — настраиваемые квоты.
  • Для логина и критичных действий полезно вводить прогрессивную задержку, а не только жёсткие отказы.

Наблюдаемость: метрики, логи, дашборды и алерты

Что собирать:

  • процент запросов с 429 по endpoint/клиенту;
  • скорость выдачи токенов и средний остаток ведра;
  • «горячие» ключи (кто чаще всех упирается в лимит);
  • распределение ошибок по IP/странам для выявления ботов.

Алерты:

  • всплеск 429 > X% по важной ручке;
  • рост уникальных IP, попавших под лимит логина;
  • обнуление Redis‑ведёр (eviction) — не хватает памяти.

Логи:

  • записывайте key, endpoint, weight, remaining — анонимизируйте персональные данные, не храните IP дольше нужного.

Нагрузочное тестирование лимитов (k6)

Пример простого теста 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-*.

Экономика: как посчитать выгоду и выбрать числа

Подход:

  1. Измерьте текущую среднюю и пиковую RPS по ключевым ручкам.
  2. Посчитайте себестоимость одной операции (CPU, память, внешние вызовы). Для внешних платных API — просто возьмите цену.
  3. Выберите «честную» среднюю скорость (refill) и разумный burst исходя из UX. Пример: 2 rps (120 rpm) с burst=60.
  4. Прогоните расчёт: если раньше 5% трафика уходило в пиковые перегибы, то лимит срезает их и экономит X рублей в месяц.

Указатель: если у вас платные внешние вызовы (например, к ИИ), ставьте лимит не на запросы, а на «стоимость» запросов — через вес в токен‑бакете.

Частые ошибки и как их избежать

  • Лимит только по IP. За одним NAT может сидеть целая компания, а у бота — ротация IP. Добавляйте ключи по пользователю/организации.
  • Лимит на уровне базы. Это поздно — трафик уже добрался до самого дорогого слоя. Отсечённые на периметре запросы дешевле.
  • Неатомарные инкременты. Без Lua/скриптов легко получить гонки и дырявые лимиты.
  • Отсутствие заголовков и понятных ошибок. Клиенты не знают, когда пробовать снова.
  • Общий лимит для всего. Делите по endpoint’ам и весам, иначе дешёвые ручки «съедают» квоту у дорогих.
  • Отсутствие наблюдаемости. Без метрик лимиты превращаются в «чёрный ящик».

Чек‑лист внедрения

  • Инвентаризация ручек: дорогие/дешёвые, публичные/внутренние.
  • Выбор ключа: IP для анонимных, api_key/user_id для авторизованных.
  • Алгоритм: токен‑бакет для API, скользящее окно для UI.
  • Периметр: базовый лимит в Nginx/CDN для грубого отсечения.
  • Приложение: тонкая логика, веса, тарифы, белые списки.
  • Хранилище: Redis, Lua‑скрипты, TTL, оценка памяти.
  • UX: 429, Retry‑After, RateLimit‑* заголовки, справка в документации.
  • Мониторинг: 429 по endpoint/клиенту, горячие ключи, алерты.
  • Нагрузочные тесты: сценарии превышения и восстановления квоты.
  • Регламент: как выдавать/менять лимиты, эскалация инцидентов.

Итог: грамотный rate limiting делает трафик управляемым, защищает от злоупотреблений и превращает хаотичные всплески нагрузки в предсказуемые цифры. Это прямая экономия на инфраструктуре и меньше инцидентов — то, за что бизнес всегда благодарит.


APIRedisrate limiting