Биддер «одной цифрой» для Wildberries на Rust: контроллер с мёртвой зоной, лимиты API и выживание под 429

от автора

Продавцы на маркетплейсах управляют рекламой руками: утром посмотрел ставки, днём подкрутил, вечером обнаружил, что бюджет слит. При этом сама цель продавца формулируется одной цифрой: «хочу, чтобы реклама съедала не больше 12% выручки» (ДРР — доля рекламных расходов) или «каждый рубль рекламы должен приносить три» (ROMI). Кабинет же оперирует только ставками за тысячу показов.

Я бэкенд-разработчик: пишу на Rust сервис аналитики для продавцов маркетплейсов, и одна из его частей — автоматический биддер, который превращает цель-цифру в непрерывное управление ставками. Всё описанное ниже — мой личный опыт эксплуатации этого кода на реальных кабинетах продавцов, а не пересказ документации. Под катом — архитектура контроллера, грабли WB Advert API (rate limit «на продавца», а не на приложение), и почему половина кода — это не про ставки, а про выживание под HTTP 429.

Стек: Rust, tokio, sqlx, PostgreSQL. Всё в статье воспроизводимо на любом языке — специфики Rust тут мало, зато много специфики API маркетплейсов.

Почему «ставка» — плохой интерфейс

Ставка — инструмент, а не цель. Между «ставкой 350 ₽ за тысячу показов» и «ДРР 12%» лежит цепочка: показы → клики (CTR) → корзины → заказы (CR) → выручка. Все коэффициенты плавают по дням недели, сезону и конкурентам. Продавец, который держит цель руками, фактически выполняет роль П-регулятора с частотой дискретизации «раз в день» и огромным лагом. Автоматизировать это — классическая задача управления с обратной связью.

Контроллер: мёртвая зона, клампы, автостоп

Каждые 30 минут воркер делает три вещи: собирает факт, сравнивает с целью, двигает ставки. Ключевые решения:

Мёртвая зона ±7%. Если фактический ДРР в пределах ±7% от цели — не трогаем ничего. Без неё контроллер «дёргается»: статистика за полчаса шумная (десятки кликов), и реакция на шум раскачивает систему сильнее, чем сам шум.

Ядро шага контроллера умещается в экран (Rust, но легко читается как псевдокод):

fn next_bid(cur_bid: i64, fact_drr: f64, target_drr: f64, cfg: &Cfg) -> Option<i64> {    let err = (fact_drr - target_drr) / target_drr;          // относительная ошибка    if err.abs() < cfg.dead_zone {                            // мёртвая зона ±7%        return None;                                          // ничего не трогаем    }    // ДРР выше цели → ставку вниз, ниже цели → вверх; шаг пропорционален ошибке    let raw = cur_bid as f64 * (1.0 - cfg.gain * err);    let max_step = cur_bid as f64 * cfg.max_step;             // кламп шага ±25%    let clamped = raw.clamp(cur_bid as f64 - max_step, cur_bid as f64 + max_step);    Some((clamped as i64).clamp(cfg.min_bid, cfg.max_bid))    // абсолютные пределы}

Ограничение шага ±25%. За одну итерацию ставка меняется не больше чем на четверть. Резкое снижение ставки на WB выбрасывает кампанию из аукциона (показы падают до нуля, статистика перестаёт поступать — и контроллер слепнет), резкое повышение — сжигает бюджет до того, как придёт обратная связь.

Дневной бюджет как жёсткий предохранитель. Если суточный лимит расходов достигнут — ставки на минимум до завтра, независимо от ДРР. Цель-процент не должна конфликтовать с абсолютным пределом «не больше N ₽ в день».

Автостоп с эскалацией на человека. Если цель проваливается N дней подряд (по умолчанию 3) — система выключает автопилот и шлёт уведомление. Ситуации «карточка умерла, никакая ставка не спасёт» алгоритмом не решаются, и честнее позвать владельца, чем бесконечно крутить ставки.

Fail-closed по списку товаров. Если не удалось прочитать список кампаний/товаров, включённых в автоматизацию, — не делаем НИЧЕГО. Любая ошибка чтения конфигурации трактуется как «прав нет». Двигать ставки на чужих кампаниях из-за недочитанного конфига — худший из возможных багов.

Журнал решений человеческим языком. Каждое действие пишется в лог вида «ДРР 14.1% выше цели 12% → ставка 320→280 ₽». Без журнала доверия к автопилоту нет: первое, что делает продавец, — проверяет, что именно система сделала ночью.

Грабли №1: rate limit считается на продавца, а не на ваше приложение

Главное открытие при работе с WB API: лимиты привязаны к токену продавца, и их выедает ЛЮБОЙ сервис, которому продавец дал ключ. У активного продавца обычно подключены 2–3 сервиса аналитики одновременно. Ваше приложение может делать один запрос в минуту и всё равно стабильно получать 429 Too Many Requests с текстом «Limited by global limiter, per seller» — потому что квоту съел кто-то другой.

Выводы, к которым я пришёл:

  1. Per-token токен-бакеты на клиентской стороне. У каждого хоста WB свой лимит (statistics — 1 запрос в минуту, advert — свои значения, feedbacks — свои). Я держу бакет на пару (класс хоста, хэш токена) и ждём в гейте ПЕРЕД каждой попыткой, а не после отказа:

// один бакет на (класс хоста, хэш токена): квоту делят все воркеры процессаlet key = (HostClass::Statistics, token_fingerprint(token));buckets.entry(key)    .or_insert_with(|| TokenBucket::new(1, Duration::from_secs(60))) // 1 rps/min    .acquire()      // ждём слот ДО запроса, а не ретраим после 429    .await;
  1. Уважать Retry-After. Наивная экспоненциальная лесенка ретраев (1-2-4-8-16 с) бесполезна: все попытки сгорают внутри той же минутной квоты. WB присылает Retry-After — его и нужно ждать, вплоть до сотен секунд.

  2. Fail-fast, если квоту едят снаружи. Если после честного ожидания всё равно 429 «per seller» — не жечь ретраи, а быстро отдать джобу планировщику: докачаем через полчаса. Шесть ретраев с ожиданием только растягивают джобу до таймаута и блокируют соседние задачи того же токена.

Грабли №2: окна дат и таймауты фоновых джоб

fullstats (статистика рекламных кампаний) принимает период не длиннее 31 дня — молча падает 400 на большем окне. Отчёт о продажах отдаёт date без таймзоны, что валит строгий RFC3339-парсер. Финансовый отчёт содержит строки без артикула (хранение, удержания) — если агрегировать «по товарам», эти деньги молча теряются.

Отдельный класс проблем — большие кабинеты. Кабинет на 200+ рекламных кампаний физически не пролезает в один прогон синка: отчёт отдаётся чанками по 10 кампаний с асинхронной генерацией (создать отчёт → поллить готовность → скачать), по 5–6 минут на чанк. Решение — курсор:

  • у джобы есть мягкий дедлайн (24 минуты из 30-минутного потолка сторожевого таймера);

  • обработали сколько успели — сохранили позицию курсора в таблицу, вышли со статусом success;

  • планировщик видит pending = true и через 10 минут ставит продолжение;

  • записи идемпотентны (upsert по ключу sku+день), поэтому частичные прогоны безопасны.

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

Грабли №3: параллельные писатели и дедлоки

Финансы, реклама и агрегатор метрик пишут в одну таблицу дневных показателей. Пока джобы жили в разных «дорожках» и бегали параллельно, Postgres честно ловил дедлоки: два многострочных UPDATE берут блокировки строк в разном порядке. Классические решения — упорядочить захват или сериализовать писателей. Поскольку все писатели живут в одном процессе, я выбрал второе: лёгкий in-process мьютекс на пару (пользователь, маркетплейс) вокруг тяжёлых батчей. SQL не тронут, дедлоки исчезли, цена — редкое ожидание на замке вместо отката транзакции.

Что получилось

Продавец задаёт одну цифру. Система каждые 30 минут сверяет факт с целью и двигает ставки всех кампаний в пределах клампов, при исчерпании бюджета уходит на минимум, при систематическом провале — выключается и зовёт человека. Проверка гипотезы «а если ДРР 10%?» превратилась из недели ручных экспериментов в одно изменение цифры.

Из общих уроков:

  • API маркетплейсов проектируйте с презумпцией «квоту делят несколько потребителей»;

  • каждая фоновая джоба должна быть возобновляемой (курсор + идемпотентные записи), иначе большие клиенты будут вечно падать по таймауту;

  • контроллеру нужны мёртвая зона и ограничение шага больше, чем «умная» формула ставки;

  • fail-closed на чтении конфигурации — обязательное свойство систем, которые двигают чужие деньги.

Сама схема — факт из API статистики, контроллер с мёртвой зоной и клампами, журнал решений — воспроизводима на любом стеке; специфика тут не в языке, а в поведении API маркетплейса.

Буду рад вопросам про поведение контроллера на разреженной статистике и про то, как я атрибутирую продажи к рекламе — это тема на отдельную статью.

ссылка на оригинал статьи https://habr.com/ru/articles/1063434/