fix(ai-chat): recycle keep-alive sockets + retry pre-response resets (#175) #179
Reference in New Issue
Block a user
Delete Branch "fix/ai-stream-reset-resilience"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Настоящий фикс #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.
Фикс
AI_STREAM_KEEPALIVE_MS). Сокет, простоявший дольше — закрывается, не переиспользуется → длинный шаг открывает свежее соединение. keepAliveMaxTimeout не даёт провайдеру раздвинуть окно.Тесты
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
- 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>