Симптом
При вызове нативного эндпоинта генерации изображений (POST /v1beta/models/{model}:generateContent) вы можете увидеть такую комбинацию:
- В журнале панели видно, что запрос успешно выполнен и подлежит тарификации;
- Клиент все равно зависает, и сбой происходит только когда срабатывает его собственный тайм-аут чтения;
- Ошибки выглядят как
Read timed out,ETIMEDOUTилиUND_ERR_BODY_TIMEOUT.
Появляется окнами
Это важно, потому что от этого зависит, как вы воспроизводите проблему и как интерпретируете то, что видите:- Внутри окна: последовательные вызовы зависают, все без исключения;
- Вне окна: десятки последовательных вызовов работают безупречно, ни одного случая.
:streamGenerateContent) и модели только для текста обычно не затронуты. Эта страница посвящена генерации изображений без потоковой передачи, где тело ответа большое — тело ответа JSON для изображения 2K имеет порядок 13 MB.Причина
Ответы с изображениями отправляются сTransfer-Encoding: chunked. Согласно спецификации HTTP/1.1, после того как сервер отправил последний data chunk, он должен отправить завершающий chunk (chunk нулевой длины), чтобы сообщить клиенту «это конец».
Именно на этом шаге все и ломается: каждый data chunk приходит, но завершающий chunk так и не отправляется, а соединение не закрывается.
У клиента остается полный, пригодный к использованию JSON-документ (изображение нормально декодируется из base64), и у него нет способа понять, что тело ответа завершено. Поэтому он продолжает ждать — пока не срабатывает собственный timeout на чтение.
Аналогия: посылка уже стоит у вас на пороге, но курьер забыл нажать «доставлено». Вы сидите и смотрите на страницу отслеживания в ожидании обновления, хотя посылка уже прямо перед вами.
Три вывода, которые напрямую определяют, как с этим работать:
Данные полные
Ожидание не помогает
Не привязано к одной машине
Как это определить
Если выполняются все три условия, вы почти наверняка сталкиваетесь именно с этим:Ответ содержит Transfer-Encoding: chunked и не содержит Content-Length
Полученные к этому моменту байты уже разбираются как полный JSON
json.loads над тем, что у вас есть — он завершается успешно, а inlineData.data внутри base64-декодируется в полное, пригодное к использованию изображение.После того как этот разбор завершается успешно, новые байты долго не поступают
Чем это отличается от двух похожих случаев
Все три случая дают похожие ошибки, но причины и способы исправления полностью разные. Не применяйте один набор критериев ко всем из них:ECONNRESET, это другой класс проблемы; см. Обрывы соединения.
Слой совместимости: завершайте ответ на стороне клиента
Идея проста: не ждите, пока соединение завершится — завершайте, как только байты, которые у вас уже есть, разбираются как полный JSON.Критично: сохраняйте период ожидания
Не завершайте сразу, как только парсинг успешно завершился. В обычном случае завершающий chunk обычно находится уже в следующем TCP-сегменте, всего в нескольких миллисекундах. Если обрывать сразу после успешного парсинга, вы будете ошибочно считать, что «завершающий chunk пришел на несколько миллисекунд позже», как «сервер его вообще не отправил». Правильный подход: как только парсинг успешен, подождите еще немного (3–5 секунд — хорошее значение по умолчанию). Если за это время приходят какие-либо байты, продолжайте работу как обычно. Только если ничего не приходит, объявляйте поток зависшим и завершайте ответ самостоятельно.Проверки, которые нужно пройти перед завершением ответа
От самых дешевых к самым дорогим. Если хотя бы одна из них не проходит, продолжайте ожидание — не завершайте ответ:Python
Читайте stream в фоновом потоке и реализуйте льготный период через таймаут очереди в главном потоке:Node.js
Node.js не нужен дополнительный поток —reader.read() уже является promise, поэтому Promise.race может ограничить время ожидания следующего фрагмента:
Выбор таймаутов
Самая простая ошибка здесь — использовать одно значение таймаута для двух разных вещей: «ожидание, пока upstream сгенерирует изображение» и «пауза между двумя чанками после первого байта». Их обычная длительность отличается на порядок. Объедините их в одно значение, и вы либо преждевременно оборвете медленную генерацию, приняв ее за сбой, либо оставите действительно зависшие запросы ждать минутами.✅ Рекомендуется
❌ Избегайте
fetch в Node 18+) имеет три независимых таймаута, а опция timeout в SDK не затрагивает ни один из них. См. раздел «Node.js: три независимых таймаута» в Обрывы соединения для правильной настройки.Повторные попытки и тарификация
Если вы определили, что сервер так и не завершил ответ, действуйте в таком порядке:Используйте уже полученное изображение — в почти всех случаях на этом все заканчивается
Повторяйте только если разбор действительно не удался
Снижайте частоту после повторных сбоев вместо плотных повторных попыток
Заметки по развёртыванию
Это рекомендации, не зависящие от языка и фреймворка, основанные на нашем собственном развёртывании:- Разместите это в одной сетевой точке входа, а не разбросанно по местам вызова. Сделайте это частью самой операции «отправить запрос». Так вы охватите сразу все пути работы с изображениями, не затронете бизнес-код и оставите только одно место для изменений, когда серверная часть будет исправлена.
- На самом деле вам нужен инкрементальный доступ на чтение. Необходимое условие — возможность видеть, что уже пришло, до завершения ответа. Почти каждый HTTP-клиент это поддерживает (потоковое чтение, callbacks по чанкам, события прогресса), но обычно это не режим по умолчанию — режим «просто отдайте мне всё тело» как раз и приводит к зависанию. Именно здесь заключается большая часть работы.
- Отсчитывайте время от последнего полученного чанка, а не от начала запроса. Сбрасывайте таймер льготного периода при каждом чанке. Так вы не будете наказывать медленные сети и не пропустите состояние «вообще ничего не движется».
- Спрячьте это за переключателем. Держите поведение за флагом, который можно в любой момент отключить. Если сразу после релиза появится что-то неожиданное, выключите его, чтобы вернуть старое поведение — без экстренного деплоя.
- Добавьте телеметрию. Логируйте каждый раз, когда срабатывает путь завершения (временная метка, количество байт, длительность ожидания). Это служит трём целям: показывает, как часто это действительно происходит, подтверждает, что слой выполняет свою работу, и подтверждает, что счётчик падает до нуля после серверного исправления — а это единственная объективная основа для решения, что этот слой можно вывести из эксплуатации.
- Дополнительное улучшение, пока вы уже этим занимаетесь. Как только у вас появится инкрементальное чтение, вы сможете показывать пользователям реальный прогресс («получаем данные, X.X MB»). Для них эта длительная загрузка раньше была полной чёрной коробкой.
Как мы развернули это у себя
Мы уже завершили и проверили эту работу в нашей собственной студии генерации изображений с ИИ (imagen.apiyi.com). Используя имитирующий сервис, который воспроизводит сбой (отправляет все тело, затем не подает сигнал завершения и не закрывает соединение), мы провели такое сравнение:
Частые вопросы
Может ли это обрезать запрос, который на самом деле был исправен?
Может ли это обрезать запрос, который на самом деле был исправен?
Могу ли я в итоге получить половину изображения?
Могу ли я в итоге получить половину изображения?
Разве это не просто маскирует проблему на стороне сервера?
Разве это не просто маскирует проблему на стороне сервера?
Нужно ли убрать это, когда проблема на стороне сервера будет исправлена?
Нужно ли убрать это, когда проблема на стороне сервера будет исправлена?
Когда обращаться в поддержку
Если после добавления указанного выше слоя совместимости по-прежнему выполняется хотя бы одно из следующих условий, соберите материалы и обратитесь в поддержку:- Полученные байты никогда не разбираются в полный JSON (это не тот сценарий, который описан на этой странице — передача действительно была прервана раньше времени);
- Даже при завершении на стороне клиента вы очень долго не получаете вообще никаких заголовков ответа (это означает, что upstream еще не начал отправку — медленная генерация или сбой upstream, а не проблема завершения);
- Частота зависаний остается стабильно высокой и не группируется в окнах времени, воспроизводясь устойчиво на протяжении длительного периода.
x-request-id, время вызова (с указанием часового пояса, например 2026-08-03 13:15 (UTC+8)), название модели и ключевые параметры, такие как imageSize, исходное исключение на стороне клиента и сколько байт было получено к моменту зависания.
Связанная документация
Обрывы соединения
ECONNRESET, SSL EOF, трёх таймаутов undici и диагностическая матрица локального proxy