руИНН
руИНН

Как краулеры выпили половину годовой квоты платного API за двое суток

Есть класс ошибок, который не ловится ни тестами, ни ревью, ни здравым смыслом: код работает ровно так, как написан, все запросы валидны, в логах ни одной аварии — а оплаченный ресурс кончился.

У нас такой ресурс был один: годовая квота к API налоговой. За двое суток от неё осталась половина.

Дальше — почему это случилось, почему первое очевидное решение (rate limit) не помогает в принципе, и какие три дыры нашлись уже в собственном предохранителе.

Как устроена квота

руИНН — сервис проверки контрагентов: вводишь ИНН, получаешь карточку компании из ЕГРЮЛ с отчётностью и арбитражными делами. Node.js, Express, файловый кэш, один процесс. Данные берутся у платного агрегатора ФНС.

Тарификация нетривиальная. Квота считается не в запросах, а в «уникальных компаниях по ИНН в год»: запросить одну компанию сто раз стоит одну единицу, сто разных по разу — сто. И лимиты раздельные по методам: реестр, отчётность, проверка, выписка — у каждого своя квота 3000 в год. Этот пункт важен, на нём мы обожжёмся второй раз.

Отсюда следует неприятное: цена зависит не от частоты запросов, а от их разнообразия. Обычный rate limit меряет ровно не то.

Что произошло

На карточке компании есть блок учредителей. Если учредитель — юрлицо, он становится ссылкой на карточку этого юрлица: удобно, можно пройти по цепочке владения. Карточка рендерилась на сервере, и при промахе кэша сервер честно шёл в API. Логика «нет в кэше — сходи и возьми» настолько естественная, что не запоминается момент, когда её пишешь.

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

Пришёл поисковый робот и стал делать то, ради чего существует: обходить ссылки. За двое суток в базе появилось около 1400 компаний, которых там быть не должно, — примерно половина годовой квоты. Никакой атаки: краулеры ходили по правилам, robots.txt соблюдали, нагрузки не создавали, сервер отвечал 200. Обнаружилось случайно, при заходе в панель провайдера по другому поводу.

Почему rate limit не спасает

Первая мысль после такого — «поставить лимит на IP». Она неверна, и это главный переносимый вывод статьи.

Rate limit защищает пропускную способность: сколько запросов в единицу времени переварит машина, причём ресурс восстанавливается сам — прошла минута, окно сдвинулось. Квота невосстановима в пределах года. Поэтому медленная утечка неотличима от нормы: робот, берущий по одной новой компании в минуту, не нарушает ни одного лимита и выпивает годовую квоту за двое суток. А per-IP счётчик вообще защищает не тот ресурс — квота глобальная, одна на весь сервис, и считать её по клиентам всё равно что охранять общий бак, ставя счётчик каждому подходящему с ведром. Ротация IP снимает такую защиту полностью, а краулерам она и не нужна: они и так ходят с десятков адресов.

Глобальный ресурс защищает только глобальный счётчик: не «сколько запросил клиент», а «сколько новых компаний сервис купил сегодня, всего».

Предохранитель

Появился модуль бюджета — по сути circuit breaker поверх квоты:

// Потолки: год, сутки, час. Считаются НОВЫЕ компании — известные бесплатны
function tryReserve(inn) {
  if (known.has(inn)) return true;              // уже оплачена, обновление бесплатно
  if (known.size >= ANNUAL_CAP) return false;
  if (daily.count >= DAILY_CAP) return false;
  if (hourly.count >= HOURLY_CAP) return false;

  known.add(inn);                                // резерв ДО await — иначе гонка
  daily.count++; hourly.count++;
  persist();
  return true;
}

Резерв берётся до сетевого вызова, синхронно. Иначе двадцать параллельных запросов на разные ИНН пройдут проверку одновременно: каждый увидит «лимит не исчерпан» и уйдёт в API. Между проверкой и инкрементом не должно быть точки await — классический TOCTOU, только ценой не в правах доступа, а в деньгах. Потолка три: годовой — от медленной утечки, суточный — от «сегодня что-то пошло не так», часовой — от залпа, иначе суточный лимит выжигается за пару минут и сервис стоит до полуночи. Конкретные значения потолков здесь не называю: защиту они не держат, а работу тому, кто захочет её проверить, облегчают.

Но главное — не лимит, а конструктивная правка: публичные страницы больше не создают компании. SSR читает только кэш; нет в кэше — редирект на поиск. Новая компания появляется единственным способом: живой человек ввёл ИНН в форму. Краулер физически не может потратить квоту, сколько бы ссылок ни обошёл.

Правило: из публичного, доступного роботам пути не должно быть достижимо создание платной сущности. Лимиты — про «сколько», конструкция — про «может ли вообще». Второе надёжнее.

Три ошибки в самом предохранителе

Дальше интереснее: в написанном предохранителе нашлись три дефекта, каждый сводил защиту на нет по-своему.

1. Резерв не откатывался — и защита стала оружием

Резерв берётся до запроса. А если компании не существует? API отвечает «не найдено», пишется маркер «такого ИНН нет» — и всё, резерв остаётся израсходованным.

Контрольная сумма ИНН считается детерминированным алгоритмом; сгенерировать валидный, но несуществующий номер — пять строк кода. Несколько десятков таких в цикле curl выжигают суточный лимит за секунды, после чего ни одна настоящая новая компания не появится до полуночи UTC. Счётчик персистится, так что рестарт лок не снимает. Предохранитель стал дешёвым DoS против собственного сервиса. Лечится откатом резерва при промахе — с отдельным потолком на промахи, чтобы перебор мусорных ИНН упирался в свою стену, а не в общую с живыми пользователями.

2. Считался один метод, а квоты раздельные

Breaker считал только реестр: «ИНН известен → компания оплачена → пропускаем». Но «известен» означало лишь наличие файла реестра в кэше, и запрос отчётности по известной компании проходил проверку мгновенно, уходя в API вообще без учёта. Обнаружилось при сверке своих счётчиков с панелью провайдера: по отчётности израсходовано 1475 из 3000 — столько же, сколько по реестру, только об этом никто не знал. Каждый просмотр карточки компании без отчётности (а таких много — ИП её вовсе не сдают) уходил живым платным запросом: пустой ответ не создавал файла, а раз файла нет — надо сходить ещё раз. Каждый раз.

Отсюда два вывода. Отсутствие данных — тоже данные: кэшируйте отдельным маркером с собственным TTL, иначе платите за каждое «нет». И моделируйте квоту так, как её считает поставщик: раздельные квоты — раздельные счётчики и алерты. Алерт на 80% висел только на реестре, поэтому всплеск по другому методу не увидел бы никто.

3. Три процесса делили файл счётчиков

Счётчики персистятся в JSON, а файл делят три процесса: сервер, ночное обновление кэша и скрипт наполнения базы. Каждый читал файл на старте и писал целиком из своей памяти — классическая lost update.

Первая починка была неправильной и поучительной. Сделали слияние «по максимуму»: берём большее из своего и дискового, счётчик же только растёт. Но если другой процесс ушёл вперёд,

max(наши 5, диск 7) = 7, и собственный инкремент исчезает. Складывать нужно дельту с прошлой записи, причём со знаком: после первого фикса счётчик научился убывать, и с «только ростом» откат терялся при следующей записи. Два бага рядом дают третий, которого нет ни в одном по отдельности.

И запись — во временный файл с rename. Оборванный writeFileSync оставляет обрезанный JSON, который при старте не парсится, и код честно начинает счёт с нуля — сам себя разблокирует ровно тогда, когда этого делать нельзя.

Про то, как это писалось

Проект собран с нуля за четыре недели активной разработки, почти весь код написан в связке с Claude Code. Честная часть: ассистент не спас ни от одной ошибки из этой статьи — и не мог. Инцидент и все три дефекта не ошибки кода, а ошибки модели предметной области: неверное представление о том, что здесь единица расхода и кто по каким путям до неё дотягивается. Код, реализующий неверную модель, синтаксически безупречен и проходит любой линтер.

Зато обратное сработало: критическую дыру из пункта 1 нашёл агент-аудитор, которому поставили задачу «представь, что хочешь навредить этому сервису». Своей головой до этого дойти сложно: думаешь о защите от перерасхода, а не о том, что она сама становится способом положить сервис.

Вывод: скорость письма выросла, а цена проектных ошибок — нет. Она даже подросла — за четыре недели принимаешь столько решений, сколько раньше принимал за полгода, и ни одно из них никто, кроме тебя, не пересматривает.

Что в итоге

Защита выстроена в четыре слоя, по убыванию надёжности:

  1. Конструкция. Публичные пути не создают платных сущностей. Не «ограничено», а «невозможно».
  2. Глобальный бюджет. Год / сутки / час, атомарный резерв, откат при промахе, отдельный счётчик на каждый метод — как у поставщика.
  3. Кэш отрицательных ответов. «Данных нет» — полноценная запись с TTL.
  4. Лимиты запросов. Per-IP и limit_req в nginx — последними: они защищают пропускную способность, а не бюджет.

Плюс алерт на 80% годовой квоты по каждому методу отдельно и команда, которая спрашивает остаток у самого поставщика: свои счётчики систематически занижают расход.

Что сделали бы иначе — завели бы модель бюджета до первого живого запроса к платному API. Не лимиты и не мониторинг, а именно модель: что здесь единица расхода, что её тратит и кто может дотянуться. Полчаса в начале против половины годовой квоты.

И общий вывод, ради которого всё писалось. О безопасности принято думать как о защите данных: не утекли бы, не подделали бы, не сломали бы. Но если сервис ходит в платное API, у вас есть ещё один актив — и он расходуется молча, без единой ошибки в логах, при полностью корректной работе кода. У этого актива нет своего раздела в чеклистах. Стоит завести.