Представьте три записи в журнале вашего воркера. В первой пришёл 429 и заголовок с ожиданием. Во второй тоже 429, но заголовка нет, а в теле ошибки упоминается предел расходов. В третьей вообще нет ответа: соединение оборвалось на тридцатой секунде долгой генерации. Выглядят они как три варианта одной неприятности, но требуют трёх разных действий. Первую задачу нужно отложить и повторить. Вторую повторять бессмысленно: надо остановить очередь. Третья остаётся в неизвестном состоянии, и объявлять её простой ошибкой рано. Если на все три случая у воркера один цикл «подождать пару секунд и отправить снова», он будет в лучшем случае бесполезно шуметь, а в худшем платить за одну и ту же работу несколько раз.
Ниже разберём, как построить очередь перед Claude Messages API на собственном VPS так, чтобы каждый из этих ответов приводил к своему действию. Claude взят как конкретный пример: коды, заголовки и условия, о которых пойдёт речь, относятся к его документации и не переносятся на других поставщиков автоматически. Если вы работаете с другим API, сверьте смысл 429 и тарификацию повторов с его собственными страницами.
Сначала прочитать ответ целиком
Код состояния — это только заголовок письма. Прежде чем назначать задержку, воркеру нужно прочитать три вещи: код и тип ошибки, тело с кодом внутри error.details, и заголовок Retry-After.
Документация Claude по ошибкам описывает несколько похожих, но разных ситуаций. 429 может означать превышение лимита скорости, а может отражать предел расходов. 529 overloaded_error говорит о временной перегрузке самого API. 402 billing_error связан с оплатой. А если вы сами выставили лимит расходов на организацию или workspace, это обычно выглядит как 400 invalid_request_error; для workspace Claude Code описано отдельное поведение, так что в этом углу стоит читать документацию, а не угадывать.
Важно, что именно в этой путанице кроется ловушка. Отсутствие Retry-After само по себе ничего не доказывает. Согласно странице о лимитах, 429 из-за месячного предела расходов содержит error.details.error_code=enforced_spend_limit_reached и не содержит Retry-After. Но «нет заголовка» и «это предел расходов» — не одно и то же. Решение принимается по структурированному коду в теле, а заголовок служит подсказкой для случая временного ограничения: там он задаёт ожидание до следующей попытки.
Практическое следствие для очереди такое. Разбор ответа должен вернуть не булево «повторять / не повторять», а одно из нескольких состояний. Вот как их удобно различать по последствиям для всей очереди, а не только для одной задачи:
| Ответ | Что произошло | Что делает очередь |
|---|---|---|
| 429 с Retry-After, без признака предела расходов | Временное ограничение скорости | Откладывает задачу не раньше указанного срока; новые отправки притормаживает |
429 с enforced_spend_limit_reached | Достигнут месячный предел расходов; Retry-After нет | Останавливает отправку всем задачам этого ключа или организации и зовёт человека |
529 overloaded_error | Временная перегрузка API, ваши лимиты тут ни при чём | Повторяет с ограниченным числом попыток и растущей паузой, не считая это проблемой своего бюджета |
402 billing_error | Проблема с оплатой | Останавливает отправку; повтор ничего не изменит, пока не решён вопрос оплаты |
400 invalid_request_error | Запрос некорректен, в том числе при самостоятельно заданном лимите расходов | Не повторяет; смотрит тело и решает, что чинить — запрос или настройки |
| Обрыв без ответа | Неизвестно, дошёл ли запрос и началась ли генерация | Переводит задачу в отдельное состояние неопределённости |
Заметьте, чем эти строки различаются по последствиям. Временные ответы касаются одной задачи или короткого окна. Ответы про деньги касаются всех задач сразу: если месячный предел исчерпан, остальные сто задач в очереди упрутся в ту же стену, и честнее остановить отправку целиком, чем позволить каждой по очереди получить свой отказ.
Три разных «потолка» внутри одного 429
Допустим, это именно временный лимит скорости. Тут тоже есть нюансы. Лимиты Messages API измеряются раздельно: в запросах в минуту, во входных токенах в минуту и в выходных токенах в минуту. Заголовки ответа сообщают предел, остаток и момент сброса. Это значит, что очередь, считающая только число запросов, слепа к двум другим измерениям.
Вот почему это важно. Сто маленьких запросов и десять огромных могут упереться в разные потолки. Сто коротких вопросов быстро съедят лимит запросов, оставив массу невостребованных токенов. Десять запросов с длинными документами на входе почти не тронут счётчик запросов, но упрутся во входные токены. А выходные токены имеют свою особенность: объём ответа вы заранее не знаете, он зависит от того, сколько модель решит написать, и от заданного вами максимума. Поэтому планировщик очереди должен держать три отдельных «ведра», а не одно.
Откуда брать оценки? Для входа перед запросом в Python SDK доступен count_tokens, он даёт оценку входных токенов. Для выхода разумно исходить из лимита, который вы сами передаёте в запросе: это верхняя граница, а фактический расход потом покажет usage ответа. Остатки и моменты сброса из заголовков помогают скорректировать картину: очередь не гадает, а сверяется с тем, что сообщает поставщик.
Есть и четвёртая причина 429, которая не про размер потолка, а про скорость его достижения. Документация предупреждает, что резкое увеличение нагрузки может вызвать acceleration limit, и советует наращивать поток постепенно. Для очереди на VPS это прямое указание: после простоя или после остановки не стоит выпускать накопленную тысячу задач одним залпом. Накопленный хвост нужно разгонять ступенями — сначала десятая часть обычной скорости, потом больше, пока ответы остаются спокойными. Иначе вы получите 429 не потому, что исчерпали лимит, а потому что слишком круто стартовали, а каждый такой отказ порождает повторы, которые только добавят давления.
Когда повторяют двое: арифметика вложенных попыток
Самая неочевидная часть истории — повторы, которые вы не писали. Python SDK Claude по умолчанию повторяет часть сетевых ошибок, а также ответы 408, 409, 429 и ошибки сервера, дважды с экспоненциальной задержкой. Таймауты тоже повторяются по умолчанию. Это удобно в скрипте на один вечер, но в системе с очередью получается второй слой повторов под вашим собственным.
Посчитаем на условном примере. Пусть ваш воркер разрешает себе три попытки на задачу, а SDK внутри каждой попытки делает ещё до двух повторов, то есть три обращения. Тогда на одну задачу выходит до девяти обращений к API. Теперь добавим шлюз между воркером и поставщиком, который тоже умеет повторять и тоже разрешает себе, скажем, три попытки. Получается до двадцати семи обращений. Каждое из них при 429 снова упирается в тот же лимит и сдвигает окно, а каждое успешное, но потерянное по дороге, может оказаться оплаченной генерацией. Числа здесь иллюстративные, но принцип перемножения не зависит от конкретных значений: попытки слоёв не складываются, а умножаются.
Решение простое по сути: повторами должен управлять один слой. В SDK отключение делается через max_retries; подтверждённый краткий пример выглядит как Anthropic(max_retries=0).
from anthropic import Anthropic
client = Anthropic(max_retries=0)Так же устроен и шлюз LiteLLM, если вы его используете. По его документации по Router, актуальный Router сам управляет повторами и задаёт max_retries:0 для клиентской библиотеки провайдера, чтобы не умножать попытки двух слоёв. Свои повторы LiteLLM задаются через num_retries, а retry_after определяет минимальное ожидание перед повтором. Через RetryPolicy можно настраивать повторы по типам ошибок, а cooldown относится к конкретному deployment, а не автоматически ко всей группе моделей. Это стоит держать в голове: если у вас несколько deployment за одним именем модели, охлаждение одного не обязано остановить остальные.
Но главное — не выбор конкретного инструмента, а принцип: в цепочке «очередь, шлюз, SDK» решение о повторе принимает ровно один участник, и это тот, кто знает про бюджет, возраст задачи и состояние остальной очереди. SDK и шлюз такого знания не имеют. Поэтому логично отдать повторы очереди, а нижние слои превратить в простых исполнителей одной попытки. Подтвердить, что у вас действительно так, можно не верой, а наблюдением: в журналах попыток одна отправка очереди должна соответствовать одному обращению к API, а не нескольким.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Одна задача от начала до конца
Теперь проведём одну задачу через систему целиком. Пусть это суммаризация длинного документа. Задача лежит в очереди на вашем VPS, у неё есть собственный идентификатор, счётчик попыток, момент постановки и крайний срок, после которого результат уже не нужен.
Ожидание
Перед отправкой планировщик оценивает входные токены через count_tokens, прибавляет верхнюю границу выходных и проверяет, помещается ли задача в три ведра с учётом последних заголовков об остатках. Если не помещается, задача просто ждёт. Это ожидание не расходует попытку: отправки не было, ответа нет, значит и повторять нечего. Такое различие важно вести честно, иначе задача сгорит по счётчику, ни разу не побывав у поставщика.
Первая попытка и временный 429
Воркер отправляет запрос и сохраняет заранее, до отправки, запись: задача такая-то, попытка первая, время старта. Приходит 429. Воркер читает тело, не находит enforced_spend_limit_reached, находит Retry-After. Он записывает идентификатор запроса из ответа, переводит задачу в «отложена до» и возвращает её в очередь. Момент следующей попытки — не раньше срока из заголовка. Если к этому моменту крайний срок задачи уже прошёл, воркер не повторяет, а закрывает её как просроченную: ответ, пришедший после того, как он стал не нужен, всё равно стоил бы денег.
Идентификатор запроса здесь не декорация. Каждый ответ API содержит request-id, он же доступен в теле ошибки как request_id. В Python SDK у объекта ответа есть _request_id, взятый из заголовка, а режим with_raw_response даёт доступ ко всем заголовкам. Сохраняя этот идентификатор рядом с попыткой, вы получаете возможность показать конкретный проблемный запрос при разборе с поддержкой и сопоставить его с записями в Claude Console. Без него все ваши 429 за ночь выглядят одинаково.
Вторая попытка и успех
Когда срок ожидания вышел, планировщик проверяет, не изменилась ли картина: нет ли уже стоп-сигнала по расходам, влезает ли задача в ведра. Затем отправляет вторую попытку. На этот раз приходит нормальный ответ. Вот тут и нужен фактический расход: у возвращённого ответа есть свойство usage с реальными input_tokens и output_tokens. Воркер записывает их в задачу и вычитает из локального учёта бюджета не оценку, а факт.
Почему это важно именно здесь? Потому что цены различают входные и выходные токены, у кеширования и дополнительных функций есть собственные условия расчёта, а оплата опирается на фактическое месячное использование, видимое в Claude Console. Оценка из count_tokens нужна для планирования, а не для бухгалтерии. Если вы будете тратить локальный бюджет по оценкам, он будет расходиться с реальностью, и рано или поздно очередь либо остановится зря, либо не остановится вовремя.
Итоговая запись по задаче получается содержательной: сколько было попыток, сколько ожидания, какие идентификаторы запросов, какой фактический расход. Из неё видно не только «выполнено», но и во что обошлось выполнение, включая бесполезные попытки, если они были.
Бюджет как отдельный предохранитель
Лимит попыток и срок жизни задачи защищают от зацикливания одной задачи. От накопительного перерасхода по всей очереди они не спасают: тысяча задач по две попытки — это по-прежнему тысяча удвоенных расходов. Поэтому нужен ещё и бюджетный ограничитель, который смотрит на очередь целиком.
Если вы используете LiteLLM как прокси, его возможности бюджетов включают индивидуальные, командные и внутригрупповые бюджеты. Здесь есть оговорка, которую легко пропустить: бюджетные проверки требуют базы данных, и без неё глобальный бюджет не ограничивает запросы. То есть включённая в конфигурации цифра без подключённой базы создаёт ложное ощущение защиты. Проверить стоит не наличие настройки, а то, что превышение действительно приводит к отказу в запросе.
Собственный ограничитель стоит устроить трёхмерным: сколько попыток разрешено задаче, сколько времени она вправе жить, сколько денег и токенов вправе потратить вся очередь за окно. Ни одно из трёх измерений не заменяет остальные. Попыток может быть мало, а срок большой, и тогда задача проживёт долго, но дёшево. Срок может быть маленьким, а попыток много, и тогда всё упрётся во время. Бюджет же ограничивает совокупность. Подчеркну: это защита, которую вы строите сами, а не обещание поставщика. Она не гарантирует возврата за неудачные попытки и не делает повторную генерацию бесплатной — за каждую завершённую генерацию платите вы.
Состояние «не знаем»
Теперь вернёмся к третьей записи из начала: соединение оборвалось, ответа нет. Документация по ошибкам отдельно отмечает, что длинное нестриминговое соединение может оборваться на сетевом пути без получения ответа от Anthropic. Что это значит для очереди? Что у вас нет ни кода, ни тела, ни request-id. Вы не знаете, дошёл ли запрос, началась ли генерация и была ли она завершена и оплачена. Все три варианта возможны.
Соблазн понятен: раз ответа нет, значит, ничего не случилось, повторим. Но это допущение, а не факт. Если запрос дошёл и модель честно отработала, повтор породит вторую генерацию того же самого, и расход будет двойной. Мы не знаем, есть ли у Messages API механизм, позволяющий безопасно объединять такие повторы, и поэтому описанная здесь архитектура на него не опирается. Она строится на другом: дедупликация задач ведётся в вашей собственной очереди, а при неизвестном исходе система не изображает, что всё известно.
На практике это значит отдельное состояние задачи, назовём его «исход неизвестен». Из него нет автоматического перехода в «повторить». Дальше допустимы разные политики, и выбирать их нужно по цене ошибки:
- Если задача дешёвая, а потеря ответа неприятна — разрешите один контролируемый повтор, но пометьте в учёте, что возможен двойной расход, и уменьшите на него бюджет по верхней оценке.
- Если задача дорогая (огромный вход, большой лимит выхода) — не повторяйте автоматически, а покажите её человеку вместе с временем старта и идентификаторами, которые удалось сохранить.
- Если ответ можно получить иначе — например, длинную генерацию можно было вести в потоковом режиме и частично сохранять — измените способ вызова, чтобы обрыв реже оставлял вас в слепой зоне.
Обратите внимание, что ни одна из политик не обещает «ровно одну генерацию». Вы можете гарантировать только то, что контролируете: не создавать в своей очереди две задачи на одно и то же и не отправлять повтор молча. А вот что произошло по ту сторону оборвавшегося соединения, вы можете выяснить лишь постфактум, сверив использование в Claude Console. Поэтому полезно хранить момент старта и параметры запроса: по ним проще сопоставить подозрительную попытку с записью об использовании.
Где всё это живёт
Очередь, планировщик с тремя вёдрами, хранилище попыток и состояний, возможно шлюз и Redis для общего состояния ограничений — это небольшие, но постоянно работающие процессы. Их естественно держать на собственном VPS: сервису не нужна мощная машина, ему нужны стабильная работа, место под базу и журналы и управление окружением. Для лёгкой очереди с одним воркером и базой хватит и скромной конфигурации. VPS MAATRIX предлагает NVMe-диски, процессоры AMD EPYC и root-доступ; в линейке тарифов от Ba с 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe до Djed с 16 vCPU, 32 ГБ и 320 ГБ. Эти ресурсы не равны обещанию конкретной пропускной способности: сколько задач очередь выдержит, покажет ваш собственный замер, а не название тарифа. Если вам нужна просто площадка для небольшого воркера и базы, стоит начать с малого и смотреть на загрузку; подробности и состав предложения — на странице аренды VPS MAATRIX.
Одну вещь важно не перепутать. Собственный сервер перед API решает задачу порядка, учёта и остановки, но не увеличивает лимиты поставщика. Лимиты привязаны к вашей организации и аккаунту в Claude, а не к IP воркера. Второй ключ той же организации или другая модель не дают независимого потолка автоматически, поэтому не стоит рассматривать их как способ обойти ограничение. Очередь нужна, чтобы тратить доступный лимит ровно и осмысленно, а не чтобы его удвоить.
Для читателей, у кого модель стоит у них самих, а не у внешнего поставщика, задача похожа, но устроена иначе: там узкое место — собственные вычислительные ресурсы. О такой постановке рассказано в материале об очереди запросов к локальной LLM.
Что делать, увидев конкретный ответ
Итог проще всего собрать не списком требований, а выбором действия по тому, что именно пришло.
Пришёл 429 с Retry-After и без признака предела расходов. Это временное ограничение. Задачу откладываем не раньше названного срока, проверяем, не вышел ли её крайний срок, и притормаживаем новые отправки по этому ключу. Попытка засчитана, идентификатор запроса сохранён.
Пришёл 429 с enforced_spend_limit_reached. Заголовка ожидания нет, и повтор до восстановления доступа не поможет. Останавливаем отправку для всей очереди этого ключа, задачи оставляем в ожидании, а не сжигаем попытки, и зовём человека: вопрос решается на стороне расходов, а не времени.
Пришёл 529. Перегружен сам API, ваши лимиты ни при чём. Повторяем в рамках разрешённого числа попыток и срока, с растущей паузой и без паники по поводу собственного бюджета.
Пришёл 402. Проблема в оплате. Останавливаем отправку: никакое ожидание её не решит.
Пришёл 400 invalid_request_error. Запрос некорректен или упёрся в лимит, который вы установили сами. Не повторяем; читаем тело и правим запрос или настройки лимитов.
Не пришло ничего: соединение оборвалось. Переводим задачу в «исход неизвестен», резервируем под неё возможный расход, и только затем решаем по цене ошибки: один контролируемый повтор, ручной разбор или смена способа вызова.
И в каждом случае, где ответ получен, записываем идентификатор запроса и, если генерация завершилась, фактический usage. Тогда журнал очереди перестаёт быть перечнем красных строк и становится рассказом о том, что произошло с каждой задачей и сколько это стоило.