Диагностика22 мин чтения

Исправляем TypeError: fetch failed в Node.js за прокси

Разбираем TypeError: fetch failed в Node.js за прокси: вывод цепочки err.cause, игнорируемый HTTPS_PROXY, несовпадение версий undici и ответы прокси 403, 407 или 502.

На этой странице

TypeError: fetch failed — это не сама ошибка. Это обёртка, в которую встроенный fetch Node.js (undici) заворачивает каждый сбой сетевого уровня. Причина лежит в err.cause, а иногда ещё на уровень глубже. За прокси причины делятся на четыре группы:

  • Прокси вообще не использовался. Встроенный fetch игнорирует HTTPS_PROXY, пока вы явно не включите поддержку через NODE_USE_ENV_PROXY=1, --use-env-proxy или http.setGlobalProxyFromEnv(), а Node 26 игнорирует глобальный диспетчер, установленный undici 5, 6 или 7 до версии 7.27.0. Вы видите getaddrinfo ENOTFOUND, ошибку подключения с адресом назначения или обычный ответ, которого прокси не зафиксировал в логе.
  • Несовпадение версий вокруг undici 8. Диспетчер undici 5 или 6, переданный в отдельный запрос fetch Node 26, или диспетчер undici 8, переданный в fetch Node 22 или 24, падает с invalid onError method или invalid onRequestStart method ещё до того, как хоть один байт дойдёт до прокси.
  • Прокси отклонил запрос. Proxy response (NNN) !== 200 when HTTP Tunneling лежит двумя уровнями ниже вместе со статусом прокси, например 403, 407, 429 или 502. Обычные http:// URL на undici 8.7 и новее ведут себя иначе.
  • Недоступный или неисправный прокси. ECONNREFUSED, ETIMEDOUT, UND_ERR_CONNECT_TIMEOUT или ENOTFOUND с именем прокси, UND_ERR_PRX_CONN, ECONNRESET, ошибка TLS или вовсе никакой ошибки.

Выведите всю цепочку, найдите самую глубокую строку в таблицах ниже и примените исправление для этого уровня. За исключением одной строки, помеченной как сообщённая, каждая строка ошибки в таблицах наблюдалась в 1 965 loopback-ячейках лаборатории, которые ipvolt прогнал 23 и 24 сентября 2026 года на Node.js v22.23.3, v24.21.0 и v26.10.0 с npm undici от 5.29.0 до 8.11.0.

Ответ 4xx или 5xx от целевого сервера никогда не даёт fetch failed: он возвращается как Response. Браузерная TypeError: Failed to fetch — другая ошибка; она возникает при сетевых сбоях и ошибках CORS, детали которых JavaScript увидеть не может.

Что означает «TypeError: fetch failed» в Node.js

undici отклоняет промис при сетевых ошибках через new TypeError('fetch failed', { cause: response.error }). Та же строка есть и в первой, и в последней версии undici, входящей в состав Node 22, 24 и 26 (шесть выпусков, от 6.11.1 до 8.10.2). Причиной может быть ошибка undici со стабильным code, например UND_ERR_INVALID_ARG, системная ошибка Node.js, например ECONNREFUSED, или DOMException с сообщением Request was cancelled., которая оборачивает настоящую ошибку ещё на уровень ниже, как в лаборатории делал каждый отклонённый CONNECT. Исключение — ваш собственный AbortSignal.timeout(): он отклоняет промис голой TimeoutError без обёртки fetch failed и без cause.

Выведите всю цепочку причин

Несколько привычных способов логирования ошибки останавливаются раньше самой глубокой строки. С лабораторным прокси, отвечающим 407, на всех трёх средах выполнения err.message показывал только fetch failed, err.cause.message останавливался на Request was cancelled., а JSON.stringify(err) печатал {}. console.log(err) до 407 доходил, но со стеками вызовов и без какого-либо маскирования. Вместо этого пройдите по цепочке с помощью print-cause.mjs — принтера, который записал каждую ячейку в лаборатории:

js
// print-cause.mjs: walk err.cause and report name, code and message at each depth.
// Reads only name, code, message, cause and an AggregateError's errors (never headers,
// bodies or stacks). Masks URL userinfo, Bearer tokens and key:value or key=value pairs
// whose key names a credential. Regex masking is not exhaustive: review before sharing.
const KEY = String.raw`[\w-]*(?:authorization|cookie|token|secret|passw(?:or)?d|api[-_]?key)[\w-]*`;
const mask = (s) => String(s)
  .replace(/\/\/[^/\s]*@/g, '//***@')
  .replace(/\bBearer\s+[\w.~+/-]+=*/gi, 'Bearer ***')
  .replace(new RegExp(String.raw`(["']?\b${KEY}["']?\s*[:=]\s*)(["']?)(?:(?:Basic|Bearer|Digest)\s+)?[^\s"'&,;]+`, 'gi'), '$1$2***');

export function causeChain(err, maxDepth = 10) {
  const chain = [];
  for (let e = err, depth = 0; e != null && depth < maxDepth; e = e.cause, depth++) {
    const entry = { depth, name: e.name ?? typeof e, code: typeof e.code === 'string' ? e.code : undefined, message: mask(e.message ?? e).slice(0, 300) };
    if (Array.isArray(e.errors)) entry.errors = e.errors.slice(0, 5).map((x) => `${x?.code ?? x?.name}: ${mask(x?.message ?? x).slice(0, 200)}`);
    chain.push(entry);
  }
  return chain;
}

export function printCauseChain(err, log = console.error) {
  for (const { depth, name, code, message, errors } of causeChain(err)) {
    log(`${'  '.repeat(depth)}[${depth}] ${name}${code ? ` (${code})` : ''}: ${message}${errors ? ` [${errors.join('; ')}]` : ''}`);
  }
}

Вызывайте её в блоке catch, например try { await fetch(url) } catch (err) { printCauseChain(err); process.exitCode = 1; }. Не пробрасывайте err дальше, оставляя его неперехваченным: тогда Node сам печатает всю ошибку целиком, включая незамаскированный [cause], что лаборатория подтвердила на одноразовом пароле. Вот вывод для лабораторного прокси с 407 на Node v26.10.0 с NODE_USE_ENV_PROXY=1:

code
[0] TypeError: fetch failed
  [1] Error: Request was cancelled.
    [2] AbortError (UND_ERR_ABORTED): Proxy response (407) !== 200 when HTTP Tunneling

Прочитайте вывод, прежде чем делиться им: всё, что не совпало с масками, например user:password@host без схемы впереди, проходит без изменений.

Если ваш код ветвится по ошибке, сравнивайте err.code, а не instanceof. Справочник ошибок undici рекомендует именно это, потому что «встроенный (глобальный) диспетчер может быть из другой версии undici, чем та, которую вы импортируете напрямую». Для UND_ERR_INVALID_ARG проверяйте и сообщение: в лаборатории этот код означал либо несовпадение версий, либо 407 от прокси на обычном http:// URL.

Найдите свою ошибку: строка, глубина, уровень и решение

Глубина 0 — это TypeError: fetch failed, а каждая строка находится на глубине 1, если в строке таблицы не сказано иное. «Прокси ничего не записал» означает, что лабораторный прокси не увидел ни одного соединения для этого fetch.

Сбой до того, как запрос дошёл до прокси

Что вы видитеУровеньРешение
invalid onError method (UND_ERR_INVALID_ARG), прокси ничего не записалДиспетчер undici 5 или 6 передан в fetch Node 26Диспетчер undici 7 или 8, собственный fetch undici либо без диспетчера с NODE_USE_ENV_PROXY=1
invalid onRequestStart method (UND_ERR_INVALID_ARG), прокси ничего не записалДиспетчер undici 8 передан в fetch Node 22 или 24Dispatcher1Wrapper, собственный fetch undici 8 или его setGlobalDispatcher(), либо агент undici 7
getaddrinfo ENOTFOUND с именем назначения, прокси ничего не записалПрокси не использовался; прямой DNS-запрос не удалсяВключите поддержку прокси из окружения. Для глобального диспетчера обновите undici до 7.27.0+ или 8.0.1+
getaddrinfo ENOTFOUND с хостом проксиИмя хоста прокси не разрешается на этой машинеПроверьте хост в URL прокси, а также DNS или VPN, которые должны его разрешать
connect ECONNREFUSED или connect ETIMEDOUT с адресом назначения, прокси ничего не записалПрокси не использовался; прямое соединение не удалосьТо же, что выше
Connect Timeout Error (UND_ERR_CONNECT_TIMEOUT) со списком адресов назначения, по сообщению в undici#4960То же самое, о чём сообщает таймаут подключения undiciТо же, что выше
Ошибки нет, ответ обычный, прокси ничего не записалПрокси был молча обойдёнТо же, что выше
Setting the TLS ServerName to an IP address is not permitted (ERR_INVALID_ARG_VALUE) на Node 26URL прокси https:// с IP-адресом, который Node 25 и новее отвергаютhttp:// для обычного HTTP-прокси или имя хоста, которое покрывает сертификат HTTPS-прокси

Сбой на прокси или за ним

Что вы видитеУровеньРешение
На глубине 2 Proxy response (NNN) !== 200 when HTTP Tunneling под Request was cancelled. на глубине 1Прокси отклонил CONNECT со статусом NNNПрочитайте NNN: 407 — учётные данные (исправление ошибки прокси 407), 403 — его политика, 429 — его ограничение частоты, 502–504 — проблемы на прокси или за ним
Proxy Authentication Required (407) (UND_ERR_INVALID_ARG)407 для обычного http:// URL, который undici 8.7 или новее переслал без CONNECTТо же исправление учётных данных; этот UND_ERR_INVALID_ARG — не несовпадение версий
Ошибки нет, но response.status равен 403, 429, 502 или 503, а origin не зафиксировал запросСобственная ошибка прокси для пересланного http:// URL (undici 8.7 и новее)Поищите в теле и заголовках признаки прокси
connect ECONNREFUSED с адресом проксиНа хосте и порту прокси никто не слушаетПроверьте хост, порт и то, что прокси запущен
AggregateError (ECONNREFUSED) с пустым сообщением и одним отклонённым адресом на каждое семейство IPТо же для имени хоста прокси, например localhostТо же, что выше
connect ETIMEDOUT с адресом проксиTCP-рукопожатие с прокси так и не завершилосьПроверьте маршрут и межсетевые экраны на пути к прокси
Connect Timeout Error (UND_ERR_CONNECT_TIMEOUT) с адресом проксиТот же сбой, о котором сообщает таймаут подключения undiciТо же, что выше
ERR_SSL_WRONG_VERSION_NUMBER с wrong version number в сообщенииВ URL прокси указано https://, а прокси говорит на обычном HTTPИспользуйте http:// в URL прокси
Proxy Connection failed (UND_ERR_PRX_CONN) над other side closed на глубине 2Прокси закрыл соединение, не ответив на CONNECT (undici 8.6 и новее)Проверьте порт, протокол и поддерживает ли прокси CONNECT
other side closed (UND_ERR_SOCKET) для http:// URLПрокси закрыл пересланный запрос, не ответив (undici 8.7 и новее)Проверьте порт и протокол, затем лог прокси
Ошибки нет, fetch никогда не завершается, а прокси записывает CONNECT за CONNECTТот же сбой на undici 8.5 и старее: цикл переподключенийКрайний срок на стороне вызывающего кода превращает его в TimeoutError; undici 8.6 и новее — в UND_ERR_PRX_CONN
Client network socket disconnected before secure TLS connection was established (ECONNRESET)Прокси ответил 200, а затем оборвал туннельСмотрите дальше прокси: его upstream или назначение
SELF_SIGNED_CERT_IN_CHAIN или UNABLE_TO_VERIFY_LEAF_SIGNATUREПрокси с инспекцией TLS предъявил сертификат от CA, которому Node не доверяетДобавьте доверие к этому CA через NODE_EXTRA_CA_CERTS или через --use-system-ca, если он есть в хранилище доверия ОС
Ошибки нет около пяти минут, затем Headers Timeout Error (UND_ERR_HEADERS_TIMEOUT) на глубине 1Прокси принял CONNECT и так и не ответилЗадайте собственный крайний срок
На глубине 0 TimeoutError: The operation was aborted due to timeout, без causeСработал ваш AbortSignal.timeout()Посмотрите в логе прокси, на каком этапе всё зависло

В каждой ячейке, которая дошла до заданного поведения прокси, — 449 в основной матрице и 803 в наборе проверок, — уровень, который лаборатория вывела только по строкам и счётчикам прокси, совпал с этим поведением.

HTTPS_PROXY задана, но fetch её игнорирует (NODE_USE_ENV_PROXY)

С заданной HTTPS_PROXY и без явного включения все три среды выполнения в лаборатории подключались напрямую, и HTTP_PROXY с http:// URL вела себя так же: назначение, доступное без прокси, возвращало 200, а прокси ничего не записывал. Код запуска Node устанавливает прокси-диспетчер только когда включение активно и задана одна из переменных HTTP_PROXY, HTTPS_PROXY, http_proxy или https_proxy. ALL_PROXY не считается.

Способ включенияДокументирован сЛаборатория: v22.23.3 / v24.21.0 / v26.10.0
NODE_USE_ENV_PROXY=1v24.0.0, v22.21.0через прокси на всех трёх
node --use-env-proxyv24.5.0, v22.21.0через прокси на всех трёх
http.setGlobalProxyFromEnv()v24.14.0, v25.4.0на v22.23.3 такой функции нет; через прокси на двух других
npm undici setGlobalDispatcher(new EnvHttpProxyAgent())экспортируется 6.28.1 и каждой undici 7 и 8 в лабораториичерез прокси, кроме v26.10.0 с 6.28.1, 7.16.0 или 7.26.0, где запросы шли напрямую
  • В более старых выпусках включения нет; используйте там диспетчер или собственный fetch undici.
  • Указывайте схему. При активном включении значение без http://, например 127.0.0.1:<port> или localhost:<port>, заставляло все три среды выполнения завершиться при запуске с TypeError: Invalid URL или Invalid URL protocol ещё до выполнения кода приложения. Задокументированы только URL прокси http:// и https://; SOCKS5 остаётся открытым пунктом в отслеживающем issue по прокси Node.
  • Node читает переменные «при запуске», как сказано в документации CLI, поэтому установка process.env.HTTPS_PROXY из кода позже не помогает. Альтернатива во время выполнения — http.setGlobalProxyFromEnv().
  • На v22.23.3 включение печатало [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental — уведомление о стабильности, а не ошибку.
  • Без заданной NO_PROXY через прокси шёл даже https://127.0.0.1. Что именно исключает NO_PROXY, различается между клиентами; см. матрицу сопоставления NO_PROXY.
  • dispatcher в отдельном запросе перекрывает включение для своего запроса: когда HTTPS_PROXY указывала на мёртвый порт, а агент — на рабочий прокси, каждый выполненный запрос использовал агент.

В логе прокси обычный http:// URL на undici 8.7 и новее, включая прокси из окружения в Node 26.5 и новее, не показывает CONNECT — только сам запрос в абсолютной форме, например GET http://origin.test/…. О тех же переменных в curl и Python см. переменные окружения прокси; о явном ProxyAgentпрокси для fetch в Node.js.

invalid onError method: старый диспетчер undici на Node 26

undici 8.0.0 убрала устаревшие обёртки обработчиков и переименовала их колбэки, например onError в onResponseError (руководство по миграции). Каждый выпуск Node 26 включает undici 8, поэтому его fetch передаёт диспетчерам обработчик только с новыми колбэками. В undici 6 первая же провалившаяся проверка, например invalid onConnect method, уходит в ветку ошибки dispatch, которая вызывает handler.onError; этого метода больше нет, поэтому undici бросает invalid onError method, а исходное сообщение теряется.

ProxyAgent из npm undici 5.29.0 и 6.28.1, переданные как dispatcher в fetch v26.10.0, падали во всех 18 ячейках ещё до какого-либо соединения, и добавление NODE_USE_ENV_PROXY=1 не помогало:

code
[0] TypeError: fetch failed
  [1] InvalidArgumentError (UND_ERR_INVALID_ARG): invalid onError method

xen-orchestra#10411 сообщает об UND_ERR_INVALID_ARG для EnvHttpProxyAgent из undici 6.28.1, переданного в fetch Node 26.

Глобальный путь ломается тише. На v26.10.0 setGlobalDispatcher() из npm undici 5.29.0 (с ProxyAgent; в этом выпуске нет EnvHttpProxyAgent), 6.28.1, 7.16.0 или 7.26.0 (с ProxyAgent или EnvHttpProxyAgent) никак не влиял на встроенный fetch. Запросы шли напрямую и заканчивались ENOTFOUND либо ответом 200 с нулём CONNECT, тогда как тот же код на v22.23.3 и v24.21.0 работал через прокси. Эти версии, как и другие теги undici 7 до 7.27.0, у которых проверяли lib/global.js (7.0.0, 7.10.0 и 7.25.0), хранят глобальный диспетчер только под Symbol.for('undici.globalDispatcher.1'), который fetch v26.10.0 игнорировал. undici 7.27.0, выпущенная 2026-06-01 с PR #5319, записывает и .1, и .2, как и 7.29.1 с 8.11.0; с этими тремя глобальный диспетчер работал через прокси на всех трёх средах выполнения. EnvHttpProxyAgent из undici 6.28.1 на v26.10.0 по-прежнему печатал своё предупреждение об экспериментальности, так что предупреждение не доказывает, что агент используется.

invalid onRequestStart method: диспетчер undici 8 на Node 22 или 24

ProxyAgent из npm undici 8.11.0, переданный в отдельный запрос встроенного fetch v22.23.3 или v24.21.0, падал во всех 18 ячейках с InvalidArgumentError (UND_ERR_INVALID_ARG): invalid onRequestStart method на глубине 1, снова до какого-либо соединения. fetch в Node 22 и 24 строит устаревший обработчик, а undici 8 отвергает любой обработчик без onRequestStart. shadcn-vue#1959 сообщает о той же строке из CLI, в который встроена undici 8.10.2, на Node 24.

На v22.23.3 и v24.21.0 оборачивание агента в Dispatcher1Wrapper — задокументированный в undici 8 мост для устаревших потребителей — доходило до прокси:

js
import { Dispatcher1Wrapper, ProxyAgent } from 'undici'; // undici 8
const dispatcher = new Dispatcher1Wrapper(new ProxyAgent(proxyUrl));
const response = await fetch(url, { dispatcher });

Как и setGlobalDispatcher(new ProxyAgent(proxyUrl)) из undici 8.11.0, который к тому же сохраняет обёрнутую копию в устаревшем слоте, откуда её читает встроенный fetch. undici 8.0.0 этого не делала, поэтому на Node 24 и 25 встроенный fetch её игнорировал; PR #4962 исправил это в 8.0.1 2026-04-03.

Выберите исправление: подберите undici под process.versions.undici

Сначала проверьте среду выполнения командой node -p process.versions.undici (документация Node.js). Согласно дереву исходников Node на каждом теге выпуска, каждый выпуск 22.x включает undici 6, каждый выпуск 24.x — 7, а каждый выпуск 26.x — 8. В лаборатории dispatcher в отдельном запросе из npm undici 5.29.0 или 6.28.1 доходил до прокси на v22.23.3 и v24.21.0, но падал на v26.10.0; из undici 7.16.0, 7.26.0, 7.27.0 или 7.29.1 — доходил на всех трёх; а из 8.11.0 — только на v26.10.0. Исправления и цена каждого из них:

  1. Используйте собственный fetch undici с его же диспетчером. В лаборатории он работал с четырьмя основными npm-версиями на каждой среде выполнения. Цена: ещё одна зависимость, а классы тел вроде FormData должны быть из того же пакета (документация undici).
  2. Откажитесь от собственного диспетчера и используйте включение в среде выполнения. Оно работало через прокси на каждой среде выполнения, где есть, но действует на весь процесс, покрывает только HTTP(S)-прокси, а NO_PROXY следует правилам встроенной undici. Corepack 0.35.0 пошёл этим путём.
  3. Совместите мажорную версию. npm undici с той же мажорной версией, что и process.versions.undici, доходила до прокси на всех трёх средах выполнения. Агент undici 7 в отдельном запросе тоже принимали все три, но это снимок текущего состояния, а не обещание для будущих линеек Node.
  4. setGlobalDispatcher() из undici 7.27.0 или более поздней 7.x либо из 8.0.1 и новее. С 7.27.0, 7.29.1 и 8.11.0 он работал через прокси на всех трёх средах выполнения, для каждого fetch в процессе. С undici 5, 6 или 7 до 7.27.0 на Node 26 он игнорируется, поэтому обновите undici до 7.27.0+ или 8.0.1+. Предпочтите последнюю 7.x: 24 сентября 2026 года npm audit помечал и 7.27.0.
  5. Dispatcher1Wrapper (undici 8). Работал через прокси в отдельном запросе на всех трёх средах выполнения.
  6. install() (undici 7.11.0 и новее). Заменяет globalThis.fetch и связанные глобальные объекты копией из npm. Только по документации; лаборатория его не запускала.

Proxy response (NNN) !== 200 when HTTP Tunneling

Когда прокси отвечает на CONNECT чем угодно, кроме 200, статус прокси появляется только на глубине 2:

code
[0] TypeError: fetch failed
  [1] Error: Request was cancelled.
    [2] AbortError (UND_ERR_ABORTED): Proxy response (403) !== 200 when HTTP Tunneling

В лаборатории каждая связка, дошедшая до прокси, который отвечал на CONNECT кодом 403, 407, 429, 502 или 503, давала эту цепочку с этим статусом — 49 https://-ячеек на каждый статус. По RFC 9110 любой ответ, кроме 2xx, означает, что туннель так и не был установлен. undici отбрасывает остальную часть ответа, включая Retry-After и тело. Чтобы их увидеть, выполните curl через тот же прокси, например curl -v -x http://PROXY_HOST:PORT https://DESTINATION/ -o /dev/null. На лабораторном прокси с 429 curl 8.7.1 и 8.22.0 напечатали его Retry-After: 30. Эту настройку описывает базовое руководство по curl.

СтатусЧто говорит проксиКуда смотреть
407Ему нужны учётные данные проксиИсправить ошибку прокси 407
403Его политика отклоняет запрос; RFC 9110 просит прокси ограничивать CONNECT известными портами или разрешёнными целямиРазрешающие правила прокси для этого назначения и порта
429Ограничение частоты; сам по себе статус не говорит, чьё (RFC 6585)Замедлитесь; прочитайте Retry-After через curl -v
502 или 504Его upstream прислал плохой ответ или не прислал вовремяПовторите в пределах бюджета; проверьте назначение со стороны прокси
503Он временно не может обработать запросПовторите позже, соблюдая Retry-After, если он есть

undici строит одно и то же сообщение из любого статуса, отличного от 200. Разбор ошибок прокси рассматривает каждый статус подробнее.

Обычный http:// URL зависит от того, какой undici принадлежит агент. undici 8.6 и старее, включая прокси из окружения в Node 22, 24 и с 26.0 по 26.4, отправляют CONNECT origin.test:80 и падают с той же цепочкой (в лаборатории — каждый путь через undici 7.29.1 и старее, 33 ячейки на статус). undici 8.7 и новее, включая прокси из окружения в Node 26.5 и новее, пересылают сам запрос без CONNECT (PR #5116). Из этих пересланных запросов (16 ячеек на статус) 407 возвращался уровнем ниже как InvalidArgumentError (UND_ERR_INVALID_ARG): Proxy Authentication Required (407) — с тем же кодом, что и несовпадение версий. 403, 429, 502 и 503 вообще не бросали исключение: fetch завершался со статусом и телом прокси, а origin запроса так и не увидел.

ECONNREFUSED, ETIMEDOUT или ENOTFOUND: чей это адрес?

connect ECONNREFUSED с IP-адресом и портом прокси (49 ячеек) означает, что там никто не слушает. Для имени хоста прокси, например localhost, Node пробует каждый адрес и вместо этого сообщает AggregateError с пустым сообщением (49 ячеек):

code
[0] TypeError: fetch failed
  [1] AggregateError (ECONNREFUSED):  [ECONNREFUSED: connect ECONNREFUSED ::1:<proxy-port>; ECONNREFUSED: connect ECONNREFUSED 127.0.0.1:<proxy-port>]

Если адрес принадлежит назначению, запрос шёл напрямую: с заданной HTTPS_PROXY и без включения отказавшее назначение давало connect ECONNREFUSED 127.0.0.1:<dest-port> на всех трёх средах выполнения.

getaddrinfo ENOTFOUND работает так же: читайте имя хоста. С URL прокси http://proxy.invalid:3128 — зарезервированным именем, которое никогда не разрешается, — каждая связка, использовавшая прокси, падала с getaddrinfo ENOTFOUND proxy.invalid на глубине 1 (49 ячеек), той же формы, что и getaddrinfo ENOTFOUND origin.test при обходе прокси.

connect ETIMEDOUT с адресом прокси (77 ячеек) означает, что TCP-рукопожатие с прокси так и не завершилось; в лаборатории использовался слушатель с заполненной очередью accept. macOS обычно сдавалась примерно через 7,8 секунды — раньше стандартного таймаута подключения undici. Назначение, которое никогда не принимает соединение, за обойдённым прокси давало ту же строку с адресом назначения.

UND_ERR_CONNECT_TIMEOUT: прокси или назначение?

connectTimeout в undici по умолчанию равен 10 секундам (документация Client), и на loopback в macOS обычно первым срабатывал таймаут ОС. Поэтому в опубликованном прогоне UND_ERR_CONNECT_TIMEOUT виден только с connectTimeout: 3000 на агентах undici 8.11.0, примерно через 3,5 секунды (13 ячеек):

code
[0] TypeError: fetch failed
  [1] ConnectTimeoutError (UND_ERR_CONNECT_TIMEOUT): Connect Timeout Error (attempted address: 127.0.0.1:<proxy-port>, timeout: 3000ms)

ProxyAgent из undici 5.29.0, 6.28.1 и 7.29.1 с той же опцией всё равно заканчивали таймаутом ОС (28 ячеек), потому что строят соединение с прокси только из опций proxyTls. На Linux или в реальной сети 10-секундный таймер undici может побеждать чаще; лаборатория этого не проверяла. undici 8.10.2 и 8.11.0 также сообщают Connect Timeout Error, когда все адреса имени хоста отказали и хотя бы один — по таймауту, какой бы таймер ни сработал, с настроенным таймаутом в сообщении и AggregateError на глубине 2 (connect.js); лаборатория использовала одиночные адреса и такой формы не получала.

Прочитайте адрес в сообщении. Адрес прокси означает, что прокси недоступен; адрес назначения — что запрос вообще не использовал прокси, как в undici#4960, где в сообщении перечислены шесть адресов назначения. undici 6 и новее печатают attempted address: или attempted addresses:; undici 5 печатает голую Connect Timeout Error без адреса, поэтому вместо этого сверяйтесь с логом прокси. О крайних сроках и бюджетах повторов см. диагностику таймаутов прокси.

ERR_SSL_WRONG_VERSION_NUMBER: URL прокси https:// для обычного прокси

Обычный HTTP-прокси всё равно проводит https://-назначения внутри туннеля CONNECT, но URL прокси, начинающийся с https://, заставляет клиент открывать TLS к самому прокси. В лаборатории обычный HTTP-прокси отвечал на это рукопожатие HTTP 400, и каждая связка, дошедшая до него, падала с Error (ERR_SSL_WRONG_VERSION_NUMBER) на глубине 1 и сообщением OpenSSL, содержащим SSL routines:tls_validate_record_header:wrong version number (85 ячеек).

С IP-адресом в https:// URL прокси 13 связок на v26.10.0 падали ещё до подключения с TypeError (ERR_INVALID_ARG_VALUE): The property 'options.servername' Setting the TLS ServerName to an IP address is not permitted.. Received '127.0.0.1' на глубине 1. Node сделал IP-адрес в имени сервера TLS ошибкой в v25.0.0 (DEP0123). Исправление в обоих случаях — http:// в URL прокси, если только прокси действительно не обслуживает TLS на этом порту.

UND_ERR_PRX_CONN: прокси закрыл соединение, не ответив на CONNECT

Одна из заготовок принимала TCP, читала CONNECT и закрывала соединение без ответа. Результат зависел от того, какой undici принадлежал прокси-агент:

  • undici 8.6 и новее (в лаборатории — каждый агент npm 8.11.0, дошедший до прокси, и встроенный прокси из окружения v26.10.0): ProxyConnectionError (UND_ERR_PRX_CONN): Proxy Connection failed на глубине 1 над SocketError (UND_ERR_SOCKET): other side closed на глубине 2, после одного CONNECT (16 ячеек).
  • undici 8.5 и старее (в лаборатории — каждый агент npm 5, 6 и 7, дошедший до прокси, и встроенный прокси из окружения Node 22 и 24): ошибки нет. Клиент тут же переподключался, пока обвязка не останавливала его на 25 CONNECT (33 ячейки).

Со стороны приложения этот цикл выглядит как зависание: fetch никогда не завершается. С AbortSignal.timeout(2000) и без остановки зацикленные клиенты отклоняли промис через 2 секунды голой TimeoutError, а прокси в опубликованном прогоне записывал от 18 600 до 20 548 CONNECT для этого единственного запроса (9 ячеек, на loopback).

Для обычного http:// URL undici 8.7 и новее не отправляют CONNECT, поэтому тот же сбой проявлялся как SocketError (UND_ERR_SOCKET): other side closed на глубине 1 (16 ячеек), тогда как undici 7 и старее зацикливались, как описано выше (33 ячейки).

PR #5441 добавил ProxyConnectionError, чтобы такие запросы завершались ошибкой вместо зацикливания (issue #3897). Класс впервые вышел в undici 8.6.0, хотя справочник ошибок 8.11.0 помечает его как v8.10.1, а v26.5.0 — первый выпуск Node 26, встроенная undici которого его содержит.

ECONNRESET после CONNECT 200: туннель оборвался

Когда прокси отвечал 200, а затем закрывал соединение, не отправив ни байта в туннель, каждая связка, дошедшая до него (49 ячеек), сообщала Client network socket disconnected before secure TLS connection was established (ECONNRESET) на глубине 1. CONNECT прошёл успешно, поэтому смотрите дальше «входной двери» прокси: его upstream, выходной узел, назначение или сам прокси, обрывающий туннель. PR #5441 охватывает только установку туннеля, поэтому этот случай не становится UND_ERR_PRX_CONN.

SELF_SIGNED_CERT_IN_CHAIN за прокси с инспекцией TLS

За прокси с инспекцией TLS, чьему CA Node не доверяет, цепочка заканчивается ошибкой сертификата. Лабораторная замена такого прокси отвечала на CONNECT кодом 200, а затем предъявляла для назначения собственный сертификат от одноразового CA. Каждая связка, дошедшая до него, падала на глубине 1 на всех трёх средах выполнения: с self-signed certificate in certificate chain (SELF_SIGNED_CERT_IN_CHAIN), когда прокси присылал вместе с ним сертификат своего CA (49 ячеек), и с unable to verify the first certificate (UNABLE_TO_VERIFY_LEAF_SIGNATURE), когда присылал только собственный сертификат (49 ячеек).

Указание в NODE_EXTRA_CA_CERTS файла с этим CA исправило все 98 ячеек; Node читает его только при старте процесса. Если CA уже есть в хранилище доверия операционной системы, флаг Node --use-system-ca (задокументирован с v22.15.0 и v23.8.0) или NODE_USE_SYSTEM_CA=1 (с v22.19.0 и v24.6.0) заставляет Node доверять и этому хранилищу (настройка корпоративной сети); лаборатория их не проверяла. Не отключайте проверку через NODE_TLS_REJECT_UNAUTHORIZED=0.

Пять минут без ошибки: задайте собственный крайний срок

Прокси, который принимал CONNECT и никогда не отвечал, не давал ошибки в пределах 15-секундного лимита обвязки (49 ячеек). В опубликованном длинном прогоне все 49 таких ячеек падали через 301–302 секунды с HeadersTimeoutError (UND_ERR_HEADERS_TIMEOUT): Headers Timeout Error, что соответствует задокументированному 300-секундному значению headersTimeout по умолчанию в undici. С signal: AbortSignal.timeout(2000) те же связки отклоняли промис примерно через 2 секунды голой TimeoutError. Передавайте signal в каждый запрос через прокси.

Когда падает код инструмента, который писали не вы

CLI, SDK и MCP-серверы часто поставляются с собственной undici и передают её агент во встроенный fetch или устанавливают его через setGlobalDispatcher(). Несовпадение возникает, когда сдвигается любая из сторон: старый инструмент на Node 26 или инструмент, перешедший на undici 8, на Node 22 или 24.

  1. Выполните node -p process.versions.undici тем же бинарником node, который использует инструмент.
  2. Найдите копии в инструменте командой npm explain undici. В лаборатории npm ls undici печатал (empty) для копий, установленных под npm-псевдонимом (npm:undici@…), которые npm explain показывал. Копия, вкомпилированная в бандл инструмента, не видна ни там, ни там; смотрите журнал изменений или трекер issue инструмента.
  3. Сначала обновите инструмент. Если его документация поддерживает включение в Node, используйте NODE_USE_ENV_PROXY=1. Иначе временно запускайте его на той линейке Node, которая принимает его копию (см. исправления выше), и сообщите напечатанную цепочку разработчикам инструмента.

Вот сообщённые примеры, которые ipvolt не воспроизводил:

  • vercel/vercel#17629, открыт на 2026-09-23: встроенная в Vercel CLI undici 5.29.0 падает с invalid onError method на Node 26.8.2, когда задана переменная прокси, и работает на Node 24.21.0.
  • nodejs/corepack#834: встроенный ProxyAgent из undici 6 падал на Node 26; Corepack 0.35.0 исправил это, потребовав NODE_USE_ENV_PROXY=1.
  • openclaw#155840: invalid onRequestStart method от диспетчера undici 8, переданного в WebSocket из undici 7 одной из зависимостей; несовпадение не ограничивается fetch.

Методика, ограничения и файлы для загрузки

ipvolt выполнил эти проверки локально 23 и 24 сентября 2026 года на macOS 15.7.4 (arm64) с официальными архивами Node.js v22.23.3, v24.21.0 и v26.10.0 и npm undici 5.29.0, 6.28.1, 7.29.1 и 8.11.0, плюс 7.16.0, 7.26.0 и 7.27.0 для проверок глобального диспетчера. Каждая ячейка выполняла один fetch в собственном дочернем процессе через loopback-прокси с одним заданным поведением, который считал соединения и запросы. Большинство ячеек запрашивали зарезервированное имя origin.test (RFC 6761), которое разрешал только лабораторный прокси. В основной матрице была 591 ячейка, в дополнительном наборе — 282 (http:// URL, URL прокси с localhost, недоступные назначения), в наборе проверок — 1 029; отдельный прогон из 63 ячеек давал никогда не отвечающему прокси работать 330 секунд. Полная методика, результаты по наборам и история прогонов — в README.

Чистый повторный прогон только из итогового архива по README классифицировал все 591 основную, 282 дополнительные, 1 029 проверочных и 63 ячейки длинного прогона так же, как опубликованные результаты. Ожидайте, что повторный прогон разойдётся в нескольких ячейках с незавершающимся рукопожатием, где таймауты подключения ОС и undici соревнуются друг с другом, а также в проверках loop-deadline, если запускать их подряд: зацикливающаяся ячейка оставляет тысячи сокетов в состоянии TIME_WAIT, поэтому следующая может записать ноль CONNECT, пока порты не освободятся примерно через 40 секунд.

Лаборатория не охватывала Linux и реальные сети, настоящие HTTPS- или SOCKS-прокси, прокси, принимающие учётные данные, 504, сопоставление NO_PROXY, http.request, Deno, Bun и реальные сторонние инструменты. Результаты показывают, как эти версии Node.js и undici сообщают о каждом заданном поведении, а не то, как ведёт себя какой-либо провайдер прокси. Lockfile намеренно фиксирует старые версии undici, и npm ci сообщает о них как об уязвимости высокой серьёзности; не копируйте эти фиксации в приложение.

Все файлы лежат по адресу https://ipvolt.com/downloads/fix-node-fetch-failed-proxy/:

Чтобы воспроизвести результаты, распакуйте архив и следуйте README: npm ci, node get-runtimes.mjs, затем каждый набор и node compare.mjs против копии опубликованного файла. Аккаунт прокси не нужен.

Если хотите узнать, когда откроется доступ к ipvolt, присоединяйтесь к списку раннего доступа. Одно письмо, когда доступ откроется. Больше ничего.

Источники и дополнительное чтение

Технические материалы, использованные при подготовке руководства. Сверяйтесь с документацией вашей версии и с поддерживаемой конфигурацией вашего провайдера.