Your platform moves outbound traffic behind a corporate proxy, and one vendor integration starts
failing with ENOTFOUND. The SDK builds its own HTTPS agent, so it never reads HTTPS_PROXY. It
offers no option to replace that agent either, which leaves a fork as the only fix.
What you get
You will end up with a harness that runs each candidate client behind a local proxy and records whether its traffic went through, plus tests for each client’s transport seam. This is for you if you approve SDKs for services that sit behind a proxy or a private CA.
Short answer
Run the client behind a local CONNECT proxy with HTTPS_PROXY set, then again with the proxy
passed through the client’s own transport option. Octokit takes a fetch function as
request.fetch, and stripe-node takes an httpClient built by Stripe.createFetchHttpClient or an
httpAgent. stripe-node builds its own agent, which ignores HTTPS_PROXY even under Node’s proxy
switch, so a client with neither option is one you fork when the network changes.
You will need
Node 22.21 or later on the 22 line, or 24.5 or later, because those releases added the
built-in proxy support
two rows below depend on.1 On an older Node those rows read “direct” whatever the SDK does, so
the harness checks the version first and stops with a message naming it. It also needs the
openssl CLI to mint a throwaway certificate, and stops the same way when the CLI is missing.
Verified 2026-10-15 against Node 22.22.0, stripe 23.0.0, @octokit/core 7.0.8, @octokit/request
10.0.16 and undici 7.30.0. Nothing here contacts Stripe or GitHub: the vendor and the proxy are
loopback stand-ins the harness starts and stops.
This is the third check in the series, after scoring the candidate’s releases and auditing its retry and timeout defaults.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| An SDK with a hard-coded transport | A direct route to the vendor that will never sit behind a proxy or a private CA | Its own agent ignores both HTTPS_PROXY and a replaced global fetch, so any network change means a fork | Your services run behind any proxy, or tests need to mock the vendor |
| Octokit request.fetch | You call GitHub and want the proxy, CA, pool and mock all set in one function | You maintain a fetch wrapper, and its README example builds a proxy agent per call | You need per-request agents that a single fetch function makes awkward |
| stripe-node httpClient | You call Stripe and want fetch semantics, or an httpAgent for the Node HTTP path | Two seams with different rules, and a fetch client that captures the global fetch once at construction | You need retries or timeouts on a layer that the client does not expose |
A pluggable transport is the difference between an SDK you can operate and one you have to fork. Octokit and stripe-node both pass, at the cost of a wrapper you own. The hard-coded client costs nothing until the day the network changes, and on that day every fix sits outside your code.
Put the client where it cannot cheat
The harness gives each client a vendor it cannot reach on its own. The vendor’s name is
api.vendor.test, under a top-level domain reserved by RFC 2606
for testing, so only the proxy can resolve it.2 A client that skips the proxy fails at the DNS
lookup, which is what a client that ignores the proxy sees on a network that resolves names at
the proxy. The proxy only tunnels, answering CONNECT
for that one name.3
Each row runs in a fresh Node process whose environment the harness builds from nothing:
async function scenario(name, { env = {}, calls = 1, trustCa = true } = {}) {
const before = proxy.state.connects.length
const childEnv = {
PATH: process.env.PATH,
...(trustCa ? { NODE_EXTRA_CA_CERTS: cert.certFile } : {}),
VENDOR_CA_FILE: cert.certFile,
...env,
}
const { stdout } = await run(process.execPath, ['scenario.mjs', name, proxy.url, String(calls)], {
env: childEnv,
timeout: 20_000,
})
const result = JSON.parse(stdout.trim().split('\n').at(-1))
return { ...result, tunnels: proxy.state.connects.length - before }
}
A proxy variable set on the machine running the check would otherwise leak into rows meant to have
none. NODE_EXTRA_CA_CERTS trusts the
throwaway certificate in every row, the way a corporate CA bundle would, so a TLS failure cannot
pass for a routing failure.
Find out what the environment does
Run the comparison:
node demo.mjs
the proxy given only through HTTPS_PROXY, NO_PROXY empty
NODE_USE_ENV_PROXY unset NODE_USE_ENV_PROXY=1
Octokit, defaults direct, the name did not resolve through the proxy
stripe-node, defaults direct, the name did not resolve direct, the name did not resolve
hard-coded client direct, the name did not resolve direct, the name did not resolve
None of the three reads HTTPS_PROXY by default. Octokit’s own README says so in its
proxy section, and stripe-node’s
says to pass an agent instead. Node’s
NODE_USE_ENV_PROXY
switch reaches the global agent and the built-in fetch, and Octokit calls the global fetch, so it
follows. stripe-node does not. In stripe 23.0.0 its Node client creates
new https.Agent({ keepAlive: true }) when the module loads, and the runtime switch configures only
Node’s own global agent and fetch, never an agent somebody else built. The hard-coded client fails for the same
reason.
Pass the proxy through the client’s own option
A seam you can pass a fetch or an agent to carries everything a network team asks for later: the proxy, a private CA, a pool, or a mock. Build the dispatcher once:
export function proxiedFetch(proxyUrl, { ca } = {}) {
// `ca` is the seam's second use: trust a private CA for this client alone.
const dispatcher = new ProxyAgent({ uri: proxyUrl, ...(ca ? { requestTls: { ca } } : {}) })
const fetch = (url, init = {}) => undiciFetch(url, { ...init, dispatcher })
fetch.close = () => dispatcher.close()
return fetch
}
/** Each client's documented seam, given the proxy explicitly rather than through the environment. */
export const seams = {
'Octokit request.fetch': (proxyUrl) => {
const fetch = proxiedFetch(proxyUrl)
return { client: octokit({ request: { fetch } }), close: fetch.close }
},
'stripe-node httpClient': (proxyUrl) => {
const fetch = proxiedFetch(proxyUrl)
return { client: stripe({ httpClient: Stripe.createFetchHttpClient(fetch) }), close: fetch.close }
},
'stripe-node httpAgent': (proxyUrl) => {
// Node 22.21 and later read a proxy from proxyEnv on any agent, not only the global one.
const agent = new HttpsAgent({ keepAlive: true, proxyEnv: { HTTPS_PROXY: proxyUrl } })
return { client: stripe({ httpAgent: agent }), close: () => agent.destroy() }
},
}
The fetch is undici’s own, paired with undici’s
ProxyAgent. Node 22’s built-in fetch is undici
6 and this directory installs undici 7, so take the fetch and the dispatcher from the same copy.
node demo.mjs
the proxy given through the client's own option, nothing in the environment
Octokit request.fetch through the proxy
stripe-node httpClient through the proxy
stripe-node httpAgent through the proxy
things that look like a seam, nothing in the environment
Octokit request.agent direct, the name did not resolve
Octokit, global fetch replaced through the proxy
stripe-node fetch client, global fetch replaced after direct, the name did not resolve
hard-coded client, global fetch replaced direct, the name did not resolve
three calls through Octokit request.fetch
one ProxyAgent, created once 3 answers, 1 tunnel opened at the proxy
a ProxyAgent per call 3 answers, 3 tunnels opened at the proxy
The second block is the pitfall in four rows. A replaced global fetch reaches Octokit because
@octokit/request looks it up on every request. stripe-node’s fetch client reads it once, at
construction, so a replacement made afterwards by a test helper never arrives. The hard-coded
client never calls fetch at all.
Check it worked
The suite asserts each seam reaches the vendor through the proxy, and that stripe-node ignores the
environment while the same environment routes Octokit, which proves the switch was on. It checks
that a private CA works through the seam where the environment route fails, and that an old Node
or a missing openssl stops the run by name. It also drives both SDKs against a mock through the
same option, with no network:
const gh = octokit({ auth: 'ghp_test', request: { fetch: fake } })
const repo = await gh.request('GET /repos/{owner}/{repo}', { owner: 'acme', repo: 'widgets' })
const st = stripe({ httpClient: Stripe.createFetchHttpClient(fake) })
const customer = await st.customers.retrieve('cus_1')
assert.equal(repo.data.full_name, 'mock/repo')
assert.equal(customer.id, 'cus_mock')
assert.deepEqual(seen.map((s) => s.url), [
'https://api.vendor.test/repos/acme/widgets',
'https://api.vendor.test/v1/customers/cus_1',
])
assert.deepEqual(seen.map((s) => s.auth), ['token ghp_test', 'Bearer sk_test_local'])
node --test transport.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
When it goes wrong
You pass request: { agent }, as Octokit’s option list
shows, but the traffic still goes direct. The fetch call in @octokit/request 10.0.16 forwards only
the method, headers, body, redirect, signal, and a duplex flag, so the agent is dropped. Pass
request.fetch instead.
Every call through the proxy opens a tunnel of its own. The proxy example in Octokit’s README
creates a ProxyAgent inside the fetch function, so each request gets an agent and a pool of its
own.
Create the dispatcher once, outside the function, as proxiedFetch does.
Traffic reaches the proxy and then fails with a certificate error. A proxy that inspects TLS
re-signs the vendor’s certificate with its own CA. Pass that CA as requestTls.ca on the
ProxyAgent, for the one client, or put it in NODE_EXTRA_CA_CERTS for the whole process.
When not to do this
Do not adopt an SDK with a hard-coded transport for a service that runs behind a proxy. The day
the route changes, your options are a fork, a monkey patch of node:https, or a rewrite, and
all three happen during an incident.
Do not count NODE_USE_ENV_PROXY as transport control. Its stability index is 1.1, active
development. Node applies it to its own global agent and fetch only, so it missed the agent
stripe-node builds for itself on this page.
Do not replace globalThis.fetch in a library you publish. It changes the transport of every
client in the process, and as the table shows, it reaches some clients and silently skips others.
Do not wrap an SDK in a fetch function to fix something the vendor exposes. stripe-node has
maxNetworkRetries and timeout options; rebuilding them in the seam gives you two retry
loops.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, stripe 23.0.0, @octokit/core 7.0.8 and undici 7.30.0.
Every output block is what the command preceding it printed. The vendor and the proxy are loopback
stand-ins on 127.0.0.1, so every request stayed on the machine.
Footnotes
-
Node 22.21.0 shipped on October 20, 2025, by the dates in Node’s release index. The project’s release schedule moved the 22 line into maintenance on October 21. Proxy support therefore arrived in the final release of the line’s active LTS period, with a stability index of 1.1, active development. ↩︎ Back to text
-
RFC 2606, dated June 1999, reserves four top-level names and recommends
.testfor testing DNS-related code. Its reasoning includes test code that “could escape from the testbed” and run on the Internet against a name somebody owns. A reserved name keeps that escape harmless, which is the property a proxy test wants from its vendor. ↩︎ Back to text -
RFC 9110 gives CONNECT a request target unique to the method, made of only the host and port of the tunnel destination. There is no default port, so a client must send one even when the URI it started from left it out. The proxy in this harness records exactly that string,
api.vendor.test:443, for every tunnel. ↩︎ Back to text