Add new rate limiter#4381
Conversation
|
@microsoft-github-policy-service agree company="GitHub" |
There was a problem hiding this comment.
Pull request overview
Implements multi-bucket client-side throttling for GitHub API requests by learning a “quota bucket” from response headers and maintaining per-bucket throttlers, while also simplifying the sliding-window implementation by removing the background cleanup task.
Changes:
- Add support for reading a new
x-github-quota-bucket-nameresponse header. - Replace the single global throttler with a per-quota-bucket throttler map (endpoint → bucket → throttler).
- Remove interval-based cleanup from the sliding window and perform cleanup on-demand during throttle decisions; adjust ramp-up logic.
Comments suppressed due to low confidence (1)
src/platform/github/common/githubApiFetcherService.ts:295
quotaUsedparsing usesparseFloat(quotaUsedHeader) || 0, which coerces any non-numeric header value to0and then records it into the throttler. This can skew throttling decisions (and interacts with theget() === 0fast-path) if the header is missing/empty/invalid. Prefer validating the parsed value (e.g.Number.isFinite) and skipping the sample when it is not a valid number.
// Record quota usage for throttle calibration, keyed by bucket. If the bucket name is not in the headers use a
// fake __global__ bucket.
const bucketName = res.headers.get(githubHeaders.quotaBucketName) || '__global__';
const quotaUsedHeader = res.headers.get(githubHeaders.totalQuotaUsed);
if (quotaUsedHeader !== null) {
const quotaUsed = parseFloat(quotaUsedHeader) || 0;
this.updateThrottlers(options.method, options.url, bucketName, quotaUsed);
}
| let shouldSend = false; | ||
|
|
||
| // If there have been no requests, send one. | ||
| if (this.totalQuotaUsedWindow.get() === 0 || this.sendPeriodWindow.size() === 0) { |
There was a problem hiding this comment.
shouldSendRequest treats totalQuotaUsedWindow.get() === 0 as "no requests". Since recordQuotaUsed can be fed 0 (e.g. when the header value is "0" or if parsing fails and falls back to 0), this condition can incorrectly bypass the PID delay logic and effectively disable throttling after ramp-up. Consider checking totalQuotaUsedWindow.size() === 0 to detect the "no requests" case, and/or ignoring non-finite/zero quota samples if 0 is not a meaningful signal from the server.
This issue also appears on line 288 of the same file.
| if (this.totalQuotaUsedWindow.get() === 0 || this.sendPeriodWindow.size() === 0) { | |
| if (this.totalQuotaUsedWindow.size() === 0 || this.sendPeriodWindow.size() === 0) { |
| private readonly throttlers = new Map<string, Throttler>(); | ||
| /** `"METHOD url"` → quota-bucket name, learned from response headers. */ | ||
| private readonly endpointBuckets = new Map<string, string>(); | ||
|
|
||
| constructor( | ||
| target: number = 80, | ||
| throttlerTarget: 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)); | ||
| this.throttlerTarget = throttlerTarget; | ||
| } | ||
|
|
||
| override dispose(): void { | ||
| this.throttlers.clear(); | ||
| this.endpointBuckets.clear(); | ||
| super.dispose(); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the throttler for a given endpoint (method + URL) by looking up | ||
| * its quota bucket. Returns `undefined` for endpoints whose bucket is not | ||
| * yet known (i.e. no prior response has provided the bucket header). | ||
| */ | ||
| private getThrottlerForEndpoint(method: string, url: string): Throttler | undefined { | ||
| const bucket = this.endpointBuckets.get(`${method} ${url}`); | ||
| return bucket ? this.throttlers.get(bucket) : undefined; | ||
| } |
There was a problem hiding this comment.
endpointBuckets keys endpoints by the full url string ("METHOD url"). If callers include query strings or other per-request variants in options.url, this can lead to unbounded growth of endpointBuckets and also prevent reusing the learned quota bucket for what is logically the same RPC. Consider keying by a stable identifier (e.g. options.telemetry.urlId) and/or normalizing the URL (strip query, canonicalize path parameters) before using it as a map key.
|
Awesome! Thanks for handling this. I fixed the merge conflicts and opened a new PR here: #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>
This implements the multibucket client side rate limiter for blackbird. This is required because we made it so that different RPCs use different quotas (you need to be able to upload very quickly when uploading documents, but we dont want you to be able to request hundreds of new checkpoints per second).
It also brings the sliding timing and N window up to where the reference implementation is. This mainly just clears out a kinda pointless background task and adds a slow start.
This is not my code base so I may have messed up some stuff stylistically, feel free to push commits on top or let me know if there's anything you'd like different.
I did a test of uploading some things and was able to get around 100 document per second.