Skip to main content
Краткий ответ: данные изображения полностью получены и декодируются в совершенно корректное изображение. Зависает лишь самый последний шаг HTTP-передачи — сообщить клиенту «это всё». Поэтому правильное решение — не более длинный timeout и не retry, а самостоятельно завершить ответ, как только данные получены, и использовать уже имеющееся у вас изображение.

Это совместимость, а не замена

Приведённый ниже код добавляет защитный слой вокруг вашей существующей логики вызова. Это не другой способ интеграции:
  • Вам не нужно менять endpoint, переключать модели, заменять SDK или настраивать какой-либо параметр запроса;
  • Исправные запросы по-прежнему проходят точно по тому же пути, что и сейчас — поведение не меняется. Эта логика совместимости никогда не срабатывает на исправном запросе;
  • Она включается только тогда, когда данные уже полностью пришли, но соединение отказывается завершаться, и передаёт вам изображение, которое вы уже получили.
Иными словами: с ней сбойный случай можно восстановить; без неё сбойный случай может завершиться только ошибкой timeout. Все остальное остаётся как есть.

Симптом

При вызове нативного эндпоинта генерации изображений (POST /v1beta/models/{model}:generateContent) вы можете увидеть такую комбинацию:
  • В журнале панели видно, что запрос успешно выполнен и подлежит тарификации;
  • Клиент все равно зависает, и сбой происходит только когда срабатывает его собственный тайм-аут чтения;
  • Ошибки выглядят как Read timed out, ETIMEDOUT или UND_ERR_BODY_TIMEOUT.
Это похоже на «панель сообщает, что задача завершилась за 30 секунд, но через 5 минут у меня по-прежнему нет изображения».
Тот же код раньше работал нормально, а теперь зависает на этом финальном шаге. Этот сценарий появился недавно — это не давний дефект в том, как вы все интегрировали. Поэтому не нужно пересматривать ваш шаблон вызова; вам достаточно добавить слой совместимости, описанный ниже.

Появляется окнами

Это важно, потому что от этого зависит, как вы воспроизводите проблему и как интерпретируете то, что видите:
  • Внутри окна: последовательные вызовы зависают, все без исключения;
  • Вне окна: десятки последовательных вызовов работают безупречно, ни одного случая.
То есть это не «всегда воспроизводится» и не «редкий случайный сбой». Если ваш тест случайно не попадет в окно, все выглядит на 100% нормально, и легко ошибочно заключить, что «все исправлено». Если же вы попадете внутрь окна, создается впечатление, что все сломано. Оба впечатления верны — просто не делайте долгосрочных выводов, опираясь только на одно из них.
Запросы с потоковой передачей (:streamGenerateContent) и модели только для текста обычно не затронуты. Эта страница посвящена генерации изображений без потоковой передачи, где тело ответа большое — тело ответа JSON для изображения 2K имеет порядок 13 MB.

Причина

Ответы с изображениями отправляются с Transfer-Encoding: chunked. Согласно спецификации HTTP/1.1, после того как сервер отправил последний data chunk, он должен отправить завершающий chunk (chunk нулевой длины), чтобы сообщить клиенту «это конец». Именно на этом шаге все и ломается: каждый data chunk приходит, но завершающий chunk так и не отправляется, а соединение не закрывается. У клиента остается полный, пригодный к использованию JSON-документ (изображение нормально декодируется из base64), и у него нет способа понять, что тело ответа завершено. Поэтому он продолжает ждать — пока не срабатывает собственный timeout на чтение. Аналогия: посылка уже стоит у вас на пороге, но курьер забыл нажать «доставлено». Вы сидите и смотрите на страницу отслеживания в ожидании обновления, хотя посылка уже прямо перед вами.
В определенные временные окна маршрут не выполняет этот финальный шаг в ответе. Мы продолжаем добиваться исправления на стороне сервера; то, что описывает эта страница, — это клиентская защита, которую нужно использовать пока что.Эта защита полезна сама по себе, и вам не нужно откатывать ее после исправления на стороне сервера: когда присутствует завершающий сигнал, она вообще не срабатывает, поэтому сама становится невидимой — нулевые накладные расходы, нулевая нагрузка на сопровождение.
Три вывода, которые напрямую определяют, как с этим работать:

Данные полные

Это не потеря пакетов, не проблема качества сети и не обрыв передачи на середине. Те байты, которые у вас есть, корректно разбираются, и изображение полностью пригодно к использованию.

Ожидание не помогает

Как только все зависает, сервер не отправляет ни одного байта больше. Проверено ожиданием 330 секунд без изменений. Увеличение timeout до нескольких сотен секунд только откладывает обнаружение.

Не привязано к одной машине

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

Как это определить

Если выполняются все три условия, вы почти наверняка сталкиваетесь именно с этим:
1

Ответ содержит Transfer-Encoding: chunked и не содержит Content-Length

Это означает, что длина тела заранее не была объявлена, поэтому клиент может опираться только на завершающий chunk, чтобы понять, что ответ завершен.
2

Полученные к этому моменту байты уже разбираются как полный JSON

Запустите json.loads над тем, что у вас есть — он завершается успешно, а inlineData.data внутри base64-декодируется в полное, пригодное к использованию изображение.
3

После того как этот разбор завершается успешно, новые байты долго не поступают

Нет ни завершающего chunk, ни закрытия соединения. Оно просто остается открытым.

Чем это отличается от двух похожих случаев

Все три случая дают похожие ошибки, но причины и способы исправления полностью разные. Не применяйте один набор критериев ко всем из них: Если ваша ошибка — ECONNRESET, это другой класс проблемы; см. Обрывы соединения.

Слой совместимости: завершайте ответ на стороне клиента

Идея проста: не ждите, пока соединение завершится — завершайте, как только байты, которые у вас уже есть, разбираются как полный JSON.

Критично: сохраняйте период ожидания

Не завершайте сразу, как только парсинг успешно завершился. В обычном случае завершающий chunk обычно находится уже в следующем TCP-сегменте, всего в нескольких миллисекундах. Если обрывать сразу после успешного парсинга, вы будете ошибочно считать, что «завершающий chunk пришел на несколько миллисекунд позже», как «сервер его вообще не отправил». Правильный подход: как только парсинг успешен, подождите еще немного (3–5 секунд — хорошее значение по умолчанию). Если за это время приходят какие-либо байты, продолжайте работу как обычно. Только если ничего не приходит, объявляйте поток зависшим и завершайте ответ самостоятельно.
Это не необязательная доработка. Пропуск периода ожидания делает обнаружение полностью бесполезным — каждый исправный запрос ошибочно помечается как сбойный. Наша первая реализация столкнулась ровно с этим: вся партия исправных запросов была отмечена как сломанная.

Проверки, которые нужно пройти перед завершением ответа

От самых дешевых к самым дорогим. Если хотя бы одна из них не проходит, продолжайте ожидание — не завершайте ответ: Проверки 6 и 7 вместе сводят частоту ложноположительных срабатываний почти к нулю: ответ представляет собой один JSON-объект, поэтому парсинг неизбежно завершается ошибкой, пока данных еще не хватает. Иными словами, вы можете завершить только тот ответ, который действительно пришел полностью.

Python

Читайте stream в фоновом потоке и реализуйте льготный период через таймаут очереди в главном потоке:
Зачем нужен дополнительный поток? Потому что requests имеет один таймаут чтения, который одновременно управляет и «ожиданием первого байта», и «ожиданием между чанками». Когда ответ зависает, цикл чтения блокируется на следующем чтении, и льготный период так и не получает шанса сработать — простой вариант с таймером на основе for chunk in ... не срабатывает именно в том случае, который он должен ловить.Чтение в фоновом потоке и вызов q.get(timeout=term_grace) в главном потоке — вот что на самом деле разделяет два таймаута. Мы сами столкнулись с этим: один таймаут, покрывающий две разные вещи, объединяет «медленную генерацию» и «не завершено» в одну неразличимую ошибку.
Этот вариант выполняет парсинг один раз, когда истекает льготный период, а не после каждого чанка — благодаря чему проверка 5 выше выполняется бесплатно. Тело размером более десятка МБ никогда не парсится повторно.

Node.js

Node.js не нужен дополнительный поток — reader.read() уже является promise, поэтому Promise.race может ограничить время ожидания следующего фрагмента:
Обратите внимание на комментарий здоровый путь в обоих фрагментах: когда сервер корректно завершает ответ, цикл естественным образом выходит через done или по завершении итерации, и ветка льготного периода так и не выполняется. Именно это на практике означает «совместимость, а не замена» — ваш существующий успешный сценарий остается неизменным вплоть до байта.

Выбор таймаутов

Самая простая ошибка здесь — использовать одно значение таймаута для двух разных вещей: «ожидание, пока upstream сгенерирует изображение» и «пауза между двумя чанками после первого байта». Их обычная длительность отличается на порядок. Объедините их в одно значение, и вы либо преждевременно оборвете медленную генерацию, приняв ее за сбой, либо оставите действительно зависшие запросы ждать минутами.

✅ Рекомендуется

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

❌ Избегайте

Один таймаут на 300 секунд для всего «на всякий случай». Когда ответ зависает, сервер больше ничего не отправляет, так что более долгое ожидание ничего не меняет и лишь откладывает обнаружение.
Если ваш продукт включает более медленные уровни, например 4K: общий таймаут может быть длиннее (модели действительно нужно это время), но порог паузы между чанками не должен расти вместе с ним — это две разные вещи, поэтому не масштабируйте их вместе.
Пользователям Node.js: undici (движок, лежащий в основе встроенного fetch в Node 18+) имеет три независимых таймаута, а опция timeout в SDK не затрагивает ни один из них. См. раздел «Node.js: три независимых таймаута» в Обрывы соединения для правильной настройки.

Повторные попытки и тарификация

Если вы определили, что сервер так и не завершил ответ, действуйте в таком порядке:
1

Используйте уже полученное изображение — в почти всех случаях на этом все заканчивается

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

Повторяйте только если разбор действительно не удался

Если полученные байты действительно невозможно разобрать в полный JSON, тогда повторите попытку. Используйте новое соединение и выдерживайте 2–3 секунды между попытками.
3

Снижайте частоту после повторных сбоев вместо плотных повторных попыток

Поскольку проблема проявляется окнами, немедленная повторная попытка, скорее всего, попадет в то же окно. Если три попытки подряд зависают, сделайте паузу на 30 секунд, прежде чем пробовать снова.
Тарификация: для этих запросов вышестоящая система уже сгенерировала изображение и начала отправлять его обратно, поэтому доставка считается завершенной, и запрос тарифицируется в обычном порядке. Фраза «Мой клиент превысил время ожидания» не означает «с меня не списали плату» — именно поэтому первый шаг важнее всего: вы уже заплатили за это изображение, так что выбросить его — и есть настоящая трата.Полную разбивку по тому, какие обрывы соединения тарифицируются, а какие нет, см. в разделе «Влияние на тарификацию» в Обрывы соединения.

Заметки по развёртыванию

Это рекомендации, не зависящие от языка и фреймворка, основанные на нашем собственном развёртывании:
  • Разместите это в одной сетевой точке входа, а не разбросанно по местам вызова. Сделайте это частью самой операции «отправить запрос». Так вы охватите сразу все пути работы с изображениями, не затронете бизнес-код и оставите только одно место для изменений, когда серверная часть будет исправлена.
  • На самом деле вам нужен инкрементальный доступ на чтение. Необходимое условие — возможность видеть, что уже пришло, до завершения ответа. Почти каждый HTTP-клиент это поддерживает (потоковое чтение, callbacks по чанкам, события прогресса), но обычно это не режим по умолчанию — режим «просто отдайте мне всё тело» как раз и приводит к зависанию. Именно здесь заключается большая часть работы.
  • Отсчитывайте время от последнего полученного чанка, а не от начала запроса. Сбрасывайте таймер льготного периода при каждом чанке. Так вы не будете наказывать медленные сети и не пропустите состояние «вообще ничего не движется».
  • Спрячьте это за переключателем. Держите поведение за флагом, который можно в любой момент отключить. Если сразу после релиза появится что-то неожиданное, выключите его, чтобы вернуть старое поведение — без экстренного деплоя.
  • Добавьте телеметрию. Логируйте каждый раз, когда срабатывает путь завершения (временная метка, количество байт, длительность ожидания). Это служит трём целям: показывает, как часто это действительно происходит, подтверждает, что слой выполняет свою работу, и подтверждает, что счётчик падает до нуля после серверного исправления — а это единственная объективная основа для решения, что этот слой можно вывести из эксплуатации.
  • Дополнительное улучшение, пока вы уже этим занимаетесь. Как только у вас появится инкрементальное чтение, вы сможете показывать пользователям реальный прогресс («получаем данные, X.X MB»). Для них эта длительная загрузка раньше была полной чёрной коробкой.

Как мы развернули это у себя

Мы уже завершили и проверили эту работу в нашей собственной студии генерации изображений с ИИ (imagen.apiyi.com). Используя имитирующий сервис, который воспроизводит сбой (отправляет все тело, затем не подает сигнал завершения и не закрывает соединение), мы провели такое сравнение: Вывод: нулевое влияние на исправные запросы, тогда как для неудачных запросов путь меняется с «ждать истечения тайм-аута, а затем завершиться с ошибкой» на «получить изображение за несколько секунд».

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

Нет. Завершение требует, чтобы полученные байты распарсились как полный JSON-документ — пока данных не хватает, парсинг неизбежно завершается с ошибкой. Добавьте сверху 3–5-секундный льготный период, и исправные запросы не будут помечаться ошибочно. Первые две строки сравнительной таблицы выше как раз показывают эти два случая рядом.
Нет. Проверяется целостность всего тела ответа, а не самого изображения. Если JSON парсится, данные изображения полные — половина изображения соответствует ошибке парсинга, а она никогда не запускает завершение.
Нет. Это не заменяет исправление на стороне сервера. Это передает пользователю результат, который уже был получен и уже был тарифицирован, при этом избегая двойной тарификации, к которой приводят слепые повторные попытки. Создаваемая этим телеметрия также помогает определить, когда возникает сбой.
Спешить не нужно. Когда присутствует завершающий сигнал, эта логика никогда не срабатывает, так что она ничего не стоит. Подождите, пока телеметрия будет на нуле в течение устойчивого периода, а затем решите, стоит ли это убирать.

Когда обращаться в поддержку

Если после добавления указанного выше слоя совместимости по-прежнему выполняется хотя бы одно из следующих условий, соберите материалы и обратитесь в поддержку:
  • Полученные байты никогда не разбираются в полный JSON (это не тот сценарий, который описан на этой странице — передача действительно была прервана раньше времени);
  • Даже при завершении на стороне клиента вы очень долго не получаете вообще никаких заголовков ответа (это означает, что upstream еще не начал отправку — медленная генерация или сбой upstream, а не проблема завершения);
  • Частота зависаний остается стабильно высокой и не группируется в окнах времени, воспроизводясь устойчиво на протяжении длительного периода.
При создании тикета укажите: x-request-id, время вызова (с указанием часового пояса, например 2026-08-03 13:15 (UTC+8)), название модели и ключевые параметры, такие как imageSize, исходное исключение на стороне клиента и сколько байт было получено к моменту зависания.

Связанная документация

Обрывы соединения

ECONNRESET, SSL EOF, трёх таймаутов undici и диагностическая матрица локального proxy

Обязательно к прочтению и лучшие практики

Синхронные вызовы, уровни таймаута, обработка base64 и тарификация при обрыве соединения

Создайте собственную асинхронную очередь

Оборачивайте синхронные вызовы в task queue, сглаживая редкие сбои с помощью повторных попыток и сохранения состояния