> ## Documentation Index
> Fetch the complete documentation index at: https://docs.apiyi.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Устранение обрывов соединения Image API

> Как диагностировать `connection reset by peer`, `write_response_body_failed` и SSL EOF. Ошибка 500 `write_response_body_failed` никогда не тарифицируется. Описано, что проверять на macOS в сравнении с Linux-серверами, три тайм-аута Node.js в undici и матрицу диагностики для локального прокси.

<Warning>
  ### Сначала тарификация: `write_response_body_failed` 500 **не тарифицируется**

  Когда шлюз возвращает `500` с `write_response_body_failed` / `connection reset by peer`, платформа уже **автоматически повторила попытку 2-3 раза** и показывает ошибку только после того, как все попытки завершились неудачей. **За эти запросы не взимается никакая плата.**

  Так что даже если в ваших логах накапливаются такие ошибки, **в вашем счете не появится соответствующее списание** — вы не платите за сбои. Есть другой случай, который *действительно* тарифицируется (когда ваш клиент уходит слишком рано); см. раздел «Влияние на тарификацию» ниже.
</Warning>

<Info>
  **Краткий ответ**: ответы API для image generation обычно имеют размер от десяти до нескольких десятков МБ, и сбой почти всегда происходит во время **загрузки ответа** (**не** из-за того, что тело запроса слишком большое — обычный text-to-image тоже дает сбой). Сначала проверьте, к какой категории относится лог консоли, затем выполните приведенные ниже самопроверки для macOS / Linux / Node.js.
</Info>

## Как выглядит ошибка

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

### Со шлюза

```json theme={null}
{
  "status_code": 500,
  "error": {
    "message": "write tcp 10.0.0.1:443->203.0.113.5:52310: write: connection reset by peer",
    "type": "shell_api_error",
    "code": "write_response_body_failed"
  }
}
```

### Со стороны клиента

| Язык / библиотека                   | Типичное исключение                                                                                                 |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Python `requests` / `urllib3`       | `SSLError(SSLEOFError(8, 'EOF occurred in violation of protocol'))`, `ChunkedEncodingError`, `ConnectionResetError` |
| Python `httpx`                      | `RemoteProtocolError`, `ReadError`                                                                                  |
| Node.js (undici / встроенный fetch) | `UND_ERR_CONNECT_TIMEOUT`, `UND_ERR_HEADERS_TIMEOUT`, `UND_ERR_BODY_TIMEOUT`, `SocketError: other side closed`      |
| Node.js (другие стеки)              | `read ECONNRESET`, `ERR_STREAM_PREMATURE_CLOSE`, `socket hang up`                                                   |
| Go                                  | `unexpected EOF`, `http2: server sent GOAWAY`                                                                       |
| curl                                | `curl: (56) Recv failure`, `curl: (18) transfer closed with outstanding read data remaining`                        |

<Tip>
  **На Node.js различайте «killed» и «closed»**: `ECONNRESET` означает, что пришел TCP RST — соединение было **killed** чем-то на пути, что указывает на сетевой переход. `SocketError: other side closed` / `ERR_STREAM_PREMATURE_CLOSE` означает, что удаленная сторона закрыла соединение **штатно** (FIN), что указывает на проблему с финализацией на стороне сервера, например на отсутствующий chunked terminator. Эти два варианта указывают в совершенно разные стороны; не рассматривайте их как один и тот же симптом.

  Также, `UND_ERR_*` может исходить только из undici (движка, стоящего за встроенным `fetch` в Node 18+), тогда как `read ECONNRESET` — это верхнеуровневая формулировка libuv, которую также выдают `axios` / `node-fetch` / модуль `http`. **Если оба типа появляются вместе, сначала проверьте, есть ли в вашем приложении два разных HTTP пути** — в таком случае это вообще не один и тот же инцидент.
</Tip>

## Сначала определите направление: кто разорвал соединение

Код `write_response_body_failed` — ключевая подсказка: он означает **шлюз не смог записать тело ответа обратно вызывающей стороне**. Это направление **downstream**, а не ошибка upstream-модели. Результат уже был сгенерирован; соединение оборвалось в момент, когда его передавали вам.

<Info>
  **Это не вызвано слишком большим телом запроса.** Загрузки при редактировании изображений используют референсные изображения, из-за чего возникает подозрение на размер загрузки — но **обычный text-to-image, у которого тело запроса всего несколько сотен байт, ломается так же часто**. Сбой происходит при **загрузке ответа**: payload изображений имеют размер от десяти до нескольких десятков MB и являются самым хрупким звеном всего пути.
</Info>

<CardGroup cols={2}>
  <Card title="Обрыв downstream" icon="arrow-down-from-line">
    `write_response_body_failed`, `connection reset by peer`, SSL EOF на стороне клиента.
    Соединение исчезло, пока шлюз передавал тело. **Если платформа возвращает 500 в таком случае, это не тарифицируется** — см. раздел о тарификации ниже.
  </Card>

  <Card title="Сбой upstream (со стороны канала)" icon="arrow-up-from-line">
    Таймауты upstream, `upstream_error`, 5xx с полезной нагрузкой upstream или HTTP 200 с аномальным `finishReason`.
    Это настоящие проблемы канала — передайте `x-request-id` в поддержку.
  </Card>
</CardGroup>

### Самый сильный сигнал: что журнал консоли говорит об этом вызове

Сначала проверьте журнал вызова в консоли, прежде чем делать что-либо еще. Это ничего не стоит и быстрее любого шага на стороне клиента сужает круг поиска:

| Что показывает журнал                | Значение                                                                          | Тарифицируется?       | Следующий шаг                                                                                                                  |
| ------------------------------------ | --------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **Обычная запись вызова**            | Запрос поступил, upstream завершился, шлюз считает его доставленным               | Тарифицируется        | Пройдите четыре шага ниже, уделяя внимание ошибочным интерпретациям на уровне приложения и преждевременным отключениям клиента |
| **500 `write_response_body_failed`** | Шлюз не смог записать тело обратно вам после **2-3 внутренних повторных попыток** | **Не тарифицируется** | Проблема на downstream-пути — отправьте идентификатор запроса в поддержку                                                      |
| **Вообще нет записи**                | Запрос **не покидал вашу машину**                                                 | Не тарифицируется     | Этап соединения — перейдите к разделам «таймауты Node.js» и «локальный proxy / VPN» ниже                                       |

<Warning>
  Это приводит к выводу, который люди часто понимают наоборот: сбои на этапе соединения, такие как `UND_ERR_CONNECT_TIMEOUT`, не могут привести к списанию, потому что запрос никогда не достигал шлюза. Так что если вы видите «много connect timeouts» и «много списаний», это точно не одни и те же запросы — разбирайте их отдельно, а не заставляйте одну причину объяснять все.
</Warning>

### Четыре шага, чтобы снять с себя подозрение

Выполняйте их по порядку; большинство случаев решается уже на первых двух:

<Steps>
  <Step title="Исключите неверное чтение на уровне приложения: вы могли получить его и не сохранить">
    «Ответа не было» обычно оказывается выводом после того, как код выбросил исключение и был пойман как «request failed» — а не доказательством, что байты вообще не пришли. Самый частый случай: `gpt-image-2-all` по умолчанию возвращает `b64_json` **без префикса `data:`**, поэтому код, который читает `data[0].url`, получает `undefined`, выбрасывает downstream, считается неудачным и запускает повторную попытку — **за которую снова взимается плата**.

    Симптомы идентичны сетевому сбою, но сетевой сбой здесь не требуется. Выведите одну строку для проверки:

    ```javascript theme={null}
    console.log(Object.keys(resp.data[0]), resp.data[0].b64_json?.length);
    ```

    Различия полей и префиксов по сериям приведены в [справочнике префикса base64](/ru/api-capabilities/image-api-best-practices#prefix-differences).
  </Step>

  <Step title="Проверьте, не сбоят ли все каналы и модели одновременно">
    Если два разных канала, работающих на двух разных моделях, в один и тот же временной интервал выдают одну и ту же ошибку, причины, специфичные для канала, по сути исключены — upstream не отказывают так синхронно.
  </Step>

  <Step title="Проверьте среду выполнения клиента: TLS stack (Python) или таймауты undici (Node.js)">
    Python: проверьте версию TLS stack. Node.js: проверьте три таймаута undici. Оба случая разобраны в следующих двух разделах. Это самая частая первопричина на практике, она полностью находится на вашей машине, и одна команда это подтверждает.
  </Step>

  <Step title="Снизьте concurrency, перейдите на последовательный режим и отключите локальный proxy">
    Повторно запустите те же запросы при concurrency 1-2 с отключенными VPN или proxy. Если в таком режиме ошибка ни разу не воспроизводится, проблема в обработке соединений клиентом, локальных ресурсах (connection pool, file descriptors, memory) или сетевом пути — не в канале.
  </Step>
</Steps>

## Главный подозреваемый: TLS-стек клиента (особенно в macOS)

**Python, который поставляется с macOS (`/usr/bin/python3`), использует LibreSSL 2.8.3**, а не OpenSSL. В сочетании с urllib3 v2 такая связка надежно вызывает `SSLEOFError` при **одновременной загрузке больших тел ответов**. Клиент самопроизвольно разрывает соединение, а шлюз исправно фиксирует стену из `connection reset by peer`.

### Одна команда для проверки

```bash theme={null}
python3 -c "import ssl; print(ssl.OPENSSL_VERSION)"
```

| Вывод                      | Вердикт                                                                                    |
| -------------------------- | ------------------------------------------------------------------------------------------ |
| `LibreSSL 2.8.3`           | ⚠️ **Высокий риск** — порождает ложные ошибки соединения при одновременных больших ответах |
| `OpenSSL 1.1.1x` или новее | ✅ Все в порядке                                                                            |

Если при импорте `requests` вы видите это предупреждение, это тот же сигнал:

```
NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+,
currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'
```

### Исправление: переключите интерпретатор

Не понижайте версию urllib3 — просто используйте Python, собранный с полноценным OpenSSL:

```bash theme={null}
# macOS: build a virtualenv on Homebrew's Python
brew install python@3.13
python3.13 -m venv venv
venv/bin/pip install requests pillow
venv/bin/python -c "import ssl; print(ssl.OPENSSL_VERSION)"   # should print OpenSSL 3.x
```

### Измеренное сравнение (2026-07-29, UTC+8)

Реальные цифры из сравнительного теста на двух каналах серии Nano Banana (`gemini-3-pro-image` / `gemini-3.1-flash-image`):

| Интерпретатор                       | Сценарий                                                                                                                              | Доля отказов на уровне транспорта                                                  |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| System python3.9 (LibreSSL 2.8.3)   | image calls при параллельных запросах 12                                                                                              | **Массовые сбои, сразу на обоих каналах**                                          |
| Homebrew python3.13 (OpenSSL 3.6.1) | те же 108 вызовов                                                                                                                     | 3 (2.8%), все на больших ответах 4K, **все успешно после одной повторной попытки** |
| Homebrew python3.13 (OpenSSL 3.6.1) | 80 выделенных повторных вызовов (параллельные запросы 24 для маленьких ответов, параллельные запросы 12 для 4K и последовательные 4K) | **0**                                                                              |

Вывод однозначен: **разница на порядок после переключения интерпретатора**, а тот факт, что ранее оба канала падали одновременно, уже доказывал, что причина не в канале.

## Что проверять на Linux-сервере (совершенно другой список)

<Info>
  Проверка TLS-stack выше **проходит практически на любой Linux-машине** — Python из дистрибутива линкуется с обычным OpenSSL, так что ловушки LibreSSL там нет. **Не останавливайтесь на этой проверке.** Проблемы на стороне сервера живут в **исходящем пути** и в **ограничениях контейнера**, а это уже совсем другой мир по сравнению с локальной разработкой.
</Info>

### 1. Таймауты бездействия на облачных NAT-шлюзах и балансировщиках нагрузки (главная причина на стороне сервера)

Это главный источник `connection reset by peer` в боевой среде. Возьмите **AWS NAT Gateway**: он использует **фиксированный, не настраиваемый таймаут бездействия в 350 секунд**, и когда он срабатывает, он отправляет **RST, а не FIN** — так что клиент видит ровно `ECONNRESET`.

Плохая часть в том, что это **каскадируется**: как только соединения из пула простаивали дольше 350 секунд, они все мертвы, поэтому ваш запрос получает RST на первом, клиент незаметно повторяет попытку на следующем соединении из пула — **которое было столь же простаивающим и тоже получает RST**. Типичный признак — «тихо какое-то время, потом несколько вызовов подряд падают, затем все снова нормально».

<Tip>
  Это тот же механизм, что и случай «keep-alive повторно использует мертвое соединение» в разделе Node.js — на сервере виновником обычно является **NAT-шлюз облачного провайдера**, а не локальное прокси-ПО.
</Tip>

Исправления (любое из них; первые два предпочтительнее):

* **Установите TCP keepalive ниже 350 секунд**, чтобы трафик шел даже в тихие периоды;
* **Ограничьте, как долго соединения могут простаивать в пуле**, чтобы потенциально мертвые отбрасывались (Node: `new Agent({ keepAliveTimeout: 60_000 })`; Python `requests`: настройте пул через `HTTPAdapter`);
* Обходите NAT-шлюз полностью, например через VPC endpoints.

Другие облака и self-hosted балансировщики нагрузки используют другие значения бездействия, но **подход одинаков: найдите самый короткий таймаут бездействия на пути и установите keepalive ниже него**.

### 2. Значение TCP keepalive по умолчанию фактически «выключено»

В Linux значение по умолчанию `tcp_keepalive_time` **7200 секунд (2 часа)**, что намного дольше любых таймаутов бездействия выше, поэтому на практике это бесполезно:

```bash theme={null}
# Inspect current values
sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes

# Adjust temporarily (inside containers this needs --sysctl or privileges;
# in production prefer sysctl.d or setting SO_KEEPALIVE in the app)
sudo sysctl -w net.ipv4.tcp_keepalive_time=60
sudo sysctl -w net.ipv4.tcp_keepalive_intvl=15
```

Включать keepalive **непосредственно в HTTP client** — более надежный путь, поскольку контейнеры часто не могут менять параметры ядра.

### 3. MTU контейнерной сети

Overlay-сети Docker / Kubernetes (flannel VXLAN и им подобные) обычно работают с MTU **1450**, а не 1500. Добавьте к этому PMTUD black hole на пути — и получите классический случай «маленькие запросы всегда проходят, большие ответы всегда зависают»:

```bash theme={null}
ip link show            # inspect the container interface MTU
# Probe the real usable MTU with do-not-fragment packets
ping -M do -s 1400 api.apiyi.com
```

### 4. Ограничения памяти контейнера → процесс получает OOMKilled

Один 4K base64 payload может достигать 20-30 MB; если загружать его целиком с `resp.json()` при параллельных запросах, легко превысить лимит памяти контейнера, и ядро убьет процесс — **что снова выглядит как «соединение просто оборвалось»**:

```bash theme={null}
# Was the container OOM-killed?
dmesg -T | grep -i -E "oom|killed process"
kubectl describe pod <pod> | grep -A3 "Last State"   # look for OOMKilled
```

См. ниже «Потоково передавайте ответ вместо загрузки целиком» для решения.

### 5. Переменные среды proxy (самая коварная на серверах)

На серверах часто глобально заданы значения `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` в `/etc/environment`, systemd unit или Dockerfile — и тот, кто их задавал, давно о них забыл. **Хуже того, языки по-разному относятся к их учету**:

| Клиент                                    | Читает ли `HTTPS_PROXY` автоматически?                        |
| ----------------------------------------- | ------------------------------------------------------------- |
| Python `requests` / `httpx`               | ✅ Да, по умолчанию                                            |
| curl                                      | ✅ Да, по умолчанию                                            |
| **Встроенный в Node.js `fetch` (undici)** | ❌ **Нет**, требуется явный `ProxyAgent` / `EnvHttpProxyAgent` |

Это несоответствие дает по-настоящему запутанные результаты: **на одной машине curl и Python ходят через прокси, а Node подключается напрямую** (или наоборот), поэтому они расходятся, и диагностика приводит к противоречивым выводам. Сначала проверьте:

```bash theme={null}
env | grep -i -E "proxy|no_proxy"
```

APIYI доступен напрямую, так что на сервере обычно вам нужен `api.apiyi.com` в `NO_PROXY` — или просто никаких переменных proxy вообще.

### Разовая самопроверка на стороне сервера

```bash theme={null}
echo "--- proxy vars ---";  env | grep -i proxy || echo "none"
echo "--- keepalive ---";   sysctl net.ipv4.tcp_keepalive_time
echo "--- MTU ---";         ip link show | grep mtu
echo "--- fd limit ---";    ulimit -n
echo "--- DNS ---";         getent hosts api.apiyi.com
echo "--- reachability and timing ---"
curl -sS -o /dev/null -w 'connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} ip=%{remote_ip}\n' \
  https://api.apiyi.com/v1/models -H "Authorization: Bearer $KEY"
```

## Node.js: три независимых тайм-аута, до которых SDK `timeout` не может дотянуться

Встроенный `fetch` в Node 18+ работает на undici, который имеет **три независимых тайм-аута**, покрывающих три этапа запроса. «Но я выставил тайм-аут на 5 минут» обычно означает, что вы изменили четвёртое значение, которое не относится ни к одному из них:

| Код ошибки                | Этап                                               | Значение по умолчанию в undici | Управляется       | Подлежит тарификации?                    |
| ------------------------- | -------------------------------------------------- | ------------------------------ | ----------------- | ---------------------------------------- |
| `UND_ERR_CONNECT_TIMEOUT` | Подключение (TCP + TLS)                            | **10 секунд**                  | `connect.timeout` | **Нет** (до шлюза запрос так и не дошел) |
| `UND_ERR_HEADERS_TIMEOUT` | Ожидание первого заголовка ответа                  | 300 секунд                     | `headersTimeout`  | Да                                       |
| `UND_ERR_BODY_TIMEOUT`    | Пауза **между последовательными фрагментами тела** | 300 секунд                     | `bodyTimeout`     | Да                                       |

<Warning>
  Параметр `timeout` в openai-node — это тайм-аут всего запроса на основе AbortController, и он не распространяется ни на один из трёх тайм-аутов выше. Увеличение `timeout` с 60 до 300 секунд оставляет `connectTimeout` на уровне 10 секунд. То же самое верно и для `AbortSignal.timeout()` в чистом `fetch()`.

  Это самая частая причина «мой тайм-аут огромный, но он все равно срабатывает» — был изменён не тот уровень.
</Warning>

### Правильная настройка

Для увеличения трёх тайм-аутов undici требуется `Agent`, либо глобально, либо для каждого запроса:

```javascript theme={null}
import { Agent, setGlobalDispatcher } from "undici";
import OpenAI from "openai";

// Image endpoints mean long silences plus MB-scale bodies — widen all three
setGlobalDispatcher(new Agent({
  connect: { timeout: 30_000 },   // connect in 30s; the default is only 10s
  headersTimeout: 300_000,        // first byte within 300s
  bodyTimeout: 300_000,           // inter-chunk gap up to 300s
}));

const client = new OpenAI({
  apiKey: process.env.APIYI_API_KEY,
  baseURL: "https://api.apiyi.com/v1",
  timeout: 300_000,   // total timeout — a different layer; set both
  maxRetries: 0,      // critical, see below
});
```

### `maxRetries` по умолчанию равен 2 и автоматически повторяет ошибки подключения

openai-node **по умолчанию использует `maxRetries: 2`, и как ошибки подключения, так и тайм-ауты входят в область действия этих автоматических повторов**. Один логический вызов поэтому может породить **три фактических запроса** даже тогда, когда в вашем коде вообще нет логики повторов (будет ли каждый из них тарифицироваться, зависит от того, в какую категорию «Влияние на тарификацию» он попадет).

Эндпоинты для изображения — это дорогие синхронные долгие запросы, поэтому **всегда задавайте `maxRetries: 0` явно и берите логику повторов на себя**, со своим backoff и своим пределом попыток. Правила тарификации описаны в [Стратегия повторов](/ru/api-capabilities/image-api-best-practices#retry-strategy).

<Tip>
  Сначала убедитесь, какой стек у вас на самом деле: `node -v`, `npm ls openai undici axios node-fetch`. Код `UND_ERR_*` лишь доказывает, что под капотом используется undici — это **не доказывает, что вы используете OpenAI SDK**. Обычный `fetch()` выдаёт те же коды, а обычный `fetch()` вообще не имеет `maxRetries`.
</Tip>

### повторное использование keep-alive уже мертвого соединения

undici по умолчанию включает пул соединений с keep-alive. Когда VPN, NAT или proxy незаметно забирает неактивное соединение, клиент этого не узнает и все равно берет это соединение из пула для следующего запроса — **запись немедленно получает RST, что проявляется как `read ECONNRESET`**.

Это самый частый источник `ECONNRESET`, когда вызовы идут с паузами, и это объясняет и «ошибки группируются в одном временном окне», и «даже первый повторный запрос завершается неудачей». Проверьте это, отключив повторное использование:

```javascript theme={null}
const agent = new Agent({ pipelining: 0, keepAliveTimeout: 1_000 });
// errors disappear ⇒ it was dead-connection reuse
```

## Локальный прокси / VPN: первое слабое место, которое выявляют эндпоинты генерации изображений

<Info>
  **APIYI напрямую доступен внутри материкового Китая и не требует прокси или VPN** (см. [Нужен ли мне прокси для использования API?](/ru/faq/network-proxy)). Поэтому **выключить прокси и повторно проверить — это самый дешевый и самый информативный одиночный шаг**, доступный вам.

  Но важно уточнить: прокси — лишь **самый крупный подозреваемый фактор**, а не установленная первопричина. Именно матрица ниже на самом деле локализует неисправность.
</Info>

Два свойства делают эндпоинты генерации изображений намного чувствительнее, чем текстовые: **30-60 секунд, когда во время генерации не проходит ни одного байта**, и **тело размером в MB, передаваемое одним всплеском**. Когда chat endpoints работают нормально, а эндпоинты генерации изображений падают, обычно виноват один из этих двух.

<CardGroup cols={2}>
  <Card title="fake-ip / промах правила маршрутизации" icon="route-off">
    В режиме fake-ip прокси промах правила направляет вас на недостижимый адрес вроде `198.18.x.x`, вызывая **строго 10-секундный** таймаут подключения. Заметьте, это не «медленно подключается» — маршрута вообще нет, поэтому увеличение `connect.timeout` не поможет. Всегда фиксируйте `remote_ip`, до которого вы фактически дошли.
  </Card>

  <Card title="Перехвачен как неактивное соединение во время генерации" icon="timer-off">
    На протяжении 30-60 секунд после отправки запроса не проходит ни одного байта, и прокси перехватывает соединение по своей политике простоя. Характерный признак — **время сбоя попадает в круглое число** — 30 / 60 / 120 секунд — независимо от размера изображения.
  </Card>

  <Card title="MTU / чёрная дыра PMTUD" icon="package-x">
    MTU туннеля ниже path MTU, а ICMP «требуется фрагментация» отбрасывается, из-за чего ломается PMTUD. Классический признак — **малые запросы всегда проходят, крупные ответы всегда застревают**, а полученные байты застывают на нескольких KB или нескольких десятках KB. Обычно помогает уменьшить MTU туннеля примерно до 1400.
  </Card>

  <Card title="Расшифровка MITM плюс полная буферизация" icon="shield-off">
    Прокси с включенной HTTPS-расшифровкой часто буферизуют крупные тела целиком и могут упереться в предел размера, либо переписывают chunked в `Content-Length` и ошибаются с длиной, из-за чего возникает RST. И снова это затрагивает только MB-объемные ответы с изображениями, никогда не текстовые вызовы.
  </Card>
</CardGroup>

### Диагностическая матрица

Это основа раздела. Есть только две оси: **сбой произошел до или после первого байта**, и **сколько байт пришло**.

| Наблюдение                      | Таймаут подключения              | Перехват неактивного соединения прокси | Чёрная дыра MTU              | Отсутствует завершающий chunked-терминатор       |
| ------------------------------- | -------------------------------- | -------------------------------------- | ---------------------------- | ------------------------------------------------ |
| TTFB (первый байт)              | Никогда не приходит              | Никогда не приходит                    | Приходит                     | **Нормальное** (соответствует времени генерации) |
| Получено байт                   | 0                                | 0                                      | **0 \< N ≪ full**            | **= full, JSON разбирается без ошибок**          |
| Время сбоя                      | **≈10.0s, очень стабильно**      | Круглое число, не зависит от размера   | Варьируется                  | **+300s** после последнего байта                 |
| Состояние завершения соединения | ConnectTimeout                   | RST                                    | Зависание или RST            | FIN (корректное закрытие)                        |
| Тарифицируется?                 | **Нет** (до шлюза дело не дошло) | См. «Влияние на тарификацию»           | См. «Влияние на тарификацию» | См. «Влияние на тарификацию»                     |
| При `response_format: "url"`    | Все равно не работает            | Все равно не работает                  | **Работает**                 | **Работает**                                     |

Последняя строка — самый ценный одиночный тест: он уменьшает тело с нескольких MB примерно до 1 KB. **Если режим URL стабильно проходит, а режим base64 стабильно падает, проблема масштабируется с объемом передачи**, что сразу исключает первые два столбца.

### Одна команда для полного временного профиля

```bash theme={null}
curl -sS -o /tmp/out.json --trace-time \
  -w '\nconnect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} bytes=%{size_download} code=%{http_code} ip=%{remote_ip}\n' \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"model":"gpt-image-2-all","prompt":"a red cube on a white table"}' \
  https://api.apiyi.com/v1/images/generations
```

Сверяйте его с матрицей: нет значения `connect` → стадия соединения; нет `ttfb` и круглый `total` → перехват как неактивного; полный `bytes`, но `total ≈ ttfb + 300` плюс `curl: (18)` → отсутствует терминатор; `bytes` застыл на десятках KB → MTU.

<Warning>
  **При A/B-тестировании режима с прокси и без прокси чередуйте прогоны — никогда не объединяйте их в пачку.** Пять прогонов через прокси подряд, а затем пять прямых прогонов позволяют ошибке временного окна исказить результат до совершенно неверного вывода; мы измеряли участки, где все ломалось, потом через несколько минут все работало, а потом снова ломалось. Запускайте `proxy → direct → proxy → direct` по одному, каждый раз записывая `remote_ip`.
</Warning>

## Other common triggers

<CardGroup cols={2}>
  <Card title="Ручное прерывание во время выполнения" icon="octagon-x">
    Ctrl+C во время отладки, перезапуск процесса, hot reload, завершение работающего скрипта — каждый крупный ответ, который еще находится в пути, оставляет на `write_response_body_failed` след на шлюзе. Это ложная тревога, которую чаще всего принимают за «канал нестабилен».
  </Card>

  <Card title="Срабатывает внешний таймаут первым" icon="timer-off">
    Таймауты worker в очереди задач, лимиты выполнения Serverless, таймауты origin на шлюзе/CDN (обычно по умолчанию 60 секунд). Любой уровень, у которого таймаут меньше времени генерации, первым разрывает соединение — см. [Обязательно к прочтению и лучшие практики](/ru/api-capabilities/image-api-best-practices#troubleshooting-timeouts-and-disconnects).
  </Card>

  <Card title="Пул соединений и чрезмерные параллельные запросы" icon="waypoints">
    Пределы пула соединений, локальные лимиты file descriptor, NAT/межсетевой экран, незаметно освобождающие долгоживущие соединения. Большие ответы остаются открытыми гораздо дольше, поэтому они намного чаще упираются в эти лимиты, чем текстовые эндпоинты.
  </Card>

  <Card title="Тело ответа исчерпывает память" icon="memory-stick">
    Один base64-блок размером 4K может достигать 20-30MB. Если загружать его целиком с `resp.json()` при параллельных запросах, это может исчерпать память контейнера и привести к завершению процесса из-за OOM — что снова выглядит как «соединение оборвалось без причины».
  </Card>
</CardGroup>

## Влияние на тарификацию: какие сбои стоят денег, а какие нет

Эти два сценария постоянно смешивают, хотя тарифицируются они противоположным образом:

<Info>
  ### Шлюз возвращает 500 `write_response_body_failed` — **не тарифицируется**

  Эта ошибка означает, что соединение оборвалось в тот момент, когда шлюз записывал обратно вам данные изображения. **Платформа автоматически повторяет попытку внутри системы 2-3 раза**, и только после неудачи всех попыток возвращает 500.

  **В этом случае списание не происходит.** Даже если такие ошибки идут подряд в одном и том же запросе, **в вашем счете не появляется соответствующая строка** — вы никогда не платите за эти сбои.
</Info>

<Warning>
  ### Ваш клиент отключился слишком рано — **тарифицируется как обычно**

  Другой случай — когда шлюз завершает доставку, а отключается первым **ваша сторона**: срабатывает таймаут клиента, во время отладки нажимается Ctrl+C, происходит перезапуск процесса или его принудительно завершает OOM.

  Генерация на сервере и в upstream **уже завершилась**, поэтому такие запросы **тарифицируются как обычно** — «я не получил изображение» не означает «с меня не списали». Многократная отправка крупных запросов на генерацию изображений во время отладки вполне может привести к вполне реальному счету.
</Warning>

Именно так их и различают в таблице выше: **проверьте, что в консоли был зарегистрирован обычный вызов или 500 `write_response_body_failed`**.

Политику повторных попыток следует выдерживать соответствующе: сбои на уровне транспорта имеет смысл повторять, но **каждая повторная попытка может быть отдельно тарифицируемым вызовом** (в зависимости от того, к какой из двух категорий она попадет). Никогда не пишите бесконечный цикл повторных попыток.

## Как правильно повторять попытку

Основное правило: **повторяйте только исключения на уровне транспорта, никогда не повторяйте ошибки на уровне HTTP**. Повторённый десять тысяч раз 4xx по-прежнему остаётся 4xx, и это лишь впустую тратит время.

```python theme={null}
import time
import requests

TRANSPORT_ERRORS = (
    requests.exceptions.SSLError,
    requests.exceptions.ConnectionError,
    requests.exceptions.ChunkedEncodingError,
    requests.exceptions.ReadTimeout,
)

def call_image_api(url, headers, body, timeout=360, retries=2):
    """Retry transport-level failures up to `retries` times; never retry HTTP
    4xx/5xx — hand those straight back to the caller.

    Note: each retry may be a newly billed request, so keep `retries` small.
    """
    attempts = []
    for i in range(retries + 1):
        try:
            resp = requests.post(url, headers=headers, json=body,
                                 stream=True, timeout=(10, timeout))
            raw = b"".join(resp.iter_content(chunk_size=8192))
            attempts.append({"attempt": i + 1, "status": resp.status_code})
            return resp.status_code, raw, attempts      # 4xx/5xx included
        except TRANSPORT_ERRORS as e:
            attempts.append({"attempt": i + 1, "error": repr(e)})
            if i == retries:
                raise
            time.sleep(2 + 3 * i)                       # back off 2s, then 5s
```

<Tip>
  **Записывайте каждую попытку отдельно** (список `attempts` выше). Иначе успешная повторная попытка клиента не оставит в журналах ничего, кроме чистого 200, и вы никогда не увидите, сколько раз на самом деле ломался транспорт. Эти данные необходимы при оценке качества канала, и они не дают вам неверно трактовать собственные повторные попытки как поведение канала.
</Tip>

### Передавайте response потоком вместо полной загрузки

Для больших тел читайте фрагмент за фрагментом с помощью `stream=True`. Это снижает пиковое потребление памяти и показывает вам **точно, на каком этапе передачи произошел сбой**:

```python theme={null}
resp = requests.post(url, headers=headers, json=body, stream=True, timeout=(10, 360))
chunks, total = [], 0
for chunk in resp.iter_content(chunk_size=8192):
    total += len(chunk)
    chunks.append(chunk)
raw = b"".join(chunks)
# total far below Content-Length  => the transfer broke midway
# total complete but connection stays open => upstream omitted the chunked
#   terminator, which is a channel-side problem
```

## Когда действительно стоит обращаться в поддержку

Когда вы уже исключили локальные причины, эскалируйте, если выполняется **хотя бы одно** из следующих условий:

* Это по-прежнему воспроизводится стабильно после перехода на корректный интерпретатор OpenSSL и снижения до последовательного выполнения;
* Сбой наблюдается только у **одного конкретного канала или модели**, тогда как остальные в том же окне работают нормально;
* Тело ответа **пришло полностью** (количество байт совпадает с `Content-Length`), но соединение так и не закрывается, пока не истечет таймаут — это означает отсутствие завершающего chunked-терминатора upstream, проблема на стороне канала;
* Ошибка однозначно направлена на upstream (`upstream_error`, raw upstream 5xx).

В тикет включите: `x-request-id`, время вызова (**с часовым поясом**, например `2026-07-29 14:32 (UTC+8)`), имя модели, ключевые параметры, такие как `imageSize`, необработанное исключение клиента и шаги самопроверки, которые вы уже выполнили.

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

<CardGroup cols={3}>
  <Card title="Обязательно к прочтению и лучшие практики" icon="book-check" href="/ru/api-capabilities/image-api-best-practices">
    Синхронные вызовы, тайм-ауты для каждой модели, обработка base64, тарификация при разрыве соединения
  </Card>

  <Card title="Создайте собственную асинхронную очередь" icon="list-checks" href="/ru/api-capabilities/image-async-queue">
    Оборачивайте синхронные вызовы в очередь задач и компенсируйте редкие сбои повторными попытками
  </Card>

  <Card title="Нужен ли мне прокси?" icon="wifi" href="/ru/faq/network-proxy">
    APIYI подключается напрямую без прокси; выполняет самопроверку на проблемы с сертификатом и DNS
  </Card>

  <Card title="Обработка ошибок Gemini при генерации изображений" icon="triangle-alert" href="/ru/api-capabilities/gemini-image-error-handling">
    Коды ошибок и обработка finishReason для генерации изображений Gemini
  </Card>
</CardGroup>
