fix(ai-chat): recycle keep-alive sockets + retry pre-response resets (#175) #179

Merged
vvzvlad merged 2 commits from fix/ai-stream-reset-resilience into develop 2026-06-25 00:11:51 +03:00

Настоящий фикс #175 (ECONNRESET). Прошлый таймаут-фикс (#176) был не туда — телеметрия это показала.

Что показала телеметрия (прод)

Падения — PRE-RESPONSE read ECONNRESET за ~500мс (НЕ таймаут 300с/15мин), коррелируют с idleSincePrevCall ~42с и большими телами; ретрай ТОГО ЖЕ запроса часто проходит.

Что показал зонд

Прямой запрос к реальному z.ai НЕ режет: тело 113KB ×4 → OK, keep-alive сокет после 45с простоя → OK. Плюс opencode из той же инфры ходит в инет без резетов. Значит провайдер здоров и egress рабочий — разница в ТРАНСПОРТЕ: undici переиспользует keep-alive сокет, который egress прода (NAT/файрвол/conntrack) тихо прибил за время простоя → следующий запрос рвётся pre-response.

Фикс

  • Рециклинг keep-alive: streaming-dispatcher (chat-fetch И MCP-dispatcher через общий streamingDispatcherOptions) ставит keepAliveTimeout/keepAliveMaxTimeout = 10с (AI_STREAM_KEEPALIVE_MS). Сокет, простоявший дольше — закрывается, не переиспользуется → длинный шаг открывает свежее соединение. keepAliveMaxTimeout не даёт провайдеру раздвинуть окно.
  • Ретрай pre-response reset: createStreamingFetch ретраит connection-level reset (ECONNRESET/UND_ERR_SOCKET/ECONNREFUSED/EPIPE/*_TIMEOUT) на свежем соединении до 2 раз. Безопасно: fetch() реджектится только ДО появления Response — начатый поток не реплеится; abort (дисконнект клиента) не ретраится.

Тесты

ai-streaming-fetch.spec: keep-alive опции, streamKeepAliveMs env, isRetryableConnectError, сервер который резетит ПЕРВОЕ соединение → ретрай уходит на свежее (+ aborted не ретраится). На стенде проверил: обычный turn стримится (reasoning+text+finish) через новый транспорт. server tsc + ai/mcp специ зелёные.

Заметка

Корень — egress прода дропает idle-соединения (Traefik только inbound). Это делает приложение устойчивым к этому, как opencode. AI_STREAM_KEEPALIVE_MS можно занизить, если egress рвёт быстрее ~10с.

🤖 Generated with Claude Code

Настоящий фикс #175 (ECONNRESET). Прошлый таймаут-фикс (#176) был не туда — телеметрия это показала. ## Что показала телеметрия (прод) Падения — PRE-RESPONSE `read ECONNRESET` за ~500мс (НЕ таймаут 300с/15мин), коррелируют с idleSincePrevCall ~42с и большими телами; ретрай ТОГО ЖЕ запроса часто проходит. ## Что показал зонд Прямой запрос к реальному z.ai НЕ режет: тело 113KB ×4 → OK, keep-alive сокет после 45с простоя → OK. Плюс opencode из той же инфры ходит в инет без резетов. Значит провайдер здоров и egress рабочий — разница в ТРАНСПОРТЕ: undici переиспользует keep-alive сокет, который egress прода (NAT/файрвол/conntrack) тихо прибил за время простоя → следующий запрос рвётся pre-response. ## Фикс - **Рециклинг keep-alive:** streaming-dispatcher (chat-fetch И MCP-dispatcher через общий streamingDispatcherOptions) ставит keepAliveTimeout/keepAliveMaxTimeout = 10с (`AI_STREAM_KEEPALIVE_MS`). Сокет, простоявший дольше — закрывается, не переиспользуется → длинный шаг открывает свежее соединение. keepAliveMaxTimeout не даёт провайдеру раздвинуть окно. - **Ретрай pre-response reset:** createStreamingFetch ретраит connection-level reset (ECONNRESET/UND_ERR_SOCKET/ECONNREFUSED/EPIPE/*_TIMEOUT) на свежем соединении до 2 раз. Безопасно: fetch() реджектится только ДО появления Response — начатый поток не реплеится; abort (дисконнект клиента) не ретраится. ## Тесты ai-streaming-fetch.spec: keep-alive опции, streamKeepAliveMs env, isRetryableConnectError, сервер который резетит ПЕРВОЕ соединение → ретрай уходит на свежее (+ aborted не ретраится). На стенде проверил: обычный turn стримится (reasoning+text+finish) через новый транспорт. server tsc + ai/mcp специ зелёные. ## Заметка Корень — egress прода дропает idle-соединения (Traefik только inbound). Это делает приложение устойчивым к этому, как opencode. `AI_STREAM_KEEPALIVE_MS` можно занизить, если egress рвёт быстрее ~10с. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-24 23:51:53 +03:00
The real cause of the long-task "Lost connection to the AI provider" — the
earlier 300s-timeout fix (#176) was the wrong layer. The provider-HTTP telemetry
on the user's deploy shows the failures are PRE-RESPONSE `read ECONNRESET` ~500ms
in (not a 300s/15min timeout), correlated with idleSincePrevCall ~42s and large
bodies; and crucially a retry of the SAME request often succeeds. A direct probe
to the real z.ai endpoint does NOT reset (113KB bodies and a 45s-idle keep-alive
reuse both succeed), and another agent (opencode) runs fine from the same infra —
so the provider is healthy and the egress network is usable. The difference is
the transport: undici's keep-alive pool REUSES a socket that the deployment's
egress (NAT / firewall / conntrack) silently dropped during a long idle gap, so
the next request resets pre-response.

Fix (brings gitmost in line with clients that don't reuse stale sockets):
- Keep-alive recycling: the streaming dispatcher (chat fetch AND the external-MCP
  dispatcher, via the shared streamingDispatcherOptions) now sets
  keepAliveTimeout + keepAliveMaxTimeout to a 10s recycle window
  (AI_STREAM_KEEPALIVE_MS), so a connection idle longer than that is closed
  instead of reused — a long-gap step opens a fresh connection. keepAliveMaxTimeout
  also caps a server-advertised keep-alive so the provider can't widen the window.
- Pre-response connection retry: createStreamingFetch retries a connection-level
  reset (ECONNRESET / UND_ERR_SOCKET / ECONNREFUSED / EPIPE / *_TIMEOUT) on a
  fresh connection up to 2 times. This is SAFE because fetch() only rejects before
  the Response resolves — a started stream is never replayed; an abort (client
  disconnect) is never retried.

Tests: ai-streaming-fetch.spec — keep-alive options, streamKeepAliveMs env,
isRetryableConnectError, and a server that resets the first connection so the
retry must land on a fresh one (+ aborted requests are not retried). Verified on
the stand that a normal turn still streams (reasoning + text + finish) through the
new transport. server tsc + ai/mcp specs green.

Note: root cause is the deployment's egress dropping idle connections (Traefik is
inbound-only); this makes the app resilient to it. AI_STREAM_KEEPALIVE_MS can be
lowered if the egress drops faster than ~10s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-25 00:10:55 +03:00
- Invert the transport layers so the pre-response retry is OUTERMOST and the
  provider-HTTP instrumentation is INNER. Before, the retry lived inside
  createStreamingFetch (under the instrumentation), so a reset the retry
  recovered from logged only a clean "OK status=200" — the
  "PRE-RESPONSE FAILED ... ECONNRESET ... idleSincePrevCall" signal went blind
  exactly when the fix works, and AI_STREAM_KEEPALIVE_MS couldn't be tuned from
  prod data. Now createStreamingFetch is the dispatcher-bound BASE (no retry) and
  a new withPreResponseRetry() wraps it; ai.service composes
  withPreResponseRetry(createInstrumentedFetch('AiService:provider-http',
  createStreamingFetch())), so every attempt — including recovered resets — flows
  through the instrumentation. (Also expresses the keepAlive-config vs retry-
  behavior boundary structurally, per review #3.)
- Add the retry-exhaustion test: a server that resets EVERY connection, asserting
  the call rejects with a retryable connection error AND exactly
  PRE_RESPONSE_CONNECT_RETRIES + 1 (= 3) requests reached the server — pinning the
  bound and that the final error propagates (guards an off-by-one / infinite loop
  / swallowed error). Existing happy-retry + abort tests moved onto
  withPreResponseRetry.

Verified on the stand: a normal turn still streams (reasoning + finish) and the
provider-HTTP telemetry still logs. server tsc + ai/mcp specs green (30).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad merged commit e262f1695c into develop 2026-06-25 00:11:51 +03:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#179