MAATRIX / Блог / Цикл в коде исчерпал лимит API за 20 минут и принёс счёт на 40 тысяч

Цикл в коде исчерпал лимит API за 20 минут и принёс счёт на 40 тысяч

MAATRIX

В десять утра в чат влетело письмо от стороннего API-провайдера: «вы превысили лимит тарифа, доплатите за перерасход». Сумма — сорок с лишним тысяч рублей за одну ночь, притом что обычный месячный расход укладывался в пару тысяч. Никто ничего не менял в проде вечером, деплоев не было, трафик на сайте был обычный. А счётчик запросов к API вырос в сотни раз ровно за двадцать минут около трёх часов ночи. Это разбор того, как мы это нашли, какие версии отбросили по пути и в чём была настоящая причина — банальная и от этого особенно обидная.

Что сломалось

Сервис — бэкенд среднего интернет-магазина на арендованном VPS: очередь заказов в RabbitMQ, воркеры на Python (gunicorn, 8 процессов), после каждого нового заказа воркер дёргает API курьерской службы, чтобы получить статус доставки и трек-номер. У курьерской службы тарифный план с лимитом запросов в месяц, дальше — платный перерасход по счётчику.

Ночью, в 03:12, курьерская служба ушла на плановое техобслуживание своего API — обычная история, у них это бывает раз в пару недель на 10-15 минут, отдают 502/504 или таймаут. Обычно наш код на это спокойно ретраит с лимитом попыток и едет дальше. В эту ночь техобслуживание закончилось в 03:20, но расход запросов у нас продолжал расти ещё несколько минут после — то есть проблема была не только во внешнем сервисе.

К 03:32 (по факту — 20 минут активной фазы) счётчик запросов у провайдера показывал прирост на несколько десятков тысяч сверх обычного. Алерта на расход API у нас не было — только алерт на 5xx от собственного сайта, а сайт всё это время работал нормально, потому что обработка трек-номеров идёт в фоне и не блокирует оформление заказа. Инцидент увидели только утром, когда пришёл счёт.

Что было в логах и метриках

Первым делом подняли логи воркера за ночь:

docker logs order-worker --since "2026-08-27T03:00:00" --until "2026-08-27T03:40:00" | grep courier_api > /tmp/incident.log
wc -l /tmp/incident.log

В файле оказалось 41 800 строк за 20 минут — это больше 30 запросов в секунду на один воркер, при том что нормальная нагрузка ночью — один запрос раз в несколько секунд, и то не всегда. Посмотрели, к каким order_id они относятся:

grep -oP 'order_id=\K[0-9]+' /tmp/incident.log | sort | uniq -c | sort -rn | head

Результат был показательный: не сотни разных заказов, а буквально три-четыре order_id, повторённые тысячи раз каждый. То есть это не всплеск обычного трафика ночных заказов — их физически столько не было, — а один и тот же запрос, зацикленный на одних и тех же объектах.

В Grafana подняли метрику исходящих HTTP-запросов с воркеров (собирается через prometheus_client middleware в самом сервисе):

rate(outbound_http_requests_total{target="courier_api"}[1m])

График — ровное плато на уровне ~35 req/s в течение 20 минут, с резким обрывом в конце. Не «пила» с ретраями раз в несколько секунд, а именно плато на максимально возможной скорости, которую позволял код. Это уже намекало, что цикл ретраев не delay'ится нормально.

Заодно проверили CPU и память воркеров — ничего необычного, нагрузка на процессор даже не поднялась заметно, потому что каждый запрос — это ожидание сетевого таймаута, а не вычисления.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Какие версии отбросили

Первая мысль — атака или скан извне. Проверили источник трафика:

docker logs order-worker --since ... | grep courier_api | grep -oP 'from \K[\d.]+' | sort -u

Все запросы уходили с одного и того же IP — самого сервера. Это исходящие вызовы к внешнему API, а не входящий трафик на наш сайт, так что «атака на нас» отпадает сразу — дело было в собственном коде, который слишком активно дёргал чужой сервис.

Вторая версия — дубли в очереди RabbitMQ: может, сообщения переотправлялись из-за проблем с ack и обрабатывались параллельно несколькими консьюмерами. Зашли в management UI RabbitMQ (http://<host>:15672), посмотрели очередь orders.tracking:

rabbitmqctl list_queues name messages messages_unacknowledged consumers

В очереди на момент инцидента было всего 3-4 неподтверждённых сообщения — ровно столько, сколько «зависших» order_id мы видели в логах. Redelivery действительно случался, но не он был источником тысяч запросов: одно сообщение висело в processing по 15-20 минут, не подтверждаясь и не отклоняясь, что само по себе странно для нормального обработчика.

Третья версия — второй экземпляр воркера, запущенный по ошибке (например, после ручного рестарта старый процесс не убился). Проверили:

ps aux | grep order-worker
docker ps --filter name=order-worker

Работал ровно один контейнер, gunicorn с 8 процессами, как и должно быть. Дублирования инстансов не было.

Четвёртая версия — баг в самой курьерской API, которая якобы отвечала быстрее обычного и провоцировала более частые ретраи. Посмотрели тайминги ответов в логах — во время техобслуживания API отвечало таймаутом (не быстрым отказом), то есть каждый запрос «висел» секунды, а не миллисекунды. Это не объясняло 35 запросов в секунду — при таких таймаутах столько запросов физически не отправить с одного потока. Значит, что-то отправляло их параллельно и без пауз.

ВерсияПроверкаИтог
Атака / скан извнеIP источника запросовОтклонено — трафик исходящий, с нашего же сервера
Дубли сообщений в очередиrabbitmqctl list_queues, management UIОтклонено — сообщений было 3-4, не тысячи
Второй инстанс воркераps aux, docker psОтклонено — процесс один
Быстрые ответы API спровоцировали штормТайминги ответов в логахОтклонено — ответы были медленными (таймауты)

Реальная причина

Досмотрели код функции, которая опрашивает статус доставки. Вот он, немного упрощённый:

def get_delivery_status(order_id):
    retries = 0
    max_retries = 5
    while retries < max_retries:
        try:
            resp = courier_api.get(f"/v1/track/{order_id}", timeout=5)
            if resp.status_code == 200:
                return resp.json()
            retries += 1
        except requests.exceptions.RequestException as e:
            retries = 0  # должно было быть retries += 1
            logger.warning(f"courier api retry for {order_id}: {e}")
            time.sleep(0.2)
    raise MaxRetriesExceeded(order_id)

Баг — в ветке except. При обычном плохом ответе (4xx/5xx со статус-кодом) счётчик retries честно увеличивался, и через 5 попыток функция сдавалась и бросала исключение выше. Но при сетевой ошибке — таймауте, обрыве соединения, DNS-сбое — код по ошибке *обнулял* счётчик вместо того, чтобы его увеличить. Автор явно писал две ветки в спешке и перепутал += с =, а тесты этот путь не покрывали, потому что для теста таймаут не эмулировали.

Пока курьерский API отвечал таймаутами (а не HTTP-ошибками), условие выхода из цикла никогда не выполнялось: retries каждый раз сбрасывался обратно в 0 прямо перед тем, как проверить retries < max_retries. Цикл был математически бесконечным на время всего окна, пока API не начинал отвечать быстрым 200 или явным 4xx/5xx вместо таймаута. time.sleep(0.2) тормозил каждый отдельный поток, но так как заказов с проблемным статусом было несколько и каждый обрабатывался в своём gunicorn-воркере параллельно, суммарная скорость запросов к API оказалась в районе 30-35 в секунду — именно то плато, что мы видели на графике.

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

Почему счёт вырос именно так

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

За 20 минут цикл сделал, по логам, порядка 42 000 запросов. Это меньше одного процента от месячного лимита в обычных условиях, но у нас в тот месяц квота уже была почти выбрана — сказался сезонный рост заказов, о котором никто не подумал пересчитать лимит. Итоговый перерасход пробил не только эти 42 000 запросов, а весь остаток бюджета, который иначе распределился бы на оставшиеся дни месяца — отсюда и сумма, ощутимо превышающая то, что кажется логичным на первый взгляд, если считать «в лоб» цена-за-запрос умножить на число лишних запросов.

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

Что изменили после разбора

Правки шли в три слоя: код, инфраструктура, наблюдаемость.

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

def get_delivery_status(order_id):
    for attempt in range(1, MAX_RETRIES + 1):
        try:
            resp = courier_api.get(f"/v1/track/{order_id}", timeout=5)
            if resp.status_code == 200:
                return resp.json()
        except requests.exceptions.RequestException as e:
            logger.warning(f"courier api retry {attempt}/{MAX_RETRIES} for {order_id}: {e}")
        if attempt < MAX_RETRIES:
            time.sleep(min(2 ** attempt, 30))  # экспоненциальный backoff с потолком
    raise MaxRetriesExceeded(order_id)

Цикл теперь строится через for с конечным диапазоном попыток — переменную-счётчик просто негде обнулить по ошибке, так как её больше нет. Добавили экспоненциальный backoff вместо фиксированной паузы в 0.2 секунды: даже если баг вернётся в другом виде, скорость запросов физически ограничена ростом задержки между попытками.

На уровне инфраструктуры добавили клиентский rate limiter перед вызовом любого стороннего платного API — простой токен-бакет на Redis, который режет исходящие запросы к конкретному хосту жёстким потолком (например, 5 запросов в секунду на воркер), независимо от того, что происходит в бизнес-логике. Если в коде когда-нибудь снова появится цикл без выхода, он упрётся в лимитер, а не в кошелёк. Заодно вынесли обработку статусов доставки в отдельный сервис на отдельном контейнере — раньше при таком инциденте воркер, который зациклился, делил ресурсы с остальной фоновой обработкой заказов, и изоляция роли снижает шанс, что один сбойный интеграционный вызов утянет за собой соседние задачи.

Главное изменение — в наблюдаемости. Раньше мониторили только доступность своего сайта; расход стороннего платного API не отслеживался никак, кроме ежемесячного счёта. Теперь:

  • метрика outbound_http_requests_total по каждому внешнему хосту экспортируется в Prometheus, с алертом на аномальный rate (> 3x от скользящего среднего за 15 минут);
  • отдельный алерт на сам факт таймаутов от конкретного внешнего API (если больше N таймаутов подряд за минуту — значит, у поставщика проблема, и это сигнал проверить, не начал ли наш код ретраить агрессивнее обычного);
  • оба алерта настроены через схему, как работает rate limiting на стороне клиента и провайдера — полезно понимать логику лимитов с обеих сторон, чтобы не удивляться, откуда взялся перерасход;
  • уведомления уходят не только на почту (которую вечером/ночью никто не читает), а в отдельный Telegram-канал дежурного — по инструкции как настроить алерты в Telegram на VPS;
  • добавили cron-задачу, которая раз в час дёргает API провайдера курьерской службы и сверяет текущий расход квоты с бюджетом на оставшиеся дни месяца, и шлёт предупреждение, если темп расхода выйдет за плановый график задолго до конца месяца.

Отдельно завели чеклист код-ревью для любого нового цикла с ретраями: у цикла обязан быть конечный диапазон попыток без возможности обнулить счётчик изнутри except, обязателен backoff, и обязателен явный тест именно на сетевую ошибку (таймаут/обрыв), а не только на HTTP-код ответа — баг был именно в непротестированной ветке. При разборе постфактум это заняло у нас меньше часа с логами под рукой; без логов и без метрики расхода API разбор растянулся бы на день гаданий, потому что первопричина никак не проявлялась в стандартном мониторинге доступности сайта. Подробнее о том, как быстро находить причину сбоя по логам, — в материале как читать логи и находить причину сбоя.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро понять, что причина — именно бесконечный цикл, а не рост легитимного трафика?

Посмотрите на разнообразие идентификаторов в запросах (order_id, user_id и так далее): если тысячи запросов относятся к нескольким одним и тем же объектам, а не к разным — это почти наверняка цикл, а не органический рост нагрузки.

Достаточно ли ограничить количество попыток в коде, или нужен ещё внешний лимитер?

Ограничение в коде — обязательный минимум, но полагаться только на него рискованно: следующий баг может обойти именно эту защиту. Внешний rate limiter (токен-бакет на Redis, прокси с лимитами, шлюз API) — это независимый слой, который не зависит от корректности конкретной функции.

Стоит ли настраивать алерт на расход платных API, если бюджет небольшой?

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

Можно ли было поймать баг тестами до продакшена?

Да, если бы тест явно эмулировал сетевой таймаут (например, через responses или httpretty с исключением Timeout), а не только разные HTTP-коды ответа. Ветки обработки исключений часто остаются непокрытыми, потому что их сложнее и лень эмулировать — именно там чаще всего и живут подобные баги.

Почему сайт продолжал работать нормально, пока цикл жрал лимит API?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →