diff --git a/extensions/copilot/src/extension/extension/vscode-node/services.ts b/extensions/copilot/src/extension/extension/vscode-node/services.ts index 0767e19cf28..cfc6b09169b 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/services.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/services.ts @@ -33,6 +33,7 @@ import { INativeEnvService, isScenarioAutomation } from '../../../platform/env/c import { NativeEnvServiceImpl } from '../../../platform/env/vscode-node/nativeEnvServiceImpl'; import { IGitCommitMessageService } from '../../../platform/git/common/gitCommitMessageService'; import { IGitDiffService } from '../../../platform/git/common/gitDiffService'; +import { GithubApiFetcherService, IGithubApiFetcherService } from '../../../platform/github/common/githubApiFetcherService'; import { IGithubRepositoryService } from '../../../platform/github/common/githubService'; import { GithubRepositoryService } from '../../../platform/github/node/githubRepositoryService'; import { IIgnoreService, NullIgnoreService } from '../../../platform/ignore/common/ignoreService'; @@ -225,6 +226,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio builder.define(IChatWebSocketManager, new SyncDescriptor(ChatWebSocketManager)); builder.define(IFeedbackReporter, new SyncDescriptor(FeedbackReporter)); builder.define(IApiEmbeddingsIndex, new SyncDescriptor(ApiEmbeddingsIndex, [/*useRemoteCache*/ true])); + builder.define(IGithubApiFetcherService, new SyncDescriptor(GithubApiFetcherService)); builder.define(IGithubCodeSearchService, new SyncDescriptor(GithubCodeSearchService)); builder.define(IAdoCodeSearchService, new SyncDescriptor(AdoCodeSearchService)); builder.define(IWorkspaceChunkSearchService, new SyncDescriptor(WorkspaceChunkSearchService)); diff --git a/extensions/copilot/src/extension/test/node/services.ts b/extensions/copilot/src/extension/test/node/services.ts index 0fcdf6145a6..0417019ccd6 100644 --- a/extensions/copilot/src/extension/test/node/services.ts +++ b/extensions/copilot/src/extension/test/node/services.ts @@ -22,6 +22,7 @@ import { IGitExtensionService } from '../../../platform/git/common/gitExtensionS import { IGitService } from '../../../platform/git/common/gitService'; import { NullGitDiffService } from '../../../platform/git/common/nullGitDiffService'; import { NullGitExtensionService } from '../../../platform/git/common/nullGitExtensionService'; +import { GithubApiFetcherService, IGithubApiFetcherService } from '../../../platform/github/common/githubApiFetcherService'; import { IInlineEditsModelService, IUndesiredModelsManager } from '../../../platform/inlineEdits/common/inlineEditsModelService'; import { InlineEditsModelService, UndesiredModels } from '../../../platform/inlineEdits/node/inlineEditsModelService'; import { ILogService } from '../../../platform/log/common/logService'; @@ -101,6 +102,7 @@ export function createExtensionUnitTestingServices(disposables: Pick | undefined { - const editorInfo = envService.getEditorInfo(); - - // Try converting vscode/1.xxx-insiders to vscode-insiders/1.xxx - const versionNumberAndSubName = editorInfo.version.match(/^(?.+?)(\-(?\w+?))?$/); - const application = versionNumberAndSubName && versionNumberAndSubName.groups?.subName - ? `${editorInfo.name}-${versionNumberAndSubName.groups.subName}/${versionNumberAndSubName.groups.version}` - : editorInfo.format(); - - return { - 'X-Client-Application': application, - 'X-Client-Source': envService.getEditorPluginInfo().format(), - 'X-Client-Feature': callerInfo.toAscii().slice(0, 1000), - }; -} diff --git a/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts b/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts index b4e251214cc..15f9771648a 100644 --- a/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts +++ b/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts @@ -10,9 +10,9 @@ import { Limiter } from '../../../util/vs/base/common/async'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IAuthenticationService } from '../../authentication/common/authentication'; -import { getGithubMetadataHeaders } from '../../chunking/common/chunkingEndpointClientImpl'; import { IEndpointProvider } from '../../endpoint/common/endpointProvider'; import { IEnvService } from '../../env/common/envService'; +import { getGithubMetadataHeaders } from '../../github/common/githubApiFetcherService'; import { logExecTime } from '../../log/common/logExecTime'; import { ILogService } from '../../log/common/logService'; import { IEmbeddingsEndpoint, postRequest } from '../../networking/common/networking'; diff --git a/extensions/copilot/src/platform/github/common/githubApiFetcherService.ts b/extensions/copilot/src/platform/github/common/githubApiFetcherService.ts new file mode 100644 index 00000000000..2af592c44b1 --- /dev/null +++ b/extensions/copilot/src/platform/github/common/githubApiFetcherService.ts @@ -0,0 +1,338 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createServiceIdentifier } from '../../../util/common/services'; +import { CallTracker } from '../../../util/common/telemetryCorrelationId'; +import { raceCancellationError } from '../../../util/vs/base/common/async'; +import { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors'; +import { Disposable, IDisposable } from '../../../util/vs/base/common/lifecycle'; +import { IEnvService } from '../../env/common/envService'; +import { ILogService } from '../../log/common/logService'; +import { ITelemetryService } from '../../telemetry/common/telemetry'; + +export const IGithubApiFetcherService = createServiceIdentifier('IGithubApiFetcherService'); + +export interface GithubRequestOptions { + readonly method: string; + readonly url: string; + readonly headers?: Record; + readonly body?: unknown; + readonly authToken: string; + + readonly telemetry: { + readonly urlId: string; // A stable identifier for the URL, used for telemetry and logging. Should not contain sensitive information. + readonly callerInfo: CallTracker; + }; + + /** Number of retries on 5xx errors. Defaults to 0 (no retries). */ + readonly retriesOn500?: number; +} + +export const githubHeaders = Object.freeze({ + requestId: 'x-github-request-id', + totalQuotaUsed: 'x-github-total-quota-used', +}); + +/** + * Provides standardized throttling and retry behavior for GitHub API requests. + */ +export interface IGithubApiFetcherService extends IDisposable { + readonly _serviceBrand: undefined; + + makeRequest(options: GithubRequestOptions, token: CancellationToken): Promise; +} + +/** + * Sliding window that holds at least N entries and all entries in the time window. + * If inserts are infrequent, the minimum-entry guarantee ensures there is always + * some history to work with; when inserts are frequent the time window dominates. + */ +class SlidingTimeAndNWindow implements IDisposable { + private values: number[] = []; + private times: number[] = []; + private sumValues = 0; + private readonly numEntries: number; + private readonly windowDurationMs: number; + private cleanupInterval: ReturnType | undefined; + + constructor(numEntries: number, windowDurationMs: number) { + this.numEntries = numEntries; + this.windowDurationMs = windowDurationMs; + this.startPeriodicCleanup(); + } + + dispose(): void { + if (typeof this.cleanupInterval !== 'undefined') { + clearInterval(this.cleanupInterval); + } + } + + increment(n: number): void { + this.values.push(n); + this.times.push(Date.now()); + this.sumValues += n; + } + + get(): number { + return this.sumValues; + } + + average(): number { + if (this.values.length === 0) { + return 0; + } + return this.sumValues / this.values.length; + } + + delta(): number { + if (this.values.length === 0) { + return 0; + } + return this.values[this.values.length - 1] - this.values[0]; + } + + size(): number { + return this.values.length; + } + + reset(): void { + this.values = []; + this.times = []; + this.sumValues = 0; + } + + private startPeriodicCleanup(): void { + this.cleanupInterval = setInterval(() => { + const tooOldTime = Date.now() - this.windowDurationMs; + while ( + this.times.length > this.numEntries && + this.times[0] < tooOldTime + ) { + this.sumValues -= this.values[0]; + this.values.shift(); + this.times.shift(); + } + }, 100); + } +} + +class Throttler implements IDisposable { + private readonly target: number; + private lastSendTime: number; + private totalQuotaUsedWindow: SlidingTimeAndNWindow; + private sendPeriodWindow: SlidingTimeAndNWindow; + private numOutstandingRequests = 0; + + constructor(target: number) { + this.target = target; + this.lastSendTime = Date.now(); + this.totalQuotaUsedWindow = new SlidingTimeAndNWindow(5, 2000); + this.sendPeriodWindow = new SlidingTimeAndNWindow(5, 2000); + } + + reset(): void { + if (this.numOutstandingRequests === 0) { + this.lastSendTime = Date.now(); + this.totalQuotaUsedWindow.dispose(); + this.sendPeriodWindow.dispose(); + this.totalQuotaUsedWindow = new SlidingTimeAndNWindow(5, 2000); + this.sendPeriodWindow = new SlidingTimeAndNWindow(5, 2000); + } + } + + recordQuotaUsed(used: number): void { + this.totalQuotaUsedWindow.increment(used); + } + + requestStarted(): void { + this.numOutstandingRequests += 1; + } + + requestFinished(): void { + this.numOutstandingRequests -= 1; + } + + /** + * PID-controller–inspired gate that decides whether a request should be + * sent right now or deferred. It uses sliding windows of recent quota + * usage and send periods to compute proportional, integral, and + * differential terms, which in turn determine a dynamic delay before + * sending the next request. The ramp-up logic at the end ensures we + * start slowly and calibrate based on server feedback before allowing + * higher concurrency. + */ + shouldSendRequest(): boolean { + const now = Date.now(); + + // Send a request occasionally even if throttled, to refresh quota info. + if (now > this.lastSendTime + 5 * 60 * 1000) { + this.reset(); + } + + let shouldSend = false; + + if (this.totalQuotaUsedWindow.get() === 0) { + shouldSend = true; + } + + if (this.sendPeriodWindow.average() > 0) { + const integral = + (this.totalQuotaUsedWindow.average() - this.target) / 100; + const differential = this.totalQuotaUsedWindow.delta(); + const delayMs = + this.sendPeriodWindow.average() * + Math.max(1 + 20 * integral + 0.5 * differential, 0.2); + if (now > this.lastSendTime + delayMs) { + shouldSend = true; + } + } + + // Ramp up slowly at start so the throttler can calibrate based on + // server feedback before allowing concurrent requests. + if ( + this.totalQuotaUsedWindow.size() < 5 && + this.numOutstandingRequests > 0 + ) { + shouldSend = false; + } + + if (shouldSend) { + this.sendPeriodWindow.increment(now - this.lastSendTime); + this.lastSendTime = now; + } + return shouldSend; + } + + dispose(): void { + this.totalQuotaUsedWindow.dispose(); + this.sendPeriodWindow.dispose(); + } +} + +export class GithubApiFetcherService extends Disposable implements IGithubApiFetcherService { + declare readonly _serviceBrand: undefined; + + private readonly throttler: Throttler; + + constructor( + target: number = 80, + @IEnvService private readonly envService: IEnvService, + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { + super(); + this.throttler = this._register(new Throttler(target)); + } + + async makeRequest(options: GithubRequestOptions, token: CancellationToken): Promise { + return this.makeRequestWithRetries(options, token, options.retriesOn500 ?? 0); + } + + private async makeRequestWithRetries( + options: GithubRequestOptions, + token: CancellationToken, + retriesRemaining: number, + ): Promise { + + // Throttle + while (!this.throttler.shouldSendRequest()) { + await raceCancellationError(sleep(5), token); + } + if (token.isCancellationRequested) { + throw new CancellationError(); + } + + this.throttler.requestStarted(); + try { + const res = await fetch(options.url, { + method: options.method, + headers: { + ...options.headers, + 'Authorization': `Bearer ${options.authToken}`, + ...getGithubMetadataHeaders(options.telemetry.callerInfo, this.envService), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + // Record quota usage for throttle calibration + const quotaUsedHeader = res.headers.get(githubHeaders.totalQuotaUsed); + const quotaUsed = quotaUsedHeader ? parseFloat(quotaUsedHeader) : 0; + if (quotaUsed > 0) { + this.throttler.recordQuotaUsed(quotaUsed); + } + + if (!res.ok) { + const willRetry = res.status >= 500 && res.status < 600 && retriesRemaining > 0; + const requestId = res.headers.get(githubHeaders.requestId); + + if (willRetry) { + this.logService.warn(`GithubApiFetcherService: ${options.method} ${options.telemetry.urlId} returned ${res.status}, github requestId: '${requestId}'. Retrying (${retriesRemaining} retries remaining)`,); + } else { + let responseBody = ''; + try { + responseBody = await res.text(); + } catch { + // noop + } + this.logService.error(`GithubApiFetcherService: ${options.method} ${options.telemetry.urlId} failed with status '${res.status}', github requestId: '${requestId}', body: ${responseBody}`,); + } + + /* __GDPR__ + "githubApiFetcherService.request.error" : { + "owner": "copilot-core", + "comment": "Logging when a GitHub API request fails", + "urlId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "A stable identifier for the URL" }, + "method": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The HTTP method used" }, + "caller": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller" }, + "statusCode": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The response status code" }, + "willRetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the request will be retried" } + } + */ + this.telemetryService.sendMSFTTelemetryEvent('githubApiFetcherService.request.error', { + urlId: options.telemetry.urlId, + method: options.method, + caller: options.telemetry.callerInfo.toString(), + }, { + statusCode: res.status, + willRetry: willRetry ? 1 : 0, + }); + + if (willRetry) { + return this.makeRequestWithRetries(options, token, retriesRemaining - 1); + } + } + + return res; + } catch (e) { + if (!isCancellationError(e)) { + this.logService.error(`GithubApiFetcherService: ${options.method} ${options.telemetry.urlId} threw: ${e}`); + } + throw e; + } finally { + this.throttler.requestFinished(); + } + } +} + +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function getGithubMetadataHeaders(callerInfo: CallTracker, envService: IEnvService): Record | undefined { + const editorInfo = envService.getEditorInfo(); + + // Try converting vscode/1.xxx-insiders to vscode-insiders/1.xxx + const versionNumberAndSubName = editorInfo.version.match(/^(?.+?)(\-(?\w+?))?$/); + const application = versionNumberAndSubName && versionNumberAndSubName.groups?.subName + ? `${editorInfo.name}-${versionNumberAndSubName.groups.subName}/${versionNumberAndSubName.groups.version}` + : editorInfo.format(); + + return { + 'X-Client-Application': application, + 'X-Client-Source': envService.getEditorPluginInfo().format(), + 'X-Client-Feature': callerInfo.toAscii().slice(0, 1000), + }; +} diff --git a/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts b/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts index 5daed06466d..056bd0904a7 100644 --- a/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts +++ b/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts @@ -15,12 +15,12 @@ import { Range } from '../../../util/vs/editor/common/core/range'; import { createDecorator, IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IAuthenticationService } from '../../authentication/common/authentication'; import { FileChunkAndScore } from '../../chunking/common/chunk'; -import { getGithubMetadataHeaders } from '../../chunking/common/chunkingEndpointClientImpl'; import { stripChunkTextMetadata } from '../../chunking/common/chunkingStringUtils'; import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { EmbeddingType } from '../../embeddings/common/embeddingsComputer'; import { IEnvService } from '../../env/common/envService'; import { AdoRepoId } from '../../git/common/gitService'; +import { getGithubMetadataHeaders } from '../../github/common/githubApiFetcherService'; import { IIgnoreService } from '../../ignore/common/ignoreService'; import { measureExecTime } from '../../log/common/logExecTime'; import { ILogService } from '../../log/common/logService'; diff --git a/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts b/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts index 27af2225594..9ecdcc7041e 100644 --- a/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts +++ b/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts @@ -14,12 +14,12 @@ import { Range } from '../../../util/vs/editor/common/core/range'; import { createDecorator, IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IAuthenticationService } from '../../authentication/common/authentication'; import { FileChunkAndScore } from '../../chunking/common/chunk'; -import { getGithubMetadataHeaders } from '../../chunking/common/chunkingEndpointClientImpl'; import { stripChunkTextMetadata, truncateToMaxUtf8Length } from '../../chunking/common/chunkingStringUtils'; import { EmbeddingType } from '../../embeddings/common/embeddingsComputer'; import { ICAPIClientService } from '../../endpoint/common/capiClient'; import { IEnvService } from '../../env/common/envService'; import { GithubRepoId, toGithubNwo } from '../../git/common/gitService'; +import { getGithubMetadataHeaders } from '../../github/common/githubApiFetcherService'; import { IIgnoreService } from '../../ignore/common/ignoreService'; import { ILogService } from '../../log/common/logService'; import { Response } from '../../networking/common/fetcherService'; diff --git a/extensions/copilot/src/platform/test/node/services.ts b/extensions/copilot/src/platform/test/node/services.ts index 5a7691580ce..ee2b5333933 100644 --- a/extensions/copilot/src/platform/test/node/services.ts +++ b/extensions/copilot/src/platform/test/node/services.ts @@ -49,6 +49,7 @@ import { IFileSystemService } from '../../filesystem/common/fileSystemService'; import { MockFileSystemService } from '../../filesystem/node/test/mockFileSystemService'; import { IGitService } from '../../git/common/gitService'; import { NullGitExtensionService } from '../../git/common/nullGitExtensionService'; +import { GithubApiFetcherService, IGithubApiFetcherService } from '../../github/common/githubApiFetcherService'; import { IGithubRepositoryService, IOctoKitService } from '../../github/common/githubService'; import { OctoKitService } from '../../github/common/octoKitServiceImpl'; import { GithubRepositoryService } from '../../github/node/githubRepositoryService'; @@ -246,6 +247,7 @@ export function createPlatformServices(disposables: Pick testingServiceCollection.define(IDomainService, new SyncDescriptor(DomainService)); testingServiceCollection.define(ICAPIClientService, new SyncDescriptor(CAPIClientImpl)); testingServiceCollection.define(INotificationService, new SyncDescriptor(NullNotificationService)); + testingServiceCollection.define(IGithubApiFetcherService, new SyncDescriptor(GithubApiFetcherService)); testingServiceCollection.define(IVSCodeExtensionContext, new SyncDescriptor(MockExtensionContext)); testingServiceCollection.define(IIgnoreService, new SyncDescriptor(NullIgnoreService)); testingServiceCollection.define(ITerminalService, new SyncDescriptor(NullTerminalService)); diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/common/githubAvailableEmbeddingTypes.ts b/extensions/copilot/src/platform/workspaceChunkSearch/common/githubAvailableEmbeddingTypes.ts index 33fbe614af2..90a5b1fabdb 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/common/githubAvailableEmbeddingTypes.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/common/githubAvailableEmbeddingTypes.ts @@ -10,10 +10,10 @@ import { CallTracker } from '../../../util/common/telemetryCorrelationId'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IAuthenticationService } from '../../authentication/common/authentication'; -import { getGithubMetadataHeaders } from '../../chunking/common/chunkingEndpointClientImpl'; import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { EmbeddingType } from '../../embeddings/common/embeddingsComputer'; import { IEnvService } from '../../env/common/envService'; +import { getGithubMetadataHeaders } from '../../github/common/githubApiFetcherService'; import { ILogService } from '../../log/common/logService'; import { Response } from '../../networking/common/fetcherService'; import { getRequest } from '../../networking/common/networking'; diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestApi.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestApi.ts deleted file mode 100644 index 4c5a92a4ebe..00000000000 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestApi.ts +++ /dev/null @@ -1,275 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CallTracker } from '../../../../util/common/telemetryCorrelationId'; -import { raceCancellationError } from '../../../../util/vs/base/common/async'; -import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; -import { CancellationError } from '../../../../util/vs/base/common/errors'; -import { IDisposable } from '../../../../util/vs/base/common/lifecycle'; -import { getGithubMetadataHeaders } from '../../../chunking/common/chunkingEndpointClientImpl'; -import { IEnvService } from '../../../env/common/envService'; -import { ILogService } from '../../../log/common/logService'; - -// Sliding window that holds at least N entries and all entries in the time window. -// This allows the sliding window to always hold some entries if inserts are infrequent, -// but if inserts are frequent enough then time window behavior takes over. -class SlidingTimeAndNWindow implements IDisposable { - private values: number[] = []; - private times: number[] = []; - private sumValues = 0; - private numEntries: number; - private windowDurationMs: number; - private cleanupInterval: ReturnType | undefined; - - constructor(numEntries: number, windowDurationMs: number) { - this.numEntries = numEntries; - this.windowDurationMs = windowDurationMs; - this.startPeriodicCleanup(); - } - - dispose(): void { - if (typeof this.cleanupInterval !== 'undefined') { - clearInterval(this.cleanupInterval); - } - } - - increment(n: number): void { - this.values.push(n); - this.times.push(Date.now()); - this.sumValues += n; - } - - get(): number { - return this.sumValues; - } - - average(): number { - if (this.values.length === 0) { - return 0; - } - return this.sumValues / this.values.length; - } - - delta(): number { - if (this.values.length === 0) { - return 0; - } - return this.values[this.values.length - 1] - this.values[0]; - } - - last(): number { - if (this.values.length === 0) { - return 0; - } - return this.values[this.values.length - 1]; - } - - size(): number { - return this.values.length; - } - - windowDuration(): number { - if (this.times.length < 2) { - // Don't return 0 so that divide-by-zero doesn't happen - return 1; - } - return this.times[this.times.length - 1] - this.times[0]; - } - - reset(): void { - this.values = []; - this.times = []; - this.sumValues = 0; - } - - private startPeriodicCleanup(): void { - this.cleanupInterval = setInterval(() => { - const tooOldTime = Date.now() - this.windowDurationMs; - while ( - this.times.length > this.numEntries && - this.times[0] < tooOldTime - ) { - this.sumValues -= this.values[0]; - this.values.shift(); - this.times.shift(); - } - }, 100); - } -} - -class Throttler { - private target: number; - private lastSendTime: number; - private totalQuotaUsedWindow: SlidingTimeAndNWindow; - private sendPeriodWindow: SlidingTimeAndNWindow; - private numOutstandingRequests = 0; - - constructor(target: number) { - this.target = target; - this.lastSendTime = Date.now(); - this.totalQuotaUsedWindow = new SlidingTimeAndNWindow(5, 2000); - this.sendPeriodWindow = new SlidingTimeAndNWindow(5, 2000); - } - - reset(): void { - if (this.numOutstandingRequests === 0) { - this.lastSendTime = Date.now(); - this.totalQuotaUsedWindow.dispose(); - this.sendPeriodWindow.dispose(); - this.totalQuotaUsedWindow = new SlidingTimeAndNWindow(5, 2000); - this.sendPeriodWindow = new SlidingTimeAndNWindow(5, 2000); - } - } - - recordQuotaUsed(used: number): void { - this.totalQuotaUsedWindow.increment(used); - } - - requestStarted(): void { - this.numOutstandingRequests += 1; - } - - requestFinished(): void { - this.numOutstandingRequests -= 1; - } - - shouldSendRequest(): boolean { - const now = Date.now(); - - // This will probably result in sending a request. We want to send a request occasionally, - // even if it would otherwise fail, so that we can update our quota information. - if (now > this.lastSendTime + 5 * 60 * 1000) { - this.reset(); - } - - let shouldSend = false; - - // If there have been no requests, send one. - if (this.totalQuotaUsedWindow.get() === 0) { - shouldSend = true; - } - - // This is modeled on a PID controller, where we are trying to target a certain quota usage - // by adjusting the request frequency. The time from the last request (delayMs) is determined - // by taking the average of the recent "delays" as the baseline, and then adding an integral - // term (the recent quota usage - the target, converted into a duration) and a derivative term - // (error - previous error, which ends up being just the difference in the total quota used at - // different times, then converted into a duration as well). - if (this.sendPeriodWindow.average() > 0) { - const integral = - (this.totalQuotaUsedWindow.average() - this.target) / 100; - const differential = this.totalQuotaUsedWindow.delta(); - const delayMs = - this.sendPeriodWindow.average() * - Math.max(1 + 20 * integral + 0.5 * differential, 0.2); - if (now > this.lastSendTime + delayMs) { - shouldSend = true; - } - } - - // If this is the start of the throttler, then let's send the first N requests slowly - // so that we can build up some state based on the server, prior to potentially sending - // a bunch of concurrent requests. - if ( - this.totalQuotaUsedWindow.size() < 5 && - this.numOutstandingRequests > 0 - ) { - shouldSend = false; - } - - if (shouldSend) { - this.sendPeriodWindow.increment(now - this.lastSendTime); - this.lastSendTime = now; - } - return shouldSend; - } - - dispose(): void { - this.totalQuotaUsedWindow.dispose(); - this.sendPeriodWindow.dispose(); - } -} - -export const githubHeaders = Object.freeze({ - requestId: 'x-github-request-id', - totalQuotaUsed: 'x-github-total-quota-used', -}); - -/** - * This API client performs requests and will manage back-off when being rate limited - */ -export class ApiClient implements IDisposable { - private readonly throttler: Throttler | null; - - constructor( - target: number | null = 80, - @IEnvService private readonly envService: IEnvService, - @ILogService private readonly logService: ILogService, - ) { - if (target === null) { - this.throttler = null; - } else { - this.throttler = new Throttler(target); - } - } - - async makeRequest( - url: string, - headers: Record, - method: string, - body: unknown | undefined, - callerInfo: CallTracker, - token: CancellationToken, - ): Promise { - if (this.throttler) { - while (!this.throttler.shouldSendRequest()) { - // Sleep a little while so that we don't have a constantly running loop. - // We probably shouldn't send requests more than this frequently anyway. - await raceCancellationError(sleep(5), token); - } - this.throttler.requestStarted(); - } - - if (token.isCancellationRequested) { - throw new CancellationError(); - } - - try { - const res = await fetch(url, { - method, - headers: { - ...headers, - ...getGithubMetadataHeaders(callerInfo, this.envService), - }, - body: body ? JSON.stringify(body) : undefined, - }); - if (!res.ok) { - const requestId = res.headers.get(githubHeaders.requestId); - const responseBody = await res.text(); - this.logService.error(`${method} to ${url} request failed with status: '${res.status}', requestId: '${requestId}', body: ${responseBody}`); - return res; - } - const quotaUsedHeader = res.headers.get(githubHeaders.totalQuotaUsed); - const quotaUsed = quotaUsedHeader ? parseFloat(quotaUsedHeader) : 0; - if (this.throttler && quotaUsed > 0) { - this.throttler.recordQuotaUsed(quotaUsed); - } - return res; - } catch (e) { - this.logService.error(`${method} to ${url} request threw with error: ${e}`); - throw e; - } finally { - this.throttler?.requestFinished(); - } - } - - dispose(): void { - this.throttler?.dispose(); - } -} - -async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts index 193bf896c36..47f341f4b6c 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts @@ -15,14 +15,13 @@ import { CancellationError, isCancellationError } from '../../../../util/vs/base import { Disposable } from '../../../../util/vs/base/common/lifecycle'; import { URI } from '../../../../util/vs/base/common/uri'; import { Range } from '../../../../util/vs/editor/common/core/range'; -import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { IAuthenticationService } from '../../../authentication/common/authentication'; import { FileChunkAndScore } from '../../../chunking/common/chunk'; import { EmbeddingType } from '../../../embeddings/common/embeddingsComputer'; +import { githubHeaders, IGithubApiFetcherService } from '../../../github/common/githubApiFetcherService'; import { ILogService } from '../../../log/common/logService'; import { CodeSearchResult } from '../../../remoteCodeSearch/common/remoteCodeSearch'; import { ITelemetryService } from '../../../telemetry/common/telemetry'; -import { ApiClient, githubHeaders } from './externalIngestApi'; export interface ExternalIngestFile { @@ -62,26 +61,20 @@ export interface IExternalIngestClient { canIngestDocument(filePath: string, data: Uint8Array): boolean; } -// Create a shared API client with throttling (target quota usage of 80) -// You can change this to `null` to ignore the throttle - export class ExternalIngestClient extends Disposable implements IExternalIngestClient { private static readonly PROMISE_POOL_SIZE = 64; private static baseUrl = 'https://api.github.com'; private readonly _ingestFilter = new IngestFilter(); - private apiClient: ApiClient; constructor( - @IInstantiationService instantiationService: IInstantiationService, + @IGithubApiFetcherService private readonly githubApiFetcherService: IGithubApiFetcherService, @IAuthenticationService private readonly authenticationService: IAuthenticationService, @ILogService private readonly logService: ILogService, @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); - this.apiClient = this._register(instantiationService.createInstance(ApiClient, 80)); - setupPanicHooks(); } @@ -100,25 +93,25 @@ export class ExternalIngestClient extends Disposable implements IExternalIngestC return typeof result.failureReason === 'undefined'; } - private getHeaders(authToken: string): Record { - const headers: Record = { + private getHeaders(): Record { + return { 'Content-Type': 'application/json', }; - - headers['Authorization'] = `Bearer ${authToken}`; - - return headers; } private async post(authToken: string, path: string, body: unknown, options: { retries?: number }, callTracker: CallTracker, token: CancellationToken): Promise { const pathId = path.replace(/^\//, '').replace(/\//g, '-'); - const retries = options.retries ?? 0; const url = `${ExternalIngestClient.baseUrl}${path}`; - const response = await this.apiClient.makeRequest(url, this.getHeaders(authToken), 'POST', body, callTracker, token); - - // Retry on 500 errors as these are often transient - const shouldRetry = response.status.toString().startsWith('5') && retries > 0; + const response = await this.githubApiFetcherService.makeRequest({ + url, + headers: this.getHeaders(), + method: 'POST', + body, + authToken, + telemetry: { urlId: pathId, callerInfo: callTracker }, + retriesOn500: options.retries, + }, token); if (!response.ok) { /* __GDPR__ @@ -126,21 +119,13 @@ export class ExternalIngestClient extends Disposable implements IExternalIngestC "owner": "copilot-core", "comment": "Logging when a external ingest POST request fails", "path": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The API path that was called" }, - "statusCode": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The response status code" }, - "willRetry": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the request will be retried" } + "statusCode": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The response status code" } } */ this.telemetryService.sendMSFTTelemetryEvent('externalIngestClient.post.error', { path: pathId, - }, { statusCode: response.status, willRetry: shouldRetry ? 1 : 0 }); - } + }, { statusCode: response.status }); - if (shouldRetry) { - this.logService.warn(`ExternalIngestClient::post(${path}): Got ${response.status}, retrying... (${retries} retries remaining)`); - return this.post(authToken, path, body, { retries: retries - 1 }, callTracker, token); - } - - if (!response.ok) { this.logService.warn(`ExternalIngestClient::post(${path}): Got ${response.status}, request failed`); throw new Error(`POST to ${pathId} failed with status ${response.status}`); } @@ -455,14 +440,16 @@ export class ExternalIngestClient extends Disposable implements IExternalIngestC } private async listFilesetsWithDetails(authToken: string, callTracker: CallTracker, token: CancellationToken): Promise> { - const resp = await this.apiClient.makeRequest( - `${ExternalIngestClient.baseUrl}/external/code/ingest`, - this.getHeaders(authToken), - 'GET', - undefined, - callTracker.add('ExternalIngestClient::listFilesetsWithDetails'), - token - ); + const resp = await this.githubApiFetcherService.makeRequest({ + url: `${ExternalIngestClient.baseUrl}/external/code/ingest`, + headers: this.getHeaders(), + method: 'GET', + authToken, + telemetry: { + urlId: 'external-code-ingest', + callerInfo: callTracker.add('ExternalIngestClient::listFilesetsWithDetails') + }, + }, token); const body = await resp.json() as { filesets?: Array<{ name: string; checkpoint: string; status: string }>; max_filesets: number }; return body.filesets ?? []; @@ -493,16 +480,16 @@ export class ExternalIngestClient extends Disposable implements IExternalIngestC } async deleteFilesetByName(authToken: string, fileSetName: string, callTracker: CallTracker, token: CancellationToken): Promise { - const resp = await this.apiClient.makeRequest( - `${ExternalIngestClient.baseUrl}/external/code/ingest`, - this.getHeaders(authToken), - 'DELETE', - { + const resp = await this.githubApiFetcherService.makeRequest({ + url: `${ExternalIngestClient.baseUrl}/external/code/ingest`, + headers: this.getHeaders(), + method: 'DELETE', + body: { fileset_name: fileSetName, }, - callTracker.add('ExternalIngestClient::deleteFilesetByName'), - token - ); + authToken, + telemetry: { urlId: 'external-code-ingest', callerInfo: callTracker.add('ExternalIngestClient::deleteFilesetByName') }, + }, token); const requestId = resp.headers.get('x-github-request-id'); const respBody = await resp.text(); this.logService.debug(`ExternalIngestClient::deleteFilesetByName(): Delete response - requestId: '${requestId}', body: ${respBody}`);