Path: blob/main/extensions/github-authentication/src/node/fetch.ts
5221 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 { workspace } from 'vscode';8import { Log } from '../common/logger';9import { Readable } from 'stream';1011export interface FetchOptions {12logger: Log;13retryFallbacks: boolean;14expectJSON: boolean;15method?: 'GET' | 'POST' | 'DELETE';16headers?: Record<string, string>;17body?: string;18signal?: AbortSignal;19}2021export interface FetchHeaders {22get(name: string): string | null;23}2425export interface FetchResponse {26ok: boolean;27status: number;28statusText: string;29headers: FetchHeaders;30text(): Promise<string>;31json(): Promise<any>;32}3334export type Fetch = (url: string, options: FetchOptions) => Promise<FetchResponse>;3536interface Fetcher {37name: string;38fetch: Fetch;39}4041const _fetchers: Fetcher[] = [];42try {43_fetchers.push({44name: 'Electron fetch',45fetch: require('electron').net.fetch46});47} catch {48// ignore49}5051const nodeFetch = {52name: 'Node fetch',53fetch,54};55const useElectronFetch = workspace.getConfiguration('github-authentication').get<boolean>('useElectronFetch', true);56if (useElectronFetch) {57_fetchers.push(nodeFetch);58} else {59_fetchers.unshift(nodeFetch);60}6162_fetchers.push({63name: 'Node http/s',64fetch: nodeHTTP,65});6667export function createFetch(): Fetch {68let fetchers: readonly Fetcher[] = _fetchers;69return async (url, options) => {70const result = await fetchWithFallbacks(fetchers, url, options, options.logger);71if (result.updatedFetchers) {72fetchers = result.updatedFetchers;73}74return result.response;75};76}7778function shouldNotRetry(status: number): boolean {79// Don't retry with other fetchers for these HTTP status codes:80// - 429 Too Many Requests (rate limiting)81// - 401 Unauthorized (authentication issue)82// - 403 Forbidden (authorization issue)83// - 404 Not Found (resource doesn't exist)84// These are application-level errors where retrying with a different fetcher won't help85return status === 429 || status === 401 || status === 403 || status === 404;86}8788async function fetchWithFallbacks(availableFetchers: readonly Fetcher[], url: string, options: FetchOptions, logService: Log): Promise<{ response: FetchResponse; updatedFetchers?: Fetcher[] }> {89if (options.retryFallbacks && availableFetchers.length > 1) {90let firstResult: { ok: boolean; response: FetchResponse } | { ok: false; err: any } | undefined;91for (const fetcher of availableFetchers) {92const result = await tryFetch(fetcher, url, options, logService);93if (fetcher === availableFetchers[0]) {94firstResult = result;95}96if (!result.ok) {97// For certain HTTP status codes, don't retry with other fetchers98// These are application-level errors, not network-level errors99if ('response' in result && shouldNotRetry(result.response.status)) {100return { response: result.response };101}102continue;103}104if (fetcher !== availableFetchers[0]) {105const retry = await tryFetch(availableFetchers[0], url, options, logService);106if (retry.ok) {107return { response: retry.response };108}109logService.info(`FetcherService: using ${fetcher.name} from now on`);110const updatedFetchers = availableFetchers.slice();111updatedFetchers.splice(updatedFetchers.indexOf(fetcher), 1);112updatedFetchers.unshift(fetcher);113return { response: result.response, updatedFetchers };114}115return { response: result.response };116}117if ('response' in firstResult!) {118return { response: firstResult.response };119}120throw firstResult!.err;121}122return { response: await availableFetchers[0].fetch(url, options) };123}124125async function tryFetch(fetcher: Fetcher, url: string, options: FetchOptions, logService: Log): Promise<{ ok: boolean; response: FetchResponse } | { ok: false; err: any }> {126try {127logService.debug(`FetcherService: trying fetcher ${fetcher.name} for ${url}`);128const response = await fetcher.fetch(url, options);129if (!response.ok) {130logService.info(`FetcherService: ${fetcher.name} failed with status: ${response.status} ${response.statusText}`);131return { ok: false, response };132}133if (!options.expectJSON) {134logService.debug(`FetcherService: ${fetcher.name} succeeded (not JSON)`);135return { ok: response.ok, response };136}137const text = await response.text();138try {139const json = JSON.parse(text); // Verify JSON140logService.debug(`FetcherService: ${fetcher.name} succeeded (JSON)`);141return { ok: true, response: new FetchResponseImpl(response.status, response.statusText, response.headers, async () => text, async () => json, async () => Readable.from([text])) };142} catch (err) {143logService.info(`FetcherService: ${fetcher.name} failed to parse JSON: ${err.message}`);144return { ok: false, err, response: new FetchResponseImpl(response.status, response.statusText, response.headers, async () => text, async () => { throw err; }, async () => Readable.from([text])) };145}146} catch (err) {147logService.info(`FetcherService: ${fetcher.name} failed with error: ${err.message}`);148return { ok: false, err };149}150}151152export const fetching = createFetch();153154class FetchResponseImpl implements FetchResponse {155public readonly ok: boolean;156constructor(157public readonly status: number,158public readonly statusText: string,159public readonly headers: FetchHeaders,160public readonly text: () => Promise<string>,161public readonly json: () => Promise<any>,162public readonly body: () => Promise<NodeJS.ReadableStream | null>,163) {164this.ok = this.status >= 200 && this.status < 300;165}166}167168async function nodeHTTP(url: string, options: FetchOptions): Promise<FetchResponse> {169return new Promise((resolve, reject) => {170const { method, headers, body, signal } = options;171const module = url.startsWith('https:') ? https : http;172const req = module.request(url, { method, headers }, res => {173if (signal?.aborted) {174res.destroy();175req.destroy();176reject(makeAbortError(signal));177return;178}179180const nodeFetcherResponse = new NodeFetcherResponse(req, res, signal);181resolve(new FetchResponseImpl(182res.statusCode || 0,183res.statusMessage || '',184nodeFetcherResponse.headers,185async () => nodeFetcherResponse.text(),186async () => nodeFetcherResponse.json(),187async () => nodeFetcherResponse.body(),188));189});190req.setTimeout(60 * 1000); // time out after 60s of receiving no data191req.on('error', reject);192193if (body) {194req.write(body);195}196req.end();197});198}199200class NodeFetcherResponse {201202readonly headers: FetchHeaders;203204constructor(205readonly req: http.ClientRequest,206readonly res: http.IncomingMessage,207readonly signal: AbortSignal | undefined,208) {209this.headers = new class implements FetchHeaders {210get(name: string): string | null {211const result = res.headers[name];212return Array.isArray(result) ? result[0] : result ?? null;213}214[Symbol.iterator](): Iterator<[string, string], any, undefined> {215const keys = Object.keys(res.headers);216let index = 0;217return {218next: (): IteratorResult<[string, string]> => {219if (index >= keys.length) {220return { done: true, value: undefined };221}222const key = keys[index++];223return { done: false, value: [key, this.get(key)!] };224}225};226}227};228}229230public text(): Promise<string> {231return new Promise<string>((resolve, reject) => {232const chunks: Buffer[] = [];233this.res.on('data', chunk => chunks.push(chunk));234this.res.on('end', () => resolve(Buffer.concat(chunks).toString()));235this.res.on('error', reject);236this.signal?.addEventListener('abort', () => {237this.res.destroy();238this.req.destroy();239reject(makeAbortError(this.signal!));240});241});242}243244public async json(): Promise<any> {245const text = await this.text();246return JSON.parse(text);247}248249public async body(): Promise<NodeJS.ReadableStream | null> {250this.signal?.addEventListener('abort', () => {251this.res.emit('error', makeAbortError(this.signal!));252this.res.destroy();253this.req.destroy();254});255return this.res;256}257}258259function makeAbortError(signal: AbortSignal): Error {260// see https://github.com/nodejs/node/issues/38361#issuecomment-1683839467261return signal.reason;262}263264265