Skip to content

Commit 7113276

Browse files
authored
Retry net::ERR_NETWORK_CHANGED with Node (#1228)
1 parent f94e477 commit 7113276

File tree

7 files changed

+75
-14
lines changed

7 files changed

+75
-14
lines changed

src/extension/prompt/node/chatMLFetcher.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,10 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
136136
ignoreStatefulMarker: opts.ignoreStatefulMarker
137137
});
138138
let tokenCount = -1;
139+
const streamRecorder = new FetchStreamRecorder(finishedCb);
140+
const enableRetryOnError = opts.enableRetryOnError ?? opts.enableRetryOnFilter;
139141
try {
140142
let response: ChatResults | ChatRequestFailed | ChatRequestCanceled;
141-
const streamRecorder = new FetchStreamRecorder(finishedCb);
142143
const payloadValidationResult = isValidChatPayload(opts.messages, postOptions);
143144
if (!payloadValidationResult.isValid) {
144145
response = {
@@ -160,7 +161,8 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
160161
postOptions.n,
161162
userInitiatedRequest,
162163
token,
163-
telemetryProperties
164+
telemetryProperties,
165+
opts.useFetcher,
164166
));
165167
tokenCount = await chatEndpoint.acquireTokenizer().countMessagesTokens(messages);
166168
const extensionId = source?.extensionId ?? EXTENSION_ID;
@@ -208,6 +210,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
208210
userInitiatedRequest: false, // do not mark the retry as user initiated
209211
telemetryProperties: { ...telemetryProperties, retryAfterFilterCategory: result.category ?? 'uncategorized' },
210212
enableRetryOnFilter: false,
213+
enableRetryOnError,
211214
}, token);
212215

213216
pendingLoggedChatRequest?.resolve(retryResult, streamRecorder.deltas);
@@ -235,6 +238,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
235238
requestId: ourRequestId,
236239
model: chatEndpoint.model,
237240
apiType: chatEndpoint.apiType,
241+
...(telemetryProperties.retryAfterErrorCategory ? { retryAfterErrorCategory: telemetryProperties.retryAfterErrorCategory } : {}),
238242
...(telemetryProperties.retryAfterFilterCategory ? { retryAfterFilterCategory: telemetryProperties.retryAfterFilterCategory } : {}),
239243
}, {
240244
totalTokenMax: chatEndpoint.modelMaxPromptTokens ?? -1,
@@ -258,6 +262,32 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
258262
} catch (err: unknown) {
259263
const timeToError = Date.now() - baseTelemetry.issuedTime;
260264
const processed = this.processError(err, ourRequestId);
265+
if (['darwin', 'linux'].includes(process.platform) && processed.type === ChatFetchResponseType.NetworkError && processed.reason.indexOf('net::ERR_NETWORK_CHANGED') !== -1) {
266+
267+
if (enableRetryOnError) {
268+
this._logService.info('Retrying chat request with node-fetch after net::ERR_NETWORK_CHANGED error.');
269+
streamRecorder.callback('', 0, { text: '', retryReason: 'network_error' });
270+
271+
// Retry with other fetchers
272+
const retryResult = await this.fetchMany({
273+
debugName: 'retry-error-' + debugName,
274+
messages,
275+
finishedCb,
276+
location,
277+
endpoint: chatEndpoint,
278+
source,
279+
requestOptions,
280+
userInitiatedRequest: false, // do not mark the retry as user initiated
281+
telemetryProperties: { ...telemetryProperties, retryAfterErrorCategory: 'electron-network-changed' },
282+
enableRetryOnFilter: opts.enableRetryOnFilter,
283+
enableRetryOnError: false,
284+
useFetcher: 'node-fetch',
285+
}, token);
286+
287+
pendingLoggedChatRequest?.resolve(retryResult, streamRecorder.deltas);
288+
return retryResult;
289+
}
290+
}
261291
if (processed.type === ChatFetchResponseType.Canceled) {
262292
this._sendCancellationTelemetry({
263293
source: telemetryProperties.messageSource ?? 'unknown',
@@ -329,6 +359,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
329359
"timeToCancelled": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Time to first token", "isMeasurement": true },
330360
"isVisionRequest": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the request was for a vision model", "isMeasurement": true },
331361
"isBYOK": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request was for a BYOK model", "isMeasurement": true },
362+
"retryAfterErrorCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response failed and this is a retry attempt, this contains the error category." },
332363
"retryAfterFilterCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response was filtered and this is a retry attempt, this contains the original filtered content category." }
333364
}
334365
*/
@@ -379,6 +410,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
379410
"timeToFirstTokenEmitted": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Time to first token emitted (visible text)", "isMeasurement": true },
380411
"isVisionRequest": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the request was for a vision model", "isMeasurement": true },
381412
"isBYOK": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request was for a BYOK model", "isMeasurement": true },
413+
"retryAfterErrorCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response failed and this is a retry attempt, this contains the error category." },
382414
"retryAfterFilterCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response was filtered and this is a retry attempt, this contains the original filtered content category." }
383415
}
384416
*/
@@ -391,6 +423,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
391423
apiType: chatEndpointInfo.apiType,
392424
reasoningEffort: requestBody.reasoning?.effort,
393425
reasoningSummary: requestBody.reasoning?.summary,
426+
...(telemetryProperties?.retryAfterErrorCategory ? { retryAfterErrorCategory: telemetryProperties.retryAfterErrorCategory } : {}),
394427
...(telemetryProperties?.retryAfterFilterCategory ? { retryAfterFilterCategory: telemetryProperties.retryAfterFilterCategory } : {})
395428
}, {
396429
totalTokenMax: chatEndpointInfo.modelMaxPromptTokens ?? -1,
@@ -447,6 +480,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
447480
"timeToComplete": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Time to complete the request", "isMeasurement": true },
448481
"isVisionRequest": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the request was for a vision model", "isMeasurement": true },
449482
"isBYOK": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the request was for a BYOK model", "isMeasurement": true },
483+
"retryAfterErrorCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response failed and this is a retry attempt, this contains the error category." },
450484
"retryAfterFilterCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response was filtered and this is a retry attempt, this contains the original filtered content category." }
451485
}
452486
*/
@@ -460,6 +494,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
460494
requestId,
461495
reasoningEffort: requestBody.reasoning?.effort,
462496
reasoningSummary: requestBody.reasoning?.summary,
497+
...(baseTelemetry?.properties.retryAfterErrorCategory ? { retryAfterErrorCategory: baseTelemetry.properties.retryAfterErrorCategory } : {}),
463498
...(baseTelemetry?.properties.retryAfterFilterCategory ? { retryAfterFilterCategory: baseTelemetry.properties.retryAfterFilterCategory } : {}),
464499
}, {
465500
totalTokenMax: chatEndpointInfo?.modelMaxPromptTokens ?? -1,

src/extension/prompt/node/pseudoStartStopConversationCallback.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor {
155155
this.stagedDeltasToApply = [];
156156
this.currentStartStop = undefined;
157157
this.nonReportedDeltas = [];
158-
if (delta.retryReason === FilterReason.Copyright) {
158+
if (delta.retryReason === 'network_error') {
159+
progress.clearToPreviousToolInvocation(ChatResponseClearToPreviousToolInvocationReason.NoReason);
160+
} else if (delta.retryReason === FilterReason.Copyright) {
159161
progress.clearToPreviousToolInvocation(ChatResponseClearToPreviousToolInvocationReason.CopyrightContentRetry);
160162
} else {
161163
progress.clearToPreviousToolInvocation(ChatResponseClearToPreviousToolInvocationReason.FilteredContentRetry);

src/platform/networking/common/fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export interface IResponseDelta {
138138
_deprecatedCopilotFunctionCalls?: ICopilotFunctionCall[];
139139
copilotConfirmation?: ICopilotConfirmation;
140140
thinking?: ThinkingDelta | EncryptedThinkingDelta;
141-
retryReason?: FilterReason;
141+
retryReason?: FilterReason | 'network_error';
142142
/** Marker for the current response, which should be presented in `IMakeChatRequestOptions` on the next call */
143143
statefulMarker?: string;
144144
}

src/platform/networking/common/fetcherService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export class Response {
4545
}
4646
}
4747

48+
export type FetcherId = 'electron-fetch' | 'node-fetch' | 'node-http';
49+
4850
/** These are the options we currently use, for ease of reference. */
4951
export interface FetchOptions {
5052
headers?: { [name: string]: string };
@@ -55,6 +57,7 @@ export interface FetchOptions {
5557
signal?: IAbortSignal;
5658
retryFallbacks?: boolean;
5759
expectJSON?: boolean;
60+
useFetcher?: FetcherId;
5861
}
5962

6063
export interface IAbortSignal {

src/platform/networking/common/networking.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { ILogService } from '../../log/common/logService';
1818
import { ITelemetryService, TelemetryProperties } from '../../telemetry/common/telemetry';
1919
import { TelemetryData } from '../../telemetry/common/telemetryData';
2020
import { FinishedCallback, OpenAiFunctionTool, OpenAiResponsesFunctionTool, OptionalChatRequestParams } from './fetch';
21-
import { FetchOptions, IAbortController, IFetcherService, Response } from './fetcherService';
21+
import { FetcherId, FetchOptions, IAbortController, IFetcherService, Response } from './fetcherService';
2222
import { ChatCompletion, RawMessageConversionCallback, rawMessageToCAPI } from './openai';
2323

2424
/**
@@ -145,6 +145,10 @@ export interface IMakeChatRequestOptions {
145145
telemetryProperties?: TelemetryProperties;
146146
/** Enable retrying the request when it was filtered due to snippy. Note- if using finishedCb, requires supporting delta.retryReason, eg with clearToPreviousToolInvocation */
147147
enableRetryOnFilter?: boolean;
148+
/** Enable retrying the request when it failed. Defaults to enableRetryOnFilter. Note- if using finishedCb, requires supporting delta.retryReason, eg with clearToPreviousToolInvocation */
149+
enableRetryOnError?: boolean;
150+
/** Which fetcher to use, overrides the default. */
151+
useFetcher?: FetcherId;
148152
}
149153

150154
export interface ICreateEndpointBodyOptions extends IMakeChatRequestOptions {
@@ -263,7 +267,8 @@ function networkRequest(
263267
requestId: string,
264268
body?: IEndpointBody,
265269
additionalHeaders?: Record<string, string>,
266-
cancelToken?: CancellationToken
270+
cancelToken?: CancellationToken,
271+
useFetcher?: FetcherId,
267272
): Promise<Response> {
268273
// TODO @lramos15 Eventually don't even construct this fake endpoint object.
269274
const endpoint = typeof endpointOrUrl === 'string' || 'type' in endpointOrUrl ? {
@@ -296,6 +301,7 @@ function networkRequest(
296301
headers: headers,
297302
json: body,
298303
timeout: requestTimeoutMs,
304+
useFetcher,
299305
};
300306

301307
if (cancelToken) {
@@ -353,7 +359,8 @@ export function postRequest(
353359
requestId: string,
354360
body?: IEndpointBody,
355361
additionalHeaders?: Record<string, string>,
356-
cancelToken?: CancellationToken
362+
cancelToken?: CancellationToken,
363+
useFetcher?: FetcherId,
357364
): Promise<Response> {
358365
return networkRequest(fetcherService,
359366
telemetryService,
@@ -365,7 +372,8 @@ export function postRequest(
365372
requestId,
366373
body,
367374
additionalHeaders,
368-
cancelToken
375+
cancelToken,
376+
useFetcher,
369377
);
370378
}
371379

src/platform/networking/node/fetcherFallback.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,15 @@ export async function fetchWithFallbacks(availableFetchers: readonly IFetcher[],
3838
}
3939
throw firstResult!.err;
4040
}
41-
return { response: await availableFetchers[0].fetch(url, options) };
41+
const fetcher = options.useFetcher && availableFetchers.find(f => f.getUserAgentLibrary() === options.useFetcher) || availableFetchers[0];
42+
if (options.useFetcher) {
43+
if (options.useFetcher === fetcher.getUserAgentLibrary()) {
44+
logService.debug(`FetcherService: using ${options.useFetcher} as requested.`);
45+
} else {
46+
logService.info(`FetcherService: could not find requested fetcher ${options.useFetcher}, using ${fetcher.getUserAgentLibrary()} instead.`);
47+
}
48+
}
49+
return { response: await fetcher.fetch(url, options) };
4250
}
4351

4452
async function tryFetch(fetcher: IFetcher, url: string, options: FetchOptions, logService: ILogService): Promise<{ ok: boolean; response: Response } | { ok: false; err: any }> {

src/platform/openai/node/fetch.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { IDomainService } from '../../endpoint/common/domainService';
1717
import { IEnvService } from '../../env/common/envService';
1818
import { ILogService } from '../../log/common/logService';
1919
import { FinishedCallback, OptionalChatRequestParams, RequestId, getProcessingTime, getRequestId } from '../../networking/common/fetch';
20-
import { IFetcherService, Response } from '../../networking/common/fetcherService';
20+
import { FetcherId, IFetcherService, Response } from '../../networking/common/fetcherService';
2121
import { IChatEndpoint, IEndpointBody, postRequest, stringifyUrlOrRequestMetadata } from '../../networking/common/networking';
2222
import { CAPIChatMessage, ChatCompletion } from '../../networking/common/openai';
2323
import { sendEngineMessagesTelemetry } from '../../networking/node/chatStream';
@@ -108,7 +108,8 @@ export async function fetchAndStreamChat(
108108
nChoices: number | undefined,
109109
userInitiatedRequest?: boolean,
110110
cancel?: CancellationToken | undefined,
111-
telemetryProperties?: TelemetryProperties | undefined
111+
telemetryProperties?: TelemetryProperties | undefined,
112+
useFetcher?: FetcherId,
112113
): Promise<ChatResults | ChatRequestFailed | ChatRequestCanceled> {
113114
const logService = accessor.get(ILogService);
114115
const telemetryService = accessor.get(ITelemetryService);
@@ -160,7 +161,9 @@ export async function fetchAndStreamChat(
160161
location,
161162
userInitiatedRequest,
162163
cancel,
163-
{ ...telemetryProperties, modelCallId });
164+
{ ...telemetryProperties, modelCallId },
165+
useFetcher,
166+
);
164167

165168
if (cancel?.isCancellationRequested) {
166169
const body = await response!.body();
@@ -465,7 +468,8 @@ async function fetchWithInstrumentation(
465468
location: ChatLocation,
466469
userInitiatedRequest?: boolean,
467470
cancel?: CancellationToken,
468-
telemetryProperties?: TelemetryProperties
471+
telemetryProperties?: TelemetryProperties,
472+
useFetcher?: FetcherId,
469473
): Promise<Response> {
470474

471475
// If request contains an image, we include this header.
@@ -514,7 +518,8 @@ async function fetchWithInstrumentation(
514518
ourRequestId,
515519
request,
516520
additionalHeaders,
517-
cancel
521+
cancel,
522+
useFetcher,
518523
).then(response => {
519524
const apim = response.headers.get('apim-request-id');
520525
if (apim) {

0 commit comments

Comments
 (0)