nodejs / undici

An HTTP/1.1 client, written from scratch for Node.js
https://nodejs.github.io/undici
MIT License
6.27k stars 550 forks source link

Significant Slowdown in Requests When Using ProxyAgent #3403

Open hamdallah90 opened 4 months ago

hamdallah90 commented 4 months ago

Hello,

I'm experiencing a significant slowdown in request performance when using ProxyAgent with the undici library. The same requests without a proxy are much faster. Below are the details of my setup and the observed behavior.

Steps to Reproduce:

  1. Set up a basic HTTP client using undici:
import {
  Client,
  Dispatcher,
  request,
  ProxyAgent,
  getGlobalDispatcher,
} from "undici";
import { Readable, Writable } from "stream";
import { IncomingHttpHeaders } from "undici/types/header";
import { createGunzip } from "zlib";
import { promisify } from "util";
import { Buffer } from "buffer";

const pipeline = promisify(require("stream").pipeline);

export class HttpClient {
  private baseURL: string;
  private defaultHeaders: Record<string, string> = {};
  private proxy: string | undefined;
  private timeout: number = 30000;
  private dispatcher: Dispatcher;

  constructor(baseURL: string, proxyneeded: boolean = true, timeout?: number) {
    this.baseURL = baseURL;

    if (timeout) {
      this.timeout = timeout;
    }

    if (proxyneeded) {
      this.proxy = "http://127.0.0.1:3128";
      this.dispatcher = new ProxyAgent(this.proxy);
    } else {
      this.dispatcher = getGlobalDispatcher();
    }
  }

  public get<T = any>(
    url: string,
    config?: RequestInit & { params?: Record<string, any> }
  ): Promise<T> {
    return this.request<T>({
      ...config,
      method: "GET",
      path: this.buildUrl(url, config?.params),
    });
  }

  public post<T = any>(
    url: string,
    data?: any,
    config?: RequestInit
  ): Promise<T> {
    return this.request<T>({
      ...config,
      method: "POST",
      path: url,
      body: data,
    });
  }

  public put<T = any>(
    url: string,
    data?: any,
    config?: RequestInit
  ): Promise<T> {
    return this.request<T>({ ...config, method: "PUT", path: url, body: data });
  }

  public delete<T = any>(
    url: string,
    config?: RequestInit & { params?: Record<string, any> }
  ): Promise<T> {
    return this.request<T>({
      ...config,
      method: "DELETE",
      path: this.buildUrl(url, config?.params),
    });
  }

  public request<T = any>(
    config: RequestInit & { path: string }
  ): Promise<T> {
    const { method, path, body, headers } = config;

    if (!method) {
      return Promise.reject(new Error("HTTP method is required"));
    }

    const url = !path.startsWith("/") ? `/${path}` : path;

    return request(this.baseURL + url, {
      method: method as Dispatcher.HttpMethod,
      body: body ? JSON.stringify(body) : undefined,
      headers: {
        ...this.defaultHeaders,
        ...headers,
      } as
        | IncomingHttpHeaders
        | string[]
        | Iterable<[string, string | string[] | undefined]>
        | null,
      headersTimeout: this.timeout,
      dispatcher: this.dispatcher,
    } as any)
      .then(({ statusCode, headers, trailers, body }: any) => {
        return this.getResponseBody(headers, body);
      })
      .then(responseContent => {
        return { data: responseContent } as T;
      });
  }

  public setHeader(name: string, value: string): void {
    this.defaultHeaders[name] = value;
  }

  public removeHeader(name: string): void {
    delete this.defaultHeaders[name];
  }

  private buildUrl(url: string, params?: Record<string, any>): string {
    if (!params) {
      return url;
    }

    const urlObj = new URL(url, this.baseURL);
    Object.keys(params).forEach((key) =>
      urlObj.searchParams.append(key, params[key])
    );
    return urlObj.toString();
  }

  private getResponseBody(
    headers: IncomingHttpHeaders,
    response: Dispatcher.BodyMixin
  ): Promise<any> {
    const buffers: Uint8Array[] = [];
    return new Promise((resolve, reject) => {
      (async () => {
        try {
          for await (const chunk of response as any) {
            buffers.push(chunk);
          }
          const buffer = Buffer.concat(buffers);

          if (headers["content-encoding"] === "gzip") {
            this.decompressGzip(buffer).then(resolve).catch(reject);
          } else {
            resolve(this.parseResponse(buffer));
          }
        } catch (err) {
          reject(err);
        }
      })();
    });
  }

  private decompressGzip(buffer: Buffer): Promise<any> {
    const gunzip = createGunzip();
    const decompressedBuffers: Buffer[] = [];
    return new Promise((resolve, reject) => {
      pipeline(
        Readable.from([buffer]),
        gunzip,
        new Writable({
          write(chunk, encoding, callback) {
            decompressedBuffers.push(chunk);
            callback();
          },
        })
      )
        .then(() => {
          const decompressed = Buffer.concat(decompressedBuffers);
          resolve(this.parseResponse(decompressed));
        })
        .catch(reject);
    });
  }

  private parseResponse(buffer: Buffer): any {
    const responseText = buffer.toString();
    try {
      return JSON.parse(responseText);
    } catch {
      return responseText;
    }
  }
}
  1. Compare the response time when using ProxyAgent versus without using any proxy.

Thank you for your attention to this issue. Any insights or fixes would be greatly appreciated.

Best regards,

metcoder95 commented 4 months ago

👋 Can you provide an Minimum Reproducible Example with plain JS to support you better?

hamdallah90 commented 4 months ago

node httpClientExample.mjs

Making 100 requests without Proxy... Total time without Proxy: 10058.17 ms Average time without Proxy: 100.57 ms

Making 100 requests with Proxy... Total time with Proxy: 45844.97 ms Average time with Proxy: 458.43 ms

this is the code

import { request, ProxyAgent, getGlobalDispatcher } from "undici";
import { createGunzip } from "zlib";
import { pipeline, Readable, Writable } from "stream";
import { promisify } from "util";
import { Buffer } from "buffer";

const pipelinePromise = promisify(pipeline);

const baseURL = "https://jsonplaceholder.typicode.com";
const proxyURL = "http://127.0.0.1:3128"; // Change this to your actual proxy URL
const timeout = 30000;
const numberOfRequests = 100;

async function fetchWithUndici(url, useProxy = false) {
  const dispatcher = useProxy ? new ProxyAgent(proxyURL) : getGlobalDispatcher();

  const { statusCode, headers, body } = await request(`${baseURL}${url}`, {
    method: 'GET',
    dispatcher,
    headersTimeout: timeout
  });

  const responseBody = await getResponseBody(headers, body);
  return { statusCode, headers, responseBody };
}

async function getResponseBody(headers, body) {
  const buffers = [];
  for await (const chunk of body) {
    buffers.push(chunk);
  }
  const buffer = Buffer.concat(buffers);

  if (headers['content-encoding'] === 'gzip') {
    return decompressGzip(buffer);
  }
  return parseResponse(buffer);
}

function decompressGzip(buffer) {
  const gunzip = createGunzip();
  const decompressedBuffers = [];

  return new Promise((resolve, reject) => {
    pipelinePromise(
      Readable.from([buffer]),
      gunzip,
      new Writable({
        write(chunk, encoding, callback) {
          decompressedBuffers.push(chunk);
          callback();
        }
      })
    )
      .then(() => {
        const decompressed = Buffer.concat(decompressedBuffers);
        resolve(parseResponse(decompressed));
      })
      .catch(reject);
  });
}

function parseResponse(buffer) {
  const responseText = buffer.toString();
  try {
    return JSON.parse(responseText);
  } catch {
    return responseText;
  }
}

async function measureRequests(useProxy) {
  const times = [];
  const startTotal = process.hrtime();

  for (let i = 0; i < numberOfRequests; i++) {
    const start = process.hrtime();
    await fetchWithUndici("/posts/1", useProxy);
    const end = process.hrtime(start);
    const timeInMs = end[0] * 1000 + end[1] / 1e6;
    times.push(timeInMs);
  }

  const endTotal = process.hrtime(startTotal);
  const totalTime = endTotal[0] * 1000 + endTotal[1] / 1e6;
  const averageTime = times.reduce((a, b) => a + b, 0) / times.length;

  return { totalTime, averageTime };
}

(async () => {
  console.log(`Making ${numberOfRequests} requests without Proxy...`);
  const { totalTime: totalTimeWithoutProxy, averageTime: averageTimeWithoutProxy } = await measureRequests(false);
  console.log(`Total time without Proxy: ${totalTimeWithoutProxy.toFixed(2)} ms`);
  console.log(`Average time without Proxy: ${averageTimeWithoutProxy.toFixed(2)} ms`);

  console.log(`Making ${numberOfRequests} requests with Proxy...`);
  const { totalTime: totalTimeWithProxy, averageTime: averageTimeWithProxy } = await measureRequests(true);
  console.log(`Total time with Proxy: ${totalTimeWithProxy.toFixed(2)} ms`);
  console.log(`Average time with Proxy: ${averageTimeWithProxy.toFixed(2)} ms`);
})();
hamdallah90 commented 4 months ago

this is when using axios

node httpClientExample.mjs

Making 100 requests without Proxy... Total time without Proxy: 10229.93 ms Average time without Proxy: 102.29 ms

Making 100 requests with Proxy... Total time with Proxy: 20142.50 ms Average time with Proxy: 201.41 ms


This is when using axios with Promise.all

node httpClientExample.mjs Making 100 requests without Proxy... Total time without Proxy: 535.43 ms Average time without Proxy: 407.45 ms Making 100 requests with Proxy... Total time with Proxy: 628.32 ms Average time with Proxy: 428.96 ms


This is when using undici with Promise.all

❯ node httpClientExample.mjs Making 100 requests without Proxy... Total time without Proxy: 499.42 ms Average time without Proxy: 373.25 ms Making 100 requests with Proxy... Total time with Proxy: 16655.35 ms Average time with Proxy: 7610.46 ms

mcollina commented 4 months ago

Looks like there is a bug somewhere, good spot!

What would be good is to have a complete way to reproduce your problem. I recommend you to create a repository and include everything, so we can reproduce this benchmark locally.

(yes, it should include a target server and the proxy).