Nuestro problema radica en un detalle concreto: somos Geonode y vendemos proxies, y el fetch integrado en Node ignora por completo las variables de entorno HTTP_PROXY y HTTPS_PROXY. Todos los demás clientes HTTP del ecosistema las respetan, por lo que los usuarios configuran un proxy, ven que las solicitudes se realizan con éxito y dan por hecho que funciona, cuando en realidad el tráfico va directamente. No hay ninguna advertencia ni se produce ningún error. La solución consiste en unas pocas líneas y se encuentra en la sección sobre proxies más abajo. Si estás utilizando un proxy para el tráfico de Node fetch y no has configurado explícitamente un distribuidor, es casi seguro que tus solicitudes no están pasando por tu proxy.
¿Fetch nativo o el paquete node-fetch?
Empieza por aquí, ya que esto determina qué debes instalar.
La documentación de Node indica que fetch se añadió en las versiones v17.5.0 y v16.15.0, dejó de estar oculto tras el indicador --experimental-fetch a partir de la versión v18.0.0 y «ya no es experimental» a partir de la versión v21.0.0. Se describe como «una implementación compatible con los navegadores de la función fetch() basada en undici, un cliente HTTP/1.1 escrito desde cero para Node.js». Headers, Request y Response siguen la misma cronología.
Por lo tanto, en cualquier versión de Node compatible actualmente, fetch es una variable global y no se necesita ninguna dependencia.
El paquete node-fetch sigue siendo útil en dos casos: para mantener código en un entorno de ejecución más antiguo y cuando se necesita alguno de los pocos comportamientos en los que su API difiere. Ten en cuenta que la versión 3 es solo para ESM, lo que supone un obstáculo para los proyectos que aún utilizan require.
Todo lo que se indica a continuación se aplica a ambos, ya que la API es deliberadamente la misma.
Las tres formas de configurar los encabezados
Un objeto simple: el caso más habitual y el que se suele utilizar:
const res = await fetch("https://api.example.com/items", {
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer eyJhbG...",
"Accept": "application/json",
},
});
**Un objeto ``Headers`
`**: cuando se crea el conjunto de forma condicional:
const headers = new Headers({ "Accept": "application/json" });
if (token) headers.set("Authorization", `Bearer ${token}`);
if (locale) headers.set("Accept-Language", locale);
const res = await fetch(url, { headers });
Una matriz de pares: útil cuando un encabezado se repite de forma legítima:
const res = await fetch(url, {
headers: [
["Accept", "application/json"],
["X-Trace", "a"],
["X-Trace", "b"],
],
});
Las tres opciones son equivalentes en el caso sencillo. El objeto ``Headers`
` resulta útil cuando se necesita lógica condicional o cuando se quiere inspeccionar lo que se ha creado antes de enviarlo.
«set
» frente a «append
»: la distinción que da lugar a sorpresas.
Los documentos de MDN definen «set()
» como «establecer un nuevo valor para un encabezado existente» y sobrescribir los valores existentes, mientras que «append()
» «añade un nuevo valor a un encabezado existente o lo añade si no existe».
const h = new Headers();
h.append("X-Custom", "one");
h.append("X-Custom", "two");
h.get("X-Custom"); // "one, two"
h.set("X-Custom", "three");
h.get("X-Custom"); // "three"
append
«accumulates»; set
«replaces». Para casi todos los encabezados que envíes, set
es lo que necesitas: enviar dos valores Authorization
no es una solicitud válida. append
es importante para los encabezados en los que se permiten varios valores, y en la práctica esa lista es muy reducida.
Los nombres de los encabezados no distinguen entre mayúsculas y minúsculas. MDN señala que «se comparan mediante una secuencia de bytes que no distingue entre mayúsculas y minúsculas» en todos los métodos, por lo que h.get("content-type")
y h.get("Content-Type")
devuelven el mismo valor. Elige una convención para facilitar la lectura y deja de preocuparte por ello.
También existe has()
para comprobar la presencia, delete()
para eliminarlos y getSetCookie()
, que devuelve un array con todos los valores de Set-Cookie
—algo necesario porque ese encabezado es el principal caso en el que varios valores coexisten realmente y un simple get()
los concatenaría en algo que no se puede dividir de forma fiable—.
Encabezados que no se pueden configurar
El motivo por el que no aparece un encabezado que has configurado.
MDN describe un «protector» en los objetos Headers que determina qué se puede modificar. Un objeto new Headers() independiente no tiene restricciones. Los encabezados asociados a un objeto Request permiten la modificación de «encabezados de solicitud no prohibidos». Y los encabezados de un objeto Response obtenidos «de Response.error(), Response.redirect() o fetch()» son inmutables: no se pueden modificar los encabezados de una respuesta una vez recibida.
Los encabezados de solicitud prohibidos son aquellos que controla el entorno de ejecución, y los intentos de establecerlos se ignoran silenciosamente en lugar de generar un error. La lista incluye Host, Connection, Content-Length, Transfer-Encoding, Origin, Referer en algunos contextos, y las familias con los prefijos Sec- y Proxy-.
Esto tiene dos consecuencias prácticas.
El silencio es el modo de fallo. Sin excepciones, sin advertencias; el encabezado simplemente no se envía. Si un servidor insiste en que no está recibiendo algo que has configurado, comprueba qué se ha transmitido realmente por la red en lugar de volver a leer tu código.
Node es más permisivo que un navegador en algunos de estos casos, ya que no hay ningún origen que proteger. El código que establece un encabezado correctamente en Node puede encontrarse con que este se omite en un navegador, lo que supone una verdadera trampa de portabilidad para el código compartido.
Para comprobar lo que has enviado realmente, envía una solicitud a un servicio que te la devuelva tal cual:
const res = await fetch("https://httpbin.org/headers", {
headers: { "X-Test": "value", "User-Agent": "MyBot/1.0" },
});
console.log(await res.json());
Lectura de los encabezados de respuesta
Por otra parte, hay un comportamiento que conviene conocer.
const res = await fetch(url);
res.headers.get("content-type");
res.headers.has("etag");
for (const [name, value] of res.headers) {
console.log(name, value);
}
La iteración devuelve nombres en minúsculas, ya que el conjunto de encabezados se normaliza.
**Set-Cookie
requiere un tratamiento especial.** Las cookies múltiples llegan como encabezados múltiples, y un simple get("set-cookie")
las devuelve unidas por comas —lo cual es ambiguo, ya que los propios valores de las cookies pueden contener comas en una fecha Expires
. getSetCookie()
existe precisamente para esto y devuelve un array:
const cookies = res.headers.getSetCookie();
Los encabezados de respuesta son inmutables. No se puede modificar lo que devuelve fetch
. Si necesitas una versión modificada, crea un nuevo Response
.
Y la comprobación que importa más que cualquier encabezado: fetch
no rechaza los estados de error HTTP. Un 404 o un 500 se resuelven con normalidad, por lo que hay que comprobar res.ok
antes de interpretar el cuerpo.
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
Omitir eso es la causa directa de Unexpected token '<'
en una llamada a .json()
: has analizado una página de error.
Encabezados predeterminados y lo que añade Node
Node establece varios encabezados automáticamente, y saber cuáles son evita confusiones.
Host — se deriva de la URL, no se puede configurar.
Connection — gestionado por el grupo de conexiones.
Content-Length — calculado a partir del cuerpo de la solicitud.
Accept — por defecto es */*, a menos que lo configures.
Accept-Encoding — Node indica que admite compresión y descomprime la respuesta de forma transparente.
User-Agent — Node envía el suyo propio por defecto, que suele identificar a undici.
Esto último es importante para cualquier comunicación con terceros. Un agente de usuario predeterminado en tiempo de ejecución es una identificación precisa, pero, para los clientes automatizados, es una identificación deficiente: un nombre honesto con una URL de contacto recibe un mejor trato que una cadena anónima en tiempo de ejecución:
headers: { "User-Agent": "AcmeBot/1.0 (+https://acme.example.com/bot)" }
Una nota sobre los cuerpos: cuando pases un objeto FormData, no establezcas tú mismo Content-Type. El entorno de ejecución debe generarlo, ya que contiene el límite multiparte, y sobrescribirlo produce una solicitud que el servidor no puede analizar. Esta es una de las causas más comunes de un error 400 o 415 inexplicable.
Configuración de encabezados para cada solicitud
Para cualquier cosa que vaya más allá de un script, centralízalo.
const DEFAULTS = {
"Accept": "application/json",
"User-Agent": "AcmeBot/1.0 (+https://acme.example.com/bot)",
};
async function api(path, options = {}) {
const res = await fetch(`https://api.example.com${path}`, {
...options,
headers: { ...DEFAULTS, ...options.headers },
});
if (!res.ok) {
const body = await res.text();
throw new Error(`HTTP ${res.status} ${path}: ${body.slice(0, 200)}`);
}
return res;
}
Hay dos detalles ahí que merecen especial atención. Expandir primero los valores por defecto significa que quien realiza la llamada puede anular cualquiera de ellos, que es precisamente el comportamiento deseado. Y incluir los primeros 200 caracteres del cuerpo del error convierte un código de estado opaco en un mensaje sobre el que se puede actuar.
Ten en cuenta que la expansión del objeto es superficial y coincide con la cadena exacta de la clave, por lo que "content-type" en las opciones del solicitante no anulará "Content-Type" en los valores por defecto: enviarás ambos y el tiempo de ejecución elegirá uno. Si los solicitantes pueden utilizar mayúsculas y minúsculas de forma arbitraria, crea en su lugar un objeto Headers y deja que su set(), que no distingue entre mayúsculas y minúsculas, se encargue de la fusión correctamente.
Depuración de encabezados que no funcionan
Una secuencia que resuelve casi cualquier problema con los encabezados en pocos minutos, siguiendo el orden que descarta más posibilidades.
Primero: comprueba qué se ha transmitido realmente por la red. Nada más de esta lista importa hasta que hayas hecho esto. Un servicio de eco de encabezados es la forma más rápida:
const res = await fetch("https://httpbin.org/headers", { headers: myHeaders });
console.log(JSON.stringify(await res.json(), null, 2));
Si tu encabezado no aparece aquí, es que nunca salió de tu proceso: está prohibido, mal escrito o sobrescrito. Si aparece aquí y el destino indica lo contrario, algo entre tú y el destino lo está eliminando.
Segundo: crea el objeto Headers e inspecciónalo antes de enviarlo. Esto permite distinguir entre «lo he construido mal» y «el entorno de ejecución lo ha descartado»:
const h = new Headers(myHeaders);
console.log([...h.entries()]);
Al construir un objeto Headers se aplica la misma normalización que aplicará el entorno de ejecución, por lo que un nombre que sobreviva aquí es un nombre que se enviará.
Tercero: comprueba si hay duplicados accidentales. La trampa de la fusión superficial: la expansión de un objeto compara las claves por cadena exacta, por lo que {...{"Content-Type": "a"}, ...{"content-type": "b"}} genera ambas entradas. Crea un objeto Headers y utiliza set() si los llamantes pueden proporcionar mayúsculas y minúsculas arbitrarias, ya que su coincidencia insensible a mayúsculas y minúsculas realiza la fusión correctamente.
Cuatro: reprodúcelo en curl. Si la misma solicitud funciona desde un terminal pero no desde Node, la diferencia está en tu código y no en el servidor:
curl -v -H "Authorization: Bearer $TOKEN" https://api.example.com/items 2>&1 | grep '^>'
Comparar los dos bloques > uno al lado del otro suele hacer que la diferencia resulte obvia.
Cinco: lee toda la respuesta, no solo el estado. Un 400 o un 401 suele incluir un cuerpo que explica exactamente qué encabezado era incorrecto, y el código que lo descarta está desperdiciando la respuesta:
if (!res.ok) console.error(res.status, (await res.text()).slice(0, 300));
Y comprueba la redirección. fetch sigue las redirecciones por defecto, y algunos encabezados —en particular, Authorization— se descartan cuando una redirección cambia a un origen diferente. Si una solicitud funciona directamente con la URL final pero no con la original, ese es casi con toda seguridad el motivo.
El problema del proxy
El comportamiento que difiere del resto de clientes HTTP de Node, y la razón por la que existe esta sección.
**La propiedad ``fetch`
de Node no lee ``HTTP_PROXY
, ``HTTPS_PROXY
ni ``NO_PROXY
`.** Configurarlas no cambia nada. Las solicitudes se envían directamente, se completan con éxito y nada indica que se haya omitido el proxy.
La solución es el ProxyAgent
de undici:
import { ProxyAgent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new ProxyAgent("http://user:pass@proxy.example.com:9000"));
// now every fetch in this process goes through the proxy
const res = await fetch("https://api.example.com/items");
. Para una sola solicitud, en lugar de para todo el proceso, pasa un «dispatcher» por cada llamada:
const agent = new ProxyAgent("http://proxy.example.com:9000");
const res = await fetch(url, { dispatcher: agent });
Ten en cuenta que dispatcher
es una extensión específica de Node y no forma parte de la API estándar de Fetch, por lo que el código que la utilice no es portable a un navegador.
Comprueba siempre que se haya aplicado correctamente. Pregunta a un servicio qué dirección ve, con y sin el «dispatcher»:
const res = await fetch("https://api.ipify.org?format=json");
console.log(await res.json());
Si la dirección no cambia, el proxy no está en la ruta —y, dado que no hay ningún error que te avise, esta comprobación es lo único que separa una configuración que funciona de una que se omite de forma silenciosa. Se trata del mismo tipo de fallo silencioso sobre el que escribimos en por qué es importante probar los proxies.
Las diferencias de comportamiento entre los clientes HTTP en este caso son exactamente el tipo de cosas que comparamos en axios vs fetch.
Preguntas frecuentes
¿Cómo configuro los encabezados con node-fetch?
Pasa un objeto headers en las opciones: fetch(url, { headers: { "Authorization": "Bearer ..." } }). También puedes pasar una instancia de Headers o una matriz de pares nombre-valor. La misma sintaxis funciona con el método integrado de Node fetch.
¿Sigo necesitando el paquete node-fetch?
Normalmente no. Node cuenta con una variable global fetch desde la versión 17.5.0, sin marca desde la versión 18 y estable desde la versión 21. Instala el paquete solo para entornos de ejecución más antiguos o si hay alguna diferencia específica de comportamiento; ten en cuenta que la versión 3 es solo para ESM.
¿Cuál es la diferencia entre headers.set y headers.append?
set sustituye cualquier valor existente de ese encabezado; append añade otro valor, por lo que dos llamadas a append producen una lista separada por comas. Utiliza set para casi todo; append solo es relevante para los encabezados en los que se permiten varios valores.
¿Por qué no se envía mi encabezado?
Lo más probable es que se trate de un encabezado prohibido que controla el entorno de ejecución —entre ellos, Host, Connection, Content-Length y la familia Sec-—. Estos se ignoran silenciosamente en lugar de generar un error. Envía una solicitud a un servicio de eco de encabezados para ver qué se transmitió realmente por la red.
¿Se distingue entre mayúsculas y minúsculas en los nombres de los encabezados en fetch?
No. MDN especifica que los nombres de los encabezados se comparan mediante una secuencia de bytes que no distingue entre mayúsculas y minúsculas en todos los métodos Headers, por lo que get("content-type") y get("Content-Type") son equivalentes. Al iterar sobre un objeto Headers se obtienen los nombres en minúsculas.
¿Cómo puedo leer varios encabezados Set-Cookie?
Utiliza res.headers.getSetCookie(), que devuelve un array. Un simple get("set-cookie") los une con comas, lo cual es ambiguo porque los propios valores de las cookies pueden contener comas en una fecha Expires.
¿Por qué Node fetch ignora mi configuración de HTTP_PROXY?
Porque no lee en absoluto esas variables de entorno, a diferencia de casi todos los demás clientes HTTP de Node. Utiliza ProxyAgent de undici con setGlobalDispatcher, o pasa un dispatcher por cada solicitud —y, a continuación, verifica la dirección de salida, ya que un proxy eludido no produce ningún error.
¿Debo establecer el «Content-Type» al enviar FormData?
No. El entorno de ejecución lo genera incluyendo el límite multiparte, y si lo configuras tú mismo se elimina dicho límite, lo que da lugar a una solicitud que el servidor no puede analizar. Esta es una causa habitual de respuestas 400 y 415 inexplicables.
Conclusión
Configurar los encabezados en Node es cuestión de una sola línea, independientemente de la API que utilices, y gracias a la función integrada fetch, la mayoría de los proyectos ya no necesitan ningún paquete para ello.
Hay tres comportamientos que explican casi toda la confusión: set sustituye, mientras que append acumula, y si se invierten estos dos, se obtienen valores de encabezado separados por comas que los servidores rechazan. Los encabezados prohibidos se descartan de forma silenciosa en lugar de generar un error, por lo que un encabezado que el servidor no recibe debe verificarse en la conexión en lugar de volver a leerse en el editor. Y fetch se resuelve en caso de errores HTTP, por lo que hay que comprobar res.ok antes de que el cuerpo tenga sentido.
La trampa específica de Node es la del proxy, y merece la pena repetirla porque falla de forma muy silenciosa: el fetch integrado ignora por completo HTTP_PROXY. Si necesitas tráfico por proxy, configura un «dispatcher» explícitamente —y luego confirma la dirección de salida—, ya que una configuración que no hace nada tiene exactamente el mismo aspecto que una que funciona.
