@@ -8,7 +8,7 @@ import { CallTracker } from '../../../util/common/telemetryCorrelationId';
88import { raceCancellationError } from '../../../util/vs/base/common/async' ;
99import { CancellationToken } from '../../../util/vs/base/common/cancellation' ;
1010import { 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' ;
1212import { IEnvService } from '../../env/common/envService' ;
1313import { ILogService } from '../../log/common/logService' ;
1414import { ITelemetryService } from '../../telemetry/common/telemetry' ;
@@ -37,6 +37,7 @@ export interface GithubRequestOptions {
3737export 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
218207export 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
352392async function sleep ( ms : number ) : Promise < void > {
0 commit comments