Rate limiting — как тестировать лимиты, о которых вспоминают после инцидента
Rate limiting — это как ремень безопасности: пока не случилось аварии, его отсутствие никак не ощущается. Продукт может годами жить без лимитов — а потом один энтузиаст со скриптом перебирает промокоды, автодополнение в поиске с каждой буквы долбит тяжёлый эндпоинт, или ретраи клиентов после короткого сбоя добивают сервер окончательно. И вот тогда лимиты появляются — за одну ночь, на нервах, без тестирования.
Я предпочитаю другой порядок: лимиты — это фича, у неё есть требования и её можно тестировать заранее. Причём тестировать надо обе стороны контракта: что сервер ограничивает — и что клиент это ограничение переживает.
Зачем лимиты (и где они обязаны быть)
Лимит — это не одна защита, а четыре разных, и у каждой свои требования. Перебор: логин, OTP-коды (шестизначный код без лимита перебирается за вменяемое время — это 10⁶ вариантов), промокоды, проверка «занят ли email». Деньги: эндпоинты, порождающие SMS/письма/платные вызовы — усиление стоимости, о котором я писал в разборе OWASP API Top 10. Стабильность: защита от retry storms и одного тяжёлого клиента — про это был пост про idempotency и ретраи. Честность: API-квоты по тарифам, где лимит — буквально то, за что платят.
Первый вопрос тестировщика — не «работает ли лимит», а «на всех ли критичных зонах он вообще есть». Login, password reset, OTP, промокоды, отправка сообщений, тяжёлый поиск, экспорт — по каждому: какой лимит, в каком скоупе? Ответ «нигде не записано» — уже находка.
Граница и честный 429
Базовый тест скучен и обязателен: N запросов проходят, N+1 упирается. Граница ±1, как в любом чек-листе валидации. Дальше — качество ответа: это должен быть 429 Too Many Requests (введённый RFC 6585), с заголовком Retry-After и машиночитаемым телом, а не HTML-страница nginx и не загадочный 500. Клиенту нужно знать две вещи: «это лимит, а не поломка» и «когда можно снова». Если ответ этого не сообщает — клиенты будут ретраить вслепую, то есть лимит сам спровоцирует шторм, от которого должен защищать.
И проверка восстановления: окно прошло — лимит отпустил. Звучит очевидно, но «залипший» лимит (сброс не работает, юзер заблокирован навсегда до ручного вмешательства) — реальный класс багов, который снаружи выглядит как «у случайных юзеров всё сломано».
Скоуп ключа: самая интересная часть
По какому ключу считается лимит — по юзеру, по IP, по токену, по девайсу? Здесь живут самые дорогие ошибки, и у каждого варианта свой набор:
- Лимит по IP наказывает невиновных: офис, университет, мобильный оператор за NAT — сотни людей с одного адреса. Тест: достигни лимита — и проверь, что залочен ты, а не «все из этой сети».
- Лимит по аккаунту на логине — готовый DoS чужого аккаунта: атакующий N раз вводит неверный пароль жертвы — и жертва заблокирована. Правильные схемы комбинируют (IP + аккаунт, прогрессивные задержки, капча) — но какая схема у вас, знает обычно только автор кода. Спроси.
- Доверие к X-Forwarded-For: если лимит по IP, а IP берётся из заголовка, который клиент может прислать сам, — лимит обходится одной строчкой в curl. Классика, проверяется за минуту.
И отдельно — консистентность между путями: лимит на /api/v1/login есть, а на /api/v2/login или на эндпоинте мобильного приложения с той же функцией — забыли. GraphQL — особый случай: один HTTP-запрос может содержать сотню операций, и лимит «по запросам» его не видит (нужны depth/complexity-лимиты — тоже из OWASP-разбора).
Окна: burst на стыке
Если лимит реализован как fixed window («100 запросов в минуту, счётчик сбрасывается в :00»), на стыке окон проходит двойной burst: 100 запросов в 59-ю секунду и ещё 100 в 01-ю — 200 запросов за две секунды при «лимите 100 в минуту». Для защиты стабильности это может быть критично. Тест простой: упрись в лимит в конце окна и повтори сразу после сброса — какой фактический burst прошёл? Sliding window и token bucket этим не страдают, но что реализовано у вас — снова вопрос, на который стоит получить ответ до инцидента.
Клиентская сторона: 429 — это часть UX
Вторая половина контракта, которую тестируют ещё реже: что делает ваш собственный клиент, получив 429? Плохие ответы: белый экран, «неизвестная ошибка», мгновенный автоматический повтор (который гарантирует, что юзер больше никогда не выйдет из-под лимита). Хорошие: понятное сообщение («слишком часто, попробуйте через минуту»), заблокированная на время кнопка, автоповтор — только с backoff и уважением Retry-After.
Тестировать это на реальном лимите неудобно, и не нужно: 429 мокается — page.route() в Playwright (показывал в посте про auth), Map Local в Proxyman/Charles для мобильного клиента. Отдельно про мобильные: офлайн-очередь действий при выходе в сеть не должна выстреливать всей пачкой в один момент — это самодельный burst, который сам себя банит.
Стейдж без лимитов = непротестированный прод
Самая частая находка: на тестовом окружении лимиты выключены — «чтобы не мешали автотестам». Понятно, откуда это берётся: реальные лимиты в CI — это флаки, параллельные воркеры банят друг друга (писал в посте про тесты в CI). Но в результате конфигурация, которая реально живёт на проде, не тестируется вообще — тот же грех, что стенд без кэша при проде с кэшем.
Рабочий компромисс: в повседневном CI лимиты ослаблены или тестовые раннеры в whitelist — осознанно и явно; но существует прогон (перед релизом, на prod-like окружении), где лимиты включены в боевой конфигурации и явно проверяются тесты границы, ответа и восстановления. Для воспроизводимой долбёжки хватает k6, hey или curl в цикле — главное, чтобы прогон был скриптом, а не «я быстро покликал».
Чек-лист тестирования rate limiting
- Инвентаризация: на всех критичных зонах (логин, OTP, reset, промокоды, отправка, поиск, экспорт) лимит есть и известен
- Граница: N проходит, N+1 → 429; восстановление после окна работает, ничего не залипает
- Ответ: 429 + Retry-After + машиночитаемое тело, не HTML и не 500
- Скоуп: по какому ключу; NAT не банит невиновных; логин-лимит не даёт DoS-ить чужой аккаунт
- X-Forwarded-For от клиента не обходит лимит
- Соседние пути: v1/v2, мобильный API, GraphQL (depth/complexity) — лимит консистентен
- Fixed window: фактический burst на стыке окон измерен и приемлем
- Клиент при 429: сообщение, пауза, backoff + Retry-After; нет мгновенного авторетрая
- Офлайн-очередь мобильного клиента не выстреливает пачкой
- Стейдж: ослабление лимитов осознанное; существует прогон с боевой конфигурацией
Лимиты — редкий случай, когда «фичи не видно, пока она работает». Именно поэтому их не тестируют: нет видимого поведения — нет и тест-кейсов. Пока не приходит один юзер со скриптом — и не оказывается, что тест-кейсы были нужны ещё в прошлом квартале.