Статья Rate Limiting в API: типовые ошибки, проверка устойчивости и защита от abuse и DoS

1774651250156.webp

С rate limiting обычно знакомятся в довольно скучный момент - когда кто-то внезапно начинает долбить API не так, как задумывал разработчик. Где-то это упирается в перебор логина, где-то в слишком бодрый клиент, где-то в тяжёлый GraphQL-запрос, который сам по себе всего один, а по факту съедает ресурсов как маленькая авария. На бумаге защита вроде бы есть: лимит выставлен, порог задан, 429 возвращается. На практике быстро выясняется, что сам факт “у нас включён rate limiting” ещё ничего не гарантирует.

Проблема в том, что лимит почти никогда не ломается в одном красивом месте. Он разваливается на стыке прокси, балансировщика, API-шлюза, приложения, ключа агрегации и логики самого запроса. Один считает по IP, второй - по токену, третий вообще смотрит только на число HTTP-запросов, как будто один GraphQL-вызов с глубокой выборкой и один короткий GET /health стоят для системы одинаково. Поэтому реальные проблемы здесь начинаются не с отсутствия лимита как такового, а с того, что он считает не то, не там и не тем ключом. OWASP в API Top 10 2023 как раз уводит разговор шире простого “не хватает rate limiting” в сторону API4: Unrestricted Resource Consumption - когда API неправильно ограничивает расход CPU, памяти, полосы, внешних интеграций и других ресурсов.

Отдельная боль в том, что современные API давно перестали быть ровным потоком одинаковых запросов. У GraphQL Foundation в актуальных рекомендациях по безопасности это разобрано довольно прямо: ограничивать нужно не только частоту, но и “стоимость” операции - глубину, ширину, пагинацию, сложность запроса. Иначе система честно выдерживает формальный лимит по числу обращений, но при этом спокойно принимает запросы, которые сами по себе слишком дорогие. На стороне WAF та же история: и Cloudflare, и AWS давно ушли от примитивной модели “считаем только IP” и позволяют строить rate limiting по нескольким признакам сразу - IP, заголовкам, cookie, методу, аргументам запроса и их комбинациям. Это уже хороший намёк на то, где обычно проседают наивные реализации.

Поэтому разберём тему со стороны того, кто хочет не просто “включить лимит”, а реально удержать API под нагрузкой. Посмотрим, где rate limiting даёт слабину, как его проверять без цирка и как собрать защиту так, чтобы она не разваливалась на GraphQL, прокси-заголовках и распределённой архитектуре.

Почему rate limiting почти никогда не сводится к одному счётчику​

Rate limiting любят показывать как простую механику: задали порог, выбрали окно, вернули 429 Too Many Requests и закрыли тему. В реальной системе всё быстро оказывается грязнее. Один и тот же API одновременно держит мобильных клиентов, внутренние сервисы, фоновые задачи, партнёрские интеграции, иногда ещё и GraphQL поверх всего этого. У каждого свой ритм, свой профиль запросов и своя цена ошибки. Поэтому сам вопрос здесь обычно звучит не “есть ли у нас лимит”, а “что именно мы ограничиваем и по какому признаку”. OWASP в API Top 10 2023 как раз ставит проблему шире простого brute-force и уводит её в сторону Unrestricted Resource Consumption: CPU, память, полоса, деньги на внешние вызовы и другие ресурсы, которые API может расходовать слишком свободно.

Вот здесь всё и начинает ехать. Для шлюза запросы часто выглядят одинаково - метод, путь, код ответа. Для приложения разница может быть огромной. Один вызов дешёвый, другой за тот же один HTTP-запрос тащит тяжёлую выборку, внешние вызовы и пачку обращений к базе. Если защита считает только количество запросов, она уже считает не то.

Отсюда и главный вопрос: вы вообще ограничиваете частоту, цену операции или смесь из нескольких признаков? Пока на это нет внятного ответа, спорить про алгоритмы рано.

1.webp


Fixed window, sliding window и token bucket​

Если убрать маркетинг, у rate limiting есть несколько базовых моделей, и у каждой свой характер. Fixed window - самая простая. Берётся окно, например минута, и внутри него считается число запросов. Пока порог не пробит, всё хорошо. Как только пробит - клиент получает блокировку или замедление. У этой модели есть старая проблема: граница окна. Если клиент успел отправить всплеск в конце одной минуты и сразу продолжил в начале следующей, система может пропустить два плотных пакета трафика почти подряд, хотя формально “лимит соблюдён”. Это не баг реализации, а сама природа fixed window. Такой же подход часто и собирают на Redis через INCR и EXPIRE - обычный счётчик в жёстком окне.

Sliding window пытается эту грубость сгладить. Он считает не “сколько запросов было в этой минуте календарно”, а “сколько запросов было за последние N секунд от текущего момента”. Для живого трафика это обычно ведёт себя аккуратнее, особенно на границах окна. Цена вопроса - более сложный расчёт и более аккуратное хранение состояния. Никакой магии тут нет: модель просто честнее считает плотность трафика во времени. Поэтому её любят там, где важна ровность поведения, а не только простота счётчика.

Token bucket решает немного другую задачу. Он даёт клиенту запас токенов, который можно расходовать, а затем постепенно пополняет его. За счёт этого система лучше переносит короткие bursts - нормальные человеческие всплески, мобильный клиент после потери сети, пачку фоновых запросов после пробуждения приложения. Но у token bucket есть важное ограничение: он хорошо работает только пока вы ограничиваете правильную сущность. Если лимит считается по неправильному ключу - например, по IP там, где у вас за одним адресом сидит NAT, прокси или наоборот целая группа разных клиентов, - алгоритм уже не спасает.

Почему один и тот же порог может быть и бесполезным, и слишком жёстким​

Самый частый провал здесь не в формуле, а в модели трафика. Команда берёт красивое число - например, 100 запросов в минуту - и делает вид, что это уже защита. Потом оказывается, что для одного клиента порог слишком жёсткий, для другого слишком мягкий, а для дорогих операций вообще бессмысленный.

GraphQL тут просто нагляднее всего. Один HTTP-запрос может тащить на себе слишком много работы, и никакой “лимит 60 запросов в минуту” сам по себе это не чинит.

С REST та же история, просто она не так бросается в глаза. GET /users/42 и GET /reports/export?period=365d для шлюза выглядят как два обычных запроса, а для приложения это вообще разная работа. Поэтому лимит без модели стоимости почти всегда начинает врать.

Где лимит начинает ломаться ещё до приложения​

Очень много проблем появляется вообще не в коде приложения, а раньше - на прокси, CDN, балансировщике и API-шлюзе. Именно там система решает, кого считать одним и тем же клиентом. Cloudflare в своей документации прямо показывает, что rate limiting можно строить по IP, ASN, cookie, заголовкам, JA3/JA4 fingerprint и другим характеристикам запроса. AWS WAF тоже уводит rate-based rules в сторону нескольких ключей агрегации, а не только одного IP. Это хороший маркер реальности: считать только IP уже давно мало.

И вот здесь начинается неприятная инженерная зона. Если вы верите любому X-Forwarded-For, но не контролируете, кто вообще имеет право его выставлять, вы уже отдали часть логики лимита на вход внешнему трафику. Если CDN считает по одному признаку, а приложение по другому, у вас два разных мира вместо одного. Если лимит стоит на шлюзе, но не понимает, что один endpoint дешёвый, а другой дорогой, защита начнёт либо душить нормальных клиентов, либо пропускать слишком затратные сценарии.

Поэтому rate limiting почти никогда не должен жить как одинокий счётчик в одном слое. Его нормальная форма - это набор ограничений по нескольким ключам: IP, учётка, токен, путь, метод, стоимость операции, иногда ещё и поведенческие сигналы поверх этого.

Если хочется посмотреть на тему шире - не только через лимиты, но и через общую логику защиты API, - загляните в статью "Защита API: OWASP Top 10 и примеры на Python".

Где rate limiting обычно ломается в реальной архитектуре​

С лимитом почти никогда не бывает одной красивой точки отказа. Обычно всё прозаичнее: один слой считает клиента по IP, второй - по токену, третий - по пути и методу, а приложение в это время живёт своей жизнью. На бумаге защита есть. В реальном трафике это уже несколько разных счётчиков, которые между собой толком не согласованы.

Один и тот же запрос проходит через несколько точек принятия решения, и в каждой его считают по-своему. Для edge это IP и, может быть, отпечаток TLS. Для reverse proxy - цепочка прокси-заголовков. Для gateway - токен, cookie, метод и путь. Для приложения - уже учётка, арендатор, сессия и цена самой операции. Если эти уровни не договорились, лимит начинает считать не то, что реально надо защищать.

2.webp


Прокси-заголовки и доверие к цепочке​

С forwarded-заголовками всё упирается в очень простую вещь: кто именно имеет право их формировать. RFC 7239 описывает Forwarded как стандартный способ передавать информацию, которая теряется при проксировании - например, исходный адрес клиента или адресы прокси в цепочке. Сам по себе этот механизм не делает заголовок “истиной”. Он просто даёт способ передавать эту информацию дальше по доверенной цепочке компонентов. Из этого следует неприятный, но важный вывод: как только приложение или gateway начинают без оглядки верить таким заголовкам вне чёткой модели доверенных прокси, логика агрегации уже становится хрупкой.

На практике это один из самых частых провалов в архитектуре защиты. Снаружи запрос пришёл через CDN и reverse proxy, внутри есть ещё один балансировщик, а приложение в итоге берёт себе “клиентский IP” из не того места или без проверки того, кто вообще этот заголовок добавил. После этого один слой считает по одному адресу, другой по другому, а третий уже работает с токеном и даже не понимает, что наверху клиент был посчитан иначе. Для rate limiting это плохой сценарий не потому, что он “сложный”, а потому что начинает ломать сам ключ агрегации.

Gateway считает одно, приложение тратит другое​

Очень много ошибок живёт на границе между API gateway и приложением. Gateway чаще всего видит путь, метод, статус-код, токен, часть заголовков и, в лучшем случае, ещё какие-то атрибуты сессии. Этого хватает, чтобы резать грубые всплески на /login, лимитировать отдельные методы и держать под контролем публичные endpoint’ы. Но gateway почти никогда не понимает, насколько дорогим окажется запрос внутри кода приложения.

Отсюда и старая проблема: лимит на gateway может выглядеть разумно, но ничего не говорить о цене операции. Один endpoint дешёвый и почти целиком живёт в кэше. Другой тянет тяжёлую выборку, агрегацию, несколько походов в базу и внешний биллинг. Формально оба могут жить под одной и той же цифрой “30 запросов в минуту”. Для системы это вообще не одно и то же. OWASP прямо увязывает эту зону с unrestricted resource consumption: для API опасны не только частые запросы, но и любые обращения, которые непропорционально расходуют вычислительные, сетевые и даже платные внешние ресурсы.

Один IP - почти всегда слишком грубо​

IP по-прежнему полезен, но как единственная опора он давно слабый. За одним адресом может сидеть NAT, мобильная сеть, корпоративный proxy, интеграция или просто пачка легитимных клиентов. Поэтому считать только по IP - почти всегда слишком грубо.

Для архитектуры вывод простой: лимит надо строить сразу по нескольким признакам идентификации. IP остаётся полезным как внешний сетевой маркер. Но рядом обычно должны жить учётка, API key, session cookie, tenant, путь, метод и иногда ещё стоимость операции. Чем раньше команда это принимает, тем меньше потом удивляется, почему “нормальный лимит на gateway” не спасает тяжёлые endpoint’ы и почему часть легитимных клиентов внезапно упирается в порог сильнее, чем абьюзный трафик.

Нормализация пути и ключа запроса​

Есть ещё одна прозаичная зона, которую часто забывают: как именно система приводит путь и признаки запроса к единому виду перед тем, как считать. Регистр, завершающий слэш, разные формы одного и того же пути, вариации query string, методы и параметры - всё это может жить в нескольких слоях по-разному. Один слой считает /api/v1/users и /api/v1/users/ одной сущностью, другой - разными. Один нормализует регистр, другой нет. Для rate limiting это не косметика, а вполне практическая разница: если ключ агрегации собирается не из канонического представления запроса, лимит начинает зависеть от особенностей маршрутизации, а не от логики защиты.

Именно поэтому у многоуровневого rate limiting всегда есть скучная, но важная инженерная часть: договориться, что именно считается одной и той же операцией. Без этой нормализации даже хороший алгоритм и аккуратный distributed store начинают считать мир в разных координатах.

GraphQL и дорогие операции​

С обычным REST всё привычнее: по пути и методу ещё можно примерно понять, где вызов дешёвый, а где сейчас начнутся лишние походы в базу и внешние сервисы. С GraphQL этот фокус быстро ломается. Снаружи всё тот же POST /graphql. Внутри - совершенно разный объём работы. GraphQL Foundation поэтому и советует смотреть не только на частоту, но и на глубину, ширину, лимиты пагинации и общую стоимость операции.

Вот здесь обычный лимит и начинает врать. Формально он есть: порог задан, счётчик тикает. А система всё равно получает запросы, которые сами по себе слишком дорогие. OWASP в API4:2023 прямо пишет об этом же, только шире: проблема не в одном только числе запросов, а в том, что API может неправильно ограничивать расход CPU, памяти, полосы, хранилища и даже платных внешних интеграций.

3.webp


Когда один запрос стоит как маленькая авария​

Самая неприятная ошибка здесь очень простая: команда считает запросы просто потому, что так удобнее. Один запрос - одна единица. Десять запросов - десять единиц. Дальше из этого рождаются красивые пороги в минуту, в секунду, по IP, по токену, по сессии. Пока запросы более-менее одинаковые, схема живёт. Как только появляются дорогие методы, она начинает давать сбой.

У GraphQL это видно особенно хорошо. Один запрос может тянуть список, внутри него ещё список, а внутри - несколько полей, каждое запускает свою работу в бекенде. Снаружи всё тихо: один клиент, один HTTP-запрос. А внутри уже полетели тяжёлые выборки, агрегации и внешние вызовы. GraphQL Foundation поэтому отдельно рекомендует ограничивать max depth, отдельно следить за вложенностью списков и вводить cost analysis для самих операций.

В REST та же история просто реже бросается в глаза. Если у тебя рядом живут GET /profile и GET /reports/export?period=365d, ставить на них один и тот же лимит “60 запросов в минуту” - плохая идея. Не потому что цифра сама по себе плохая, а потому что она ничего не говорит о цене этих двух вызовов для системы.

Глубина, ширина и почему aliases тоже надо считать​

С GraphQL быстро выясняется, что одной глубины мало. Можно ограничить depth, но оставить широкую выборку. Можно обрезать пагинацию, но оставить слишком вольные aliases. Можно закрыть одну форму дорогого запроса и спокойно пропустить другую.

Поэтому защита здесь почти всегда многослойная: ограничение глубины, отдельный контроль вложенных списков, верхняя граница для pagination arguments и рядом - хотя бы приблизительная оценка стоимости самой операции. Не “сколько пришло HTTP-запросов”, а “сколько работы этот запрос сейчас просит у системы”.

И вот тут rate limiting начинает пересекаться уже не только с API gateway, но и с логикой самого приложения. Потому что стоимость запроса знает не edge и не прокси. Её по-настоящему понимает только тот слой, который видит схему, резолверы, походы к базе и внешние вызовы.

Дорогие операции бывают не только в GraphQL​

GraphQL тут просто нагляднее, но дорогие операции живут в любом API. Длинные фильтры, жирные отчёты, массовые выгрузки, поиск по большим индексам, генерация документов, экспорт истории, цепочки внешних вызовов - всё это быстро ломает наивный счётчик по частоте.

Поэтому хороший rate limiting почти всегда приходится делить на два контура. Первый - грубый по числу обращений. Он режет шум, перебор и тупой abuse. Второй - по стоимости операции. Он уже защищает систему от вызовов, которые сами по себе слишком тяжёлые. Эти два слоя не заменяют друг друга. Они работают рядом.

Почему “дорогой запрос” лучше считать заранее​

Самая неприятная ситуация - когда система понимает, что запрос дорогой, уже после того как его начала исполнять. В этот момент часть ущерба уже случилась: память занята, запросы в базу пошли, кэш прогрелся не туда, внешние вызовы улетели. Поэтому всё, что можно проверить до выполнения, лучше проверять до выполнения. Именно так GraphQL Foundation и формулирует рекомендации вокруг demand control: ограничивать объём данных, глубину и сложность запроса до запуска его в обработку.

Для REST мысль та же, просто она редко формулируется так явно. Если у тебя есть параметры, которые резко меняют цену операции - размер страницы, глубина вложенных объектов, период выгрузки, число возвращаемых элементов, флаги “include everything” - их лучше резать на входе, а не надеяться, что потом rate limiting как-нибудь усреднит ситуацию.

Если смотреть на API глазами защитника, обычно ищут три вещи: самые дорогие запросы по метрикам, параметры, которые сильнее всего раздувают их цену, и место, где такие операции можно отрезать раньше - на валидации, на схеме, на gateway или в бизнес-логике.

Если хочется отдельно раскопать GraphQL и gRPC как источник дорогих и неудобных для защиты вызовов, посмотри статью "Веб-пентест 2026: новые векторы атак на GraphQL и gRPC API".

Как проверять rate limiting без цирка и без вредных сценариев​

С rate limiting часто делают одну и ту же глупость: не разобравшись, что именно считает защита и где у неё границы, сразу начинают давить API как придётся. В итоге тест быстро превращается либо в шум, который ничего не доказывает, либо в кривую имитацию DoS, после которой команда знает только одно: если долго стучать по системе, ей становится плохо. Это и так было понятно.

Нормальная проверка начинается куда скучнее. Сначала снимают baseline: как endpoint ведёт себя в штатном ритме, какие заголовки возвращает, где появляются 429, есть ли Retry-After, одинаково ли считаются разные ключи идентификации, какие методы и пути реально сидят под лимитом. Для REST сервисов 429 Too Many Requests давно считается штатным ответом именно под rate limiting или подозрение на DoS, это отдельно отмечено и в OWASP REST Security Cheat Sheet. А в WSTG OWASP сама идея теста на limits формулируется довольно прямо: приложение должно уметь не давать пользователю выходить за допустимое число операций, а проверка начинается с того, как именно этот предел вообще устроен.

Только после этого есть смысл идти дальше - не в режиме “давай насыпем побольше и посмотрим, что отвалится”, а в режиме аккуратного наращивания. Если API живёт в production, такие проверки лучше заранее согласовывать с владельцем сервиса, SRE и теми, кто отвечает за откат. Если есть staging, ещё лучше: сначала проверить механику там, а в production идти уже с понятным планом и узким окном. Смысл не в том, чтобы любой ценой “уронить” лимит. Смысл в том, чтобы понять, считает ли система именно то, что должна считать.

4.webp


Baseline: без него дальше вообще не о чем говорить​

Если endpoint ни разу не посмотрели в нормальном режиме, дальше начинается гадание. Поэтому сначала снимают очень простые вещи: обычную задержку, форму ответа, заголовки, поведение при мягком росте частоты, момент появления 429, наличие Retry-After, различия между разными путями, методами и типами клиента. Для OWASP WSTG сам смысл таких тестов в том и состоит, чтобы понять, как система ограничивает число операций и где это ограничение начинает работать.

На этом этапе лимит не надо “побеждать”. Сначала надо понять, как он вообще устроен. Он режет по IP или по учётке? Одинаково ли считает авторизованный и неавторизованный вызов? Возвращает ли одинаковые ответы на разных путях? Есть ли мягкий порог до жёсткого блока? Не начинает ли система деградировать раньше, чем вообще возвращает 429? Для отчёта это полезнее любой красивой истории в духе “мы всё-таки продавили лимит”.

Мягкое наращивание лучше тупого шума​

Дальше уже можно двигаться к проверке устойчивости, но в рамках нормальной дисциплины. Не бить сервис максимально возможным темпом, а увеличивать частоту постепенно и смотреть, что именно начинает меняться. Иногда лимит срабатывает ровно. Иногда до 429 уже растёт латентность, отваливаются соседние запросы, меняются заголовки, включается challenge, проседает внешний сервис, а сам rate limiter продолжает делать вид, что всё штатно. Вот это и надо ловить.

Для GraphQL сюда добавляется ещё один слой. GraphQL Foundation отдельно рекомендует demand control: ограничение глубины, ширины, пагинации и сложности. Значит, и в тестировании надо смотреть не только на “сколько запросов прошло”, но и на то, что происходит с более затратными операциями. Один и тот же темп на двух GraphQL-запросах может давать совершенно разную картину просто потому, что у них разная цена внутри приложения.

Что фиксировать по ходу проверки​

Здесь очень легко свалиться в привычную для AppSec яму: написать в отчёте “rate limiting можно обойти” или “rate limiting работает некорректно”, а дальше оставить всем самим догадываться, что это значит. Намного полезнее фиксировать картину слоями.

Сначала - по какому признаку реально считает защита.
Потом - где этот счётчик живёт: edge, gateway, приложение.
Потом - как выглядит срабатывание: 429, Retry-After, challenge, сброс соединения, замедление, частичный блок.
Дальше - начинает ли система деградировать раньше, чем лимит официально включился.
И отдельно - одинаково ли это ведёт себя для дешёвых и дорогих операций.

OWASP API4:2023 как раз и подталкивает смотреть не только на “есть limit / нет limit”, а на то, ограничивает ли API потребление ресурсов адекватно. Это совсем другой уровень отчёта. Не “лимит отсутствует”, а “лимит считает только частоту, но не контролирует дорогие операции, из-за чего дорогой endpoint начинает грузить систему до срабатывания формального порога”. Это уже полезно и для разработчика, и для SRE, и для владельца продукта.

Как строить защиту, которая не разваливается​

Самая частая ошибка здесь выглядит дефолтно: команда ставит один лимит на входе, получает первые 429 и решает, что вопрос закрыт. Через какое-то время выясняется, что этот лимит режет самые простые случаи, но плохо понимает реальную жизнь API: где сидят тяжёлые методы, где начинается дорогой поиск, где внешний сервис берёт деньги за каждый вызов, где один tenant может спокойно задавить соседнего, а где публичный endpoint и служебный метод почему-то живут под одним и тем же порогом. OWASP API4:2023 как раз про это и говорит - проблема не только в частоте, а в том, что API может слишком свободно расходовать CPU, память, полосу, хранилище и даже оплачиваемые внешние интеграции вроде SMS, почты или биометрии.

Поэтому нормальная защита почти никогда не живёт в одном счётчике. Её собирают слоями. Самый внешний слой режет грубый поток ещё до приложения. Дальше идёт более точный счёт на gateway или edge-уровне - по пути, методу, токену, cookie, maybe заголовкам, если они действительно входят в доверенную модель. И уже рядом с приложением появляется третий уровень, который смотрит не только на частоту, но и на цену операции. Иначе у тебя всегда будет перекос: либо система душит нормальных клиентов за слишком общий порог, либо пропускает дорогие вызовы просто потому, что они формально укладываются в “20 запросов в минуту”. Cloudflare и AWS WAF давно идут именно в эту сторону: лимиты там можно собирать не только по IP, а по комбинациям признаков запроса - заголовкам, cookie, query arguments и другим полям.

5.webp


Один лимит на всех почти всегда получается кривым​

API редко живёт в одном ритме. /login, /search, /graphql, /reports/export, отправка OTP и внутренний служебный endpoint никогда не стоят для системы одинаково. Но очень многие реализации почему-то начинают именно с этого - одна цифра на весь класс запросов. Потом начинается знакомый цирк. На логин лимит слишком мягкий. На поиск слишком жёсткий. На экспорт он вообще ничего не значит. А на GraphQL он делает вид, что один короткий запрос и одна жирная операция через ту же точку входа - это одинаковая нагрузка.

Отсюда и первое правило: лимиты надо резать по типам операций, а не держать в одной куче. Отдельно аутентификация и восстановление доступа. Отдельно поиск и тяжёлые чтения. Отдельно дорогие бизнес-операции, где каждая ошибка может стоить не CPU, а денег. Отдельно внутренние служебные методы. Это скучная работа, но без неё rate limiting очень быстро становится декоративным.

Edge должен резать шум, а не притворяться приложением​

От edge и WAF часто ждут слишком многого. Отсюда потом и разочарование: “мы же поставили rate rules, почему backend всё равно проседает?”. Да потому что edge хорошо умеет одно - быстро и дёшево резать грубый поток. Он хорошо видит всплески по IP, по токену, по cookie, по отдельным маршрутам, по части заголовков. Он может отфильтровать простой перебор, склеить challenge-механизм, поджать слишком агрессивных клиентов до того, как они доедут до origin. Cloudflare это прямо и продаёт: блокировать excessive traffic на edge до того, как он ляжет на origin.

Но edge не должен притворяться приложением. Он не знает реальную цену запроса. Он не знает, сколько SQL-вызовов сейчас уйдёт за POST /graphql, насколько тяжёлый этот отчёт и какие внешние интеграции зацепит конкретный бизнес-метод. Если пытаться закрыть всё одним внешним слоем, защита получится красивой только на картинке.

Для GraphQL без demand control всё быстро становится грустно​

С GraphQL это особенно заметно, и тут уже не отмахнёшься. Если API живёт на GraphQL, ограничивать только число HTTP-запросов - это слишком наивно. GraphQL Foundation в текущих рекомендациях прямо советует demand control: лимиты пагинации, ограничение глубины и ширины, query complexity analysis. И это не “опциональные украшения”, а базовая гигиена, если ты вообще хочешь, чтобы rate limiting видел дорогие операции, а не только количество POST /graphql.

По-хорошему GraphQL надо защищать двумя слоями сразу. Снаружи - общий лимит по частоте, чтобы не ловить тупой abuse и не оставлять точку входа без внешнего контроля. Внутри - отдельный контроль стоимости операции, который понимает глубину, пагинацию, breadth и общую цену запроса для схемы и резолверов. Иначе система может честно выдерживать “50 запросов в минуту”, а на деле каждая из этих пятидесяти операций будет стоить как маленькая авария.

Общее состояние должно быть действительно общим​

Эта ошибка старая, но её до сих пор умудряются допускать. В одном инстансе всё выглядит прилично: лимит срабатывает, счётчик живёт, тест проходит. Как только сервис масштабируется горизонтально, внезапно оказывается, что каждый экземпляр считает отдельно. Для кластера это уже не “100 запросов в минуту”, а сколько получится суммарно по всем нодам. Дальше начинается классическое “в стейдже всё было нормально, а в production почему-то нет”.

Поэтому если API живёт на нескольких инстансах, state для rate limiting должен быть общим. Не локальная память процесса, не случайный счётчик на одном поде, а нормальный распределённый слой. Обычно это и приводит всех к Redis или другому общему хранилищу состояния. И тут важна не только сама идея общего счётчика, но и аккуратность обновления, чтобы окно, инкремент и TTL не жили каждый своей жизнью. AWS WAF, кстати, отдельно предупреждает, что его rate-based rules отслеживаются на уровне конкретного rule instance, а не как какая-то глобальная магия “на весь ваш мир”, так что здесь тоже нельзя придумывать больше согласованности, чем есть на самом деле.

Challenge-механизмы полезны, но сами по себе они проблему не решают​

Когда лимит начинает срабатывать, многие команды сразу тянутся к CAPTCHA или challenge-механике. Это нормальный шаг, но не серебряная пуля. Такая эскалация хорошо работает на границе, где нужно отличить обычного пользователя от странного потока и не резать всё насмерть сразу. Но challenge не лечит архитектурные проблемы. Если лимит считает не ту сущность, если дорогие операции живут мимо него, если tenant’ы не разделены, если внешний слой не согласован с приложением - CAPTCHA просто прикроет симптом.

Поэтому challenge хорош именно как промежуточный слой: мягкий порог, потом challenge, потом жёсткий блок. Не вместо нормального rate limiting, а рядом с ним.

Без наблюдаемости лимит быстро слепнет​

Это место почему-то недооценивают постоянно. Rate limiting без метрик и нормальных логов очень быстро становится вещью “вроде включено, вроде работает”. А потом никто не может ответить на простые вопросы: какой ключ реально сработал, где именно лимит включился, какие маршруты чаще всего упираются в порог, какая доля блоков оказалась легитимной, не начал ли один tenant душить соседнего, не стал ли challenge включаться слишком поздно.

Здесь нужен не пафосный “AI anomaly detection”, а обычная инженерная наблюдаемость. Видно ли число срабатываний по маршрутам. Видно ли их по tenant’ам, токенам, cookie, IP и аккаунтам. Видно ли, что происходит с latency и ошибками до срабатывания лимита. Видно ли дорогие запросы отдельно от дешёвых. Без этого rate limiting превращается в чёрный ящик, который или режет слишком много, или режет слишком поздно.

Здесь не надо выбирать между защитой и удобством​

Это вообще ложная развилка, хотя её любят почти все. Хороший rate limiting не должен одновременно бесить всех легитимных клиентов и пропускать то, что реально опасно. Для этого его и приходится собирать из нескольких слоёв, а не из одной цифры в конфиге. Внешний - грубый и дешёвый. Внутренний - ближе к приложению и к реальной цене операции. Отдельный - для дорогих бизнес-методов. И всё это на общем состоянии, с понятной нормализацией ключей и с нормальной наблюдаемостью.

Если делать по-человечески, то защита начинает выглядеть не как “мы поставили ограничение”, а как вполне внятная система:
  • edge режет мусор;
  • gateway считает то, что реально видно на HTTP-слое;
  • приложение понимает стоимость операции;
  • распределённый счётчик не расползается по инстансам;
  • challenge включается как промежуточный слой, а не как замена логике;
  • метрики показывают, что происходит на самом деле.

Где rate limiting перестаёт быть формальностью​

Rate limiting почти никогда не проваливается из-за того, что кто-то забыл поставить цифру в конфиге. Обычно всё ломается тише и скучнее: лимит считают не по той сущности, доверяют не тем заголовкам, режут частоту, но не видят цену операции и слишком рано успокаиваются после первых 429. Снаружи защита вроде бы есть. Внутри API продолжает отдавать дорогие GraphQL-запросы, тяжёлые выборки, поиск, отчёты и бизнес-методы так, будто никто их всерьёз не считал. Поэтому разговор про rate limiting на практике всегда шире одного счётчика - это уже вопрос архитектуры, общей точки агрегации, стоимости запроса и того, насколько разные слои системы вообще согласованы между собой.

Нормальная защита здесь собирается не из одной “правильной” настройки, а из нескольких слоёв, у каждого из которых своя работа. Внешний слой режет грубый шум. Gateway считает HTTP-картину и разные классы клиентов. Приложение понимает, где запрос действительно дорогой. Общее состояние держит лимит одинаковым для всех инстансов. Метрики и логи показывают, что происходит на самом деле, а не в красивом отчёте после релиза. Как только один из этих кусков выпадает, rate limiting быстро превращается в формальность: вроде включён, а защищает не то место, которое реально болит.

И здесь для любого API остаётся один неприятный вопрос: ваш rate limiting действительно ограничивает то, что дорого для системы, или он до сих пор просто считает запросы и делает вид, что этого достаточно?
 
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab