Notre problème réside dans un piège bien précis : nous sommes Geonode et nous vendons des proxys, et l'fetch intégré à Node ignore complètement les variables d'environnement HTTP_PROXY et HTTPS_PROXY. Tous les autres clients HTTP de l'écosystème les prennent en compte ; ainsi, les utilisateurs configurent un proxy, voient que les requêtes aboutissent et supposent que tout fonctionne correctement — alors que le trafic passe directement. Il n’y a ni avertissement ni message d’erreur. La solution ne nécessite que quelques lignes de code et se trouve dans la section consacrée aux proxys ci-dessous. Si vous utilisez un proxy pour le trafic fetch de Node et que vous n’avez pas explicitement défini de dispatcher, vos requêtes ne passent presque certainement pas par votre proxy.
Utiliser la fonction « fetch » native ou le package node-fetch ?
Commencez par là, car cela déterminera ce que vous allez installer.
La documentation de Node indique que la fonction fetch a été ajoutée dans les versions v17.5.0 et v16.15.0, qu'elle n'est plus masquée par le drapeau --experimental-fetch depuis la version v18.0.0, et qu'elle n'est « plus expérimentale » depuis la version v21.0.0. Elle est décrite comme « une implémentation compatible avec les navigateurs de la fonction fetch(), basée sur undici, un client HTTP/1.1 écrit entièrement de zéro pour Node.js ». Headers, Request et Response suivent le même calendrier.
Ainsi, sur toute version de Node actuellement prise en charge, fetch est une variable globale et vous n’avez pas besoin de dépendance.
Le package node-fetch reste utile dans deux cas : la maintenance de code sur un environnement d’exécution plus ancien, et le besoin d’utiliser l’un des rares comportements pour lesquels son API diffère. Notez que la version 3 est exclusivement ESM, ce qui pose problème aux projets utilisant encore require.
Tout ce qui suit s’applique aux deux, puisque l’API est délibérément identique.
Les trois façons de définir des en-têtes
Un objet simple — le cas le plus courant et celui à utiliser la plupart du temps :
const res = await fetch("https://api.example.com/items", {
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer eyJhbG...",
"Accept": "application/json",
},
});
**Un objet « Headers
»** — lorsque vous construisez l’en-tête de manière conditionnelle :
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 });
Un tableau de paires — utile lorsqu’un en-tête se répète légitimement :
const res = await fetch(url, {
headers: [
["Accept", "application/json"],
["X-Trace", "a"],
["X-Trace", "b"],
],
});
Ces trois méthodes sont équivalentes dans les cas simples. L’objet « Headers
» prend tout son sens lorsque vous avez besoin d’une logique conditionnelle ou lorsque vous souhaitez vérifier ce que vous avez construit avant l’envoi.
« set
» contre « append
» : une distinction qui réserve des surprises.
Les documents MDN définissent « set()
» comme l’attribution d’« une nouvelle valeur à un en-tête existant » et le remplacement des valeurs existantes, tandis que « append()
» « ajoute une nouvelle valeur à un en-tête existant ou le crée s’il n’existe pas ».
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
remplace. Pour presque tous les en-têtes que vous envoyez, set
est ce qu’il vous faut — envoyer deux valeurs Authorization
n’a aucun sens. append
s’applique aux en-têtes pour lesquels plusieurs valeurs sont valides, ce qui, dans la pratique, représente une liste restreinte.
Les noms d’en-têtes ne sont pas sensibles à la casse. MDN précise qu’ils sont « mis en correspondance par une séquence d’octets insensible à la casse » pour toutes les méthodes ; ainsi, h.get("content-type")
et h.get("Content-Type")
renvoient la même valeur. Choisissez une convention pour faciliter la lecture et ne vous en préoccupez plus.
Il existe également has()
pour tester la présence, delete()
pour supprimer, et getSetCookie()
, qui renvoie un tableau de toutes les valeurs de Set-Cookie
— ce qui est nécessaire car cet en-tête est le principal cas où plusieurs valeurs coexistent réellement et où un simple get()
les concatènerait en un ensemble impossible à séparer de manière fiable.
En-têtes que vous ne pouvez pas définir
Raison pour laquelle un en-tête que vous avez configuré n'apparaît pas.
MDN décrit un « garde » sur les objets Headers qui détermine ce qui peut être modifié. Un en-tête autonome new Headers() ne fait l’objet d’aucune restriction. Les en-têtes associés à un en-tête Request autorisent la modification des « en-têtes de requête non interdits ». Quant aux en-têtes d’un en-tête Response obtenu « depuis Response.error(), Response.redirect() ou fetch() », ils sont immuables : vous ne pouvez pas modifier les en-têtes d’une réponse après l’avoir reçue.
Les en-têtes de requête interdits sont ceux contrôlés par le moteur d’exécution, et toute tentative de les définir est ignorée silencieusement plutôt que de générer une erreur. La liste comprend Host, Connection, Content-Length, Transfer-Encoding, Origin, Referer dans certains contextes, ainsi que les familles préfixées par Sec- et Proxy-.
Cela a deux conséquences pratiques.
Le silence est le mode de défaillance. Aucune exception, aucun avertissement ; l’en-tête n’est tout simplement pas envoyé. Si un serveur affirme ne pas recevoir un élément que vous avez défini, vérifiez ce qui a réellement été transmis plutôt que de relire votre code.
Node est plus permissif qu’un navigateur pour certains de ces cas, car il n’y a pas d’origine à protéger. Un code qui définit correctement un en-tête dans Node peut constater que celui-ci est ignoré dans un navigateur, ce qui constitue un véritable piège de portabilité pour le code partagé.
Pour vérifier ce que vous avez réellement envoyé, adressez une requête à un service qui vous renvoie l’en-tête en écho :
const res = await fetch("https://httpbin.org/headers", {
headers: { "X-Test": "value", "User-Agent": "MyBot/1.0" },
});
console.log(await res.json());
Lecture des en-têtes de réponse
L'autre aspect, et il y a un comportement qu'il convient de connaître.
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);
}
L'itération renvoie des noms en minuscules, car l'ensemble des en-têtes est normalisé.
** La méthode ``Set-Cookie`
nécessite un traitement particulier.** Les cookies multiples sont renvoyés sous forme d’en-têtes multiples, et une simple méthode ``get("set-cookie")
les renvoie séparés par des virgules — ce qui est ambigu, car les valeurs des cookies peuvent elles-mêmes contenir des virgules dans une date de type ``Expires
. La méthode ``getSetCookie()
` existe précisément pour cela et renvoie un tableau :
const cookies = res.headers.getSetCookie();
Les en-têtes de réponse sont immuables. Vous ne pouvez pas modifier ce que fetch
a renvoyé. Si vous avez besoin d’une version modifiée, construisez un nouvel objet Response
.
Et la vérification qui importe plus que n’importe quel en-tête : fetch
ne rejette pas les statuts d’erreur HTTP. Un 404 ou un 500 se résout normalement, donc res.ok
doit être testé avant d’interpréter le corps de la requête.
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
Négliger cette étape est la cause directe de l’erreur « Unexpected token '<'
» lors d’un appel à .json()
— vous avez analysé une page d’erreur.
En-têtes par défaut et ajouts de Node
Node définit automatiquement plusieurs en-têtes ; il est utile de les connaître pour éviter toute confusion.
Host — dérivé de l’URL, ne peut pas être défini.
Connection — géré par le pool de connexions.
Content-Length — calculé à partir du corps de la requête.
Accept — prend par défaut la valeur */*, sauf si vous le définissez vous-même.
Accept-Encoding — Node indique qu’il prend en charge la compression et décompresse la réponse de manière transparente.
User-Agent — Node envoie le sien par défaut, identifiant généralement undici.
Ce dernier point est important pour toute communication avec un tiers. Un agent utilisateur par défaut du runtime constitue une identification précise, mais pour les clients automatisés, c’est un mauvais choix : un nom honnête accompagné d’une URL de contact est mieux traité qu’une chaîne anonyme du runtime :
headers: { "User-Agent": "AcmeBot/1.0 (+https://acme.example.com/bot)" }
Remarque concernant les corps de requête : lorsque vous transmettez un objet FormData, ne définissez pas vous-même Content-Type. Le runtime doit le générer, car il contient la limite multipart, et le remplacer entraîne une requête que le serveur ne peut pas analyser. C’est l’une des causes les plus courantes d’un code d’erreur 400 ou 415 inexpliqué.
Définition des en-têtes pour chaque requête
Pour tout ce qui va au-delà d'un simple script, centralisez-le.
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;
}
Deux détails méritent ici d’être soulignés. La diffusion des valeurs par défaut en premier signifie qu’un appelant peut les remplacer, ce qui correspond au comportement souhaité. Et l’inclusion des 200 premiers caractères du corps de l’erreur transforme un code d’état opaque en un message sur lequel vous pouvez agir.
Notez que la propagation de l’objet est superficielle et correspond exactement à la chaîne de la clé ; ainsi, la valeur « "content-type" » dans les options de l’appelant ne remplacera pas la valeur « "Content-Type" » des valeurs par défaut — vous enverrez les deux, et le moteur d’exécution choisira l’une d’entre elles. Si les appelants sont susceptibles d’utiliser une casse arbitraire, créez plutôt un objet de type « Headers » et laissez son « set() », insensible à la casse, gérer correctement la fusion.
Débogage des en-têtes qui ne fonctionnent pas
Une procédure permettant de résoudre pratiquement tous les problèmes d’en-tête en quelques minutes, en éliminant les possibilités les plus improbables en premier.
Premièrement : vérifiez ce qui a réellement été transmis sur le réseau. Rien d’autre dans cette liste n’a d’importance tant que vous n’avez pas effectué cette vérification. Un service d’écho d’en-tête est la solution la plus rapide :
const res = await fetch("https://httpbin.org/headers", { headers: myHeaders });
console.log(JSON.stringify(await res.json(), null, 2));
Si votre en-tête n’apparaît pas ici, c’est qu’il n’a jamais quitté votre processus : il est interdit, mal orthographié ou a été écrasé. S’il est présent ici et que la cible indique le contraire, c’est qu’un élément situé entre vous et la cible le supprime.
Deuxièmement — construisez l’objet Headers et inspectez-le avant l’envoi. Cela permet de distinguer « je l’ai mal construit » de « le runtime l’a supprimé » :
const h = new Headers(myHeaders);
console.log([...h.entries()]);
La construction d’un objet Headers applique la même normalisation que celle effectuée par le runtime ; ainsi, un nom qui survit à cette étape est un nom qui sera envoyé.
Trois — vérifiez s’il y a des doublons accidentels. Le piège de la fusion superficielle : une propagation d’objet fait correspondre les clés par chaîne de caractères exacte, donc {...{"Content-Type": "a"}, ...{"content-type": "b"}} produit les deux entrées. Créez un objet Headers et utilisez set() si les appelants peuvent fournir une casse arbitraire, car sa correspondance insensible à la casse effectue correctement la fusion.
Quatre — reproduisez le problème dans curl. Si la même requête fonctionne depuis un terminal mais pas depuis Node, la différence se situe dans votre code plutôt qu’au niveau du serveur :
curl -v -H "Authorization: Bearer $TOKEN" https://api.example.com/items 2>&1 | grep '^>'
La comparaison côte à côte des deux blocs > permet généralement de mettre en évidence la différence.
Cinq — lisez l’intégralité de la réponse, pas seulement le code d’état. Un code 400 ou 401 contient souvent un corps expliquant exactement quel en-tête était incorrect, et un code qui l’ignore jette la réponse à la poubelle :
if (!res.ok) console.error(res.status, (await res.text()).slice(0, 300));
Et vérifiez la redirection. fetch suit les redirections par défaut, et certains en-têtes — notamment Authorization — sont ignorés lorsqu’une redirection mène à une origine différente. Si une requête fonctionne directement vers l’URL finale mais pas vers l’URL d’origine, c’est presque certainement la raison.
Le piège du proxy
Ce comportement, qui diffère de celui de tous les autres clients HTTP Node, est la raison d’être de cette section.
**La variable ``fetch`
de Node ne prend pas en compte les valeurs ``HTTP_PROXY
, ``HTTPS_PROXY
ou ``NO_PROXY
`.** Leur définition ne change rien. Les requêtes sont envoyées directement, aboutissent, et rien ne vous indique que le proxy a été contourné.
La solution proposée par undici est disponible sur ProxyAgent
:
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");
Pour une requête unique plutôt que pour l’ensemble du processus, transmettez un dispatcher à chaque appel :
const agent = new ProxyAgent("http://proxy.example.com:9000");
const res = await fetch(url, { dispatcher: agent });
Notez que dispatcher
est une extension spécifique à Node et ne fait pas partie de l’API Fetch standard ; le code qui l’utilise n’est donc pas portable vers un navigateur.
Vérifiez toujours que cela a bien pris effet. Demandez à un service quelle adresse il voit, avec et sans le dispatcher :
const res = await fetch("https://api.ipify.org?format=json");
console.log(await res.json());
Si l’adresse ne change pas, le proxy n’est pas dans le chemin d’accès — et comme aucune erreur ne vient vous alerter, cette vérification est la seule chose qui vous permet de distinguer une configuration fonctionnelle d’une configuration contournée en silence. Il s'agit du même type d'échec silencieux que nous avons évoqué dans Pourquoi il est important de tester les proxys.
Les différences de comportement entre les clients HTTP sont exactement le genre de choses que nous avons comparées dans Axios vs Fetch.
Questions fréquentes
Comment définir des en-têtes avec node-fetch ?
Passez un objet headers dans les options : fetch(url, { headers: { "Authorization": "Bearer ..." } }). Vous pouvez également passer une instance de Headers ou un tableau de paires nom-valeur. La même syntaxe fonctionne avec la fonction intégrée de Node, fetch.
Ai-je encore besoin du package node-fetch ?
En général, non. Node dispose d’une variable globale fetch depuis la version 17.5.0, sans indicateur depuis la version 18 et en version stable depuis la version 21. N’installez ce package que pour les anciens environnements d’exécution ou en cas de différence de comportement spécifique — et notez que la version 3 est réservée à ESM.
Quelle est la différence entre headers.set et headers.append ?
set remplace toute valeur existante pour cet en-tête ; append ajoute une nouvelle valeur, de sorte que deux appels à append produisent une liste séparée par des virgules. Utilisez set dans la plupart des cas — append n’a d’importance que pour les en-têtes où plusieurs valeurs sont autorisées.
Pourquoi mon en-tête n’est-il pas envoyé ?
Il s’agit très probablement d’un en-tête interdit par le moteur d’exécution — parmi lesquels Host, Connection, Content-Length et la famille Sec-. Ceux-ci sont ignorés en silence plutôt que de générer une erreur. Envoyez une requête à un service d’écho d’en-têtes pour voir ce qui a réellement été transmis sur le réseau.
Les noms d’en-têtes sont-ils sensibles à la casse dans fetch ?
Non. MDN précise que les noms d’en-têtes sont mis en correspondance par une séquence d’octets insensible à la casse dans toutes les méthodes Headers ; ainsi, get("content-type") et get("Content-Type") sont équivalents. L’itération sur un objet Headers renvoie les noms en minuscules.
Comment lire plusieurs en-têtes Set-Cookie ?
Utilisez res.headers.getSetCookie(), qui renvoie un tableau. Une simple get("set-cookie") les relie par des virgules, ce qui est ambigu car les valeurs des cookies peuvent elles-mêmes contenir des virgules dans une date de type Expires.
Pourquoi Node fetch ignore-t-il mon paramètre HTTP_PROXY ?
Parce qu’il ne lit absolument pas ces variables d’environnement, contrairement à presque tous les autres clients HTTP Node. Utilisez ProxyAgent d’undici avec setGlobalDispatcher, ou transmettez un dispatcher par requête — puis vérifiez l’adresse de sortie, car un proxy contourné ne génère aucune erreur.
Dois-je définir le Content-Type lors de l’envoi de FormData ?
Non. Le moteur d’exécution le génère en incluant la délimitation multipart, et le définir vous-même supprime cette délimitation, ce qui produit une requête que le serveur ne peut pas analyser. C’est une cause fréquente de réponses 400 et 415 inexpliquées.
Conclusion
La configuration des en-têtes dans Node se résume à une seule ligne, quelle que soit l’API utilisée, et grâce à l’fetch, intégrée, la plupart des projets n’ont plus du tout besoin d’un package dédié.
Trois comportements sont à l’origine de la quasi-totalité des confusions. « set » remplace tandis que « append » accumule, et inverser cet ordre produit des valeurs d’en-tête séparées par des virgules que les serveurs rejettent. Les en-têtes interdits sont ignorés sans message d’erreur ; il faut donc vérifier sur le réseau si le serveur ne reçoit pas un en-tête, plutôt que de le relire dans votre éditeur. Et fetch est résolu en cas d’erreurs HTTP ; il faut donc vérifier res.ok avant que le corps du message n’ait un sens.
Le piège spécifique à Node.js concerne le proxy, et il vaut la peine d’être répété car il passe inaperçu : la fonction intégrée fetch ignore complètement HTTP_PROXY. Si vous avez besoin d’un trafic proxy, définissez explicitement un dispatcher — puis vérifiez l’adresse de sortie, car une configuration qui ne fait rien ressemble exactement à une configuration qui fonctionne.
