Долгое время я использовал пакет request (теперь устаревший) для выполнения HTTP-запросов в Node.js. Затем в моду вошли промисы (promises), и я переключился на request-promise (также ныне устаревший). Затем я начал использовать axios и думал, что на этом все... но ошибался. История HTTP в Node.js продолжает эволюционировать, и это прекрасно.

Выполнение запросов HTTP - одна из самых распространенных задач в Node.js. Будь то обращение к API, получение данных из внешнего сервиса или разработка веб-скрейпера (scraper), важно знать, как делать это эффективно. Хорошая новость состоит в том, что начиная с Node.js 18 в качестве глобальной переменной доступен стандартный fetch(). Если вы использовали fetch() в браузере, то уже знаете, как использовать его на сервере. Никаких дополнительных зависимостей, никаких оберток, только тот же знакомый API, предоставляющий все необходимое для выполнения запросов HTTP современным способом в Node.js.

Однако между "это работает на моей машине" и "это готово к продакшну" лежит минное поле нюансов: запрос повисает навечно, необработанные сетевые ошибки, логика повторных попыток, приводящая к дублированию запросов, и тесты, которые неожиданно обращаются к реальным API. Это руководство охватывает все, что нужно, для подготовки кода к продакшну, и не только основы, но паттерны, подводные камни и стратегии тестирования, которые проявляются только тогда, когда что-то идет не так.

В примерах этого руководства используется await верхнего уровня, что требует ESM (модулей ECMAScript). Для запуска примеров требуется либо установить "type": "module" в package.json, либо использовать расширение .mjs.

Предполагается, что используется Node.js 18+.

Быстрый ответ: используйте fetch()

Я понял, у вас нет времени становиться экспертом во всех вопросах, связанных с выполнением HTTP-запросов в Node.js, поэтому вот вам быстрый ответ.

При использовании Node.js 18+ простейшим способом выполнения запроса HTTP является встроенная функция fetch():

const response = await fetch('https://api.example.com/data')
const data = await response.json()
console.log(data)

Вот и все. Ни пакетов для установки, ни сложной настройки. Но для продакшна такой код не годится: повисшие запросы, тихие падения, утечки памяти из необработанных потоков и т.п.

Использование встроенного Fetch API

Начиная с Node.js 18 fetch() доступен глобально без каких-либо импортов. Это тот же fetch(), который вы могли использовать в браузере, что делает его знакомым и легким в использовании:

Базовый GET-запрос

try {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts/1')

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`)
  }

  const data = await response.json()
  console.log('Post title:', data.title)
} catch (error) {
  console.error('Failed to fetch:', error.message)
}

Несколько важных замечаний:

  • fetch() возвращает промис (promise), который разрешается (resolve) в объект Response (ответ)

  • промис отклоняется (reject) только при сетевых ошибках, а не при семантических ошибках HTTP (4xx, 5xx и др.). Это важно: сетевые ошибки означают, что запрос не может быть выполнен (падение DNS, отклонение подключения, таймаут), а семантические ошибки означают, что запрос достиг сервера и получил валидный ответ HTTP, но этот ответ указывает, что что-то пошло не так либо на клиенте (4xx), либо на сервере (5xx)

  • всегда следует проверять response.ok для обработки семантических ошибок

  • для разбора (parse) тела ответа и его загрузки в память следует использовать .json, .text() или .blob()

Почему fetch() требует двух шагов?

Некоторые библиотеки скрывают эту сложность и возвращают полный ответ за один вызов, почему fetch() требует двух? Это связано с тем, что протокол HTTP делит ответы на заголовки (headers) и тело (body).

Первый вызов fetch() устанавливает соединение, отправляет запрос и разбирает только заголовки. На этом этапе можно проверить статус-код и решить, стоит ли продолжать чтение из нижележащего сокета. Если статус "плохой", можно остановиться и сэкономить полосу пропускания (bandwidth).

Тело ответа может быть огромным (например, видеофайл или модель ИИ), поэтому читать его следует по-разному:

  • по частям (chunks) в потоке (stream) (например, в файл) - для больших тел

  • как двоичный файл, текст или JSON - для малых тел (несколько МБ, максимум)

POST-запрос с JSON-телом

POST-запросы используются, когда необходимо прикрепить к запросу данные. Запрос будет содержать тело с вашей полезной нагрузкой (payload) и кодировкой (encoding) в зависимости от того, что ожидает сервер. Мы сообщаем серверу о кодировке данных с помощью заголовка Content-Type.

В следующем примере мы используем JSON (application/json), который является самым распространенным форматом для API. Другие популярные форматы:

  • multipart/form-data - для загрузки файлов

  • application/x-www-form-urlencoded - для классической отправки формы HTML

try {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: 'My New Post',
      body: 'This is the content of my post.',
      userId: 1,
    }),
  })

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`)
  }

  const data = await response.json()
  console.log('Created post:', data)
} catch (error) {
  console.error('Failed to create post:', error.message)
}

Добавление заголовков и аутентификация

В предыдущем примере мы рассмотрели добавление заголовка для типа содержимого. Но существуют и другие важные случаи для заголовков, особенно, аутентификация:

const token = process.env.API_TOKEN

try {
  const response = await fetch('https://api.example.com/protected', {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/json',
      'X-Custom-Header': 'custom-value',
    },
  })

  if (response.status === 401) {
    throw new Error('Unauthorized: Check your API token')
  }

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`)
  }

  const data = await response.json()
  console.log(data)
} catch (error) {
  console.error('Request failed:', error.message)
}

Паттерн Authorization: Bearer <token> называется аутентификацией с помощью токена Bearer, как определено в RFC 6750. Это самый распространенный метод аутентификации в REST API.

Некоторые сервисы, вроде AWS, используют более сложные схемы аутентификации. Например, AWS Signature v4 использует специальные заголовки, вроде Credential, SignedHeaders и Signature, для подписания (sign) запросов.

Для чего нужен заголовок Accept? Он сообщает серверу предпочтительный формат ответа. Например, некоторые серверы могут возвращать данные как JSON, XML или обычный (plain) текст в зависимости от этого заголовка. Это может быть очень полезным, например, при работе с API и веб-сайтами, оптимизированными для LLM. Без правильного заголовка Accept они могут возвращать многословный формат HTML по умолчанию, заставляя вас понапрасну сжигать ценные токены. Заголовок Accept на MDN.

Установка таймаутов

Хорошей практикой является отмена запроса по истечении определенного времени. Это особенно важно, когда запросы находятся в критической части потока выполнения (workflow), поскольку повисший запрос может замедлить весь поток.

Современный Node.js предоставляет простой способ добавления таймаутов с помощью AbortSignal.timeout():

try {
  const response = await fetch('https://api.example.com/data', {
    signal: AbortSignal.timeout(5000), // Отмена через 5 секунд
  })

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`)
  }

  const data = await response.json()
  console.log(data)
} catch (error) {
  if (error.name === 'TimeoutError') {
    console.error('Request timed out')
  } else {
    console.error('Request failed:', error.message)
  }
}

AbortSignal.timeout() доступен с Node.js 18 (или 17.3+).

Обработка разных типов ответа

Помните, двухэтапный процесс, который мы обсуждали ранее? Вот некоторые однострочники для распространенных сценариев:

// Ответ JSON
const jsonData = await fetch(url).then((res) => res.json())

// Текстовые ответы (HTML, обычный текст)
const textData = await fetch(url).then((res) => res.text())

// Двоичные данные (изображения, файлы)
const blobData = await fetch(url).then((res) => res.blob())
const arrayBuffer = await fetch(url).then((res) => res.arrayBuffer())

// Заголовки ответа
const response = await fetch(url)
console.log('Content-Type:', response.headers.get('Content-Type'))
console.log('All headers:', Object.fromEntries(response.headers))

Синтаксис .then() эквивалентен второму await, но позволяет писать лаконичные однострочники, когда нет необходимости предварительно проверять статус ответа.

Стриминг запросов и ответов

При работе с большими телами запросов (такими как загрузка видеофайла) или большими телами ответов (такими как скачивание набора данных (dataset)), мы не хотим загружать все в память разом. Загрузка большой полезной нагрузки в память может замедлить процесс или обрушить его, если памяти не хватит. Для надежных систем важно, чтобы использование памяти было стабильным и предсказуемым, независимо от размера полезной нагрузки. Стриминг (streaming) позволяет обрабатывать данные по частям по мере их получения с помощью одного малого буфера.

Стриминг загрузки

Для загрузки большого файла без его помещения в память целиком можно сделать тело запроса потоком:

import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'

async function uploadLargeFile(url, filePath) {
  const fileStats = await stat(filePath)
  const fileStream = createReadStream(filePath)

  const response = await fetch(url, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/octet-stream',
      'Content-Length': fileStats.size.toString(),
    },
    body: fileStream,
    duplex: 'half', // Требуется для стриминга тела запроса
  })

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status}`)
  }

  return response.json()
}

// Использование
await uploadLargeFile('https://api.example.com/upload', './large-video.mp4')

Настройка duplex: 'half' в данном случае является обязательной. Она сообщает fetch(), что мы отправляем данные в одном направлении и потенциально получаем данные в другом направлении.

Стриминг скачивания

Для больших ответов актуальна обработка данных по мере поступления без ожидания всего ответа. Свойство response.body предоставляет доступ к ReadableStream (потоку для чтения):

const response = await fetch('https://example.com/large-file')

if (!response.ok) {
  throw new Error(`HTTP error! status: ${response.status}`)
}

// response.body - это ReadableStream
const reader = response.body.getReader()
const decoder = new TextDecoder()

while (true) {
  const { done, value } = await reader.read()
  if (done) break

  const chunk = decoder.decode(value, { stream: true })
  process.stdout.write(chunk)
}

Такой подход полезен для обработки потоков NDJSON, Server-Sent Events (SSE) или любого большого ответа, когда мы хотим отображать прогресс или обрабатывать данные инкрементально. Более продвинутые паттерны можно найти в нашем руководстве по асинхронным итераторам JavaScript.

Для скачивания файлов ответ может напрямую соединяться с диском с помощью Readable.fromWeb():

import { createWriteStream } from 'node:fs'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'

const response = await fetch('https://example.com/large-file.zip')

if (!response.ok) {
  throw new Error(`HTTP error! status: ${response.status}`)
}

const nodeStream = Readable.fromWeb(response.body)
await pipeline(nodeStream, createWriteStream('./download.zip'))
console.log('Download complete!')

Выполнение нескольких запросов одновременно

Несколько запросов HTTP могут выполняться конкурентно для лучшей производительности:

async function fetchMultipleUsers(userIds) {
  const requests = userIds.map((id) =>
    fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((res) =>
      res.json(),
    ),
  )

  // Ждем завершения всех запросов
  const users = await Promise.all(requests)
  return users
}

// Использование
const users = await fetchMultipleUsers([1, 2, 3, 4, 5])
console.log(`Fetched ${users.length} users`)
users.forEach((user) => console.log(`- ${user.name}`))

Если некоторые запросы могут упасть, но мы хотим продолжить с успешными, можно использовать Promise.allSettled():

async function fetchMultipleWithFallback(urls) {
  const requests = urls.map((url) => fetch(url).then((res) => res.json()))

  const results = await Promise.allSettled(requests)

  return results.map((result, index) => {
    if (result.status === 'fulfilled') {
      return { url: urls[index], data: result.value, error: null }
    } else {
      return { url: urls[index], data: null, error: result.reason.message }
    }
  })
}

При обработке большого количества элементов (сотен или тысяч), запуск всех запросов одновременно может перегрузить сервер или исчерпать ресурсы системы. В этих случаях требуется ограниченная конкурентность (limited concurrency): запуск запросов конкурентно, но с ограничением максимального количества одновременно выполняемых запросов.

Обработка распространенных сценариев

Повторное выполнение провалившихся запросов

Сетевые запросы могут проваливаться по многим причинам: сервер может быть временно перегружен, сбой сети может разорвать соединение, может быть достигнут лимит попыток подключения (rate limit) или сервис может находиться в процессе перезагрузки. Эти падения часто являются временными, тот же запрос может быть успешным, если повторить его снова через какое-то время.

Реализация механизма повторов - хорошая практика для большинства запросов HTTP, особенно для критических операций. Вот простая утилита:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  let lastError

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options)

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      return await response.json()
    } catch (error) {
      lastError = error
      console.log(`Attempt ${attempt} failed: ${error.message}`)

      if (attempt < maxRetries) {
        //  Экспоненциальная задержка: время ожидания увеличивается между попытками
        const delay = Math.pow(2, attempt) * 100
        await new Promise((resolve) => setTimeout(resolve, delay))
      }
    }
  }

  throw new Error(`Failed after ${maxRetries} attempts: ${lastError.message}`)
}

Помощник fetchWithRetry() оборачивает fetch() и автоматически повторно выполняет упавшие запросы maxRetries раз. Он применяет экспоненциальную задержку между попытками (200, 400, 800 мс...), давая серверу время на восстановление. Повторные попытки запускаются любыми ошибками.

Обратите внимание, что этот пример ограничен: ответ всегда разбирается как JSON и любой не ОК статус-код считается ошибкой, т.е. не запускает повторное выполнение. Более гибкий подход предполагает возврат сырого (raw) ответа для разбора вызывающим или добавление логики для выполнение повторного запроса при определенных статус-кодах (например, 429 Too Many Requests или 503 Service Unavailable).

Этот помощник лучше всего работает с идемпотентными GET-запросами. Для запросов POST/PUT/DELETE повторное выполнение запроса может привести к дублированию сторонних эффектов (например, создание нескольких одинаковых заказов). Кроме того, если options.body - это поток, он может быть разобран только один раз: повторное выполнение провалится.

Данные формы и загрузка файлов

Ранее мы рассмотрели, как стримить файл в виде сырых байтов, когда все тело запроса - это просто содержимое файла. Такой подход работает, когда API принимает сырое двоичное тело, но большинство реальных API ожидает формат multipart/form-data. Этот формат следует веб-стандартам (это та же кодировка, которая используется формами HTML с enctype="multipart/form-data") и позволяет включать поля с метаданными, такими как описание, теги или идентификатор пользователя вместе с файлом.

Для отправки запросов multipart/form-data используется API FormData:

import { readFile } from 'node:fs/promises'

async function uploadFile(url, filePath) {
  const fileContent = await readFile(filePath)

  const formData = new FormData()
  formData.append('file', new Blob([fileContent]), 'upload.txt')
  formData.append('description', 'My uploaded file')

  const response = await fetch(url, {
    method: 'POST',
    body: formData,
    // Обратите внимание: заголовок Content-Type устанавливать не нужно - fetch установит его автоматически
    // с правильной границей (boundary) для multipart/form-data
  })

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status}`)
  }

  return await response.json()
}

В этом примере используется readFile(), который загружает весь файл в память перед загрузкой. Это хорошо работает для малых файлов (несколько МБ), но вызывает проблемы в случае больших файлов. Ваш процесс может замедлиться или упасть при попытке загрузить видео в несколько ГБ таким способом.

Для больших файлов лучше использовать стриминг (см. ниже).

Стриминг больших файлов с FormData

Во избежание загрузки больших файлов в память при использовании multipart/form-data можно создать файлоподобный (file-like) объект с помощью метода stream():

import { createReadStream } from 'node:fs'
import { basename } from 'node:path'

async function uploadLargeFileWithFormData(url, filePath, metadata) {
  const fileName = basename(filePath)

  const formData = new FormData()

  // Добавляем поля с метаданными
  formData.append('description', metadata.description)
  formData.append('category', metadata.category)

  // Добавля��м файл как поток (без его полной загрузки в память)
  formData.append('file', {
    [Symbol.toStringTag]: 'File',
    name: fileName,
    stream: () => createReadStream(filePath),
  })

  const response = await fetch(url, {
    method: 'POST',
    body: formData,
  })

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status}`)
  }

  return response.json()
}

// Использование
await uploadLargeFileWithFormData(
  'https://api.example.com/videos',
  './large-video.mp4',
  { description: 'My vacation video', category: 'travel' },
)

stream() возвращает поток для чтения (readable stream). fetch() (поддерживаемый undici) распознает этот паттерн и стримит содержимое файла, вместо его загрузки в память. Свойство [Symbol.toStringTag]: 'File' позволяет добавлять объект в FormData как валидный файл.

Эта техника специфична для Node.js и основана на том, как undici обрабатывает файлоподобные объекты. Это не будет работать в браузерах, в которых есть собственный File API.

Обработка параметров строки запроса URL

При формировании URL с параметрами строки запроса (query parameters), велик соблазн конкатенировать строки вручную: ${baseUrl}?query=${userInput}. Это опасно. Если userInput содержит специальные символы, такие как &, = или #, обработка URL сломается или будет вести себя неожиданно. Более того, если данные приходят от пользователя, атакующий может внедрить дополнительные параметры или манипулировать структурой URL.

Всегда используйте API URL и URLSearchParams для безопасного формирования URL. Они обрабатывают кодировку автоматически:

function buildUrl(baseUrl, params) {
  const url = new URL(baseUrl)

  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) {
      url.searchParams.append(key, value)
    }
  }

  return url.toString()
}

// Использование
const url = buildUrl('https://api.example.com/search', {
  query: 'node.js',
  page: 1,
  limit: 20,
  sort: 'date',
})

console.log(url)
// https://api.example.com/search?query=node.js&page=1&limit=20&sort=date

const response = await fetch(url)

Помощник buildUrl() создает объект URL и использует searchParams.append() для добавления каждого параметра. Это автоматически кодирует специальные символы. Например, строка запроса c++ & c# превращается в c%2B%2B%20%26%20c%23, поэтому URL остается валидным и безопасным. Помощник также пропускает undefined и null, что полезно, когда некоторые параметры являются опциональными.

Тестирование с помощью моков

При написании юнит-тестов для кода, выполняющего запросы HTTP, мы не хотим обращаться к реальным API. Мокирование (mocking) позволяет контролировать ответы, тестировать сценарии ошибок и быстро запускать тесты без сетевых зависимостей.

fetch() Node.js поддерживается undici, который предоставляет MockAgent для перехвата запросов. Хотя undici поддерживает встроенный fetch(), утилиты мокирования не являются глобальными переменными. Для доступа к ним undici должна быть установлена как зависимость:

npm install undici --save-dev

В сочетании со встроенным движком тестов (test runner), эффективные юнит-тесты можно писать без сторонних библиотек мокирования.

Базовое мокирование с помощью MockAgent

Допустим, у нас есть функция, извлекающая данные пользователя:

export async function getUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`)

  if (!response.ok) {
    throw new Error(`Failed to fetch user: ${response.status}`)
  }

  return response.json()
}

Вот как можно ее протестировать с помощью мокированых ответов:

import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, it } from 'node:test'
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'
import { getUser } from './user-service.js'

describe('getUser', () => {
  let mockAgent
  let originalDispatcher

  beforeEach(() => {
    originalDispatcher = getGlobalDispatcher()
    mockAgent = new MockAgent()
    mockAgent.disableNetConnect() // Предотвращаем случайные реальные запросы
    setGlobalDispatcher(mockAgent)
  })

  afterEach(async () => {
    await mockAgent.close()
    setGlobalDispatcher(originalDispatcher) // Восстанавливаем оригинального диспетчера
  })

  it('returns user data for valid id', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }

    mockAgent
      .get('https://api.example.com')
      .intercept({ path: '/users/1', method: 'GET' })
      .reply(200, mockUser)

    const user = await getUser(1)

    assert.deepEqual(user, mockUser)
  })

  it('throws an error when user not found', async () => {
    mockAgent
      .get('https://api.example.com')
      .intercept({ path: '/users/999', method: 'GET' })
      .reply(404, { error: 'Not found' })

    await assert.rejects(() => getUser(999), {
      message: 'Failed to fetch user: 404',
    })
  })
})

Очистка afterEach важна: она закрывает агента мокирования (освобождает ресурсы) и восстанавливает оригинального диспетчера. Это предотвращает загрязнение тестов, когда моки из одного теста протекают (leak) в другой, а также утечку ресурсов при запуске множества тестов. Вызов disableNetConnect() добавляет дополнительную сетевую защиту, обеспечивая быстрый провал тестов при попытке отправки реального запроса HTTP.

Рассмотрим, что происходит в тесте. Мы импортируем describe, it и beforeEach из встроенного движка тестов Node.js (node:test), а также assert для утверждений. В хуке beforeEach создается новый MockAgent, который регистрируется как глобальный диспетчер с по��ощью setGlobalDispatcher(). Это указывает undicifetch()) пропускать все запросы через наш мок.

В каждом тесте используется mockAgent.get() для определения источника (origin), .intercept() для определения пути и метода, а также .reply() для определения мокированого ответа. Когда наша функция getUser() вызывает fetch(), она получает мокированый ответ, вместо выполнения реального сетевого запроса.

Запускаем тест:

node --test user-service.test.js

Мокирование POST-запросов

Аналогичным образом можно мокировать запросы POST. Это полезно для тестирования функций, создающих или обновляющих ресурсы:

it('creates a new post', async () => {
  const newPost = { title: 'Hello', body: 'World', userId: 1 }
  const createdPost = { id: 42, ...newPost }

  mockAgent
    .get('https://api.example.com')
    .intercept({ path: '/posts', method: 'POST' })
    .reply(201, createdPost)

  const response = await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newPost),
  })

  const data = await response.json()
  assert.equal(data.id, 42)
})

Лучшие практики

Резюмируем ключевые практики, позволяющие писать надежный, поддерживаемый код:

  1. Всегда обрабатывайте ошибки: сетевые запросы могут падать. Используйте try/catch и проверяйте статус-коды ответов.

  2. Устанавливайте таймауты: не позволяйте запросам висеть вечно. Используйте AbortController или соответствующие настройки библиотек.

  3. Валидируйте ответы: не предполагайте структуру ответа. Валидируйте его перед доступом к свойствам.

  4. Используйте переменные окружения для URL и токенов: не кодируйте ключи API или чувствительные URL.

  5. Учитывайте ограничение попыток подключения: реализуйте стратегии задержки для повторных запросов.

  6. Логируйте запросы при разработке: добавьте логирование для отладки проблем, но избегайте логирования чувствительных данных.

Пример функции, реализующей эти практики:

async function apiRequest(endpoint, options = {}) {
  // [Практика #4] Загружаем чувствительные значения из переменных окружения
  const baseUrl = process.env.API_BASE_URL
  const token = process.env.API_TOKEN

  // [Практика #1] Оборачиваем в try/catch для обработки ошибок
  try {
    const response = await fetch(`${baseUrl}${endpoint}`, {
      ...options,
      signal: AbortSignal.timeout(10000), // [Практика #2] Устанавливаем таймаут в 10 сек
      headers: {
        Authorization: `Bearer ${token}`, // [Практика #4] Используем переменную окружения для токена
        'Content-Type': 'application/json',
        ...options.headers,
      },
    })

    // [Практика #1] Проверяем статус ответа и обрабатываем ошибки
    if (!response.ok) {
      const errorBody = await response.text()
      throw new Error(`API error ${response.status}: ${errorBody}`)
    }

    // [Практика #3] Разбираем ответ (вызывающий должен валидировать структуру)
    return await response.json()
  } catch (error) {
    // [Практика #2] Обрабатываем таймаут отдельно
    if (error.name === 'TimeoutError') {
      throw new Error(`Request to ${endpoint} timed out`)
    }

    throw error
  }
}

Вопросы производительности

Хотя fetch() - рекомендуемый выбор для большинства приложений, следует отметить, что это не самый быстрый вариант. Встроенный fetch() поддерживается undici, но добавляет накладные расходы от WebStreams (в соответствии со спецификацией). Согласно бенчмаркам http.request() с keep-alive может быть на 50% быстрее, а использование undici.request() напрямую может быть в 7-10 раз быстрее, чем fetch().

Для большинства приложений эта разница несущественна - она, как правило, нивелируется сетевой задержкой, а эргономические преимущества fetch() перевешивают снижение производительности. Однако, при разработке высоконагруженных систем (шлюзы API, прокси или сервисов, выполняющих тысячи запросов в секунду), рассмотрите следующие варианты:

  • прямое использование undici.request() для максимальной пропускной способности

  • использование модулей http/https с пулом соединений

  • профилирование конкретной рабочей нагрузки перед оптимизацией

Следует отметить, что благодаря оптимизациям WebStreams в Node.js 22 производительность fetch() существенно возросла.

Случаи использования внешних библиотек

Хотя встроенные функции Node.js обрабатывают большинство кейсов, внешние библиотеки, вроде axios, got или ky предоставляют дополнительный функционал:

  • автоматические повторные попытки с настраиваемыми стратегиями

  • перехватчики запросов/ответов для логирования, обновления токенов аутентификации и т.п.

  • события прогресса для больших загрузок/скачиваний

  • встроенная обработка таймаутов

  • поддержка проксирования из коробки

  • встроенная отмена запросов

Для простых приложений используйте встроенный fetch(). Используйте внешние библиотеки, когда вам требуется определенный функционал или когда вы хотите уменьшить количество шаблонного кода в сложном приложении.

Если производительность имеет решающее значение, попробуйте использовать undici напрямую через undici.request(). Это та же библиотека, которая поддерживает fetch(), но ее прямое использование пропускает слой WebStreams и использует нативные потоки Node.js. Это существенно повышает производительность, а API остается современным и основанным на промисах. Интерфейс немного отличается от браузерного fetch(), но в вещах, которые редко имеют значение в серверном коде.

Использование модулей http и https

Во всех примерах выше использовался fetch(), что является рекомендуемым подходом в современном Node.js. Однако вы можете столкнуться с необходимостью использовать низкоуровневые модули http/https в следующих случаях:

  • работа с версиями Node.js 18-, где fetch() недоступен

  • интеграция с библиотеками, которые ожидают объекты http.IncomingMessage или http.ClientRequest

  • обновление устаревшей кодовой базы по частям

  • продвинутые случаи использования, требующие прямого доступа к нижележащему сокету или подключению

Модули node:http и node:https являются частью Node.js почти с самого начала, и их по-прежнему можно встретить во многих кодовых базах. Это низкоуровневые API, предоставляющие прямой доступ к потокам запросов и ответов.

В чем между ними разница? Модуль http предназначен для обычных соединений HTTP (обычно, порт 80), а https - для шифрованных подключений TLS/SSL (обычно, порт 443).

Базовый GET-запрос с помощью https

import https from 'node:https'

function httpsGet(url) {
  return new Promise((resolve, reject) => {
    https
      .get(url, (response) => {
        // Обработка перенаправлений
        if (
          response.statusCode >= 300 &&
          response.statusCode < 400 &&
          response.headers.location
        ) {
          return resolve(httpsGet(response.headers.location))
        }

        if (response.statusCode !== 200) {
          reject(new Error(`HTTP error! status: ${response.statusCode}`))
          response.resume() // Потребляем ответ для освобождения памяти
          return
        }

        const chunks = []

        response.on('data', (chunk) => chunks.push(chunk))

        response.on('end', () => {
          const body = Buffer.concat(chunks).toString()
          resolve(JSON.parse(body))
        })

        response.on('error', reject)
      })
      .on('error', reject)
  })
}

// Использование
const data = await httpsGet('https://jsonplaceholder.typicode.com/posts/1')
console.log('Post title:', data.title)

Этот подход требует больше кода, чем fetch(), но предоставляет доступ к сырому потоку и полному контролю над обработкой данных.

POST-запрос с http.request()

import https from 'node:https'

function httpsPost(url, data) {
  return new Promise((resolve, reject) => {
    const urlObj = new URL(url)
    const postData = JSON.stringify(data)

    const options = {
      hostname: urlObj.hostname,
      port: urlObj.port || 443,
      path: urlObj.pathname + urlObj.search,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(postData),
      },
    }

    const req = https.request(options, (response) => {
      const chunks = []

      response.on('data', (chunk) => chunks.push(chunk))

      response.on('end', () => {
        const body = Buffer.concat(chunks).toString()

        if (response.statusCode >= 200 && response.statusCode < 300) {
          resolve(JSON.parse(body))
        } else {
          reject(new Error(`HTTP ${response.statusCode}: ${body}`))
        }
      })
    })

    req.on('error', reject)

    // Установка таймаута
    req.setTimeout(10000, () => {
      req.destroy(new Error('Request timed out'))
    })

    // Записываем данные и завершаем запрос
    req.write(postData)
    req.end()
  })
}

// Использование
const newPost = await httpsPost('https://jsonplaceholder.typicode.com/posts', {
  title: 'My Post',
  body: 'Content here',
  userId: 1,
})
console.log('Created:', newPost)

Стриминг с помощью http/https

Указанные модули предоставляют прямой доступ к потокам Node.js. Хотя fetch() также поддерживает стриминг (как было показано выше), во многих кодовых базах можно встретить такой паттерн:

import https from 'node:https'
import { createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'

async function downloadFile(url, destPath) {
  return new Promise((resolve, reject) => {
    https
      .get(url, async (response) => {
        if (response.statusCode !== 200) {
          reject(new Error(`Failed to download: ${response.statusCode}`))
          response.resume()
          return
        }

        const fileStream = createWriteStream(destPath)

        try {
          await pipeline(response, fileStream)
          console.log(`Downloaded to ${destPath}`)
          resolve()
        } catch (error) {
          reject(error)
        }
      })
      .on('error', reject)
  })
}

// Использование
await downloadFile(
  'https://nodejs.org/dist/v22.0.0/node-v22.0.0.tar.gz',
  './node-source.tar.gz',
)

Заключение

Современный Node.js предоставляет отличные встроенные возможности для выполнения запросов HTTP:

Метод

Случаи использования

Версия Node.js

fetch()

GET, POST, стриминг, загрузка файлов

18+

undici.request()

Для максимальной производительности

18+ (требуется установка)

http.request()

Незашифрованное соединение, устаревшие интеграции или высокая пропускная способность

Все версии

https.request()

Зашифрованное соединение, устаревшие интеграции или высокая пропускная способность

Все версии

Рекомендуется использовать fetch(). Он знаком большинству разработчиков, основан на промисах, поддерживает потоковую передачу и не требует внешних зависимостей. Однако, когда производительность имеет решающее значение, undici.request() позволяет обойти накладные расходы WebStreams и может быть в 7-10 раз быстрее.

Функции http.request() и https.request() — это низкоуровневые альтернативы, доступные во всех версиях Node.js. Они полезны для интеграции со старыми системами или когда требуется высокая производительность без добавления зависимостей. Ключевое различие между ними заключается в том, что протокол необходимо выбирать явно: http.request() для незашифрованных соединений и https.request() для зашифрованных. fetch() и undici выбирают протокол автоматически на основе URL.