stripe-node
stripe-node copied to clipboard
Default Node `httpClient` configuration does not get mocked by MSW nor upcoming Nock version
Describe the bug
Hi there. I'm using nock@beta and it is no longer able to intercept requests from this library when using the default Node.js httpClient settings (i.e., this HTTP client).
This is because nock is adding support for native fetch and now uses @mswjs/interceptors to intercept requests (which is also used by msw).
The workaround is fairly trivial — configure the Stripe SDK to use fetch (see the workaround section here). But once nock@14 is released, it will not support this library without additional end user configuration. Based on a handful of issues in this repo, I cannot imagine this is the desired behavior:
https://github.com/stripe/stripe-node/issues/1844 https://github.com/stripe/stripe-node/pull/1854 https://github.com/stripe/stripe-node/pull/1866
To Reproduce
See https://github.com/kanadgupta/nock-beta-stripe-sdk
Expected behavior
The current Nock beta and its underlying interceptor library (@mswjs/interceptors) should be able to mock requests from the Stripe SDK without having to set the httpClient configuration option.
Code snippets
No response
OS
macOS
Node version
Node v20.16.0
Library version
stripe-node 17.2.1
API version
2024-09-30.acacia
Additional context
I initially filed a bug report in the nock repo: https://github.com/nock/nock/issues/2785
It appears that this is happening due to a known limitation where @mswjs/interceptors is unable to intercept certain requests with node:http. The maintainer documented a way to fix this here: https://github.com/mswjs/msw/issues/2259#issuecomment-2379379566
@kanadgupta thanks for the super detailed report! We'll take a look and see what our options are here. The fix seems straightforward, but we don't want to break anything else in the process.
(filed internally as: http://go/j/DEVSDK-2261)
@kanadgupta thanks so much for the detailed explanation and for the workaround! I was really having issues with the MSW and Stripe calls hanging. Thanks again🍺
@iamgutz Where you able to intercept the requests with MSW?
Nothing works for us.
We tried creating our own request client, using the Stripe.createHttpClient() and adding the patchFlush / patchEnd helpers.
Using Stripe's HTTP client just ignores MSW - the requests always hit the real backend. Using patchFlush gets MSW to intercept the requests, but for some reason we can't get the data of the request.
import http from 'node:http';
import https from 'node:https';
import Stripe from 'stripe';
import invariant from 'tiny-invariant';
const { STRIPE_SECRET_KEY } = process.env;
invariant(STRIPE_SECRET_KEY, 'STRIPE_SECRET_KEY is not set');
// Why is this needed?
// See: https://github.com/nock/nock/issues/2785#issuecomment-2427076034
const isTestEnvironment = Boolean(process.env.CI ?? process.env.VITEST);
// /**
// * Patch `module_.request` so that headers are flushed immediately
// * (MSW’s interceptor can see them before Stripe defers `.end()`).
// */
// function patchFlush(module_: typeof http | typeof https) {
// const orig = module_.request;
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// module_.request = function (options: any, callback: any) {
// const request = orig.call(this, options, callback);
// if (typeof request.flushHeaders === 'function') {
// request.flushHeaders();
// }
// return request;
// };
// }
// function patchEnd(module_: typeof http | typeof https) {
// const original = module_.request;
// module_.request = function (options: any, callback: any) {
// const request: any = original.call(this, options, callback);
// request.on('socket', (socket: any) => {
// // instead of waiting for socket.connect, just end it now
// request.end();
// });
// return request;
// };
// }
// if (isTestEnvironment) {
// patchFlush(http);
// patchFlush(https);
// patchEnd(http);
// patchEnd(https);
// }
class Client implements Stripe.HttpClient {
async makeRequest(
url: string,
options: Stripe.RequestOptions,
): Promise<Stripe.HttpClient.Response> {
return fetch(url, options);
}
getClientName(): string {
return 'Stripe-Node-HTTP-Client';
}
}
export const stripeAdmin = new Stripe(STRIPE_SECRET_KEY, {
httpClient: isTestEnvironment ? new Client() : undefined,
});
This is what we get using the "flush version"
createCustomerMock Request {
method: 'POST',
url: 'https://api.stripe.com/v1/customers',
headers: Headers {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Stripe/v1 NodeBindings/18.0.0',
'X-Stripe-Client-User-Agent': '{"bindings_version":"18.0.0","lang":"node","publisher":"stripe","uname":"Darwin%20jans-macbook-pro-2.home%2024.3.0%20Darwin%20Kernel%20Version%2024.3.0%3A%20Thu%20Jan%20%202%2020%3A24%3A16%20PST%202025%3B%20root%3Axnu-11215.81.4~3%2FRELEASE_ARM64_T6000%20arm64%0A","typescript":"false","lang_version":"v22.14.0","platform":"darwin","httplib":"node"}',
'Stripe-Version': '2025-03-31.basil',
'Idempotency-Key': 'stripe-node-retry-1a2d695b-e633-4a4a-a5d3-828e1853f44a',
'Content-Length': '162',
Authorization: 'Bearer sk_test_51RGbfwPti3AuUdaNa1OO2Pvn77IYVfTdBuQYw6hn0MbEGlyLo5HdI73tWl1by4FBU1MCxGAlIDgkKGsunqdCLAm200VYyu4lyY',
Host: 'api.stripe.com',
Connection: 'close'
},
destination: '',
referrer: 'about:client',
referrerPolicy: '',
mode: 'cors',
credentials: 'same-origin',
cache: 'default',
redirect: 'follow',
integrity: '',
keepalive: false,
isReloadNavigation: false,
isHistoryNavigation: false,
signal: AbortSignal { aborted: false }
}
With a mock like this:
const createCustomerMock = http.post(
'https://api.stripe.com/v1/customers',
async ({ request }) => {
console.log('createCustomerMock', request);
// 1. Read the raw request body as text…
const bodyText = await request.text(); // :contentReference[oaicite:0]{index=0}
console.log('bodyText', bodyText);
// 2. …and parse that URL-encoded string into fields
const form = new URLSearchParams(bodyText); // :contentReference[oaicite:1]{index=1}
const email = form.get('email');
const description = form.get('description');
// etc.
// 3. Grab any headers you need
const authHeader = request.headers.get('Authorization');
const stripeVersion = request.headers.get('Stripe-Version');
// 4. Return a mock 201 response with JSON
return HttpResponse.json(
{
id: 'cus_mocked_123',
email,
description,
},
{
status: 201,
},
);
},
);
Okay, for anyone finding this issue, this works for us:
import Stripe from 'stripe';
import invariant from 'tiny-invariant';
const { STRIPE_SECRET_KEY } = process.env;
invariant(STRIPE_SECRET_KEY, 'STRIPE_SECRET_KEY is not set');
// Why is this needed?
// See: https://github.com/nock/nock/issues/2785#issuecomment-2427076034
const isTestEnvironment = Boolean(process.env.CI ?? process.env.VITEST);
/**
* A passthrough wrapper around the global `fetch` function.
*
* This is necessary because passing `fetch` directly to
* `Stripe.createFetchHttpClient` does not guarantee correct `this` binding
* in all environments (such as Node.js or test runners).
*
* In particular, in certain test environments (e.g., using MSW, Nock, or when
* mocks are applied), passing `fetch` point-free (i.e., just `fetch`) may
* result in `this` being undefined, leading to unexpected errors like
* `TypeError: Illegal invocation`.
*
* Wrapping `fetch` inside a new function (`passthroughFetch`) ensures:
* - Correct argument forwarding
* - Proper binding of `this` context (implicitly bound to `globalThis`)
* - More predictable async behavior across environments
*
* See also: https://github.com/nock/nock/issues/2785#issuecomment-2427076034
*
* @param args - The arguments to pass to `fetch`, matching
* `Parameters<typeof fetch>`.
* @returns A `Promise<Response>` from calling the global `fetch`.
*/
const passthroughFetch = (...args: Parameters<typeof fetch>) => fetch(...args);
export const stripeAdmin = new Stripe(STRIPE_SECRET_KEY, {
httpClient: isTestEnvironment
? Stripe.createFetchHttpClient(passthroughFetch)
: undefined,
});
It's worth pointing out that there are four possible reasons MSW/Interceptors/Nock doesn't intercept a request:
- The request issuer uses an unsupported request module. This is unlikely since we cover most major request modules in Node.js. This may happen if the library is using a third-party request client that itself uses an unsupported request module (e.g. Undici uses
netdirectly and we don't support that presently). - The request issuer's request module is hoisted. This is your typical import order issue where if Stripe imports
httpbefore you initialize API mocking, it will keep a hoisted, non-modified version of the module in its scope and, thus, no interception will be possible. This is usually negated by introducing API mocking at the correct phase of your developmenmt/testing, such as a global setup of your test runner. - The request issuer uses supported request module but uses it unconventionally or incorrectly. We've seen a number of these but I doubt that's the case for Stripe.
- The request issuer does everything correctly but there's a bug on our side. Always a possibility. Always happy to look into it given a minimal reproduction repository + steps.