Skip to content

Default Node httpClient configuration does not get mocked by MSW nor upcoming Nock version #2211

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
kanadgupta opened this issue Oct 21, 2024 · 5 comments

Comments

@kanadgupta
Copy link

kanadgupta commented Oct 21, 2024

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:

#1844
#1854
#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: nock/nock#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: mswjs/msw#2259 (comment)

@xavdid-stripe
Copy link
Member

@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)

@iamgutz
Copy link

iamgutz commented Feb 27, 2025

@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🍺

@janhesters
Copy link

janhesters commented Apr 27, 2025

@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,
      },
    );
  },
);

@janhesters
Copy link

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,
});

@kettanaito
Copy link

It's worth pointing out that there are four possible reasons MSW/Interceptors/Nock doesn't intercept a request:

  1. 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 net directly and we don't support that presently).
  2. The request issuer's request module is hoisted. This is your typical import order issue where if Stripe imports http before 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.
  3. 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.
  4. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants