豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 4cfbad9

Browse files
mjbvzitsibitziCopilot
authored
Resubmit #4381 (#4395)
* Add new rate limiter * Fix for service injections * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Sam Cutler <itsibitzi@github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 237e0fd commit 4cfbad9

File tree

1 file changed

+113
-73
lines changed

1 file changed

+113
-73
lines changed

src/platform/github/common/githubApiFetcherService.ts

Lines changed: 113 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CallTracker } from '../../../util/common/telemetryCorrelationId';
88
import { raceCancellationError } from '../../../util/vs/base/common/async';
99
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
1010
import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors';
11-
import { Disposable, dispose, IDisposable } from '../../../util/vs/base/common/lifecycle';
11+
import { Disposable, IDisposable } from '../../../util/vs/base/common/lifecycle';
1212
import { IEnvService } from '../../env/common/envService';
1313
import { ILogService } from '../../log/common/logService';
1414
import { ITelemetryService } from '../../telemetry/common/telemetry';
@@ -37,6 +37,7 @@ export interface GithubRequestOptions {
3737
export const githubHeaders = Object.freeze({
3838
requestId: 'x-github-request-id',
3939
totalQuotaUsed: 'x-github-total-quota-used',
40+
quotaBucketName: 'x-github-quota-bucket-name',
4041
});
4142

4243
/**
@@ -53,24 +54,16 @@ export interface IGithubApiFetcherService extends IDisposable {
5354
* If inserts are infrequent, the minimum-entry guarantee ensures there is always
5455
* some history to work with; when inserts are frequent the time window dominates.
5556
*/
56-
class SlidingTimeAndNWindow implements IDisposable {
57+
class SlidingTimeAndNWindow {
5758
private values: number[] = [];
5859
private times: number[] = [];
5960
private sumValues = 0;
6061
private readonly numEntries: number;
6162
private readonly windowDurationMs: number;
62-
private cleanupInterval: ReturnType<typeof setInterval> | undefined;
6363

6464
constructor(numEntries: number, windowDurationMs: number) {
6565
this.numEntries = numEntries;
6666
this.windowDurationMs = windowDurationMs;
67-
this.startPeriodicCleanup();
68-
}
69-
70-
dispose(): void {
71-
if (typeof this.cleanupInterval !== 'undefined') {
72-
clearInterval(this.cleanupInterval);
73-
}
7467
}
7568

7669
increment(n: number): void {
@@ -107,22 +100,25 @@ class SlidingTimeAndNWindow implements IDisposable {
107100
this.sumValues = 0;
108101
}
109102

110-
private startPeriodicCleanup(): void {
111-
this.cleanupInterval = setInterval(() => {
112-
const tooOldTime = Date.now() - this.windowDurationMs;
113-
while (
114-
this.times.length > this.numEntries &&
115-
this.times[0] < tooOldTime
116-
) {
117-
this.sumValues -= this.values[0];
118-
this.values.shift();
119-
this.times.shift();
120-
}
121-
}, 100);
103+
/**
104+
* Removes entries that are both outside the time window and exceed the
105+
* minimum entry count. Called explicitly before throttle decisions so
106+
* that the window reflects the current state.
107+
*/
108+
cleanUpOldValues(now: number): void {
109+
const tooOldTime = now - this.windowDurationMs;
110+
while (
111+
this.times.length > this.numEntries &&
112+
this.times[0] < tooOldTime
113+
) {
114+
this.sumValues -= this.values[0];
115+
this.values.shift();
116+
this.times.shift();
117+
}
122118
}
123119
}
124120

125-
class Throttler implements IDisposable {
121+
class Throttler {
126122
private readonly target: number;
127123
private lastSendTime: number;
128124
private totalQuotaUsedWindow: SlidingTimeAndNWindow;
@@ -139,8 +135,6 @@ class Throttler implements IDisposable {
139135
reset(): void {
140136
if (this.numOutstandingRequests === 0) {
141137
this.lastSendTime = Date.now();
142-
this.totalQuotaUsedWindow.dispose();
143-
this.sendPeriodWindow.dispose();
144138
this.totalQuotaUsedWindow = new SlidingTimeAndNWindow(5, 2000);
145139
this.sendPeriodWindow = new SlidingTimeAndNWindow(5, 2000);
146140
}
@@ -163,9 +157,7 @@ class Throttler implements IDisposable {
163157
* sent right now or deferred. It uses sliding windows of recent quota
164158
* usage and send periods to compute proportional, integral, and
165159
* differential terms, which in turn determine a dynamic delay before
166-
* sending the next request. The ramp-up logic at the end ensures we
167-
* start slowly and calibrate based on server feedback before allowing
168-
* higher concurrency.
160+
* sending the next request.
169161
*/
170162
shouldSendRequest(): boolean {
171163
const now = Date.now();
@@ -175,13 +167,24 @@ class Throttler implements IDisposable {
175167
this.reset();
176168
}
177169

178-
let shouldSend = false;
170+
this.totalQuotaUsedWindow.cleanUpOldValues(now);
171+
this.sendPeriodWindow.cleanUpOldValues(now);
179172

180-
if (this.totalQuotaUsedWindow.get() === 0) {
181-
shouldSend = true;
173+
// Ramp up slowly at start so the throttler can calibrate based on
174+
// server feedback before allowing concurrent requests.
175+
if (
176+
this.totalQuotaUsedWindow.size() < 5 &&
177+
this.numOutstandingRequests > 0
178+
) {
179+
return false;
182180
}
183181

184-
if (this.sendPeriodWindow.average() > 0) {
182+
let shouldSend = false;
183+
184+
// If there have been no requests, send one.
185+
if (this.totalQuotaUsedWindow.get() === 0 || this.sendPeriodWindow.size() === 0) {
186+
shouldSend = true;
187+
} else if (this.sendPeriodWindow.average() > 0) {
185188
const integral =
186189
(this.totalQuotaUsedWindow.average() - this.target) / 100;
187190
const differential = this.totalQuotaUsedWindow.delta();
@@ -193,35 +196,28 @@ class Throttler implements IDisposable {
193196
}
194197
}
195198

196-
// Ramp up slowly at start so the throttler can calibrate based on
197-
// server feedback before allowing concurrent requests.
198-
if (
199-
this.totalQuotaUsedWindow.size() < 5 &&
200-
this.numOutstandingRequests > 0
201-
) {
202-
shouldSend = false;
203-
}
204-
205199
if (shouldSend) {
206200
this.sendPeriodWindow.increment(now - this.lastSendTime);
207201
this.lastSendTime = now;
208202
}
209203
return shouldSend;
210204
}
211-
212-
dispose(): void {
213-
this.totalQuotaUsedWindow.dispose();
214-
this.sendPeriodWindow.dispose();
215-
}
216205
}
217206

218207
export class GithubApiFetcherService extends Disposable implements IGithubApiFetcherService {
219208
declare readonly _serviceBrand: undefined;
220209

210+
/**
211+
* The target percentage usage of each throttler. Higher is faster but too close to 100 and you
212+
* can have queries rejected
213+
*/
214+
private readonly throttlerTarget = 80;
215+
/** Quota-bucket name → {@link Throttler} that governs requests in that bucket. */
221216
private readonly throttlers = new Map<string, Throttler>();
217+
/** `"METHOD url"` → quota-bucket name, learned from response headers. */
218+
private readonly endpointBuckets = new Map<string, string>();
222219

223220
constructor(
224-
private readonly throttlerTarget: number = 80,
225221
@IEnvService private readonly envService: IEnvService,
226222
@ILogService private readonly logService: ILogService,
227223
@ITelemetryService private readonly telemetryService: ITelemetryService,
@@ -230,9 +226,50 @@ export class GithubApiFetcherService extends Disposable implements IGithubApiFet
230226
}
231227

232228
override dispose(): void {
233-
super.dispose();
234-
dispose(this.throttlers.values());
235229
this.throttlers.clear();
230+
this.endpointBuckets.clear();
231+
super.dispose();
232+
}
233+
234+
/**
235+
* Computes a normalized key for an endpoint, combining HTTP method and URL
236+
* pathname. This avoids fragmenting throttling state across different query
237+
* strings for the same logical endpoint.
238+
*/
239+
private getEndpointKey(method: string, url: string): string {
240+
try {
241+
const parsed = new URL(url);
242+
return `${method} ${parsed.pathname}`;
243+
} catch {
244+
// Fall back to the raw URL if it cannot be parsed (e.g. relative URL),
245+
// preserving existing behavior in those cases.
246+
return `${method} ${url}`;
247+
}
248+
}
249+
250+
/**
251+
* Returns the throttler for a given endpoint (method + pathname) by looking
252+
* up its quota bucket. Returns `undefined` for endpoints whose bucket is not
253+
* yet known (i.e. no prior response has provided the bucket header).
254+
*/
255+
private getThrottlerForEndpoint(method: string, url: string): Throttler | undefined {
256+
const endpointKey = this.getEndpointKey(method, url);
257+
const bucket = this.endpointBuckets.get(endpointKey);
258+
return bucket ? this.throttlers.get(bucket) : undefined;
259+
}
260+
261+
/**
262+
* Updates the endpoint → quota-bucket and bucket → throttler mappings from
263+
* response headers. Creates a new throttler on demand when a bucket is seen
264+
* for the first time.
265+
*/
266+
private updateThrottlers(method: string, url: string, bucket: string, quotaUsed: number): void {
267+
if (!this.throttlers.has(bucket)) {
268+
this.throttlers.set(bucket, new Throttler(this.throttlerTarget));
269+
}
270+
this.throttlers.get(bucket)!.recordQuotaUsed(quotaUsed);
271+
const endpointKey = this.getEndpointKey(method, url);
272+
this.endpointBuckets.set(endpointKey, bucket);
236273
}
237274

238275
async makeRequest(options: GithubRequestOptions, token: CancellationToken): Promise<Response> {
@@ -245,17 +282,18 @@ export class GithubApiFetcherService extends Disposable implements IGithubApiFet
245282
retriesOn500Remaining: number,
246283
retryOnRateLimitedRemaining: number,
247284
): Promise<Response> {
248-
const throttler = this.getThrottler(options.url);
249-
250-
// Throttle
251-
while (!throttler.shouldSendRequest()) {
252-
await raceCancellationError(sleep(5), token);
253-
}
254-
if (token.isCancellationRequested) {
255-
throw new CancellationError();
285+
// Throttle based on the URL's quota bucket (if known from prior responses)
286+
const throttler = this.getThrottlerForEndpoint(options.method, options.url);
287+
if (throttler) {
288+
while (!throttler.shouldSendRequest()) {
289+
await raceCancellationError(sleep(5), token);
290+
}
291+
if (token.isCancellationRequested) {
292+
throw new CancellationError();
293+
}
256294
}
257295

258-
throttler.requestStarted();
296+
throttler?.requestStarted();
259297
try {
260298
const res = await fetch(options.url, {
261299
method: options.method,
@@ -268,10 +306,23 @@ export class GithubApiFetcherService extends Disposable implements IGithubApiFet
268306
});
269307

270308
// Record quota usage for throttle calibration
309+
// Record quota usage for throttle calibration, keyed by bucket. If the bucket name is not in the headers use a
310+
// fake __global__ bucket.
311+
const bucketNameHeader = res.headers.get(githubHeaders.quotaBucketName);
312+
const bucketName = bucketNameHeader || '__global__';
271313
const quotaUsedHeader = res.headers.get(githubHeaders.totalQuotaUsed);
272-
const quotaUsed = quotaUsedHeader ? parseFloat(quotaUsedHeader) : 0;
273-
if (quotaUsed > 0) {
274-
throttler.recordQuotaUsed(quotaUsed);
314+
315+
// Learn the endpoint → bucket mapping whenever we have a bucket header, even if quota-used is missing.
316+
if (bucketNameHeader && quotaUsedHeader === null) {
317+
this.updateThrottlers(options.method, options.url, bucketName, 0);
318+
}
319+
320+
// Only record quota usage when the parsed value is finite and greater than zero.
321+
if (quotaUsedHeader !== null) {
322+
const quotaUsed = parseFloat(quotaUsedHeader);
323+
if (Number.isFinite(quotaUsed) && quotaUsed > 0) {
324+
this.updateThrottlers(options.method, options.url, bucketName, quotaUsed);
325+
}
275326
}
276327

277328
if (!res.ok) {
@@ -333,20 +384,9 @@ export class GithubApiFetcherService extends Disposable implements IGithubApiFet
333384
}
334385
throw e;
335386
} finally {
336-
throttler.requestFinished();
387+
throttler?.requestFinished();
337388
}
338389
}
339-
340-
private getThrottler(urlId: string): Throttler {
341-
const existingThrottler = this.throttlers.get(urlId);
342-
if (existingThrottler) {
343-
return existingThrottler;
344-
}
345-
346-
const throttler = new Throttler(this.throttlerTarget);
347-
this.throttlers.set(urlId, throttler);
348-
return throttler;
349-
}
350390
}
351391

352392
async function sleep(ms: number): Promise<void> {

0 commit comments

Comments
 (0)