Редкие ECONNRESET и «socket hang up» между сервисами при keep-alive: гонка таймаутов простоя и как её закрыть

Что было. Два сервиса на Node.js общаются по HTTP с постоянными соединениями. Раз в несколько тысяч запросов клиент получает ECONNRESET или socket hang up, хотя сервер жив и ничего не логирует. Воспроизводится стабильно, если посылать запрос ровно раз в 5 секунд.

Почему. У сервера есть таймаут простоя keep-alive соединения (в Node.js по умолчанию 5 секунд, server.keepAliveTimeout). Когда он истекает, сервер закрывает сокет. Пакет FIN идёт к клиенту несколько миллисекунд, и если клиент в это окно уже отправил новый запрос в «живой» с его точки зрения сокет, ответом будет сброс. Это не ошибка реализации, а свойство протокола; обращение закрыли как ожидаемое поведение и перечислили меры.

Как закрыть гонку.

  1. Таймаут простоя у клиента короче, чем у сервера. Клиент должен сам закрывать соединение раньше, чем это сделает сервер, тогда он никогда не отправит запрос в умирающий сокет. В Node.js: агент с keepAlive: true и freeSocketTimeout (в agentkeepalive) или keepAliveTimeout в undici на 1–2 секунды меньше серверного; в Go IdleConnTimeout у Transport; в Python requests через адаптер с пулом и pool_maxsize, таймаут на стороне сервера длиннее.
  2. Таймаут у сервера длиннее. server.keepAliveTimeout = 65000 (и headersTimeout больше него) на сервере, если перед ним nginx с keepalive_timeout 60s в upstream: правило то же, сервер закрывает позже клиента.
  3. Повтор идемпотентных запросов. GET/HEAD/PUT/DELETE можно безопасно повторить при ECONNRESET на первой же попытке отправки; для POST повтор допустим только с ключом идемпотентности.

Та же гонка с nginx посередине.

  • nginx → приложение: upstream { keepalive 32; } плюс proxy_http_version 1.1; proxy_set_header Connection ""; и keepalive_timeout в upstream меньше, чем keepAliveTimeout приложения. Иначе в error.log появляется upstream prematurely closed connection / recv() failed (104: Connection reset by peer).
  • клиент → nginx: keepalive_timeout 65; на стороне nginx больше, чем таймаут у клиентов (браузеры и большинство HTTP-библиотек закрывают простаивающие соединения раньше минуты).

Как убедиться, что это ваш случай. Ошибки редкие, распределены случайно, чаще при низкой нагрузке (сокеты успевают простоять до таймаута), исчезают при keepAlive: false. Если ошибки массовые или при пиковой нагрузке, причина другая: лимиты дескрипторов, падения воркеров, таймауты запросов.

Параметр описан в документации Node.js.