How-to › Operate integrations in production

How to check whether a vendor SDK lets you control the transport#

Run each candidate client behind a local CONNECT proxy, then through its own transport option, and record which ones still reach the vendor.

Audience
Platform team
Level
advanced
Topic
Evaluate, adopt and upgrade SDKs
Languages
TypeScript
Verified

Read first: Evaluate a third-party SDK before you depend on it

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
An SDK with a hard-coded transportA direct route to the vendor that will never sit behind a proxy or a private CAIts own agent ignores both HTTPS_PROXY and a replaced global fetch, so any network change means a forkYour services run behind any proxy, or tests need to mock the vendor
Octokit request.fetchYou call GitHub and want the proxy, CA, pool and mock all set in one functionYou maintain a fetch wrapper, and its README example builds a proxy agent per callYou need per-request agents that a single fetch function makes awkward
stripe-node httpClientYou call Stripe and want fetch semantics, or an httpAgent for the Node HTTP pathTwo seams with different rules, and a fetch client that captures the global fetch once at constructionYou 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.

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

  1. 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

  2. RFC 2606, dated June 1999, reserves four top-level names and recommends .test for 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

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.