Rerun fallbacks (#260297)

This commit is contained in:
Christof Marti
2025-09-08 16:28:54 +02:00
parent 931b8b5276
commit f5f2ea58fc
5 changed files with 77 additions and 81 deletions

View File

@@ -18,6 +18,10 @@ export class Log {
this.output.trace(message);
}
public debug(message: string): void {
this.output.debug(message);
}
public info(message: string): void {
this.output.info(message);
}

View File

@@ -172,6 +172,7 @@ async function exchangeCodeForToken(
}
const result = await fetching(endpointUri.toString(true), {
logger,
retryFallbacks: true,
expectJSON: true,
method: 'POST',
headers: {
@@ -406,6 +407,7 @@ class DeviceCodeFlow implements IFlow {
});
const result = await fetching(uri.toString(true), {
logger,
retryFallbacks: true,
expectJSON: true,
method: 'POST',
headers: {
@@ -484,6 +486,7 @@ class DeviceCodeFlow implements IFlow {
try {
accessTokenResult = await fetching(refreshTokenUri.toString(true), {
logger,
retryFallbacks: true,
expectJSON: true,
method: 'POST',
headers: {
@@ -579,6 +582,7 @@ class PatFlow implements IFlow {
logger.info('Getting token scopes...');
const result = await fetching(serverUri.toString(), {
logger,
retryFallbacks: true,
expectJSON: false,
headers: {
Authorization: `token ${token}`,

View File

@@ -179,6 +179,7 @@ export class GitHubServer implements IGitHubServer {
// Defined here: https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28#delete-an-app-token
const result = await fetching(uri.toString(true), {
logger: this._logger,
retryFallbacks: true,
expectJSON: false,
method: 'DELETE',
headers: {
@@ -222,6 +223,7 @@ export class GitHubServer implements IGitHubServer {
this._logger.info('Getting user info...');
result = await fetching(this.getServerUri('/user').toString(), {
logger: this._logger,
retryFallbacks: true,
expectJSON: true,
headers: {
Authorization: `token ${token}`,
@@ -282,6 +284,7 @@ export class GitHubServer implements IGitHubServer {
try {
const result = await fetching('https://education.github.com/api/user', {
logger: this._logger,
retryFallbacks: true,
expectJSON: true,
headers: {
Authorization: `token ${session.accessToken}`,
@@ -324,6 +327,7 @@ export class GitHubServer implements IGitHubServer {
if (!isSupportedTarget(this._type, this._ghesUri)) {
const result = await fetching(this.getServerUri('/meta').toString(), {
logger: this._logger,
retryFallbacks: true,
expectJSON: true,
headers: {
Authorization: `token ${token}`,

View File

@@ -7,9 +7,11 @@ import * as http from 'http';
import * as https from 'https';
import { workspace } from 'vscode';
import { Log } from '../common/logger';
import { Readable } from 'stream';
export interface FetchOptions {
logger: Log;
retryFallbacks: boolean;
expectJSON: boolean;
method?: 'GET' | 'POST' | 'DELETE';
headers?: Record<string, string>;
@@ -64,97 +66,71 @@ _fetchers.push({
});
export function createFetch(): Fetch {
let _fetcher: Fetcher | undefined;
let fetchers: readonly Fetcher[] = _fetchers;
return async (url, options) => {
if (!_fetcher) {
let firstResponse: FetchResponse | undefined;
let firstError: any;
for (const fetcher of _fetchers) {
try {
const res = await fetcher.fetch(url, options);
if (fetcher === _fetchers[0]) {
firstResponse = res;
}
if (!res.ok) {
options.logger.info(`fetching: ${fetcher.name} failed with status: ${res.status} ${res.statusText}`);
continue;
}
if (!options.expectJSON) {
options.logger.info(`fetching: ${fetcher.name} succeeded (not JSON)`);
_fetcher = fetcher;
return res;
}
const text = await res.text();
if (fetcher === _fetchers[0]) {
// Update to unconsumed response
firstResponse = new FetchResponseImpl(
res.status,
res.statusText,
res.headers,
async () => text,
async () => JSON.parse(text),
);
}
const json = JSON.parse(text); // Verify JSON
options.logger.info(`fetching: ${fetcher.name} succeeded (JSON)`);
if (fetcher !== _fetchers[0]) {
const retry = await retryFetch(_fetchers[0], url, options);
if ('res' in retry && retry.res.ok) {
_fetcher = _fetchers[0];
return retry.res;
}
}
_fetcher = fetcher;
return new FetchResponseImpl(
res.status,
res.statusText,
res.headers,
async () => text,
async () => json,
);
} catch (err) {
if (fetcher === _fetchers[0]) {
firstError = err;
}
options.logger.info(`fetching: ${fetcher.name} failed with error: ${err.message}`);
}
}
_fetcher = _fetchers[0]; // Do this only once
if (firstResponse) {
return firstResponse;
}
throw firstError;
const result = await fetchWithFallbacks(fetchers, url, options, options.logger);
if (result.updatedFetchers) {
fetchers = result.updatedFetchers;
}
return _fetcher.fetch(url, options);
return result.response;
};
}
async function retryFetch(fetcher: Fetcher, url: string, options: FetchOptions): Promise<{ res: FetchResponse } | { err: any }> {
async function fetchWithFallbacks(availableFetchers: readonly Fetcher[], url: string, options: FetchOptions, logService: Log): Promise<{ response: FetchResponse; updatedFetchers?: Fetcher[] }> {
if (options.retryFallbacks && availableFetchers.length > 1) {
let firstResult: { ok: boolean; response: FetchResponse } | { ok: false; err: any } | undefined;
for (const fetcher of availableFetchers) {
const result = await tryFetch(fetcher, url, options, logService);
if (fetcher === availableFetchers[0]) {
firstResult = result;
}
if (!result.ok) {
continue;
}
if (fetcher !== availableFetchers[0]) {
const retry = await tryFetch(availableFetchers[0], url, options, logService);
if (retry.ok) {
return { response: retry.response };
}
logService.info(`FetcherService: using ${fetcher.name} from now on`);
const updatedFetchers = availableFetchers.slice();
updatedFetchers.splice(updatedFetchers.indexOf(fetcher), 1);
updatedFetchers.unshift(fetcher);
return { response: result.response, updatedFetchers };
}
return { response: result.response };
}
if ('response' in firstResult!) {
return { response: firstResult.response };
}
throw firstResult!.err;
}
return { response: await availableFetchers[0].fetch(url, options) };
}
async function tryFetch(fetcher: Fetcher, url: string, options: FetchOptions, logService: Log): Promise<{ ok: boolean; response: FetchResponse } | { ok: false; err: any }> {
try {
const res = await fetcher.fetch(url, options);
if (!res.ok) {
options.logger.info(`fetching: ${fetcher.name} failed with status: ${res.status} ${res.statusText}`);
return { res };
const response = await fetcher.fetch(url, options);
if (!response.ok) {
logService.info(`FetcherService: ${fetcher.name} failed with status: ${response.status} ${response.statusText}`);
return { ok: false, response };
}
if (!options.expectJSON) {
options.logger.info(`fetching: ${fetcher.name} succeeded (not JSON)`);
return { res };
logService.debug(`FetcherService: ${fetcher.name} succeeded (not JSON)`);
return { ok: response.ok, response };
}
const text = await response.text();
try {
const json = JSON.parse(text); // Verify JSON
logService.debug(`FetcherService: ${fetcher.name} succeeded (JSON)`);
return { ok: true, response: new FetchResponseImpl(response.status, response.statusText, response.headers, async () => text, async () => json, async () => Readable.from([text])) };
} catch (err) {
logService.info(`FetcherService: ${fetcher.name} failed to parse JSON: ${err.message}`);
return { ok: false, err, response: new FetchResponseImpl(response.status, response.statusText, response.headers, async () => text, async () => { throw err; }, async () => Readable.from([text])) };
}
const text = await res.text();
const json = JSON.parse(text); // Verify JSON
options.logger.info(`fetching: ${fetcher.name} succeeded (JSON)`);
return {
res: new FetchResponseImpl(
res.status,
res.statusText,
res.headers,
async () => text,
async () => json,
)
};
} catch (err) {
options.logger.info(`fetching: ${fetcher.name} failed with error: ${err.message}`);
return { err };
logService.info(`FetcherService: ${fetcher.name} failed with error: ${err.message}`);
return { ok: false, err };
}
}
@@ -168,6 +144,7 @@ class FetchResponseImpl implements FetchResponse {
public readonly headers: FetchHeaders,
public readonly text: () => Promise<string>,
public readonly json: () => Promise<any>,
public readonly body: () => Promise<NodeJS.ReadableStream | null>,
) {
this.ok = this.status >= 200 && this.status < 300;
}
@@ -192,6 +169,7 @@ async function nodeHTTP(url: string, options: FetchOptions): Promise<FetchRespon
nodeFetcherResponse.headers,
async () => nodeFetcherResponse.text(),
async () => nodeFetcherResponse.json(),
async () => nodeFetcherResponse.body(),
));
});
req.setTimeout(60 * 1000); // time out after 60s of receiving no data

View File

@@ -76,6 +76,7 @@ suite('fetching', () => {
test('should use Electron fetch', async () => {
const res = await createFetch()(`http://localhost:${port}/json`, {
logger,
retryFallbacks: true,
expectJSON: true,
});
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
@@ -87,6 +88,7 @@ suite('fetching', () => {
test('should use Electron fetch 2', async () => {
const res = await createFetch()(`http://localhost:${port}/text`, {
logger,
retryFallbacks: true,
expectJSON: false,
});
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
@@ -98,6 +100,7 @@ suite('fetching', () => {
test('should fall back to Node.js fetch', async () => {
const res = await createFetch()(`http://localhost:${port}/json?expectAgent=node`, {
logger,
retryFallbacks: true,
expectJSON: true,
});
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
@@ -109,6 +112,7 @@ suite('fetching', () => {
test('should fall back to Node.js fetch 2', async () => {
const res = await createFetch()(`http://localhost:${port}/json?expectAgent=node&error=html`, {
logger,
retryFallbacks: true,
expectJSON: true,
});
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
@@ -120,6 +124,7 @@ suite('fetching', () => {
test('should fall back to Node.js http/s', async () => {
const res = await createFetch()(`http://localhost:${port}/json?expectAgent=undefined`, {
logger,
retryFallbacks: true,
expectJSON: true,
});
const actualAgent = res.headers.get('x-client-user-agent') || 'None';
@@ -131,6 +136,7 @@ suite('fetching', () => {
test('should fail with first error', async () => {
const res = await createFetch()(`http://localhost:${port}/text`, {
logger,
retryFallbacks: true,
expectJSON: true, // Expect JSON but server returns text
});
const actualAgent = res.headers.get('x-client-user-agent') || 'None';