Path: blob/main/extensions/copilot/src/platform/networking/node/nodeFetcher.ts
13400 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as http from 'http';6import * as https from 'https';7import { Readable } from 'stream';8import { generateUuid } from '../../../util/vs/base/common/uuid';9import { IEnvService } from '../../env/common/envService';10import { collectSingleLineErrorMessage } from '../../log/common/logService';11import { FetchOptions, HeadersImpl, IAbortController, IHeaders, PaginationOptions, ReportFetchEvent, Response, safeGetHostname } from '../common/fetcherService';12import { IFetcher, userAgentLibraryHeader } from '../common/networking';1314export class NodeFetcher implements IFetcher {1516static readonly ID = 'node-http' as const;1718constructor(19private readonly _envService: IEnvService,20private readonly _reportEvent: ReportFetchEvent = () => { },21private readonly _userAgentLibraryUpdate?: (original: string) => string,22) {23}2425getUserAgentLibrary(): string {26return NodeFetcher.ID;27}2829async fetch(url: string, options: FetchOptions): Promise<Response> {30const headers = { ...options.headers };31if (!headers['User-Agent']) {32headers['User-Agent'] = `GitHubCopilotChat/${this._envService.getVersion()}`;33}34headers[userAgentLibraryHeader] = this._userAgentLibraryUpdate ? this._userAgentLibraryUpdate(this.getUserAgentLibrary()) : this.getUserAgentLibrary();3536let body = options.body;37if (options.json) {38if (options.body) {39throw new Error(`Illegal arguments! Cannot pass in both 'body' and 'json'!`);40}41headers['Content-Type'] = 'application/json';42body = JSON.stringify(options.json);43}4445const method = options.method || 'GET';46if (method !== 'GET' && method !== 'POST' && method !== 'PUT') {47throw new Error(`Illegal arguments! 'method' must be 'GET', 'POST', or 'PUT'!`);48}4950const signal = options.signal ?? new AbortController().signal;51if (signal && !(signal instanceof AbortSignal)) {52throw new Error(`Illegal arguments! 'signal' must be an instance of AbortSignal!`);53}5455const internalId = generateUuid();56const hostname = safeGetHostname(url);57try {58const response = await this._fetch(url, method, headers, body, signal, internalId, hostname);59this._reportEvent({ internalId, timestamp: Date.now(), outcome: 'success', phase: 'requestResponse', fetcher: NodeFetcher.ID, hostname, statusCode: response.status });60return response;61} catch (e) {62e.fetcherId = NodeFetcher.ID;63const outcome = e && !isAbortError(e) ? 'error' as const : 'cancel' as const;64this._reportEvent({ internalId, timestamp: Date.now(), outcome, phase: 'requestResponse', fetcher: NodeFetcher.ID, hostname, reason: e });65throw e;66}67}6869async fetchWithPagination<T>(baseUrl: string, options: PaginationOptions<T>): Promise<T[]> {70const items: T[] = [];71const pageSize = options.pageSize ?? 20;72let page = options.startPage ?? 1;73let hasNextPage = false;7475do {76const url = options.buildUrl(baseUrl, pageSize, page);77const response = await this.fetch(url, options);7879if (!response.ok) {80// Return what we've collected so far if request fails81return items;82}8384const data = await response.json();85const pageItems = options.getItemsFromResponse(data);86items.push(...pageItems);8788hasNextPage = pageItems.length === pageSize;89page++;90} while (hasNextPage);9192return items;93}9495private _fetch(url: string, method: 'GET' | 'POST' | 'PUT', headers: { [name: string]: string }, body: string | undefined, signal: AbortSignal, internalId: string, hostname: string): Promise<Response> {96return new Promise((resolve, reject) => {97const module = url.startsWith('https:') ? https : http;98const req = module.request(url, { method, headers }, res => {99if (signal.aborted) {100res.destroy();101req.destroy();102reject(makeAbortError(signal));103return;104}105106const nodeFetcherResponse = new NodeFetcherResponse(req, res, signal);107resolve(new Response(108res.statusCode || 0,109res.statusMessage || '',110nodeFetcherResponse.headers,111nodeFetcherResponse.body(),112NodeFetcher.ID,113this._reportEvent,114internalId,115hostname,116));117});118req.setTimeout(60 * 1000); // time out after 60s of receiving no data119req.on('error', reject);120121if (body) {122req.write(body);123}124req.end();125});126}127async disconnectAll(): Promise<void> {128// Nothing to do129}130makeAbortController(): IAbortController {131return new AbortController();132}133isAbortError(e: any): boolean {134return isAbortError(e);135}136isInternetDisconnectedError(_e: any): boolean {137return false;138}139isFetcherError(e: any): boolean {140return e && ['EADDRINUSE', 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EPIPE', 'ETIMEDOUT'].includes(e.code);141}142isNetworkProcessCrashedError(_e: any): boolean {143return false;144}145getUserMessageForFetcherError(err: any): string {146return `Please check your firewall rules and network connection then try again. Error Code: ${collectSingleLineErrorMessage(err)}.`;147}148}149150function makeAbortError(signal: AbortSignal): Error {151// see https://github.com/nodejs/node/issues/38361#issuecomment-1683839467152return signal.reason;153}154function isAbortError(e: any): boolean {155// see https://github.com/nodejs/node/issues/38361#issuecomment-1683839467156return e && e.name === 'AbortError';157}158159class NodeFetcherResponse {160161readonly headers: IHeaders;162163constructor(164readonly req: http.ClientRequest,165readonly res: http.IncomingMessage,166readonly signal: AbortSignal167) {168this.headers = new HeadersImpl(res.headers);169}170171public text(): Promise<string> {172return new Promise<string>((resolve, reject) => {173const chunks: Buffer[] = [];174this.res.on('data', chunk => chunks.push(chunk));175this.res.on('end', () => resolve(Buffer.concat(chunks).toString()));176this.res.on('error', reject);177this.signal.addEventListener('abort', () => {178this.res.destroy();179this.req.destroy();180reject(makeAbortError(this.signal));181});182});183}184185public async json(): Promise<any> {186const text = await this.text();187return JSON.parse(text);188}189190public body(): ReadableStream<Uint8Array> {191this.signal.addEventListener('abort', () => {192this.res.emit('error', makeAbortError(this.signal));193this.res.destroy();194this.req.destroy();195});196return Readable.toWeb(this.res);197}198}199200201