HTTP-клиент в Node: undici, пулы, таймауты и утечка сокетов
undici — современный HTTP-клиент Node и то, на чём работает глобальный fetch. Переиспользуй соединения через пул, ставь таймауты, повторяй только идемпотентные запросы и всегда вычитывай тело, иначе утекут сокеты.
Сервис оформления заказов был здоров, пока не накатил трафик «чёрной пятницы»: тогда p99 исходящего вызова к платёжному API подскочил с 40мс до 900мс — при том что сам платёжный API чувствовал себя нормально. Причина была в нашем собственном клиенте: каждый исходящий запрос шёл через http.request с агентом по умолчанию, а тот открывает свежее TCP-соединение и полный TLS-хендшейк на каждый вызов. При десяти запросах в секунду это десять хендшейков в секунду к одному хосту, каждый добавляет два round-trip ещё до того, как уйдёт первый байт полезной нагрузки. Мы платили за пул соединений, который ни разу не включили. Второй сбой в том квартале был хуже: воркер тёк файловыми дескрипторами до EMFILE, потому что кто-то прочитал res.statusCode, сделал ветвление и вышел раньше времени — не вычитав тело ответа. Каждое брошенное тело держало сокет, который так и не вернулся в пул.
undici — и почему fetch это уже undici
В Node есть два HTTP-клиента. Старый — это http/https (http.request, http.get). Современный — undici (специализированный HTTP-клиент, написанный с нуля именно под Node.js): с keep-alive (длительное переиспользование TCP-соединений) пулом HTTP/1.1, заметно большей пропускной способностью и аккуратным promise-API. Выбирать между «использовать fetch» и «использовать undici» не нужно: в Node глобальный fetch построен на undici. Когда ты вызываешь fetch(url), запрос отправляется через глобальный Agent от undici, так что настройка undici настраивает и fetch.
import { request, Agent, Pool, setGlobalDispatcher } from "undici";
// Прямой запрос undici — возвращает { statusCode, headers, body }.
const { statusCode, body } = await request("https://api.example.com/orders");
const orders = await body.json(); // вычитывает тело
// fetch ЭТО undici: передай свой пул через опцию dispatcher.
const pool = new Pool("https://api.example.com", { connections: 64 });
const res = await fetch("https://api.example.com/orders", { dispatcher: pool });Agent управляет пулом на каждый origin (поднимает по Pool на каждый хост, с которым ты общаешься); Pool управляет множеством соединений к одному origin; Client — это одно соединение к одному origin. Большинство приложений используют Agent — напрямую или неявно через fetch.
▸Почему это работает
Почему fetch в Node ведёт себя как undici, а не как в браузере? Потому что fetch в Node — это не отдельная реализация, а именно fetch из undici, вынесенный в глобальную область, и внутри он использует глобальный диспетчер undici (Agent). Это здорово: пулинг соединений достаётся бесплатно, а ещё можно подменить настроенный пул для конкретного вызова опцией dispatcher или на весь процесс через setGlobalDispatcher(new Agent({ connections: 128 })). Но это и ловушка, если считать fetch «просто HTTP без состояния»: он делит общий пул, поэтому утёкшее тело или исчерпанный пул в одной части кода деградируют fetch повсюду.
Keep-alive: плати за хендшейк один раз, а не на каждый запрос
Новый HTTP-запрос по новому соединению стоит TCP-хендшейка (один round-trip) плюс, для HTTPS, TLS-хендшейка (ещё один-два). На канале с задержкой 30мс это 60–90мс чистой подготовки до того, как пойдут данные — и ты платишь это на каждый запрос, если соединения не переиспользуются. Keep-alive держит TCP+TLS-соединение открытым после ответа, чтобы следующий запрос к тому же origin его переиспользовал, сводя стоимость хендшейка почти к нулю.
Именно поэтому поведение старого клиента по умолчанию — ловушка. С Node 19 глобальный агент включает keep-alive, но остаётся ненастроенным — maxSockets равен Infinity, дисциплины пула нет, — так что на нагрузке агента всё равно конструируют явно, чтобы осознанно ограничить и переиспользовать соединения. undici держит соединения живыми и пулит их по умолчанию.
// Старый http: глобальный агент keep-alive'ит с Node 19, но ненастроен —
// задай свой Agent, чтобы ограничить maxSockets и переиспользовать осознанно.
import http from "node:http";
const agent = new http.Agent({ keepAlive: true, maxSockets: 64 });
http.get({ host: "api.example.com", path: "/x", agent }, (res) => {
res.resume(); // вычитать — см. ниже
});
// undici: keep-alive пул по умолчанию; просто переиспользуй один диспетчер.
import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({ connections: 64, pipelining: 1 }));Опция connections ограничивает, сколько соединений undici открывает на origin (Agent строит по Pool на origin, а connections лимитирует этот пул); pipelining задаёт, сколько запросов может быть «в полёте» на одном соединении (по умолчанию 1 — оставь так, пока не измеришь выгоду, потому что pipelining HTTP/1.1 порождает head-of-line блокировку). Размеряй пул под ёмкость нижестоящего сервиса, а не под бесконечность: безграничный пул превращает всплеск трафика в наводнение соединений, которое валит downstream.
| Аспект | http.request (агент по умолчанию) | undici / глобальный fetch |
|---|---|---|
| Keep-alive | Включён с Node 19, но ненастроен (maxSockets: Infinity) — задай new http.Agent({ keepAlive: true, maxSockets }) | Включён по умолчанию; пул на origin |
| Ручка размера пула | maxSockets | connections (на origin) |
| API | Колбэки / потоки | Promise; body.json() и т.д. |
| Нужно вычитывать тело? | Да (res.resume()) | Да (body.dump() / вычитать) |
Таймауты и повторы: ограничивай всё, повторяй почти ничего
Запрос без таймаута может висеть вечно, а висящий запрос держит соединение пула вне оборота. undici даёт три ручки, которые важны: headersTimeout (время на получение заголовков ответа, по умолчанию 300e3 = 300с), bodyTimeout (максимальный промежуток между чанками тела, по умолчанию 300e3) и timeout на подключение (установление TCP/TLS, по умолчанию 10e3 = 10с). Значения по умолчанию в 300 секунд щедрые — для пользовательского вызова нужны однозначные секунды, заданные на уровне диспетчера.
import { Agent } from "undici";
const agent = new Agent({
connections: 64,
connect: { timeout: 2_000 }, // сдаться при подключении через 2с
headersTimeout: 5_000, // заголовки должны прийти за 5с
bodyTimeout: 10_000, // нет чанка тела 10с -> отмена
});| Ручка | От чего защищает | По умолчанию в undici |
|---|---|---|
connect.timeout | Мёртвый хост / SYN в чёрную дыру | 10с |
headersTimeout | Сервер принял, но не отвечает | 300с |
bodyTimeout | Медленное / зависшее потоковое тело | 300с |
Повторы — это место, где наивный код опасен. Повторить GET обычно безопасно — он идемпотентен, так что дважды даёт тот же эффект, что и один раз. Повторить POST /charge — нет: таймаут означает, что ты не знаешь, прошёл ли платёж, и слепой повтор может списать с клиента дважды. Хуже того, когда downstream уже перегружен и каждый клиент повторяет при ошибке, начинается retry-шторм — сбой запускает волну дублирующих запросов, которая хоронит и без того страдающий сервис ещё глубже. Безопасная политика повторов: повторять только идемпотентные методы (или запросы с ключом идемпотентности), ограничивать число попыток и использовать экспоненциальный backoff с джиттером, чтобы повторы расходились во времени, а не синхронизировались.
Тело, которое нужно всегда вычитывать
В undici каждое тело ответа должно быть полностью вычитано или уничтожено. Если видишь, как пул медленно голодает под нагрузкой без явного CPU-пика, первое, что проверяй — невычитанные тела. Соединение нельзя вернуть в пул, пока его текущий ответ не дочитан до конца — протоколу нужно знать, где заканчивается этот ответ, прежде чем соединение понесёт следующий запрос. Если ты прочитал statusCode, сделал ветвление и вышел, не тронув body, то это соединение застряло. На нагрузке у пула кончаются свободные соединения (истощение сокетов), а поскольку каждое удерживаемое соединение — это открытый файловый дескриптор, ты в итоге упираешься в EMFILE и шторм ECONNRESET, когда ОС и пиры рвут зависшее.
import { request } from "undici";
const { statusCode, body } = await request("https://api.example.com/orders");
if (statusCode === 200) {
return await body.json(); // вычитывает тело — соединение освобождено
}
await body.dump(); // не интересно? всё равно сбрось, не теки
return null;Правило безусловно: вычитывай тело (body.json(), body.text(), body.arrayBuffer() или дочитывай поток) на счастливом пути и делай body.dump() на каждом пути, где выходишь раньше. То же касается старого http: вызывай res.resume(), чтобы вычитать ответ, который ты не читаешь.
На нагрузке воркер дорастает до EMFILE, и пул перестаёт обслуживать запросы. Горячий путь читает res.statusCode, логирует его и выходит, не читая тело. Наиболее вероятная причина?
Запрос на списание с клиента (POST /charge) истёк по таймауту. Ты не знаешь, прошёл ли он. Какая политика повторов верна?
- 01Почему всегда нужно вычитывать или сбрасывать тело ответа undici, и что ломается, если этого не делать?
- 02Почему повтор POST /charge после таймаута опасен, и какова безопасная политика?
У клиентской стороны HTTP в Node есть один современный ответ: undici, на котором также работает глобальный fetch, так что запрос через fetch течёт через пуловый Agent undici, а тюнингованный пул можно подменить опцией dispatcher. Первый рычаг — переиспользование соединений: keep-alive держит TCP+TLS-соединение открытым, чтобы платить за хендшейк один раз, а не на каждый запрос; undici делает это и пулит по умолчанию, тогда как легаси-глобальный агент keep-alive’ит с Node 19, но остаётся ненастроенным (безграничный maxSockets), так что на нагрузке задаёшь явный new http.Agent({ keepAlive: true, maxSockets }). Размеряй пул через connections под реальную ёмкость downstream, а не под бесконечность, и оставляй pipelining равным 1, пока не измерил выгоду. Ограничивай каждый запрос: таймаут подключения для мёртвых хостов, headersTimeout для серверов, которые приняли, но не отвечают, bodyTimeout для зависших потоков — значения по умолчанию в 300 секунд слишком щедры для пользовательских вызовов. Повторяй почти ничего: только идемпотентные методы или запросы с ключом идемпотентности, с лимитом и экспоненциальным backoff плюс джиттером, иначе наивный повтор спишет с клиента дважды, а синхронная волна станет retry-штормом. И правило, что тихо роняет сервисы: всегда вычитывай или делай body.dump() ответа, потому что невычитанное тело держит своё соединение вне пула, истощает сокеты и течёт файловыми дескрипторами до EMFILE (ошибка ОС «слишком много открытых файлов») и ECONNRESET. Теперь, когда сервис в пиковый день начнёт медленно деградировать с растущими FD и без явной утечки в бизнес-логике — проверь каждый ранний выход на отсутствующий body.dump(): в половине случаев это и есть весь фикс.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.