fix(ai): устойчивость pre-response ECONNRESET — бюджет ретраев, jittered backoff, keep-alive (#310) #317

Merged
vvzvlad merged 1 commits from fix/310-econnreset-tuning into develop 2026-07-03 21:25:35 +03:00
Collaborator

Summary

Тюнинг устойчивости AI-стриминга к pre-response ECONNRESET (issue #310, закрыт — это не диагностика «кто рвёт», а запас прочности, как договорились). В проде #175-ретрай восстанавливал сброс, но из 3 попыток было сожжено 2 — запаса нет, ещё один сброс уронил бы turn.

  • Бюджет ретраев 2 → 4 (всего 5 попыток), env-настраиваемо через AI_STREAM_PRE_RESPONSE_RETRIES (0 = без ретрая; пусто/мусор → дефолт 4).
  • Backoff: линейный 150*(attempt+1) → capped exponential + full jitter (preResponseBackoffMs, чистый инъектируемый хелпер): база 150ms, ×2 на попытку, cap 2000ms, задержка = random в [0, capped]. Убирает синхронный retry-storm и размазывает реконнекты по окну сбросов.
  • Keep-alive дефолт 10s → 4s, чтобы undici ресайклил idle-сокеты до того, как их прибьёт ~5s idle-cutoff апстрима/мидлбокса (частая причина pre-response reset). Override через AI_STREAM_KEEPALIVE_MS сохранён.
  • .env.example документирует обе ручки.

Таймаут (900s), RETRYABLE_CONNECT_CODES и инструментирование не тронуты.

relates to #310

How verified

  • apps/server jest ai-streaming-fetch.spec.ts20 passed. Неваккуумно: границы backoff (rand=0→0, rand=1→ верх 150/300/600/1200/2000; attempt0,rand0.5→75 вместо старых 150); env-парс (default/override/0/negative/NaN/empty); интеграционные хиты (retries=2 → 3 попытки, =4 → 5). Падало бы на старом коде.
  • tsc --noEmit / eslint по затронутому файлу — чисто.
  • Внутренний ревью (мой): APPROVE.

Если это не заглушит 500/WRN в логах — переоткрывай, копнём проксю/апстрим (там уже нужен доступ к проду).

Checklist

  • сбросы поглощаются, не исчерпывая бюджет ретраев (запас 5 попыток + jitter)
  • рекомендованные AI_STREAM_KEEPALIVE_MS / AI_STREAM_PRE_RESPONSE_RETRIES в .env.example
  • вне scope не менялось (таймаут, retryable-коды, телеметрия)
## Summary Тюнинг устойчивости AI-стриминга к pre-response `ECONNRESET` (issue #310, закрыт — это не диагностика «кто рвёт», а запас прочности, как договорились). В проде #175-ретрай восстанавливал сброс, но из 3 попыток было сожжено 2 — запаса нет, ещё один сброс уронил бы turn. - **Бюджет ретраев 2 → 4** (всего 5 попыток), env-настраиваемо через `AI_STREAM_PRE_RESPONSE_RETRIES` (`0` = без ретрая; пусто/мусор → дефолт 4). - **Backoff**: линейный `150*(attempt+1)` → capped exponential + full jitter (`preResponseBackoffMs`, чистый инъектируемый хелпер): база 150ms, ×2 на попытку, cap 2000ms, задержка = random в `[0, capped]`. Убирает синхронный retry-storm и размазывает реконнекты по окну сбросов. - **Keep-alive дефолт 10s → 4s**, чтобы undici ресайклил idle-сокеты до того, как их прибьёт ~5s idle-cutoff апстрима/мидлбокса (частая причина pre-response reset). Override через `AI_STREAM_KEEPALIVE_MS` сохранён. - `.env.example` документирует обе ручки. Таймаут (900s), `RETRYABLE_CONNECT_CODES` и инструментирование не тронуты. relates to #310 ## How verified - `apps/server` jest `ai-streaming-fetch.spec.ts` → **20 passed**. Неваккуумно: границы backoff (`rand=0→0`, `rand=1→` верх 150/300/600/1200/2000; `attempt0,rand0.5→75` вместо старых 150); env-парс (default/override/`0`/negative/NaN/empty); интеграционные хиты (`retries=2 → 3 попытки`, `=4 → 5`). Падало бы на старом коде. - `tsc --noEmit` / eslint по затронутому файлу — чисто. - Внутренний ревью (мой): APPROVE. Если это не заглушит 500/WRN в логах — переоткрывай, копнём проксю/апстрим (там уже нужен доступ к проду). ## Checklist - [x] сбросы поглощаются, не исчерпывая бюджет ретраев (запас 5 попыток + jitter) - [x] рекомендованные `AI_STREAM_KEEPALIVE_MS` / `AI_STREAM_PRE_RESPONSE_RETRIES` в `.env.example` - [x] вне scope не менялось (таймаут, retryable-коды, телеметрия)
agent_coder added 1 commit 2026-07-03 18:24:55 +03:00
In prod the AI provider resets the connection pre-response (ECONNRESET); the
#175 pre-response retry recovers it, but 2 of the 3 allowed attempts were burned
in a single turn — no headroom, and one more reset would surface an error to the
user. This is tuning for resilience (not a diagnosis of who resets):

- Retry budget 2 → 4 (total 5 attempts), env-configurable via
  AI_STREAM_PRE_RESPONSE_RETRIES (0 = no retry; empty/invalid → default 4).
- Backoff: linear 150*(attempt+1) → capped exponential + full jitter
  (preResponseBackoffMs, a pure injectable helper): base 150ms, ×2 per attempt,
  capped 2000ms, delay = random in [0, capped]. Avoids a synchronized retry
  storm and spreads reconnects across the reset window.
- Keep-alive default 10_000 → 4_000 ms so undici recycles idle sockets before a
  ~5s upstream/middlebox idle cutoff can poison them (a common pre-response
  reset cause). Still env-overridable via AI_STREAM_KEEPALIVE_MS.
- .env.example documents both knobs.

Timeout (900s), RETRYABLE_CONNECT_CODES, and the instrumentation are unchanged.

refs #310

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-03 18:24:56 +03:00
Collaborator

Ревью — #317 (ai: устойчивость pre-response ECONNRESET retry, #310), round 1, head 808a5c70, base develop

Scope: реальная дельта — ТОЛЬКО 808a5c70 поверх смердженного #305 (родитель 36b35395); 3 файла (.env.example +18 доки, ai-streaming-fetch.ts +78, .spec +119).

Вердикт: PASS — три скоординированных изменения устойчивости корректны (retry идемпотентен/bounded, backoff bounded), объективка зелёная, регрессий нет. Готово к мержу.

Полный веер (stability, regressions, test-coverage, coherence, conventions, documentation). Объективка запущена мной (детач 808a5c70): server jest ai-streaming-fetch.spec20 passed; tsc (в jest ts-компиляции) чисто.

Подтверждено по коду + прогоны

  • Retry-корректность (крукс) — SOUND. Ретрай срабатывает ТОЛЬКО когда baseFetch РЕДЖЕКТИТ (undici fetch() реджектит строго ДО резолва Response) → started/streaming-Response не реплеится → идемпотентно, нет дубль-эффекта/двойного счёта у провайдера. Цикл BOUNDED (attempt>=maxRetries → throw; maxRetries = finite floored env-значение), завершается за ≤maxRetries+1. abort (init.signal.aborted) короткозамыкает ПЕРЕД backoff (нет retry-storm на отмену). isRetryableConnectError-gate не менялся (HTTP-500-с-телом резолвит Response → не ретраится; только connection-level pre-response коды).
  • Backoff — корректен. preResponseBackoffMs(a,rand)=rand()*min(150*2^a, 2000) (capped-exp + FULL jitter, anti-thundering-herd): rand=0→0, rand=1→cap, mean=cap/2, ∈[0,cap]; 2**a→Infinity клампится Math.min ДО умножения (нет overflow).
  • Env-parse — корректен. preResponseConnectRetries(): пустая/whitespace→default 4 (НЕ 0), «0»→0 (single attempt/off), invalid/negative→default, valid→floor. Правильно НЕ переиспользует positiveEnv (тот бы отверг валидный 0). empty-vs-0 различие load-bearing и покрыто тестом.
  • Регрессий нет. Смена default'ов (keep-alive 10→4, retries 2→4) — worst-case доп-латентность только на failing-пути (≤~2.25с jittered backoff, bounded), success-путь не тронут. Caller withPreResponseRetry(instrument(createStreamingFetch())) (ai.service:59) — контракт typeof fetch неизменен. Удалённый PRE_RESPONSE_CONNECT_RETRIES=2 — ноль остаточных ссылок. Новые экспорты без коллизий.
  • Coherence. Три изменения когерентны: keep-alive↓ снижает ВЕРОЯТНОСТЬ stale-socket-reset (рециклит до middlebox-cutoff; extra churn = свежие конекты = БЕЗОПАСНЫЙ путь, reset только на переиспользовании протухшего); retries↑ поглощает остаточные burst'ы; jitter разносит одновременные ретраи. Retry — outermost-слой, re-invoke'ит полный baseFetch → undici уничтожает протухший сокет → следующая попытка на НОВОМ конекте (не тот же poisoned). 4с < ~5с cutoff с ~1с запасом, env-tunable — не sign-off-риск.
  • Тесты не-вакуозны. preResponseConnectRetries (empty→4 vs «0»→0 оба ассертятся — мутанты ловятся); preResponseBackoffMs (cap+jitter, attempt0 rand0.5→75≠150 старый линейный); withPreResponseRetry exhaustion (requests===MAX_ATTEMPTS пиннит off-by-one И infinite-loop через hang→timeout), honours-raised-budget→5, NOT-retry-aborted→0. Изоляция env через beforeEach/afterEach.
  • Docs. Все три поверхности (.env.example / jsdoc+constants / tests) согласованы (4000, retries 4, total=value+1→5, backoff base150/cap2000); старые «10s»/«retries 2»/линейный-backoff убраны полностью. Conventions: _MS-суффикс верно, rand-инъекция чистая, паттерн env-config консистентен.
## Ревью — #317 (ai: устойчивость pre-response ECONNRESET retry, #310), round 1, head `808a5c70`, base develop Scope: реальная дельта — ТОЛЬКО `808a5c70` поверх смердженного #305 (родитель `36b35395`); 3 файла (.env.example +18 доки, ai-streaming-fetch.ts +78, .spec +119). **Вердикт: PASS** — три скоординированных изменения устойчивости корректны (retry идемпотентен/bounded, backoff bounded), объективка зелёная, регрессий нет. Готово к мержу. Полный веер (stability, regressions, test-coverage, coherence, conventions, documentation). **Объективка запущена мной** (детач `808a5c70`): server `jest ai-streaming-fetch.spec` → **20 passed**; tsc (в jest ts-компиляции) чисто. ### Подтверждено по коду + прогоны - **Retry-корректность (крукс) — SOUND.** Ретрай срабатывает ТОЛЬКО когда `baseFetch` РЕДЖЕКТИТ (undici `fetch()` реджектит строго ДО резолва Response) → started/streaming-Response не реплеится → идемпотентно, нет дубль-эффекта/двойного счёта у провайдера. Цикл BOUNDED (`attempt>=maxRetries → throw`; maxRetries = finite floored env-значение), завершается за ≤maxRetries+1. `abort` (`init.signal.aborted`) короткозамыкает ПЕРЕД backoff (нет retry-storm на отмену). `isRetryableConnectError`-gate не менялся (HTTP-500-с-телом резолвит Response → не ретраится; только connection-level pre-response коды). - **Backoff — корректен.** `preResponseBackoffMs(a,rand)=rand()*min(150*2^a, 2000)` (capped-exp + FULL jitter, anti-thundering-herd): rand=0→0, rand=1→cap, mean=cap/2, ∈[0,cap]; `2**a`→Infinity клампится `Math.min` ДО умножения (нет overflow). - **Env-parse — корректен.** `preResponseConnectRetries()`: пустая/whitespace→default 4 (НЕ 0), «0»→0 (single attempt/off), invalid/negative→default, valid→floor. Правильно НЕ переиспользует `positiveEnv` (тот бы отверг валидный 0). empty-vs-0 различие load-bearing и покрыто тестом. - **Регрессий нет.** Смена default'ов (keep-alive 10→4, retries 2→4) — worst-case доп-латентность только на failing-пути (≤~2.25с jittered backoff, bounded), success-путь не тронут. Caller `withPreResponseRetry(instrument(createStreamingFetch()))` (ai.service:59) — контракт `typeof fetch` неизменен. Удалённый `PRE_RESPONSE_CONNECT_RETRIES=2` — ноль остаточных ссылок. Новые экспорты без коллизий. - **Coherence.** Три изменения когерентны: keep-alive↓ снижает ВЕРОЯТНОСТЬ stale-socket-reset (рециклит до middlebox-cutoff; extra churn = свежие конекты = БЕЗОПАСНЫЙ путь, reset только на переиспользовании протухшего); retries↑ поглощает остаточные burst'ы; jitter разносит одновременные ретраи. Retry — outermost-слой, re-invoke'ит полный baseFetch → undici уничтожает протухший сокет → следующая попытка на НОВОМ конекте (не тот же poisoned). 4с < ~5с cutoff с ~1с запасом, env-tunable — не sign-off-риск. - **Тесты не-вакуозны.** preResponseConnectRetries (empty→4 vs «0»→0 оба ассертятся — мутанты ловятся); preResponseBackoffMs (cap+jitter, attempt0 rand0.5→75≠150 старый линейный); withPreResponseRetry exhaustion (`requests===MAX_ATTEMPTS` пиннит off-by-one И infinite-loop через hang→timeout), honours-raised-budget→5, NOT-retry-aborted→0. Изоляция env через beforeEach/afterEach. - **Docs.** Все три поверхности (.env.example / jsdoc+constants / tests) согласованы (4000, retries 4, total=value+1→5, backoff base150/cap2000); старые «10s»/«retries 2»/линейный-backoff убраны полностью. Conventions: `_MS`-суффикс верно, `rand`-инъекция чистая, паттерн env-config консистентен. <!-- state:review reviewed_head=808a5c70df58 round=1 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-03 19:05:09 +03:00
Collaborator

(служебное: коррекция маркера — в прошлом маркере round-1 PASS я опечатался в укороченном sha; ниже полный. Код не менялся, вердикт прежний: PASS.)

_(служебное: коррекция маркера — в прошлом маркере round-1 PASS я опечатался в укороченном sha; ниже полный. Код не менялся, вердикт прежний: PASS.)_ <!-- state:review reviewed_head=808a5c70df0ccb633651a3ba9569445e3a7805a6 round=1 verdict=pass -->
vvzvlad merged commit d9517ff3f1 into develop 2026-07-03 21:25:35 +03:00
vvzvlad deleted branch fix/310-econnreset-tuning 2026-07-03 21:25:39 +03:00
Sign in to join this conversation.