Undici в Node.js: HTTP-запросы, таймауты и потоки — мастерская и 9 задач
Девять задач HTTP-интеграции: каталог, заказы, соединения, таймауты, файлы, повторы и тесты. Мастерская с 23 реальными записями Node.js и исходники Windows/Linux.

HTTP-запросы в Node.js: соединения, таймауты, повторы и файлы. Девять практических задач, 23 записи реальных запусков, мастерская и исходники для Windows/Linux.
Undici: когда вашему серверу нужно обратиться к другому серверу
В приложении уже есть интерфейс, база и свои маршруты. Но каталог хранится в соседнем сервисе, доставка рассчитывается у партнёра, отчёт приходит файлом, а внешний API иногда отвечает слишком долго. Здесь нужен HTTP-клиент: код, который отправляет запрос, получает ответ и превращает его в понятный результат вашего приложения. Undici решает именно эту задачу в Node.js.
В статье собран небольшой стенд с собственным учебным API. Мы прочитаем каталог, создадим заказ с защитой от повторного запроса, ограничим соединения, остановим зависшее обращение, скачаем файл потоком и разберём ошибки. Отдельно посмотрим на повторы, тесты без сети и диагностические события. Все данные вымышленные: опыты не создают настоящих заказов и не обращаются к чужим личным кабинетам.
Главный результат — не список методов библиотеки, а готовый набор приёмов для интеграции. Вы получите исходники клиента и сервера, тесты, записи выполнения и небольшой gateway, который можно запустить на Windows или Linux. На странице работает мастерская воспроизведения этих запусков: она позволяет спокойно посмотреть каждый шаг, сравнить варианты и скачать результат.
Где применять: интеграции, backend для интерфейса и фоновые задачи
Представьте панель сервисной компании. Карточка клиента должна одновременно показать доступные услуги, состояние заявки и последние документы. Часть информации может приходить из вашего API, часть — из отдельной CRM. Серверный слой забирает данные из разрешённых источников и возвращает интерфейсу стабильную структуру. Пользователь видит одну удобную страницу, а не технические особенности нескольких поставщиков.
Другой практический случай — фоновая задача. Рабочий процесс забирает выгрузку, проверяет её размер, сохраняет файл и передаёт дальше на обработку. Здесь важны расход памяти, возможность отмены и понятная запись о сбое. Полная загрузка файла в строку бывает удобной в маленьком примере, но плохо подходит для больших отчётов или десятков параллельных задач.
Ещё HTTP-клиент нужен для webhook-отправки, синхронизации справочников и проверки состояния зависимостей. Под каждую ситуацию выбирают собственную политику: можно ли повторить действие, сколько ждать, какой ответ считать успешным. Undici предоставляет транспортные инструменты, а правила бизнеса остаются в приложении. Если задача начинается с приёма входящего HTTP, полезен наш материал о Hono; здесь мы разбираем противоположное направление.
Встроенный fetch и пакет Undici: что выбрать
В современных Node.js уже есть глобальный fetch. Для обычного чтения JSON часто достаточно его: знакомый API, объект Response, проверка ok и чтение тела. Установка отдельной зависимости не обязательна только ради одного запроса. При этом реализация встроенного fetch связана с версией Node, а не с зависимостью, которую вы добавили в package.json.
Пакет Undici устанавливается и обновляется отдельно. В нашем проекте он нужен для request, явного Pool, MockAgent и управления жизненным циклом соединений. Эти возможности удобно объединять в небольшой модуль интеграции, который переиспользуют API-маршруты и фоновые задачи. У каждого подхода есть своя область удобства; переписывать работающий код только из-за названия библиотеки не требуется.
Сравнивайте конкретный контракт, а не обещание «быстрее во всех случаях». На задержку влияет удалённый сервер, TLS, объём данных, количество соединений, обработка JSON и ваша нагрузка. Мастерская показывает разницу поведения на управляемых примерах, но не составляет рейтинг HTTP-клиентов. Подробности встроенного API доступны в документации Node.js.
console.log(process.version);
console.log(process.versions.undici); // версия встроенной реализации
const response = await fetch('https://api.example.com/catalog', {
signal: AbortSignal.timeout(3000)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const catalog = await response.json();
Версии и важная особенность Undici 8
Исходники подготовлены с Undici 8.12.4 и Bootstrap 5.3.9; точные зависимости закреплены в package-lock.json. Для Undici 8 нужен Node.js не ниже 22.19.0. Для новых развёртываний проекта удобно выбрать поддерживаемую LTS-ветку 24 и проверить её точную установленную версию перед запуском. Установка через npm ci воспроизводит lock-файл, а не подбирает свежие версии на лету.
Восьмая ветка обновила низкоуровневый интерфейс dispatcher. В нашем проверенном Node 24 передача Agent из пакета Undici 8 непосредственно во встроенный fetch приводила к ошибке обработчика. Для такого сочетания пример использует публичную обёртку Dispatcher1Wrapper. Это не требуется для request из того же пакета. Важно проверять именно комбинацию версий вашего приложения, а не механически переносить старый фрагмент.
В официальном руководстве перехода на Undici 8 описаны изменения dispatcher и HTTP/2. Наш локальный стенд использует HTTP/1.1 на loopback и не заявляет проверку TLS или HTTP/2. В JSON записи отдельно сохранены версии установленного пакета и встроенного клиента, чтобы эксперимент можно было повторить.
import { Agent, Dispatcher1Wrapper } from 'undici';
const agent = new Agent({ connections: 2 });
try {
const res = await fetch('http://127.0.0.1:8561/catalog', {
dispatcher: new Dispatcher1Wrapper(agent),
signal: AbortSignal.timeout(3000)
});
console.log(res.status, await res.json());
} finally {
await agent.close();
}
Мини-мастерская: девять задач и 23 записанных запуска
Начните с GET и переключите ответ 200 на 404. Затем откройте опыт с пулом: один и три соединения дают разное число одновременных обработчиков на учебном сервере. В файловом опыте сравните полный файл, превышение лимита и отмену. После каждого варианта можно открыть JSON, посмотреть события и скачать запись для своего конспекта.
Кнопка «Воспроизвести запись» последовательно раскрывает уже сохранённые события. «Следующий шаг» подходит для внимательного разбора, «Показать итог» сразу открывает результат, «Сбросить» возвращает опыт к началу. Темп просмотра управляет только показом. Значения миллисекунд получены при настоящем выполнении Node.js и не пересчитываются по скорости анимации интерфейса.
Это честный replay: публичная страница не запускает Node.js и не делает произвольные серверные запросы по адресу посетителя. Полноценный runtime находится в скачиваемом проекте. Команда capture поднимает свой локальный upstream, выполняет сценарии, сохраняет результаты и закрывает ресурсы. Такой формат позволяет изучать ошибки прямо в статье без публичного открытого прокси и без необходимости сначала настраивать сервер.
Как устроен стенд: клиент, upstream и запись результата
Учебный upstream — обычный Node HTTP-сервер. Он умеет отдавать каталог, принимать вымышленный заказ, задерживать заголовки или часть тела и передавать небольшой бинарный файл. Сервер запускается на свободном локальном порту для каждого сценария. Поэтому тесты не требуют внешнего API, учётной записи или настоящего токена.
Клиентский модуль содержит повторяемые операции: ограниченный GET с повторами, потоковую загрузку и безопасное формирование записи журнала. Сценарии соединяют эти операции с конкретными ответами upstream. В capture.json попадают результат, события, измеренное время, дата и SHA256 исходников. Хеши позволяют понять, каким кодом получена запись; они не превращают локальный опыт в независимый промышленный benchmark.
Рядом есть отдельный gateway. Он принимает только GET по фиксированному пути /api/catalog, обращается к заранее заданному origin и возвращает проверенный каталог. Это небольшой пример настоящего серверного применения. Его можно подключить к интерфейсу или использовать как основу модуля интеграции, но права доступа, доменные правила и секреты конкретного проекта нужно добавить самостоятельно.
undici-project/
src/upstream.mjs # собственный учебный API
src/client.mjs # запросы, повторы, скачивание
src/scenarios.mjs # 9 задач / 23 варианта
src/gateway.mjs # реальный HTTP → HTTP сервис
tests/ # интеграционные проверки
tools/capture.mjs # повторить и записать опыты
public/ # статическая мастерская
deploy/ # systemd и пример reverse proxy
Опыт 1. GET JSON: статус, заголовки и тело
В каталоге нам нужны три вещи: HTTP-статус, тип содержимого и сами данные. Метод request возвращает их раздельно. Сначала мы получаем объект ответа, затем читаем тело. После чтения JSON приложение всё равно должно проверить ожидаемую структуру: наличие массива, типы идентификаторов и нужные поля. Успешный синтаксический разбор ещё не означает правильный контракт API.
Переключение на 404 показывает важное отличие: сервер ответил, соединение сработало, но запрошенный ресурс отсутствует. Такой ответ не обязан попадать в catch как транспортное исключение. Политику по статусам задаёт вызывающий код: 404 иногда означает нормальное отсутствие записи, а иногда — ошибку конфигурации маршрута.
В интеграционном модуле полезно возвращать доменную ошибку вроде «услуга не найдена», сохраняя исходный статус для журнала. Интерфейсу не нужно показывать stack trace или внутренний адрес upstream. В мастерской намеренно раскрыты технические поля, потому что здесь читатель изучает инструмент. В рабочей админке форма результата должна соответствовать задаче оператора.
import { request } from 'undici';
const response = await request('http://127.0.0.1:8561/catalog', {
method: 'GET', signal: AbortSignal.timeout(3000)
});
const data = await response.body.json();
if (response.statusCode !== 200) {
throw new Error(`Каталог вернул HTTP ${response.statusCode}`);
}
console.log(data.items);
Тело ответа: прочитать, отбросить или остановить
Ответ — это ресурс, а не просто строка в переменной. Если запрос завершился заголовками, но тело никто не потребляет и не закрывает, приложение может удерживать соединение дольше необходимого. В маленьком скрипте это выглядит как странная задержка завершения процесса; в сервисе под нагрузкой превращается в исчерпание доступных соединений.
Для небольшого ожидаемого JSON удобно вызвать json. Если содержимое не нужно, можно явно отбросить тело. Если ответ слишком большой или больше не нужен пользователю, нужно остановить чтение в соответствии с выбранным API. Нельзя рассчитывать, что сборщик мусора немедленно и вовремя освободит сетевой ресурс именно в момент перегрузки.
В исходниках каждый путь рассматривается отдельно. Успешные JSON читаются, служебные ответы сбрасываются, pipeline связывает жизненный цикл потока с файлом, а ошибка лимита уничтожает дальнейшее чтение. Это полезная привычка при ревью интеграций: для каждой ветки if и catch задайте вопрос, что происходит с телом ответа и соединением после выхода из функции.
const res = await request(url, { signal });
if (res.statusCode === 204) {
await res.body.dump();
return null;
}
// Контракт этой операции ожидает небольшой JSON.
return await res.body.json();
Опыт 2. POST и ключ идемпотентности
Двойной клик пользователя, повтор задачи очереди и потерянный ответ — разные причины одного риска: действие может выполниться повторно. В опыте мы отправляем сумму и ключ заказа дважды. При совпадении ключа и тела сервер возвращает уже созданный заказ. При том же ключе и другой сумме получает конфликт 409. В обоих случаях создана ровно одна запись.
Заголовок Idempotency-Key сам по себе ничего не гарантирует. Договорённость должна поддерживаться upstream: где хранится ключ, как долго, с каким пользователем связан, что происходит при одновременных запросах и при изменённом теле. Наш in-memory сервер показывает контракт для обучения, но не решает устойчивое хранение и транзакции между несколькими процессами.
Для реального API ключ обычно относится к намерению выполнить одну операцию. Его сохраняют до отправки, повторно используют при неясном результате той же операции и меняют для нового действия. Не генерируйте новый ключ на каждой попытке, иначе защита потеряет смысл. Отмена клиентского ожидания тоже не доказывает, что операция на стороне сервера не состоялась.
const res = await request(`${origin}/orders`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'idempotency-key': 'demo-order-42'
},
body: JSON.stringify({ amount: 2500 }),
signal: AbortSignal.timeout(3000)
});
console.log(res.statusCode, await res.body.json());
Опыт 3. Pool: несколько соединений к одному origin
Один сервис часто выполняет много запросов к одному API. Создавать новый клиент для каждого вызова неудобно: теряется общий контроль соединений и усложняется завершение процесса. Pool удобно держать рядом с модулем интеграции и переиспользовать в течение жизни приложения. Важная граница — один origin, то есть сочетание протокола, хоста и порта.
В опыте шесть запросов обращаются к обработчику с одинаковой искусственной задержкой. При одном соединении сервер наблюдает максимум один активный обработчик, при трёх — до трёх. В записи показаны реальные события начала и конца. Не нужно судить по одной общей цифре времени: именно наблюдаемая конкуренция объясняет, что изменилось.
Мы явно оставили pipelining равным единице и не усложняем демонстрацию наложением нескольких HTTP/1.1-запросов на одно соединение. Для вашего API увеличивать параллельность стоит постепенно, учитывая ограничения партнёра и время ответа. Больше соединений не гарантирует лучшую работу перегруженного сервера. API Pool и параметры описаны в официальном разделе.
import { Pool } from 'undici';
const pool = new Pool('http://127.0.0.1:8561', {
connections: 3, pipelining: 1
});
try {
const res = await pool.request({
method: 'GET', path: '/catalog',
signal: AbortSignal.timeout(3000)
});
console.log(await res.body.json());
} finally { await pool.close(); }
Очередь приложения: почему одного лимита соединений мало
Pool ограничивает соединения, но ваше приложение может продолжать создавать новые обещания и складывать работу в ожидание. Если входящий поток значительно выше пропускной способности upstream, растёт очередь, увеличивается память и пользователь ждёт всё дольше. Транспортный лимит полезен, однако не заменяет ограничение количества одновременно принятых операций.
В gateway из архива предусмотрена простая граница: максимум шестнадцать активных обращений при четырёх соединениях к upstream. Новые запросы сверх лимита получают 503. Это пример явного поведения при насыщении, а не универсальная настройка для любого сервиса. Значения выбирают по измерениям и по тому, сколько работы допустимо держать в ожидании.
Для массовой выгрузки тысяч записей не запускайте Promise.all на весь массив бездумно. Обрабатывайте ограниченные группы или используйте очередь с контролем конкуренции. У каждой операции должен быть срок полезности: если клиент ушёл или задача отменена, бессмысленно занимать слот её продолжением. На уровне интерфейса лучше показать понятное ожидание или предложить повтор позже, чем бесконечный индикатор загрузки.
Опыт 4. Три разных причины остановки запроса
Медленный ответ бывает устроен по-разному. Сервер может вообще не прислать заголовки, прислать их быстро и затем остановить тело, либо продолжать отвечать, когда пользователь уже отменил действие. Поэтому один безымянный «таймаут 5 секунд» часто скрывает слишком много деталей. В мастерской эти случаи разведены по вариантам.
headersTimeout относится к ожиданию заголовков, bodyTimeout — к промежуткам получения данных тела. Общий дедлайн операции задаём отдельно через AbortSignal. Важно включать в этот бюджет всю полезную работу: ожидание соединения, чтение тела и повторы. Если на каждой новой попытке создавать полный свежий бюджет, общая длительность может неожиданно вырасти в несколько раз.
В нашем запуске ошибки фаз и пользовательская отмена получены настоящим Node-клиентом. Искусственная задержка upstream подобрана достаточно большой, чтобы различие было воспроизводимым. Точное имя и код исключения могут зависеть от API и версии; обработку стоит строить на проверенном контракте своего клиента. Параметры фаз доступны в описании Client.
const controller = new AbortController();
const overall = AbortSignal.timeout(3000);
const signal = AbortSignal.any([controller.signal, overall]);
const res = await request(url, {
headersTimeout: 1500,
bodyTimeout: 1500,
signal
});
// Действие пользователя или закрытие входящего запроса:
// controller.abort();
const text = await res.body.text();
Точность таймеров и отмена уже начатой операции
Не проверяйте сетевой таймаут условием «должен сработать ровно на пятидесятой миллисекунде». Планировщик, event loop и внутренние таймеры клиента вносят задержку. При подготовке стенда короткий parser timeout не успевал сработать до очень короткого ответа; увеличение управляемой задержки сделало нужную фазу очевидной. Тесты проверяют тип результата и освобождение ресурсов, а не хрупкую абсолютную длительность.
AbortSignal останавливает ожидание и клиентскую работу, но не отправляет универсальную команду отката бизнес-операции. Если запрос на создание заказа дошёл до внешнего сервиса, заказ может появиться даже после локальной отмены. Для безопасного восстановления нужны идентификатор операции, ключ идемпотентности и способ узнать итог по контракту API.
У gateway отмена связана с закрытием входящего ответа. Если потребитель исчез, сервер перестаёт ждать upstream. В производственном коде следите, чтобы завершённый нормальный ответ не воспринимался как аварийное отключение. Поэтому пример проверяет состояние writableFinished и удаляет обработчик после завершения. Маленькая деталь жизненного цикла избавляет от ложных ошибок и ненужной работы.
Опыт 5. Скачать файл потоком и проверить результат
Для небольшого JSON полное чтение удобно. Для файла полезнее поток: данные поступают порциями и передаются следующему этапу без обязательного хранения всего содержимого в памяти. В нашем опыте upstream отдаёт файл размером 256 КиБ. Клиент одновременно считает байты, обновляет SHA256 и пишет временный файл.
Запись сначала попадает в файл с расширением .part. Только успешное завершение pipeline приводит к переименованию в готовый файл. Такой порядок помогает отличить целый результат от обрывка. Вариант с лимитом 32 КиБ прерывает чтение по фактическому количеству данных, а вариант отмены демонстрирует удаление частичного результата.
Для реальной задачи нужны дополнительные решения: отдельный приватный каталог, уникальные имена, запас свободного места, срок хранения и политика замены существующего файла. Путь назначения должен формировать серверный код, а не произвольная строка посетителя. В демонстрации каждый запуск получает свою временную папку, а после записи доказательств она удаляется. Механизм передачи потока описан в документации Node Streams.
import { download } from './src/client.mjs';
const result = await download(
'http://127.0.0.1:8561/file',
'/private/unique-job/report.bin',
{
maxBytes: 10 * 1024 * 1024,
signal: AbortSignal.timeout(15000),
onProgress: ({ bytes }) => console.log(bytes)
}
);
console.log(result.bytes, result.sha256);
Backpressure, реальный лимит размера и Web Streams
Если диск или следующий обработчик медленнее сети, полезно передавать ограничение скорости обратно по цепочке. Это и есть практический смысл backpressure: не накапливать бесконечный буфер только потому, что источник может присылать данные быстрее. В исходниках Node pipeline связывает поток ответа, преобразователь подсчёта и поток записи.
Заголовок Content-Length можно использовать как предварительную подсказку, но окончательную границу нужно проверять по действительно прочитанным байтам. Заголовок может отсутствовать, а некоторые контракты допускают другие способы передачи. Если фактический размер превысил лимит, прекращайте дальнейшее чтение и очищайте только принадлежащий операции временный результат.
Ещё одна частая путаница — тип потока. Response.body у fetch является Web ReadableStream, а многие файловые примеры Node ожидают Node stream. В нашем download используется тело request и Node pipeline. Если переносите подход на fetch, явно преобразуйте поток средствами Node или используйте совместимый интерфейс. Не смешивайте рецепты только потому, что оба объекта называются body.
Опыт 6. HTTP 200, но JSON сломан
Внешний сервис может вернуть корректный HTTP-ответ с некорректным содержимым. Например, вместо ожидаемого JSON пришла обрезанная строка, HTML от прокси или сообщение другой версии API. Сетевой уровень при этом мог сработать нормально. Поэтому в журнале полезно различать статус ответа, ошибку чтения и ошибку проверки схемы данных.
Первый вариант мастерской намеренно отдаёт неверный JSON и получает ошибку разбора. Второй возвращает 204: приложение сознательно трактует его как отсутствие представления и возвращает null. Третий показывает, что тело не является повторно читаемым контейнером. После первого чтения json повторное text отклоняется; конкретное исключение в записи относится к установленной версии библиотеки.
Если нужно проверить и исходный текст, и результат разбора, один раз прочитайте ограниченный текст, затем вызовите JSON.parse сами. Но не сохраняйте в журнал весь ответ автоматически: в нём могут находиться персональные данные и секреты. Для диагностики обычно достаточно типа содержимого, размера, идентификатора запроса и безопасного описания ошибки.
const res = await request(url, { signal });
if (res.statusCode === 204) {
await res.body.dump();
return null;
}
// Только для заранее ограниченного небольшого ответа:
const text = await res.body.text();
const data = JSON.parse(text);
// Далее — проверка полей и типов по контракту вашего API.
Опыт 7. Повторы после 503 и 429
Повтор помогает пережить короткую перегрузку, но может усилить аварию, если все клиенты начинают немедленно повторять запросы без ограничений. В нашем помощнике повторяется только GET и только после выбранных статусов 429 или 503. Есть предел попыток, общий AbortSignal и ограничение допустимого ожидания.
Учебный upstream дважды отказывает, затем возвращает успех. Можно выбрать вариант с двумя разрешёнными попытками: клиент честно возвращает последний отказ, а не делает скрытый бесконечный цикл. История в мастерской показывает каждый статус отдельно. Это полезно при проектировании интерфейса: пользователь должен понимать, что загрузка восстанавливается, а после исчерпания бюджета получить понятное действие.
Реальные правила зависят от API. Не каждый 5xx следует повторять, а сетевой обрыв после отправки изменяющего запроса оставляет неопределённость результата. В нашем примере POST вообще не включён в общий retry. Для задач с записью сначала нужен контракт идемпотентности. Если библиотека предлагает готовый retry-механизм, это тоже требует настройки методов, статусов, бюджета и обработки тела.
import { getWithRetry } from './src/client.mjs';
const result = await getWithRetry(url, {
maxAttempts: 3,
maxDelay: 1000,
signal: AbortSignal.timeout(3000),
onAttempt: ({ attempt, status }) => {
console.log({ attempt, status });
}
});
if (result.status !== 200) {
// Покажите понятный отказ или передайте задачу на повтор позже.
}
Retry-After, jitter и предел полезного ожидания
Заголовок Retry-After бывает числом секунд или HTTP-датой. В исходниках разобраны оба варианта. Если сервер просит ждать дольше допустимого бюджета нашего клиента, помощник прекращает автоматические повторы. Нельзя просто обрезать ожидание до маленького числа и отправить запрос раньше, будто указание upstream выполнено.
Когда подходящего заголовка нет, пример использует увеличивающуюся задержку с небольшой случайной добавкой. Jitter уменьшает вероятность того, что множество экземпляров одновременно повторят запрос в одну точку времени. Это не лекарство от любой перегрузки: нужен общий предел числа операций и понимание допустимой нагрузки поставщика.
Пауза между попытками тоже должна реагировать на отмену. Иначе пользователь ушёл, основной запрос отменился, а таймер всё равно разбудил код для следующего обращения. В нашем помощнике один signal передаётся и запросу, и ожиданию. Для фоновых заданий длительный повтор обычно лучше планировать в очереди, сохранив состояние операции, чем держать живой процесс спящим несколько часов.
Опыт 8. MockAgent: тестировать интеграцию без сети
Интеграционный код должен проверяться и тогда, когда внешний сервис недоступен или требует платного аккаунта. MockAgent позволяет задать ожидаемый маршрут и ответ для клиента Undici. В успешном варианте мы получаем вымышленный каталог и проверяем, что все запланированные перехватчики были использованы.
Второй вариант обращается к маршруту, который не описан в моках. Реальная сеть отключена, поэтому ошибка сразу указывает на нарушение тестового контракта. Это удобнее, чем случайно отправить запрос в интернет и получить нестабильный результат. Но такой тест не подтверждает DNS, сертификаты и поведение настоящего поставщика — для этого нужен отдельный контролируемый интеграционный прогон.
Обратите внимание на dispatcher. Мок должен быть передан тому клиенту, который действительно выполняет запрос. Созданный отдельно Pool не становится тестовым автоматически только из-за глобальной настройки другого объекта. Удобно внедрять dispatcher в функцию интеграции параметром: production передаёт обычный клиент, тест — MockAgent или MockPool. Подробнее — официальный API MockAgent.
import { MockAgent, request } from 'undici';
const mock = new MockAgent();
mock.disableNetConnect();
mock.get('https://catalog.test')
.intercept({ path: '/catalog', method: 'GET' })
.reply(200, { items: [{ id: 42, name: 'Мок' }] });
try {
const res = await request('https://catalog.test/catalog', {
dispatcher: mock
});
console.log(await res.body.json());
mock.assertNoPendingInterceptors();
} finally { await mock.close(); }
Опыт 9. Диагностика и журнал без токенов
Когда внешний запрос замедляется, одной строки «ошибка API» обычно недостаточно. Нужен идентификатор, который связывает входящую операцию, исходящий запрос и сообщение об отказе. В опыте передаётся вымышленный x-request-id, наблюдаются диагностические события клиента и формируется компактная запись с маршрутом, статусом и длительностью.
Важно выбирать разрешённые поля, а не сериализовать весь объект запроса и потом пытаться вычистить секреты. В нашем безопасном журнале отсутствуют Authorization, Cookie, тело и query-параметры. Полный URL тоже может содержать персональные сведения или временный токен. Для трассировки достаточно нормализованного маршрута и отдельного идентификатора операции.
Диагностические подписки в сценарии снимаются после опыта. В долгоживущем сервисе обычно регистрируют их один раз на процесс, следят за объёмом событий и не выполняют тяжёлую работу внутри обработчика. Для структурированных логов подход можно связать с Pino. Каналы самого клиента описаны в документации Undici.
const entry = {
requestId: operationId,
route: '/catalog',
status: response.statusCode,
durationMs: Math.round(performance.now() - started)
};
console.log(JSON.stringify(entry));
// Не добавляем request.headers, Authorization и полное тело ответа.
Из лаборатории в приложение: настоящий gateway
В архиве есть небольшой серверный слой для интерфейса. Он слушает loopback, принимает только фиксированный маршрут и запрашивает каталог у заранее настроенного upstream. Адрес не приходит от посетителя в параметре URL. Это сохраняет понятную границу: сервис интегрируется с конкретной системой, а не скачивает любую страницу по желанию клиента.
Gateway ограничивает активные запросы, использует общий Pool, проверяет объём ответа по реальным байтам и минимальную форму каталога. При сбое наружу возвращается стабильная ошибка 502 или 504, а техническая информация остаётся в безопасном журнале. Входящий клиент не должен зависеть от текста исключения конкретной версии Undici.
Для боевого интерфейса добавьте свои права доступа, проверку пользователя и бизнес-правила. Каталог из примера намеренно публичный и вымышленный. Если API возвращает приватные сведения, одной защиты адреса upstream недостаточно. Секреты интеграции должны оставаться на сервере, а набор доступных действий — соответствовать роли пользователя вашего приложения.
// В отдельных терминалах:
npm run upstream
npm run gateway
# Проверка настоящей цепочки клиент → gateway → upstream:
curl -i http://127.0.0.1:8562/api/catalog
Windows: первый запуск в PowerShell
Распакуйте архив в отдельную папку и перейдите внутрь undici-project. Установите поддерживаемый Node LTS с официального сайта, затем откройте новый терминал: старое окно может ещё не видеть изменённый PATH. Проверка версии нужна до установки зависимостей, иначе ошибка требований движка появится уже в середине работы.
В PowerShell используем npm.cmd, чтобы запуск не зависел от политики выполнения скрипта npm.ps1. Последовательность ниже устанавливает точные зависимости, выполняет тесты, заново записывает реальные сценарии и собирает статическую мастерскую. После start откройте адрес 127.0.0.1:8560. Остановка своего локального процесса — Ctrl+C в его терминале.
Если порт занят, не завершайте случайный процесс по имени. Выберите другой порт через переменную PORT для мастерской и согласованно настройте порт upstream и его адрес для gateway. Тесты и capture используют свободные локальные порты автоматически. Им не нужны права администратора, публичный домен или установленный сервер базы данных.
node --version
npm.cmd ci
npm.cmd test
npm.cmd run capture
npm.cmd run build
npm.cmd start
# Другой порт мастерской при необходимости:
$env:PORT = '8565'
npm.cmd start
Windows: проверить реальные запросы и переменные окружения
Мастерская удобна для просмотра, но для настоящего HTTP-пути откройте два дополнительных терминала в папке проекта. В одном запускается учебный upstream, в другом gateway. Третий терминал с curl показывает статус, заголовки и тело ответа. Так проще увидеть, где возникает ошибка: на входе вашего сервиса или при обращении к зависимости.
Переменная окружения задаётся для текущего процесса и его потомков. Не записывайте настоящие ключи в команду, которую потом скопируете в публичную статью или общий лог. В учебном примере токен не нужен. Если используете параметры окружения для другой задачи после опыта, удалите или измените только добавленные вами значения.
При обращении к собственному API за пределами компьютера проверяйте сетевой доступ и TLS отдельно. Успешный запрос к loopback подтверждает код и локальную цепочку, но не доказывает правильность DNS, firewall и сертификата облачного сервера. Это разные этапы развёртывания, и полезно держать их раздельно в своём списке приёмки.
# Терминал 1
npm.cmd run upstream
# Терминал 2
$env:UPSTREAM_ORIGIN = 'http://127.0.0.1:8561'
$env:PORT = '8562'
npm.cmd run gateway
# Терминал 3
curl.exe -i http://127.0.0.1:8562/api/catalog
Linux: тот же проект без Windows-зависимостей
На Linux используются те же исходники и lock-файл. Сначала проверьте установленный Node: пакет из репозитория старого дистрибутива может оказаться слишком ранним для выбранной ветки Undici. Устанавливайте поддерживаемую версию из доверенного канала и фиксируйте используемый runtime в документации проекта.
Не переносите готовую папку node_modules с Windows. Выполните npm ci на целевой системе или в чистом контейнере, чтобы зависимости и служебные файлы соответствовали окружению. В этом проекте нет собственной нативной компиляции, однако правило чистой установки остаётся полезным для воспроизводимости.
Локальный сервер мастерской слушает только 127.0.0.1. На удалённом сервере для временного просмотра можно использовать SSH-туннель, не открывая учебный порт всему интернету. Для постоянного публичного размещения статические файлы отдаёт существующий веб-сервер, а gateway работает отдельным процессом за reverse proxy. Команды запуска — начало, а управление процессом и доступом описаны дальше.
node --version
npm ci
npm test
npm run capture
npm run build
npm start
# В разных терминалах:
npm run upstream
UPSTREAM_ORIGIN=http://127.0.0.1:8561 PORT=8562 npm run gateway
curl -i http://127.0.0.1:8562/api/catalog
Linux/VPS: отдельный пользователь и systemd
Для постоянного сервиса не оставляйте gateway в случайном SSH-окне. Создайте системного пользователя без интерактивного входа, разместите код в отдельном каталоге и установите зависимости. Процессу достаточно читать приложение; права на изменение исходников ему не нужны. Переменные окружения удобнее хранить в отдельном файле с ограниченным доступом.
В deploy лежит unit для systemd. Перед установкой проверьте фактический путь Node командой command -v: ExecStart должен указывать на существующий исполняемый файл. В примере указан /usr/bin/node. Если runtime установлен другим способом, исправьте путь явно; systemd не обязан загружать настройки вашего интерактивного shell.
UPSTREAM_ORIGIN заменяется на origin вашего разрешённого API с согласованным маршрутом каталога. Учебный upstream из архива не является промышленным сервисом и не должен неожиданно стать источником реальных данных. После запуска сначала проверьте loopback, затем журналы и только потом внешний домен. Параметры защиты unit — разумная основа для этого простого процесса, но потребности другого приложения могут отличаться.
sudo useradd --system --home /opt/undici-demo --shell /usr/sbin/nologin undici-demo
sudo install -d -o root -g undici-demo -m 0750 /opt/undici-demo
# Разместите файлы и выполните npm ci в /opt/undici-demo.
sudo install -o root -g undici-demo -m 0640 deploy/undici-demo.env.example /etc/undici-demo.env
sudoedit /etc/undici-demo.env
command -v node
sudo install -m 0644 deploy/undici-gateway.service /etc/systemd/system/undici-gateway.service
sudo systemctl daemon-reload
sudo systemctl enable --now undici-gateway
sudo systemctl status undici-gateway --no-pager
sudo journalctl -u undici-gateway -n 50 --no-pager
HTTPS и reverse proxy: подключить домен
Gateway слушает 127.0.0.1:8562, а публичный TLS завершает Caddy или Nginx. Это позволяет использовать привычную конфигурацию домена, сертификатов и журналов. В примере Caddy остаётся только проксирование к локальному процессу. Перед применением замените учебное имя и объедините фрагмент с существующей конфигурацией, сохранив действующие сайты.
Для обычной автоматической выдачи сертификата нужны правильные DNS-записи и доступность необходимых портов согласно выбранному способу проверки. Положительный ответ по HTTP на локальном порту не проверяет эти условия. После reload откройте внешний HTTPS-адрес и убедитесь, что возвращается ожидаемый JSON, а ошибки backend не раскрывают внутренние детали.
Если сервер уже обслуживается через Nginx, нет необходимости менять весь стек ради этого примера. Добавьте нужный location с proxy_pass на loopback, согласуйте таймауты и формат журналов. Внешний proxy не заменяет ограничения исходящего клиента: размер ответа upstream, права пользователя и допустимые маршруты по-прежнему контролирует приложение.
api.example.com {
reverse_proxy 127.0.0.1:8562
}
# После объединения с /etc/caddy/Caddyfile:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -i https://api.example.com/api/catalog
Завершение процесса и освобождение соединений
При обновлении сервиса важно перестать принимать новую работу, дать уже начатым запросам ограниченное время и закрыть пул. Мгновенное завершение процесса может оборвать ответы, а бесконечное ожидание — остановить развёртывание. В gateway предусмотрен приём SIGINT и SIGTERM, закрытие входящего сервера и отдельный предельный срок принудительной очистки.
Pool.close предназначен для аккуратного завершения работы; принудительный destroy уместен, когда разрешённый срок уже исчерпан. Конкретный порядок зависит от приложения: фоновой очереди потребуется прекратить брать новые задания, а файловой выгрузке — завершить или убрать частичный результат. Не сводите graceful shutdown к одному обработчику, который просто пишет строку в console.
В тестах и capture ресурсы закрываются в finally. Это особенно важно для отрицательных сценариев: ошибка не должна оставлять чужие порты, открытые сокеты и временные файлы. После изучения проекта остановите только запущенные вами процессы. Такой подход делает пример удобным для повторного запуска и снижает число непонятных конфликтов на рабочем компьютере.
process.once('SIGTERM', () => {
stopping = true;
server.close(async () => {
await pool.close();
});
server.closeIdleConnections();
// В полном примере есть ограниченный срок принудительной остановки.
});
Ошибки интеграции: как искать причину
Если запрос не стартует, сначала проверьте версию Node, установленный пакет и совместимость dispatcher. Ошибка invalid onRequestStart в проверенном сочетании Node 24 и Undici 8 оказалась именно проблемой интерфейса обработчиков, а не недоступностью API. Если соединение работает, но статус неожиданный, посмотрите маршрут, метод и контракт ответа.
Если приложение зависает после ответа, проверьте чтение тела и закрытие клиента. Если память растёт при выгрузках, ищите полную буферизацию и неограниченный запуск запросов. Если данные приходят дважды, исследуйте повторы POST, устойчивое хранение ключа идемпотентности и поведение upstream при потерянном ответе. Эти проблемы относятся к разным слоям и требуют разных исправлений.
Удобный диагностический журнал отвечает на вопросы «какая операция», «к какому разрешённому маршруту», «сколько длилась» и «какой статус или код ошибки». Полезно отдельно считать долю отказов и длительность успешных обращений. Не превращайте журнал в копию всех входных и выходных данных: подробность должна помогать поиску причины, сохраняя понятный и безопасный контракт.
Что проверяют тесты и как повторить записи
Набор тестов выполняет реальные локальные HTTP-запросы, проверяет отсутствие дубля заказа, фактическую конкуренцию на upstream, таймауты фаз, отмену, SHA256 файла и очистку .part. Отдельно проверены неполный контракт тела, ограничение retry, MockAgent и отсутствие секретных полей в журнале. Gateway проходит отдельную проверку настоящей цепочки HTTP → HTTP и отклонения лишнего маршрута.
Capture запускается отдельно от тестов и сохраняет 23 результата для мастерской. Если меняете исходник, повторите тесты, затем capture и build. Не подменяйте старую запись новой подписью о версии: в JSON есть хеши конкретного кода. Собственная запись позволит увидеть, какие измерения зависят от машины, а какие свойства должны оставаться стабильными.
Локальный прогон и проверка в Linux-контейнере подтверждают воспроизводимость проекта в этих средах. Они не являются доказательством работы вашего внешнего VPS, домена и сертификата. Для собственного развёртывания дополните их запросом к публичному адресу, проверкой рестарта, журналов и поведения при недоступном upstream. Браузерная мастерская решает задачу изучения, а не заменяет приёмку production.
npm test
npm run capture
npm run build
# В записи можно проверить:
# runtime.node / runtime.undici / runtime.builtInUndici
# sourceHashes / capturedAt / records / events
Исходники, связанные материалы и официальная документация
В скачиваемом архиве находятся полный авторский код, lock-файл, локальная статическая мастерская, записи, тесты, README и шаблоны Linux-развёртывания. Зависимости устанавливаются командой npm ci; готовая node_modules в архив не включена. Авторские файлы распространяются по MIT, сведения о сторонних лицензиях приложены отдельно.
Если вы пишете похожую интеграцию на PHP, посмотрите Guzzle: HTTP-запросы и сравнение с cURL. Для входящего API на Node полезны Hono и Fastify. Связывайте эти инструменты по задаче: один принимает запрос интерфейса, другой обращается к разрешённому внешнему сервису.
Основной справочник — Undici, параметры запросов — Dispatcher, соединения — Pool, тестовые перехватчики — MockAgent. При обновлении major сверяйтесь с руководством миграции и прогоняйте свои граничные сценарии: таймаут, отмена, неверный ответ и повтор операции обычно важнее ещё одного успешного GET.


