Три часа ночи, график обращений к базе уходит вертикально вверх, очередь ответов наружу растёт на глазах. Кто-то выкачивает каталог товаров целиком, вежливо, аккуратно и очень быстро. Ещё минута, и сервис отвечает с задержками уже всем остальным. Такие сцены переживает каждый публичный интерфейс, вопрос лишь в том, встречает ли его защита. Известны два пути: нормировать темп обращений на входе или заставлять каждого клиента доказывать, что он человек. Первый путь работает и измеряется в настройках и числах. Второй в контексте программного интерфейса почти всегда оказывается чужой мебелью, и это стоит показать на конкретных конфигурациях, арифметике и живых сценариях.
Сервис без ограничений встречает шторм автоматических обращений
У публичного API нет охраны на входе, есть документация и честное слово. По этому слову приходят парсеры конкурентов, сканеры уязвимостей, торговые боты, мониторы доступности, любители рекурсивного скачивания и одна-единственная строка кода, в которой разработчик партнёра забыл паузу между итерациями цикла. Каждому нужен ответ, каждый приходит снова, и каждый запрос стоит ресурсов.
Стоимость запросов различается на порядок. Проверка баланса по идентификатору это одно чтение из базы. Поиск по каталогу с фильтрами, сортировкой и подсчётом итогов это десятки чтений, работа индексов и сборка ответа на процессоре. В отраслевом перечне рисков OWASP API Security Top 10 выпуска 2023 года отведена целая категория неограниченному потреблению ресурсов: туда попадают и скорость обращений, и объём выгрузок, и отсутствие предела пагинации. Пока пределы не заданы, интерфейс доверяет всем одинаково, а расплачиваются все сразу.
Последствия расходятся каскадом. Пул соединений к базе исчерпан, кэш работает вхолостую, потому что каждый бот тянет уникальные страницы, автоскейлер поднимает лишние машины, и счёт за облако растёт вместе с задержками. Дешёвый трафик на входе превращается в дорогую нагрузку на выходе, и платит за неё владелец сервиса. Есть и тонкость, из-за которой фильтрация по содержимому запроса не спасает. Бот почти неотличим от честного клиента на уровне одного пакета: тот же метод, те же заголовки, тот же формат. Разница живёт не в содержимом, а в темпе и в повторяемости. Значит, и защита должна смотреть не на структуру обращения, а на частоту и на распределение запросов во времени.
Капча в машинном интерфейсе ломает саму идею интеграции
Капча создавалась для браузера: покажи картинку, найди перекрёстки, нажми кнопку. У неё есть понятная среда обитания, человек за экраном и сессия, в которой можно нарисовать виджет. У программного интерфейса ничего этого нет. Мобильное приложение, партнёрский скрипт, ночная синхронизация по расписанию, внешний мониторинг, все они общаются запросами и ответами. Вставить в JSON-ответ картинку с перекрёстками нельзя, а если вставить, клиент не знает, что с ней делать.
Казалось бы, выход найден: современные сервисы вроде reCAPTCHA присваивают взаимодействию балл от 0 до 1 без всяких картинок, выше порога пропускаем, ниже блокируем. Но балл считается по поведению в браузере, требует JavaScript и долго наблюдает за движениями мыши и за историей сессии. Скрипт партнёра не имеет ни браузера, ни мыши, ни поведенческих сигналов. Такой рубеж непроходим, и непроходим он не для ботов, а именно для законных клиентов. Получается защита, которая жалит своих.
Добавим цену по обе стороны барьера. Честному человеку с плохим зрением или медленным соединением капча создаёт препятствие, о котором стандарты доступности говорят не первый год. Оператор фермы распознавания платит копейки за тысячу решений и проходит дальше. Соотношение усилий получилось обратным ожиданиям: свои страдают, чужие почти не замечают. Место капчи всё-таки есть, но не в API, а в браузерных формах, и к этому месту статья ещё вернётся. Есть и арифметика в лоб. Ночная синхронизация, которая делает двадцать тысяч обращений по расписанию, при капче на входе потребует двадцать тысяч человеческих решений. Любой, кто отвечал за интеграции, увидит здесь противоречие сразу. С квотами та же ситуация решается строкой в тарифе: для пакетных задач выделяется отдельный лимит с отдельным окном. Механизм, который ломает законные сценарии, в итоге просто отключают, и сервис возвращается к исходной уязвимости.
Ограничение частоты запросов в роли главного рубежа на входе
Идея квоты проста до арифметики: не запрещать вход, а нормировать темп. Клиенту разрешается столько-то обращений за окно времени, всё сверх этого получает отказ с кодом 429. Вопрос в том, как именно считать окно, потому что от этого зависит, насколько ровно квота режет поток.
Фиксированное окно считается проще всех, но на границе отрезка честный лимит превращается в двойной: сто обращений в конце минуты и сто в начале следующей складываются в короткий всплеск, который успевает ударить по базе. Скользящее окно хранит отметки времени и убирает этот скачок ценой памяти под журнал обращений. Токен-ведро наполняется токенами с постоянной скоростью, каждый запрос забирает один токен, а накопленный запас позволяет краткому всплеску пройти без отказов. Разработчики nginx используют близкий вариант под названием дырявое ведро, по-английски leaky bucket: излишки сливаются, средний темп удерживается точно.
Отдельная история с ключом квоты. Персональный ключ для каждого интегратора это ещё и выключатель: если клиент начал вести себя плохо, его лимит сужается без правок в коде, а при необходимости ключ перевыпускается. Считать по IP-адресу легко, но за сетевым преобразованием адресов сидит целый офис, и двести сотрудников делят один лимит на всех. Токен авторизации честнее, он считает каждого клиента по его идентификатору. В практике обычно совмещают оба ключа: грубый лимит по адресу на периметре и тонкий по токену внутри приложения.
Настройка лимитов от веб-сервера до общего хранилища счётчиков
Самый быстрый рубеж поднимается на веб-сервере, до того как запрос дойдёт до кода приложения. В nginx для этого есть модуль limit_req, и его базовая конфигурация занимает пять строк.
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
server {
location /api/ {
limit_req zone=api burst=10 nodelay;
limit_req_status 429;
}
}
Здесь ключом служит двоичный адрес клиента, скорость задана тридцатью обращениями в минуту, а burst разрешает запас в десять запросов, который благодаря nodelay не подсаживает задержку и не заставляет честные всплески ждать в очереди. Директива limit_req_status меняет стандартный код ошибки на 429, чтобы клиенты отличали исчерпанную квоту от прочих отказов. Перед включением отсечения на боевых маршрутах есть безопасная репетиция: директива limit_req_dry_run on не отбивает запросы, а лишь пишет в журнал, кого отбило бы. Двух суток наблюдения обычно хватает, чтобы откалибровать скорость и burst по реальному профилю, а не по фантазиям. Прикидка по памяти проста: состояние одного адреса занимает 64 байта, значит зона в 10 мегабайт вмещает порядка 160 тысяч адресов, для приличного сервиса хватает с запасом.
Как только приложений становится несколько, локальные счётчики начинают врать, потому что каждое видит свою долю трафика. Общий склад устраивается в Redis, а добавление и срок жизни умещаются в два обращения.
const key = `quota:${ip}:${minuteWindow}`;
const hits = await redis.incr(key);
if (hits === 1) await redis.expire(key, 60);
if (hits > 100) {
res.status(429).set("Retry-After", "60").end();
return;
}
Фиксированное окно с ключом за минуту устроено элементарно, но скачок на границе отрезка остаётся. Скользящее окно поверх Redis собирается из сортированного множества: в него попадают отметки времени, устаревшие записи удаляются, счётчик берётся функцией zcard. Атомарность здесь обязательна, иначе два параллельных запроса прочтут одно значение и оба пройдут.
await redis.zremrangeByScore(key, 0, now - windowMs);
const used = await redis.zcard(key);
if (used >= limit) return tooMany();
await redis.zadd(key, now, `${now}:${requestId}`);
Запись на каждое обращение стоит памяти, поэтому такой схеме место на тяжёлых эндпоинтах, где точность важнее объёма, а для массовых маршрутов достаточно счётчика с окном.
Для сложных политик есть готовая библиотека rate-limiter-flexible с реализацией токен-ведра поверх Redis, для Express и Django существуют свои middleware, подключаемые парой строк. Свою схему имеет смысл писать, только когда требования вышли за готовые комбинации: раздельные квоты на группы эндпоинтов, приоритеты для платных тарифов, окно от недели и больше.
Вежливый отказ с числами, который экономит нервы и трафик
Отказ по квоте не должен выглядеть как обрыв провода. Ответ 429 с заголовками превращает тупик в договор: клиент видит, сколько ему осталось и когда можно продолжить.
HTTP/1.1 429 Too Many Requests
Retry-After: 27
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
Retry-After подсказывает, через сколько секунд повторить попытку, семейство X-RateLimit сообщает предел и остаток. Хорошие клиентские библиотеки читают эти числа и сами замедляются, не доводя до отказов. Крупные публичные API годами живут на этой практике, и партнёры воспринимают её как признак зрелости, а не враждебности. В теле ответа достаточно короткого сообщения с кодом ошибки и ссылкой на раздел документации про лимиты.
Той же парой заголовков продиктованы и коммерческие тарифы. Бесплатному плану достаётся скромная квота, платному щедрая, клиент видит остаток в каждом ответе, и апгрейд тарифа выглядит естественным продолжением переполненного лимита, а не ультиматумом. Счётчик отказов по каждому маршруту стоит завести в системе метрик: всплеск числа 429 означает либо атаку, либо ошибку настройки, и в обоих случаях об этом лучше узнать из графика, чем из письма партнёра.
У капчи такого словаря нет. Её язык это экран и молчание, а программному клиенту нужны номер, число и время. Клиентская сторона тоже живёт по правилам: получив 429, библиотека уменьшает параллелизм, выдерживает указанную паузу и повторяет попытку, экспоненциальное нарастание задержки при повторных отказах снимает напряжение само собой. Кто игнорирует Retry-After, обычно сам же и страдает от вечных отказов. Квоты говорят с машинами на их языке, поэтому договор выполняется с обеих сторон, а не подменяется состязанием в распознавании картинок.
Последовательность внедрения лимитов от измерения до тонких квот
На живом проекте этот рубеж обычно ставится за одну итерацию, если идти в правильном порядке:
- Снять профиль реального трафика и выбрать ключ квоты, адрес, токен или их сочетание;
- Установить лимиты на веб-сервере с запасом в два-три раза выше измеренного честного максимума;
- Вернуть код 429 с заголовками Retry-After и X-RateLimit, чтобы клиентские библиотеки могли подстраиваться;
- Вынести тяжёлые эндпоинты в отдельные зоны со строгими квотами, поиск и выгрузки не должны делить лимит с проверкой баланса;
- Перенести счётчики в Redis, когда приложение размножится на несколько машин;
- Добавить капчу точечно, и только в браузерные сценарии с риском злоупотребления вроде формы входа.
Плюс неделя наблюдения за журналом отказов: если честные пользователи ловят 429, лимит сжат, если боты проходят свободно, он разжат. Настройка это не разовое решение, а гайка, которую подкручивают по профилю трафика.
Где проходит граница между квотами и проверкой на человека
Вывод, к которому приводит вся арифметика выше, звучит так: капча и квоты защищают от разных вещей и потому не заменяют друг друга. Капча фильтрует не ботов, а отсутствие у клиента браузера, её законное место в человеческих сценариях на сайте. Квоты не отличают злой умысел от усердия, зато переводят конфликт в измеримую плоскость: темп, остаток, время ожидания. Публичному API нужен именно договор о темпе, потому что его клиенты это программы.
Хороший стек выглядит буднично: грубые пороги на периметре, где облачный шлюз отсекает ливни ещё до входа в сеть, тонкие квоты по токенам в приложении, общий склад счётчиков в Redis, честные ответы 429 с числами. Капча остаётся точечным инструментом на границе между человеком и браузером и в протокол API не вмешивается. Сначала норма, затем отказ с числами, и лишь для самых подозрительных человеческих форм испытание картинкой. Так сервис остаётся открытым для интеграций и закрытым для штормов.