8

Question

Why am I getting an error of an unsupported request when running this code in Node.js v18.4.0, but it succeeds in v16.16.0?

Code

The direct code that is returning an error is my unit tests for the library I'm making for my company internally. Pardon some exclusions of the code, but I've tried to tidy it up as much as possible for posting publicly.

// ~/http/tests/Client.spec.ts

import { HttpClient } from '../src';

describe('Standard HTTP Client', () => {
  const { request: http } = HttpClient;
  
  it('should save the response when a state is given', async () => {
    const response = await http({ method: 'GET', url: 'https://www.google.com' });
    
    expect(response).toBeDefined();
  });
});

The result in v16 is a success, but the result in v18 is:

Error: unsupported

    at configSecureContext (node:internal/tls/secure-context:282:15)
    at Object.createSecureContext (node:_tls_common:117:3)
    at Object.connect (node:_tls_wrap:1624:48)
    at Agent.createConnection (node:https:150:22)
    at Agent.createSocket (node:_http_agent:346:26)
    at Agent.addRequest (node:_http_agent:297:10)
    at new ClientRequest (node:_http_client:322:16)
    at Client.request (node:https:360:10)
    at Client.promiseHandler (/home/user/projects/node/http/src/Client.ts:72:25)
    at new Promise (<anonymous>)

Supporting Code

Here is the HttpClient class I've implemented. If some statements appear verbose, it's likely because there was logging in the original implementation that I have stripped out for the purposes of this post.

Note: comment added above line 72 for the error message, in the private method promiseHandler.

Another note, the original code was found on StackOverflow when I started this effort months ago. I can't be sure of the original post I saw, but this is a likely answer for my inspiration. I expanded it from the original implementation to an object-oriented approach, and formalized the data declarations of a request body and response object, since we have some expected behavior about logging information at runtime.

// ~/http/src/Client.ts

import { request as http, ClientRequest, IncomingMessage } from 'http';
import { request as https } from 'https';

import './extensions';
import { PromiseExecutor } from './common';

import { IHttpResponse, IRequestArgs } from './interfaces';
import { Request as HttpRequest } from './Request';
import { Response as HttpResponse } from './Response';

type RequestMethod = typeof http | typeof https;

export class Client {
  private redirects: Map<URL, number>;
  private request?: ClientRequest;
  private readonly requestOptions: HttpRequest;
  private response?: HttpResponse;
  private reject: PromiseExecutor;
  private resolve: PromiseExecutor;

  constructor(request: IRequestArgs) {
    this.requestOptions = new HttpRequest(request);
    this.redirects = new Map();
    this.redirects.set(this.requestOptions.url as URL, 1);
    this.reject = () => void 0;
    this.resolve = () => void 0;
  }

  private get boundPromiseHandler() {
    return this.promiseHandler.bind(this);
  }

  private get boundRequestHandler() {
    return this.requestHandler.bind(this);
  }

  private get boundRequestErrorHandler() {
    return this.requestErrorHandler.bind(this);
  }

  handleRedirect(this: Client, response: IHttpResponse): Promise<IHttpResponse> {
    const { headers } = response;
    
    if(!headers || !headers.has('location')) {
      return Promise.resolve(response);
    }

    const location = headers.get('location')!;
    const url = new URL(location);

    const redirects = this.redirects.get(url) ?? 0;
    this.redirects.set(url, redirects + 1);
    this.requestOptions.setRequestUrl(url);
    
    if(this.redirects.get(url)! < 2) {
      return Client.request(this.requestOptions as IRequestArgs, this);
    }

    return Promise.resolve(response);
  }

  get isSecure() {
    return [ 'ssh:', 'sftp:', 'https:' ].includes(this.requestOptions.protocol ?? 'http');
  }

  get promise(): Promise<IHttpResponse> {
    return new Promise(this.boundPromiseHandler) as Promise<IHttpResponse>;
  }

  private promiseHandler(resolve: PromiseExecutor, reject: PromiseExecutor): void {
    const { requestOptions: { body, hasPayload = false, ...rest } } = Object.assign(this, { resolve, reject }) as this;
    // This is line #72, position 25 is the dot (.) before `requestMethod`
    this.request = this.requestMethod(rest, this.boundRequestHandler).on('error', this.boundRequestErrorHandler);
    
    // Data payloads must be written to the request stream
    if(hasPayload) {
      this.request.write(body);
    }

    this.request.end();
  }

  static async request(options: IRequestArgs, client?: Client): Promise<IHttpResponse> {
    client = client ?? new Client(options);
    const response = await client.promise;

    if(Number.between(response.statusCode ?? 0, 300, 399)) {
      return client.handleRedirect(response);
    }

    return response;
  }

  get requestMethod(): RequestMethod {
    if(this.isSecure) {
      return https;
    }

    return http;
  }

  private requestHandler(response: IncomingMessage) {
    this.response = new HttpResponse(response, this.resolve, this.reject);
  }

  private requestErrorHandler(error?: Error) {
    if(error) {
      return this.reject(error);
    }

    return this.reject(new Error('An error occurred while handling the HTTP Response'));
  }

  toJSON() {
    return {
      request: this.requestOptions,
      response: this.response,
    }
  }

  toString() {
    return JSON.stringify(this, null, 2);
  }
}

Misc

I can add additional supporting code, but I felt the question was already fairly verbose. The Request class is just an enumeration of the RequestOptions interfaces from node:http and node:https, with some common defaults like method = options.method ?? 'GET'. The Response class has a little more meat to it, but it's just responsible for concatenating data chunks received on the data event, and then attempting to parse the response body as JSON.

The full code can be seen in a GitHub repo I just created for this question.

Solonotix
  • 449
  • 3
  • 15
  • 4
    Looks similar ~ https://github.com/nodejs/node/issues/40672 – Phil Jul 25 '22 at 23:13
  • @Phil Wow. I'm honestly amazed how quickly you found that. I guess my search terms weren't quite right because I looked both for breaking changes in v18 changelog as well as open issues, but it was hard to find a match in 1.3k open. – Solonotix Jul 26 '22 at 13:10

0 Answers0