Массовая обработка запросов без упора в лимит скорости

Один ключ с лимитом на батч-задаче — гарантированный 429 на середине прогона. Разбираем, как строить массовую обработку так, чтобы это не случилось.

4 мин · · Обновлено · разработка

Батч-задача на тысячи запросов ведёт себя иначе, чем интерактивный чат: ошибки накапливаются, ретраи множатся, а лимит на ключе, который был незаметен в разработке, за час прогона превращается в стену. Ниже — конкретные приёмы, которые снимают эту стену, не отключая лимиты вообще (это не всегда возможно и не всегда разумно), а работая с ними.

Понять, какой именно 429 прилетел

Своего лимита скорости у шлюза нет. 429 от самого Zukko означает исчерпанный лимит расхода конкретного ключа, и код в ответе прямо это говорит — key_daily_limit_reached или key_monthly_limit_reached. Любой другой 429 пришёл от провайдера модели. Это принципиально разные ситуации. 429 провайдера — сигнал притормозить и повторить через экспоненциальный backoff, он снимется сам через какое-то время. Лимит ключа снимется только с началом нового дня (сброс в 00:00 UTC) или месяца либо после ручного изменения лимита в кабинете — долбить его ретраями бессмысленно, воркер должен переключиться на другой ключ или остановиться.

bash
if resp.status_code == 429:
    code = resp.json().get("error", {}).get("code")
    if code in ("key_daily_limit_reached", "key_monthly_limit_reached"):
        switch_to_next_key()
    else:
        time.sleep(backoff_seconds)

Разделить нагрузку на несколько ключей

На странице ключа в кабинете для каждого ключа задаётся свой дневной/месячный лимит и список разрешённых моделей. Для батч-обработки логично завести пул из нескольких ключей и распределять запросы между ними — например, по хэшу id задачи или по подзадачам. Пропускную способность это не увеличивает (ограничения скорости на шлюзе нет, а баланс у всех ключей один — аккаунта), зато каждая часть прогона получает свой бюджет: зациклившаяся подзадача исчерпает лимит своего ключа, а не весь бюджет батча.

Идемпотентные ретраи вместо голых

Сетевой обрыв на середине батч-прогона — это не про лимит токенов, это таймаут клиента или сети, и стоит перепроверить max_tokens и таймауты HTTP-клиента, прежде чем что-то менять в логике ретраев. Сам ретрай нужно делать с заголовком Idempotency-Key, сохраняя одно и то же значение для одного и того же логического запроса:

bash
import uuid

idempotency_key = str(uuid.uuid4())
for attempt in range(3):
    resp = requests.post(
        "https://api.zukko.pro/v1/chat/completions",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Idempotency-Key": idempotency_key,
        },
        json=payload,
        timeout=30,
    )
    if resp.status_code < 500 and resp.status_code != 429:
        break
    time.sleep(2 ** attempt)

Если исходный запрос на самом деле дошёл до модели и обработался, повтор с тем же Idempotency-Key в течение 24 часов вернёт тот же ответ без повторного списания — это критично именно в батче, где сумма случайных двойных списаний на тысячах запросов заметна на счету.

Кэш для повторяющихся входов

Если в батче много полностью одинаковых запросов — например, классификация повторяющихся значений, — стоит включить кэш заголовком X-Zukko-Cache: true: это основной способ, он работает для любой модели. temperature: 0 тоже включает кэш, но только для моделей, которые принимают это значение; новые модели Claude (claude-sonnet-5, claude-opus-5-5) могут отклонить temperature, отличную от 1, с ошибкой 400, поэтому в запросах к ним temperature не передают. Одинаковые запросы кэшируются на срок до 7 суток (для чата), повтор возвращается мгновенно и бесплатно, а в ответе виден заголовок X-Zukko-Cache: hit. Важно: кэш срабатывает только при полном совпадении тела запроса — «почти одинаковые» запросы и запросы с общей фиксированной частью промпта (шаблон + разные данные) из кэша не отдаются. Попадания в кэш не пишутся в /v1/usage. Для батч-джоба это не оптимизация ради оптимизации: если во входных данных 30% точных дублей, кэш реально снимает почти треть стоимости прогона.

bash
resp = requests.post(url, headers={**headers, "X-Zukko-Cache": "true"}, json=payload)
if resp.headers.get("X-Zukko-Cache") == "hit":
    cache_hits += 1

Следить за балансом по ходу прогона, а не после

Каждый обычный (нестриминговый) ответ несёт X-Zukko-Cost-Micros (стоимость этого запроса в микродолларах, 1,000,000 = $1) и X-Fetch-Balance-Cents (остаток после списания — несмотря на название, тоже в микродолларах; точный баланс в центах — GET /v1/balance). У потоковых ответов (stream: true) этих заголовков нет, поэтому батч лучше гонять без стриминга. На батче из тысяч запросов это позволяет посчитать среднюю стоимость запроса по первым сотне и экстраполировать на весь объём ещё до того, как прогон дойдёт до середины — и остановиться заранее, если баланса объективно не хватит, вместо того чтобы поймать 402 на 6000-м запросе из 10000 и разбираться, какая часть данных обработана.

bash
cost_micros = int(resp.headers.get("X-Zukko-Cost-Micros", 0))
running_total += cost_micros

Проверить каталог перед большим прогоном, а не хардкодить модель

GET /v1/models отдаёт актуальный список моделей (только id, object, created, owned_by), а GET /v1/models/info — их цены и лимиты контекста. Перед запуском батча на несколько часов имеет смысл дёрнуть эти эндпоинты и свериться с тем, что зашито в конфиге — если модель успели выключить, лучше узнать об этом заранее, чем поймать 503 model_disabled на середине прогона и потерять частично обработанный батч.

bash
curl https://api.zukko.pro/v1/models/info

503 и model_disabled — это не повод падать целиком

503 provider_unavailable или model_disabled означает временное отключение модели в каталоге, а не проблему с запросом. На батче разумно ловить именно эти коды отдельно от прочих 5xx и откладывать соответствующие элементы очереди в отдельный список для повторной обработки позже, а не ронять весь джоб или тратить те же самые ретраи, что и на обычный сетевой сбой.

Собрать всё вместе

Рабочий батч-раннер в итоге выглядит так: пул ключей с настроенными лимитами, Idempotency-Key на каждый логический запрос, заголовок X-Zukko-Cache: true там, где вход предсказуемо повторяется, чтение X-Zukko-Cost-Micros для прогнозирования остатка бюджета и отдельная обработка 429 с кодом ключа, 503/model_disabled и обычных сетевых таймаутов. Ни один из этих пунктов не требует ничего сверх стандартных HTTP-заголовков и кодов ответа — вся логика собирается поверх обычного requests или httpx, без специального SDK.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.