[bug][ai-chat] z.ai (GLM-5.2 coding plan) intermittently stalls/RSTs the chat stream (UND_ERR_HEADERS_TIMEOUT / ECONNRESET) #140

Closed
opened 2026-06-23 03:31:51 +03:00 by Ghost · 1 comment

Симптом

AI-чат к настроенному провайдеру (z.ai GLM-5.2, OpenAI-compatible, Base URL https://api.z.ai/api/coding/paas/v4/chat/completions) интермиттентно виснет на «Thinking…» и затем падает с баннером «Lost connection to the AI provider» / «Lost connection to the server».

  • Изначально казалось Safari-специфичным, но по факту воспроизводится во всех браузерах (подтверждено в Chrome и Safari).
  • Test endpoint (Settings → AI → Save and test) при этом стабильно «Connection successful».
  • Не зависит от ветки браузер↔сервер: это server↔provider (исходящий запрос из AiChatService/aiFetch к z.ai).

Ключевое отличие: тест проходит, чат — нет

Test endpoint Чат
вызов generateText({ prompt: 'ping', maxOutputTokens: 16 })ai.service.ts:testConnection streamText({...})ai-chat.service.ts:stream
запрос крошечный, не-стриминговый, без инструментов/системного промпта/истории стриминговый, + системный промпт + инструменты + история + текущий документ
результат <- 200 in ~2000ms виснет / падает

Модель/транспорт/эндпоинт у обоих идентичны (getChatModelcreateOpenAI({ baseURL, fetch: aiFetch }).chat(model)). Разница — стриминг + размер/состав запроса.

Доказанные факты (из серверных логов)

Наблюдаемые режимы падения (интермиттентно, на одном и том же запросе):

  1. 300s headers timeout (наш undici срезает):
AiChatController  AI chat stream START chat=new ua="...Safari..."
AiHttp  provider request #1 -> POST api.z.ai/api/coding/paas/v4/chat/completions
AiHttp  provider request #1 x after 301274ms: fetch failed <- [UND_ERR_HEADERS_TIMEOUT] Headers Timeout Error

z.ai не присылает заголовки ответа >300с, и наш undici headersTimeout (дефолт 300_000ms) обрывает запрос. Провайдер при этом соединение НЕ рвёт.

  1. Быстрый ECONNRESET (рвёт сторона z.ai):
AiHttp  provider request #2 x after 2075ms: fetch failed   (после развёрнутого лога: read ECONNRESET)
AiChatService  AI chat stream error: Failed after 3 attempts. Last error: Cannot connect to API: read ECONNRESET

z.ai сбрасывает соединение через ~2–9с.

  1. Иногда отвечает нормально: provider request <- 200 in 1974ms / 8310ms (headers received).

Вывод: coding-эндпоинт z.ai (api/coding/paas/v4) ведёт себя нестабильно — то отвечает быстро, то застревает без заголовков (дольше нашего таймаута), то рвёт соединение. Тривиальный ping проскакивает, т.к. отвечает за <2с.

Гипотезы, что проверили, и результат

  1. Safari-специфика (idle SSE / Connection: keep-alive на HTTP/2) → коммит 1b4de2b4 (server-side SSE heartbeat : ping каждые 15с + снятие hop-by-hop Connection/Keep-Alive).
    Результат: SSE-устойчивость браузер↔сервер улучшена и сам по себе фикс корректен, но корень не тут — виснет и в Chrome. Оставлено как полезная защита, проблему не лечит.

  2. Кастомный транспорт undici (aiFetch/RetryAgent), добавленный в 1af5d34a → проверено байпасом: коммит 7c308728 ввёл env-флаг AI_BYPASS_RESILIENT_FETCH=true, который уводит чат-модель на дефолтный глобальный fetch (без RetryAgent).
    Результат: с байпасом всё равно падает (тот же ECONNRESET/headers timeout) → транспорт ни при чём.

  3. Гонка генерации заголовка со стримом. Для нового чата generateTitle (generateText) запускался параллельно со streamText к тому же z.ai. Лог показывал два одновременных provider request на один ход: один <- 200 in 8310ms, другой x after 301209ms (завис). Coding-план z.ai стопорит один из двух параллельных запросов.
    Фикс: коммит fd66ee6cgenerateTitle перенесён из «перед pipe» в onFinish (уходит соло, после завершения стрима).
    Результат: параллельный запрос убран (подтверждено логом — теперь один getChatModel/provider request на ход), 300с-гонка ушла, но соло-стрим всё равно падает (ECONNRESET ~2с либо headers timeout 300с). Фикс правильный (гонку убрал), но это была не единственная причина.

  4. Наш headersTimeout (300с) слишком короткий. Развёрнутый лог причины (коммит b7abb7eaaiFetch логирует цепочку err.cause с кодами) дал решающее [UND_ERR_HEADERS_TIMEOUT]. Пробовали поднять headersTimeout/bodyTimeout до 600с (env-configurable) — откатили (не закоммичено): владелец считает, что не поможет (z.ai в режиме «застрял» не отвечает и в большем окне; это лишь митигация, не корень).

Диагностика, добавленная в код (на develop)

  • 7c308728 — тайминговые логи жизненного цикла хода: AI chat stream START (chatId + UA), first chunk (...) after Nms, FINISHED in Nms, длительности в onError/onAbort/disconnect. Плюс env-флаг AI_BYPASS_RESILIENT_FETCH.
  • b7abb7eaaiFetch пишет provider request #N -> METHOD host/path, <- status in Nms (headers received) и при падении цепочку причин fetch failed <- [CODE] message (chat-вызовы на info, embeddings на debug; секреты не логируются — только host+path).

Смотреть: docker logs <container> 2>&1 | grep -iE "AiHttp|AI chat stream".

Текущий вывод

Корень — на стороне z.ai coding-эндпоинта (api/coding/paas/v4): он интермиттентно не отдаёт заголовки ответа и/или рвёт соединение на «тяжёлый» стриминговый запрос (reasoning + инстр��менты + большой контекст), тогда как тривиальный ping проходит. Наш код после fd66ee6c шлёт один корректный запрос на ход; обрыв происходит внутри обращения к z.ai.

Что проверить на стенде (открытые вопросы)

  • Отдаёт ли z.ai coding-план заголовки сразу (реальный стриминг) или буферизует до конца ответа? Снять HTTP-дамп (tcpdump/прокси/curl -N с тем же телом запроса) и посмотреть, когда приходит статус-строка 200 и первый SSE-чанк.
  • Воспроизводится ли на стандартном z.ai chat-эндпоинте (не coding/paas)?
  • Зависит ли от размера/состава запроса — отключить инструменты (tools) и/или инъекцию текущего документа и проверить, проходит ли тогда стрим.
  • Есть ли у coding-плана лимит конкурентности/очередь, дающий RST/stall под нагрузкой?
  • Реально ли помогает подъём headersTimeout (т.е. z.ai отвечает в большем окне) или он зависает намертво — повторить с временно поднятым таймаутом и посмотреть, доходит ли до <- 200.
  • Сравнить успешные (<- 200 in 8310ms) и зависшие ходы: что отличает запрос (длина промпта, число tool-спеков, размер документа)?

Затронутые коммиты / файлы (develop)

Коммиты:

  • 1af5d34a — fix(ai-chat): reconnect on provider ECONNRESET via a resilient fetch (ввёл aiFetch/RetryAgent).
  • 1b4de2b4 — fix(ai-chat): keep SSE stream alive in Safari (heartbeat + strip hop-by-hop Connection).
  • 7c308728 — chore(ai-chat): add stream timing logs + env-gated aiFetch bypass (diagnostics).
  • fd66ee6c — fix(ai-chat): stop title generation racing the chat stream (provider stall).
  • b7abb7ea — feat(ai-http): log detailed fetch error cause chain.

Файлы:

  • apps/server/src/integrations/ai/ai-http.tsaiFetch, baseAgent (connect.timeout:10s, keepAliveTimeout:4s, undici default headersTimeout/bodyTimeout 300s), RetryAgent (maxRetries:2, errorCodes без UND_ERR_HEADERS_TIMEOUT).
  • apps/server/src/integrations/ai/ai.service.tsgetChatModel (+ env AI_BYPASS_RESILIENT_FETCH), testConnection.
  • apps/server/src/core/ai-chat/ai-chat.service.tsstream() / streamText (onChunk/onStepFinish/onFinish/onError/onAbort), generateTitle.
  • apps/server/src/core/ai-chat/ai-chat.controller.ts — POST /api/ai-chat/stream, res.hijack(), onClose (abort при дисконнекте клиента).

Доступные env-рычаги (в коде сейчас)

  • AI_BYPASS_RESILIENT_FETCH=true — чат-модель идёт через дефолтный fetch (без RetryAgent). Для изоляции транспорта.
  • (AI_HTTP_HEADERS_TIMEOUT_MS / AI_HTTP_BODY_TIMEOUT_MS — пробовали, откатили, в коде сейчас нет.)
## Симптом AI-чат к настроенному провайдеру (z.ai **GLM-5.2**, OpenAI-compatible, Base URL `https://api.z.ai/api/coding/paas/v4` → `/chat/completions`) **интермиттентно виснет на «Thinking…»** и затем падает с баннером «Lost connection to the AI provider» / «Lost connection to the server». - Изначально казалось **Safari-специфичным**, но по факту воспроизводится **во всех браузерах** (подтверждено в Chrome и Safari). - **Test endpoint** (Settings → AI → Save and test) при этом стабильно **«Connection successful»**. - Не зависит от ветки браузер↔сервер: это **server↔provider** (исходящий запрос из `AiChatService`/`aiFetch` к z.ai). ## Ключевое отличие: тест проходит, чат — нет | | Test endpoint | Чат | |---|---|---| | вызов | `generateText({ prompt: 'ping', maxOutputTokens: 16 })` — `ai.service.ts:testConnection` | `streamText({...})` — `ai-chat.service.ts:stream` | | запрос | крошечный, не-стриминговый, без инструментов/системного промпта/истории | стриминговый, + системный промпт + инструменты + история + текущий документ | | результат | `<- 200 in ~2000ms` | виснет / падает | Модель/транспорт/эндпоинт у обоих **идентичны** (`getChatModel` → `createOpenAI({ baseURL, fetch: aiFetch }).chat(model)`). Разница — стриминг + размер/состав запроса. ## Доказанные факты (из серверных логов) Наблюдаемые режимы падения (**интермиттентно**, на одном и том же запросе): 1. **300s headers timeout** (наш undici срезает): ``` AiChatController AI chat stream START chat=new ua="...Safari..." AiHttp provider request #1 -> POST api.z.ai/api/coding/paas/v4/chat/completions AiHttp provider request #1 x after 301274ms: fetch failed <- [UND_ERR_HEADERS_TIMEOUT] Headers Timeout Error ``` z.ai **не присылает заголовки ответа >300с**, и наш undici `headersTimeout` (дефолт 300_000ms) обрывает запрос. Провайдер при этом соединение НЕ рвёт. 2. **Быстрый ECONNRESET** (рвёт сторона z.ai): ``` AiHttp provider request #2 x after 2075ms: fetch failed (после развёрнутого лога: read ECONNRESET) AiChatService AI chat stream error: Failed after 3 attempts. Last error: Cannot connect to API: read ECONNRESET ``` z.ai сбрасывает соединение через ~2–9с. 3. **Иногда отвечает нормально**: `provider request <- 200 in 1974ms / 8310ms (headers received)`. Вывод: coding-эндпоинт z.ai (`api/coding/paas/v4`) ведёт себя нестабильно — то отвечает быстро, то застревает без заголовков (дольше нашего таймаута), то рвёт соединение. Тривиальный `ping` проскакивает, т.к. отвечает за <2с. ## Гипотезы, что проверили, и результат 1. **Safari-специфика (idle SSE / `Connection: keep-alive` на HTTP/2)** → коммит `1b4de2b4` (server-side SSE heartbeat `: ping` каждые 15с + снятие hop-by-hop `Connection`/`Keep-Alive`). **Результат:** SSE-устойчивость браузер↔сервер улучшена и сам по себе фикс корректен, но **корень не тут** — виснет и в Chrome. Оставлено как полезная защита, проблему не лечит. 2. **Кастомный транспорт undici (`aiFetch`/`RetryAgent`)**, добавленный в `1af5d34a` → проверено байпасом: коммит `7c308728` ввёл env-флаг `AI_BYPASS_RESILIENT_FETCH=true`, который уводит чат-модель на дефолтный глобальный `fetch` (без RetryAgent). **Результат:** с байпасом **всё равно падает** (тот же ECONNRESET/headers timeout) → **транспорт ни при чём**. 3. **Гонка генерации заголовка со стримом.** Для нового чата `generateTitle` (`generateText`) запускался **параллельно** со `streamText` к тому же z.ai. Лог показывал **два** одновременных `provider request` на один ход: один `<- 200 in 8310ms`, другой `x after 301209ms` (завис). Coding-план z.ai стопорит один из двух параллельных запросов. **Фикс:** коммит `fd66ee6c` — `generateTitle` перенесён из «перед pipe» в `onFinish` (уходит соло, после завершения стрима). **Результат:** параллельный запрос убран (подтверждено логом — теперь **один** `getChatModel`/`provider request` на ход), 300с-гонка ушла, **но соло-стрим всё равно падает** (ECONNRESET ~2с либо headers timeout 300с). Фикс правильный (гонку убрал), но это была не единственная причина. 4. **Наш `headersTimeout` (300с) слишком короткий.** Развёрнутый лог причины (коммит `b7abb7ea` — `aiFetch` логирует цепочку `err.cause` с кодами) дал решающее `[UND_ERR_HEADERS_TIMEOUT]`. Пробовали поднять `headersTimeout`/`bodyTimeout` до 600с (env-configurable) — **откатили (не закоммичено)**: владелец считает, что не поможет (z.ai в режиме «застрял» не отвечает и в большем окне; это лишь митигация, не корень). ## Диагностика, добавленная в код (на `develop`) - `7c308728` — тайминговые логи жизненного цикла хода: `AI chat stream START` (chatId + UA), `first chunk (...) after Nms`, `FINISHED in Nms`, длительности в `onError`/`onAbort`/disconnect. Плюс env-флаг `AI_BYPASS_RESILIENT_FETCH`. - `b7abb7ea` — `aiFetch` пишет `provider request #N -> METHOD host/path`, `<- status in Nms (headers received)` и при падении цепочку причин `fetch failed <- [CODE] message` (chat-вызовы на info, embeddings на debug; секреты не логируются — только host+path). Смотреть: `docker logs <container> 2>&1 | grep -iE "AiHttp|AI chat stream"`. ## Текущий вывод Корень — **на стороне z.ai coding-эндпоинта** (`api/coding/paas/v4`): он интермиттентно не отдаёт заголовки ответа и/или рвёт соединение на «тяжёлый» стриминговый запрос (reasoning + инстр��менты + большой контекст), тогда как тривиальный `ping` проходит. Наш код после `fd66ee6c` шлёт **один** корректный запрос на ход; обрыв происходит **внутри** обращения к z.ai. ## Что проверить на стенде (открытые вопросы) - Отдаёт ли z.ai coding-план **заголовки сразу** (реальный стриминг) или **буферизует** до конца ответа? Снять HTTP-дамп (`tcpdump`/прокси/`curl -N` с тем же телом запроса) и посмотреть, когда приходит статус-строка `200` и первый SSE-чанк. - Воспроизводится ли на **стандартном** z.ai chat-эндпоинте (не `coding/paas`)? - Зависит ли от **размера/состава** запроса — отключить инструменты (`tools`) и/или инъекцию текущего документа и проверить, проходит ли тогда стрим. - Есть ли у coding-плана **лимит конкурентности/очередь**, дающий RST/stall под нагрузкой? - Реально ли помогает подъём `headersTimeout` (т.е. z.ai отвечает в большем окне) или он зависает намертво — повторить с временно поднятым таймаутом и посмотреть, доходит ли до `<- 200`. - Сравнить успешные (`<- 200 in 8310ms`) и зависшие ходы: что отличает запрос (длина промпта, число tool-спеков, размер документа)? ## Затронутые коммиты / файлы (`develop`) Коммиты: - `1af5d34a` — fix(ai-chat): reconnect on provider ECONNRESET via a resilient fetch (ввёл `aiFetch`/`RetryAgent`). - `1b4de2b4` — fix(ai-chat): keep SSE stream alive in Safari (heartbeat + strip hop-by-hop Connection). - `7c308728` — chore(ai-chat): add stream timing logs + env-gated aiFetch bypass (diagnostics). - `fd66ee6c` — fix(ai-chat): stop title generation racing the chat stream (provider stall). - `b7abb7ea` — feat(ai-http): log detailed fetch error cause chain. Файлы: - `apps/server/src/integrations/ai/ai-http.ts` — `aiFetch`, `baseAgent` (`connect.timeout:10s`, `keepAliveTimeout:4s`, undici default `headersTimeout`/`bodyTimeout` 300s), `RetryAgent` (maxRetries:2, errorCodes без `UND_ERR_HEADERS_TIMEOUT`). - `apps/server/src/integrations/ai/ai.service.ts` — `getChatModel` (+ env `AI_BYPASS_RESILIENT_FETCH`), `testConnection`. - `apps/server/src/core/ai-chat/ai-chat.service.ts` — `stream()` / `streamText` (onChunk/onStepFinish/onFinish/onError/onAbort), `generateTitle`. - `apps/server/src/core/ai-chat/ai-chat.controller.ts` — POST `/api/ai-chat/stream`, `res.hijack()`, `onClose` (abort при дисконнекте клиента). ## Доступные env-рычаги (в коде сейчас) - `AI_BYPASS_RESILIENT_FETCH=true` — чат-модель идёт через дефолтный `fetch` (без `RetryAgent`). Для изоляции транспорта. - (`AI_HTTP_HEADERS_TIMEOUT_MS` / `AI_HTTP_BODY_TIMEOUT_MS` — пробовали, **откатили**, в коде сейчас нет.)
Owner

Контекст: с GLM и инстурментами все работало, но иногда ломалось (там есть коммиты где мы чинили транспорт на беке и восстановление переписки на фронте). Потом сломалось совсем. Бекенд ллм живой, курлом проверяется. Делаю вывод что это регрессия в одном из коммтов. Предлагаю бисектом найти и починить.

Контекст: с GLM и инстурментами все работало, но иногда ломалось (там есть коммиты где мы чинили транспорт на беке и восстановление переписки на фронте). Потом сломалось совсем. Бекенд ллм живой, курлом проверяется. Делаю вывод что это регрессия в одном из коммтов. Предлагаю бисектом найти и починить.
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#140