Path: blob/main/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherRetry.spec.ts
13405 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 { Raw } from '@vscode/prompt-tsx';6import { afterEach, beforeEach, describe, expect, it } from 'vitest';7import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';8import { CopilotToken } from '../../../../platform/authentication/common/copilotToken';9import { IFetchMLOptions } from '../../../../platform/chat/common/chatMLFetcher';10import { IChatQuotaService } from '../../../../platform/chat/common/chatQuotaService';11import { ChatFetchResponseType, ChatLocation } from '../../../../platform/chat/common/commonTypes';12import { IInteractionService } from '../../../../platform/chat/common/interactionService';13import { ConfigKey } from '../../../../platform/configuration/common/configurationService';14import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';15import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';16import { ICAPIClientService } from '../../../../platform/endpoint/common/capiClient';17import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';18import { MockCAPIClientService } from '../../../../platform/ignore/node/test/mockCAPIClientService';19import { ElectronFetchErrorChromiumDetails, ILogService } from '../../../../platform/log/common/logService';20import { FinishedCallback } from '../../../../platform/networking/common/fetch';21import { IFetcherService, IHeaders, Response } from '../../../../platform/networking/common/fetcherService';22import { IChatEndpoint } from '../../../../platform/networking/common/networking';23import { NullChatWebSocketManager } from '../../../../platform/networking/node/chatWebSocketManager';24import { NoopOTelService } from '../../../../platform/otel/common/noopOtelService';25import { resolveOTelConfig } from '../../../../platform/otel/common/otelConfig';26import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';27import { NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';28import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';29import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';30import { TelemetryData } from '../../../../platform/telemetry/common/telemetryData';31import { TestLogService } from '../../../../platform/testing/common/testLogService';32import { InstantiationServiceBuilder } from '../../../../util/common/services';33import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';34import { Event } from '../../../../util/vs/base/common/event';35import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';36import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';37import { IPowerService, NullPowerService } from '../../../power/common/powerService';38import { ChatMLFetcherImpl } from '../chatMLFetcher';3940describe('ChatMLFetcherImpl retry logic', () => {41let disposables: DisposableStore;42let fetcher: ChatMLFetcherImpl;43let mockFetcherService: MockFetcherService;44let configurationService: InMemoryConfigurationService;45let cancellationTokenSource: CancellationTokenSource;46let endpoint: IChatEndpoint;4748beforeEach(() => {49disposables = new DisposableStore();50cancellationTokenSource = disposables.add(new CancellationTokenSource());5152mockFetcherService = new MockFetcherService();53configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());54configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '500,502');55configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);5657const logService = new TestLogService();58const telemetryService = new NullTelemetryService();59const experimentationService = new NullExperimentationService();6061endpoint = createMockEndpoint();6263fetcher = new ChatMLFetcherImpl(64mockFetcherService as unknown as IFetcherService,65telemetryService,66new NullRequestLogger(),67logService,68new TestAuthenticationService() as unknown as IAuthenticationService,69createMockInteractionService(),70createMockChatQuotaService(),71new TestCAPIClientService() as unknown as ICAPIClientService,72createMockConversationOptions(),73configurationService,74experimentationService,75createMockPowerService(),76new InstantiationServiceBuilder([77[IFetcherService, mockFetcherService as unknown as IFetcherService],78[ITelemetryService, telemetryService],79[ICAPIClientService, new TestCAPIClientService() as unknown as ICAPIClientService],80]).seal() as unknown as IInstantiationService,81new NullChatWebSocketManager(),82new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),83);8485// Skip delays in tests for faster execution86fetcher.connectivityCheckDelays = [0, 0, 0];87});8889afterEach(() => {90disposables.dispose();91});9293function createBaseOpts(): IFetchMLOptions {94return {95debugName: 'test',96messages: [{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] }],97endpoint,98location: ChatLocation.Panel,99enableRetryOnError: true,100requestOptions: {},101finishedCb: undefined,102};103}104105describe('server error retry with configured status codes', () => {106it('retries on 500 status code when configured', async () => {107// Order: 1) initial fetch → 500, 2) connectivity check → 200, 3) retry → success108mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));109mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check110mockFetcherService.queueResponse(createSuccessResponse('Hello!')); // retry111112const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);113114expect(result.type).toBe(ChatFetchResponseType.Success);115expect(mockFetcherService.fetchCallCount).toBeGreaterThanOrEqual(2);116});117118it('retries on 502 status code when configured', async () => {119// Order: 1) initial fetch → 502, 2) connectivity check → 200, 3) retry → success120mockFetcherService.queueResponse(createErrorResponse(502, 'Bad Gateway'));121mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check122mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry123124const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);125126expect(result.type).toBe(ChatFetchResponseType.Success);127});128129it('does not retry on 404 status code', async () => {130mockFetcherService.queueResponse(createErrorResponse(404, 'Not Found'));131132const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);133134expect(result.type).toBe(ChatFetchResponseType.NotFound);135expect(mockFetcherService.fetchCallCount).toBe(1);136});137138it('does not retry when enableRetryOnError is false', async () => {139mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));140141const opts = createBaseOpts();142opts.enableRetryOnError = false;143const result = await fetcher.fetchMany(opts, cancellationTokenSource.token);144145expect(result.type).toBe(ChatFetchResponseType.Failed);146expect(mockFetcherService.fetchCallCount).toBe(1);147});148149it('respects custom status codes from configuration', async () => {150// Configure to only retry on 503151configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '503');152153mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));154155const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);156157// Should NOT retry because 500 is not in the configured list158expect(result.type).toBe(ChatFetchResponseType.Failed);159expect(mockFetcherService.fetchCallCount).toBe(1);160});161});162163describe('network error retry', () => {164it('retries after connectivity check succeeds', async () => {165configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);166167// Use ENOTFOUND instead of ECONNRESET - ECONNRESET triggers auto-retry in networking.ts168// Order: 1) initial fetch → error, 2) connectivity check → 200, 3) retry → success169mockFetcherService.queueError(createNetworkError('ENOTFOUND'));170mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check171mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry172173const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);174175expect(result.type).toBe(ChatFetchResponseType.Success);176});177178it('does not retry when RetryNetworkErrors is disabled', async () => {179configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, false);180181// Use ENOTFOUND instead of ECONNRESET - ECONNRESET triggers auto-retry in networking.ts182mockFetcherService.queueError(createNetworkError('ENOTFOUND'));183184const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);185186expect(result.type).toBe(ChatFetchResponseType.NetworkError);187expect(mockFetcherService.fetchCallCount).toBe(1);188});189});190191describe('status code parsing', () => {192it('handles comma-separated status codes with spaces', async () => {193configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '500, 502 , 503');194195mockFetcherService.queueResponse(createErrorResponse(502, 'Bad Gateway'));196mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check197mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry198199const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);200201expect(result.type).toBe(ChatFetchResponseType.Success);202});203204it('handles invalid status codes gracefully', async () => {205configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '500,invalid,502');206207mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));208mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check209mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry210211// Should still retry on 500 even with invalid entry in config212const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);213214expect(result.type).toBe(ChatFetchResponseType.Success);215});216217it('does not retry when configuration is empty string', async () => {218configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '');219220mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));221222const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);223224// Empty config means no status codes to retry - should fail without retry225expect(result.type).toBe(ChatFetchResponseType.Failed);226expect(mockFetcherService.fetchCallCount).toBe(1);227});228229it('does not retry when configuration contains only invalid values', async () => {230configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, 'invalid,abc,xyz');231232mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));233234const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);235236// All invalid means no valid status codes - should fail without retry237expect(result.type).toBe(ChatFetchResponseType.Failed);238expect(mockFetcherService.fetchCallCount).toBe(1);239});240});241242describe('connectivity check failure', () => {243it('does not retry server error when connectivity check fails', async () => {244configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '500,502');245246// Order: 1) initial fetch → 500, 2) connectivity checks fail (3 attempts)247mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));248// Connectivity check retries 3 times (with 0ms delays in tests)249mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 1st connectivity check250mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 2nd connectivity check251mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 3rd connectivity check252253const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);254255// Should fail because connectivity check never succeeded256expect(result.type).toBe(ChatFetchResponseType.Failed);257});258});259260describe('network process crash fallback to node-fetch', () => {261it('falls back to node-fetch and retries when network process crashed and flag is enabled', async () => {262configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);263configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, true);264265// 1) initial fetch → network process crash error266// 2) connectivity check via node-fetch → success267// 3) retry via node-fetch → success268mockFetcherService.queueError(createNetworkProcessCrashedError());269mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check270mockFetcherService.queueResponse(createSuccessResponse('Recovered!')); // retry271272const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);273274expect(result.type).toBe(ChatFetchResponseType.Success);275// Verify that connectivity check and retry used node-fetch276const fetcherIds = mockFetcherService.fetcherIdsUsed;277// fetcherIds[0] = initial request (default fetcher)278// fetcherIds[1] = connectivity check (should be node-fetch)279// fetcherIds[2] = retry request (should be node-fetch)280expect(fetcherIds[1]).toBe('node-fetch');281expect(fetcherIds[2]).toBe('node-fetch');282});283284it('does NOT fall back to node-fetch when flag is disabled', async () => {285configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);286configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, false);287288// 1) initial fetch → network process crash error289// 2-4) connectivity checks via default fetcher → all fail (dead network process)290mockFetcherService.queueError(createNetworkProcessCrashedError());291mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 1st connectivity check292mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 2nd connectivity check293mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 3rd connectivity check294295const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);296297// Should fail: the connectivity checks used the dead default fetcher298expect(result.type).toBe(ChatFetchResponseType.NetworkError);299// Verify that connectivity checks did NOT use node-fetch300const fetcherIds = mockFetcherService.fetcherIdsUsed;301expect(fetcherIds[1]).toBeUndefined(); // default fetcher, not node-fetch302});303304it('does NOT fall back to node-fetch for non-crash network errors', async () => {305configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);306configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, true);307308// Regular network error (not a crash) — should NOT trigger node-fetch fallback309mockFetcherService.queueError(createNetworkError('ENOTFOUND'));310mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check311mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry312313const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);314315expect(result.type).toBe(ChatFetchResponseType.Success);316// Verify that connectivity check used the default fetcher, not node-fetch317const fetcherIds = mockFetcherService.fetcherIdsUsed;318expect(fetcherIds[1]).toBeUndefined(); // default fetcher319});320321it('does NOT fall back when RetryNetworkErrors is disabled even if crash flag is enabled', async () => {322configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, false);323configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, true);324325mockFetcherService.queueError(createNetworkProcessCrashedError());326327const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);328329// Should fail without retry: the general retry-on-network-error flag is off330expect(result.type).toBe(ChatFetchResponseType.NetworkError);331expect(mockFetcherService.fetchCallCount).toBe(1);332});333334it('sets isNetworkProcessCrash flag on the error result', async () => {335configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, false);336configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, false);337338mockFetcherService.queueError(createNetworkProcessCrashedError());339340const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);341342expect(result.type).toBe(ChatFetchResponseType.NetworkError);343if (result.type === ChatFetchResponseType.NetworkError) {344expect(result.isNetworkProcessCrash).toBe(true);345}346});347348it('does not set isNetworkProcessCrash flag for regular network errors', async () => {349configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, false);350351mockFetcherService.queueError(createNetworkError('ENOTFOUND'));352353const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);354355expect(result.type).toBe(ChatFetchResponseType.NetworkError);356if (result.type === ChatFetchResponseType.NetworkError) {357expect(result.isNetworkProcessCrash).toBeUndefined();358}359});360});361});362363// --- Test Helpers ---364365/**366* Mock fetcher service that queues responses for testing retry logic.367*/368class MockFetcherService {369private _responseQueue: (Response | Error)[] = [];370private _fetchCallCount = 0;371372get fetchCallCount(): number {373return this._fetchCallCount;374}375376queueResponse(response: Response): void {377this._responseQueue.push(response);378}379380queueError(error: Error): void {381this._responseQueue.push(error);382}383384/**385* The `useFetcher` values passed to each `fetch` call, in order.386* Used to verify that the retry logic correctly switches fetchers.387*/388private _fetcherIdsUsed: (string | undefined)[] = [];389390get fetcherIdsUsed(): (string | undefined)[] {391return this._fetcherIdsUsed;392}393394async fetch(_url: string, options?: any): Promise<Response> {395this._fetchCallCount++;396this._fetcherIdsUsed.push(options?.useFetcher);397const next = this._responseQueue.shift();398if (!next) {399throw new Error('No more queued responses');400}401if (next instanceof Error) {402throw next;403}404return next;405}406407fetchWithPagination<T>(): Promise<T[]> {408throw new Error('Method not implemented.');409}410411disconnectAll(): Promise<void> {412return Promise.resolve();413}414415makeAbortController(): AbortController {416return new AbortController();417}418419isAbortError(_err: unknown): boolean {420return false;421}422423isInternetDisconnectedError(_err: unknown): boolean {424return false;425}426427isFetcherError(err: unknown): boolean {428return err instanceof Error && 'code' in err;429}430431isNetworkProcessCrashedError(err: unknown): boolean {432return !!(err && typeof err === 'object' && 'chromiumDetails' in err &&433(err as { chromiumDetails?: ElectronFetchErrorChromiumDetails }).chromiumDetails?.network_process_crashed === true);434}435436getUserMessageForFetcherError(_err: unknown): string {437return 'Network error occurred';438}439440getUserAgentLibrary(): string {441return 'test-agent';442}443}444445/**446* Extended mock authentication service that returns a valid token.447*/448class TestAuthenticationService extends MockAuthenticationService {449override getCopilotToken(_force?: boolean): Promise<CopilotToken> {450return Promise.resolve({451token: 'test-token',452username: 'test-user',453} as CopilotToken);454}455}456457/**458* Extended mock CAPI client service that provides the ping URL.459*/460class TestCAPIClientService extends MockCAPIClientService {461get capiPingURL(): string {462return 'https://api.github.com/copilot_internal/ping';463}464}465466function createMockInteractionService(): IInteractionService {467return {468_serviceBrand: undefined,469onInteractionStateChanged: Event.None,470sendChatInteraction: () => { },471getInteractionState: () => undefined,472} as unknown as IInteractionService;473}474475function createMockEndpoint(): IChatEndpoint {476return {477url: 'https://api.github.com/copilot/chat/completions',478urlOrRequestMetadata: 'https://api.github.com/copilot/chat/completions',479model: 'test-model',480modelMaxPromptTokens: 8192,481maxOutputTokens: 4096,482supportsToolCalls: true,483supportsVision: false,484supportsPrediction: false,485showInModelPicker: true,486isDefault: true,487isFallback: false,488policy: 'enabled',489getHeaders: async () => ({}),490createRequestBody: () => ({491model: 'test-model',492messages: [],493stream: true494}),495acquireTokenizer: () => ({496countMessagesTokens: async () => 100,497countTokens: async () => 100,498tokenize: async () => [],499}),500processResponseFromChatEndpoint: async (_telemetryService: ITelemetryService, _logService: ILogService, response: Response, _expectedNumChoices: number, finishedCb: FinishedCallback, telemetryData: TelemetryData, _cancellationToken?: CancellationToken) => {501// Stream the response text through the callback502const text = await response.text();503if (finishedCb) {504await finishedCb(text, 0, { text });505}506// Return an async iterable of ChatCompletion objects507return {508[Symbol.asyncIterator]: async function* () {509yield {510message: { role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }] },511choiceIndex: 0,512requestId: {513headerRequestId: response.headers.get('x-request-id') || 'test-request-id',514gitHubRequestId: response.headers.get('x-github-request-id') || '',515completionId: '',516created: 0,517serverExperiments: '',518deploymentId: '',519},520tokens: [],521usage: undefined,522model: 'test-model',523blockFinished: true,524finishReason: 'stop',525telemetryData: telemetryData,526};527}528};529},530acceptChatPolicy: async () => true,531doRequest: async () => {532throw new Error('Not implemented');533},534} as unknown as IChatEndpoint;535}536537function createMockChatQuotaService(): IChatQuotaService {538return {539_serviceBrand: undefined,540processQuotaHeaders: () => { },541} as unknown as IChatQuotaService;542}543544function createMockConversationOptions() {545return {546_serviceBrand: undefined,547maxResponseTokens: 4096,548temperature: 0.5,549topP: 1,550rejectionMessage: 'rejected',551};552}553554function createMockPowerService(): IPowerService {555return new NullPowerService();556}557558/**559* Simple FakeHeaders implementation that accepts initial headers.560*/561class FakeHeaders implements IHeaders {562constructor(private readonly headers = new Map<string, string>()) { }563get(name: string): string | null {564return this.headers.get(name.toLowerCase()) ?? null;565}566*[Symbol.iterator](): Iterator<[string, string]> {567yield* this.headers.entries();568}569}570571function createSuccessResponse(content: string): Response {572const streamContent = `data: {"choices":[{"delta":{"content":"${content}"},"index":0}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop","index":0}]}\n\ndata: [DONE]\n\n`;573return Response.fromText(574200,575'OK',576new FakeHeaders(new Map([577['content-type', 'text/event-stream'],578])),579streamContent,580'node-fetch'581);582}583584function createErrorResponse(status: number, statusText: string): Response {585return Response.fromText(586status,587statusText,588new FakeHeaders(),589JSON.stringify({ error: { message: statusText } }),590'node-fetch'591);592}593594function createNetworkError(code: string): Error & { code: string } {595const error = new Error(`Network error: ${code}`) as Error & { code: string };596error.code = code;597return error;598}599600/**601* Creates an error that simulates Electron's network process crashing.602* Electron attaches `chromiumDetails` with structured error info to the error object.603*/604function createNetworkProcessCrashedError(): Error & { code: string; chromiumDetails: ElectronFetchErrorChromiumDetails } {605const error = new Error('net::ERR_FAILED') as any;606error.code = 'ERR_FAILED';607error.chromiumDetails = { is_request_error: true, network_process_crashed: true } satisfies ElectronFetchErrorChromiumDetails;608return error;609}610611612