open atlas
↑ К треку
Node.js с нуля до senior NODE · 11 · 03

HTTP-клиент в Node: undici, пулы, таймауты и утечка сокетов

undici — современный HTTP-клиент Node и то, на чём работает глобальный fetch. Переиспользуй соединения через пул, ставь таймауты, повторяй только идемпотентные запросы и всегда вычитывай тело, иначе утекут сокеты.

NODE Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Сервис оформления заказов был здоров, пока не накатил трафик «чёрной пятницы»: тогда 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
Ручка размера пулаmaxSocketsconnections (на 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) истёк по таймауту. Ты не знаешь, прошёл ли он. Какая политика повторов верна?

Вспомните перед уходом
  1. 01
    Почему всегда нужно вычитывать или сбрасывать тело ответа undici, и что ломается, если этого не делать?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.