Код 499: клиент оборвал соединение раньше ответа

Ответ пришёл наполовину и пропал — знакомая картина при долгой генерации. Разбираем, почему это почти никогда не лимит токенов, а таймаут на стороне клиента.

4 мин · · Обновлено · ошибки

Код 499 — статус, которым nginx и большинство прокси перед API помечают ситуацию, когда клиент сам закрыл соединение раньше, чем сервер успел дописать ответ. С точки зрения API это не ошибка в привычном смысле: сервер ничего не возвращал специально, разговор оборвался с другой стороны провода.

Почему это почти всегда таймаут, а не лимит токенов

Самая частая причина обрыва — таймаут HTTP-клиента, выставленный короче, чем реально нужно модели на генерацию. Это особенно заметно на длинных ответах: большой max_tokens или включённый extended thinking — оба случая требуют заметно больше времени, чем короткий чат-ответ на пару предложений.

Разница с ограничением по токенам принципиальная. Если бы модель упёрлась в лимит контекста или max_tokens, ответ пришёл бы полностью, просто обрезанным по finish_reason. При 499 ответа не приходит вообще — соединение разрывается раньше, чем данные успевают дойти до клиента, независимо от того, сколько токенов модель успела сгенерировать.

Где чаще всего стоит таймаут по умолчанию

curl без явного --max-time не оборвёт соединение сам, но библиотеки внутри инструментов почти всегда ставят свой таймаут. У requests в Python его нет по умолчанию тоже, а вот у многих HTTP-клиентов в IDE-плагинах и прокси-обёртках — есть, и часто он в районе 30-60 секунд, чего extended thinking на сложном запросе может не уложиться.

Проверить это можно, увеличив таймаут явно:

bash
curl https://api.zukko.pro/v1/messages \
  --max-time 180 \
  -H "Authorization: Bearer fetch_ваш_ключ" \
  -H "Content-Type: application/json" \
  -H "anthropic-version: 2023-06-01" \
  -d '{"model":"claude-opus-5-5","max_tokens":4096,"thinking":{"type":"enabled","budget_tokens":8000},"messages":[{"role":"user","content":"Распиши план миграции сервиса на новую очередь сообщений"}]}'
bash
import requests

resp = requests.post(
    "https://api.zukko.pro/v1/messages",
    timeout=180,  # секунд, а не значение по умолчанию библиотеки
    headers={
        "Authorization": "Bearer fetch_ваш_ключ",
        "anthropic-version": "2023-06-01",
    },
    json={
        "model": "claude-opus-5-5",
        "max_tokens": 4096,
        "thinking": {"type": "enabled", "budget_tokens": 8000},
        "messages": [{"role": "user", "content": "Распиши план миграции"}],
    },
)
bash
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 180_000);

const resp = await fetch("https://api.zukko.pro/v1/chat/completions", {
  method: "POST",
  signal: controller.signal,
  headers: {
    Authorization: "Bearer fetch_ваш_ключ",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "gpt-5.6-sol",
    max_tokens: 4096,
    messages: [{ role: "user", content: "Распиши план миграции" }],
  }),
});
clearTimeout(timeout);

Стриминг снижает шанс обрыва

Если ответ ждётся целиком одним куском, любой промежуточный прокси или балансировщик может решить, что соединение зависло, и закрыть его сам — даже если ваш собственный таймаут выставлен щедро. У стриминга ("stream": true в /v1/chat/completions или /v1/responses, аналогично у /v1/messages) от сервера постоянно идут байты, и большинству промежуточных узлов этого достаточно, чтобы не считать соединение мёртвым.

Генерация изображений и более долгие таймауты

Генерация изображений через Zukko временно недоступна — раздел оставлен для справки. Актуальный список моделей — GET https://api.zukko.pro/v1/models.

У моделей с kind=image — nano-banana-2-text-to-image, openai-gpt-image-2-edit и остальных из каталога — обработка одного запроса обычно занимает больше времени, чем текстовый ответ на пару абзацев, а тарификация у них отдельная: за штуку, а не за токены. Клиентский таймаут, рассчитанный на чат, для /v1/images/generations часто оказывается слишком коротким. Если 499 стабильно случается именно на генерации изображений, а не на тексте — это первое, что стоит поднять отдельно от таймаута, выставленного для /v1/messages и /v1/chat/completions.

Idempotency-Key: как повторить запрос, не заплатив дважды

Если запрос оборвался, самый простой способ — повторить его. Проблема в том, что обрыв соединения клиентом не останавливает списание: шлюз дочитывает ответ провайдера до конца и списывает всю генерацию, так что простой повтор без защиты означает оплату дважды. Чтобы этого не было, добавляйте заголовок Idempotency-Key со случайным значением при первой попытке — уникальным для этого запроса и тем же самым на всех его повторах (не генерируйте его заново при ретрае):

bash
curl https://api.zukko.pro/v1/messages \
  -H "Authorization: Bearer fetch_ваш_ключ" \
  -H "Idempotency-Key: retry-20260919-01" \
  -H "anthropic-version: 2023-06-01" \
  -d '{"model":"claude-opus-5-5","max_tokens":4096,"messages":[...]}'

Повтор с тем же ключом и тем же телом запроса в течение 24 часов вернёт тот же результат без повторного списания. Если тело запроса изменится, а ключ останется тем же — получите 422 idempotency_conflict, и это тоже полезный сигнал: значит, вы действительно повторяете, а не отправляете новый запрос под старым именем.

Как проверить, что произошло на самом деле

Раз ответ на клиента не пришёл, узнать, добралась ли генерация до конца, можно по истории. GET /v1/usage отдаёт список списаний с курсорной пагинацией — если оборвавшийся запрос там есть, значит модель успела отработать и результат существует на стороне провайдера, просто не долетел до вас. Учтите, что обрыв не отменяет генерацию: шлюз дочитывает ответ провайдера и списывает её целиком, поэтому запись может появиться не сразу, а когда генерация завершится. Если записи нет и через несколько минут — запрос действительно не состоялся; для надёжности повторяйте его с тем же Idempotency-Key, тогда повторный вызов ничего не задвоит.

Заголовки X-Zukko-Cost-Micros и X-Fetch-Balance-Cents приходят только вместе с успешным обычным (не потоковым) ответом, поэтому при 499 вы их не увидите в принципе — это ещё один способ отличить "ответ был, но не долетел" от "запроса не случилось": сверяйтесь с балансом через GET /v1/balance до и после (с поправкой на то, что списание за оборванную генерацию всё равно произойдёт).

Что делать на практике

Если 499 повторяется регулярно на одних и тех же запросах — это почти всегда сочетание большого max_tokens или включённого extended thinking с таймаутом клиента, унаследованным по умолчанию от библиотеки или прокси. Поднимите таймаут явно, переключитесь на стриминг там, где инструмент это поддерживает, и используйте Idempotency-Key на любые запросы, где повтор возможен — тогда обрыв соединения превращается из риска двойного списания в обычную сетевую случайность, которую можно просто повторить.

zukko

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

за Claude Code & ChatGPT

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