Version
v25.2.0 (affects v24 identically)
Platform
Linux d4e4f8af51de 6.11.11-linuxkit #1 SMP Wed Oct 22 09:37:46 UTC 2025 aarch64 GNU/Linux
Subsystem
https
What steps will reproduce the bug?
I used Docker to test:
docker run --rm \
-e HTTPS_PROXY=http://google.com \
-e NODE_USE_ENV_PROXY=1 \
node:25.2.0 -e 'require("https").request("https://wherever").on("error", console.error)'
Equivalent plain command line:
HTTPS_PROXY=http://google.com NODE_USE_ENV_PROXY=1 \
node -e 'require("https").request("https://wherever").on("error", console.error)'
How often does it reproduce? Is there a required condition?
It reproduces reliably. Conditions are:
- enable using an HTTPS proxy from the environment, with
NODE_USE_ENV_PROXY=1 (or --use-env-proxy)
- use a proxy that will reject the tunnel request (I'm using
http://google.com here which will obviously reject tunneling requests)
What is the expected behavior? Why is that the expected behavior?
I expected the error event to only be emitted once. This is what happens with other types of error, for example if the proxy itself cannot be resolved (ENOTFOUND) or it rejects the connection (ECONNRESET).
What do you see instead?
Instead, the error event is emitted twice.
This is a concern because an unhandled error event will abort the process, so code that handles the expects error to only be emitted once and uses EventEmitter.once or events.once can break.
For example, this fails with an unhandled error event, after printing out the first error event:
$ HTTPS_PROXY=http://google.com NODE_USE_ENV_PROXY=1 \
node -e 'require("https").request("https://wherever").once("error", console.error)'
Error [ERR_PROXY_TUNNEL]: Failed to establish tunnel to wherever:443 via http://google.com: HTTP/1.1 404 Not Found
at onProxyData (node:https:256:19)
at Socket.read (node:https:220:11)
at Socket.emit (node:events:508:28)
at emitReadable_ (node:internal/streams/readable:832:12)
at process.processTicksAndRejections (node:internal/process/task_queues:88:21) {
code: 'ERR_PROXY_TUNNEL',
statusCode: 404
}
node:events:486
throw er; // Unhandled 'error' event
^
Error [ERR_PROXY_TUNNEL]: Failed to establish tunnel to wherever:443 via http://google.com: HTTP/1.1 404 Not Found
at onProxyData (node:https:256:19)
at Socket.read (node:https:220:11)
at Socket.emit (node:events:508:28)
at emitReadable_ (node:internal/streams/readable:832:12)
at process.processTicksAndRejections (node:internal/process/task_queues:88:21)
Emitted 'error' event on ClientRequest instance at:
at emitErrorEvent (node:_http_client:108:11)
at _destroy (node:_http_client:962:9)
at onSocketNT (node:_http_client:982:5)
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
code: 'ERR_PROXY_TUNNEL',
statusCode: 404
}
Node.js v25.2.0
$ echo $?
1
Additional information
It may be argued that error being emitted only once isn't really guaranteed (that I could find), so here is an illustration of how existing code can rely on such behaviour. We encountered this when trying to use https://github.com/panva/openid-client v5.7.0 (version 6 uses fetch so is not affected) in conjunction with NODE_USE_ENV_PROXY. The code in openid-client uses events.once together with Promise.race to wait for either the response or timeout events to occur on the ClientRequest: https://github.com/panva/openid-client/blob/45c96f67ce0644bd829f61e82fba3dd8c051c89e/lib/helpers/request.js#L135
[response] = await Promise.race([once(req, 'response'), once(req, 'timeout')]);
This relies on the fact that events.once will also listen to the error event. But it will unsubscribe from error after the first event, so the process crashes as the second error event is emitted.
Here's a reproduction of the issue as encountered, assuming npm install openid-client@5.7.0.
This scenario, where the proxy refuses to establish a tunnel, fails with an Unhandled 'error' event crash:
$ HTTPS_PROXY=http://google.com NODE_USE_ENV_PROXY=1 \
node -e 'require("openid-client").Issuer.discover("https://wherever").then(console.log, console.error);'
node:events:486
throw er; // Unhandled 'error' event
^
Error [ERR_PROXY_TUNNEL]: Failed to establish tunnel to wherever:443 via http://google.com: HTTP/1.1 404 Not Found
at onProxyData (node:https:256:19)
at Socket.read (node:https:220:11)
at Socket.emit (node:events:508:28)
at emitReadable_ (node:internal/streams/readable:832:12)
at process.processTicksAndRejections (node:internal/process/task_queues:89:21)
Emitted 'error' event on ClientRequest instance at:
at emitErrorEvent (node:_http_client:107:11)
at _destroy (node:_http_client:954:9)
at onSocketNT (node:_http_client:974:5)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21) {
code: 'ERR_PROXY_TUNNEL',
statusCode: 404
}
$ echo $?
1
This one, where the proxy cannot be resolved, has a saner behaviour (the same as when no proxy is involved) of rejecting the promise returned by discover(), allowing recovery:
$ HTTPS_PROXY=http://proxy.invalid NODE_USE_ENV_PROXY=1 \
node -e 'require("openid-client").Issuer.discover("https://wherever").then(console.log, console.error);'
Error: getaddrinfo ENOTFOUND proxy.invalid
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26) {
errno: -3008,
code: 'ENOTFOUND',
syscall: 'getaddrinfo',
hostname: 'proxy.invalid'
}
$ echo $?
0
Version
v25.2.0 (affects v24 identically)
Platform
Subsystem
https
What steps will reproduce the bug?
I used Docker to test:
docker run --rm \ -e HTTPS_PROXY=http://google.com \ -e NODE_USE_ENV_PROXY=1 \ node:25.2.0 -e 'require("https").request("https://wherever").on("error", console.error)'Equivalent plain command line:
HTTPS_PROXY=http://google.com NODE_USE_ENV_PROXY=1 \ node -e 'require("https").request("https://wherever").on("error", console.error)'How often does it reproduce? Is there a required condition?
It reproduces reliably. Conditions are:
NODE_USE_ENV_PROXY=1(or--use-env-proxy)http://google.comhere which will obviously reject tunneling requests)What is the expected behavior? Why is that the expected behavior?
I expected the
errorevent to only be emitted once. This is what happens with other types of error, for example if the proxy itself cannot be resolved (ENOTFOUND) or it rejects the connection (ECONNRESET).What do you see instead?
Instead, the
errorevent is emitted twice.This is a concern because an unhandled
errorevent will abort the process, so code that handles the expectserrorto only be emitted once and usesEventEmitter.onceorevents.oncecan break.For example, this fails with an unhandled
errorevent, after printing out the firsterrorevent:Additional information
It may be argued that
errorbeing emitted only once isn't really guaranteed (that I could find), so here is an illustration of how existing code can rely on such behaviour. We encountered this when trying to use https://github.com/panva/openid-client v5.7.0 (version 6 usesfetchso is not affected) in conjunction withNODE_USE_ENV_PROXY. The code inopenid-clientusesevents.oncetogether withPromise.raceto wait for either theresponseortimeoutevents to occur on theClientRequest: https://github.com/panva/openid-client/blob/45c96f67ce0644bd829f61e82fba3dd8c051c89e/lib/helpers/request.js#L135This relies on the fact that
events.oncewill also listen to theerrorevent. But it will unsubscribe fromerrorafter the first event, so the process crashes as the seconderrorevent is emitted.Here's a reproduction of the issue as encountered, assuming
npm install openid-client@5.7.0.This scenario, where the proxy refuses to establish a tunnel, fails with an
Unhandled 'error' eventcrash:This one, where the proxy cannot be resolved, has a saner behaviour (the same as when no proxy is involved) of rejecting the promise returned by
discover(), allowing recovery: