Coalesce content exclusion fetches to stop exhausting the GitHub API rate limit - #328268
Merged
Conversation
…rate limit Every caller that discovered a new repository triggered a refresh of the content exclusion rules for *every* known repository, so request volume grew quadratically with repository count. In a workspace with many git repos (an AOSP checkout, in the reported case) this produced ~16k requests to api.github.com in 30 minutes, exhausting the account's 5k/hour REST budget and starving everything sharing it, including Copilot token refresh. The endpoint is https://api.github.com/copilot_internal/content_exclusion, so it draws on the user's ordinary REST quota rather than a CAPI budget. - Coalesce per repository using shared DeferredPromises, a short batching window and a bounded-concurrency Limiter, so each repo is fetched at most once per TTL and each caller only waits on the repos it asked for. A regression test measures 75 requests -> 3 for 26 repositories. - Only cache rules on a successful response. Empty placeholder rules were written on discovery and left in place on failure, making a failed fetch indistinguishable from "this repo has no exclusions" and preventing a retry for 30 minutes, so exclusions silently stopped applying while rate limited. - Only memoise a negative verdict once the relevant rules actually loaded, which otherwise left files checked during an outage permanently allowed. - Add a shared rateLimitBackoffMiddleware covering 429 and quota-exhausted 403 responses, honouring Retry-After and x-ratelimit-reset, and move both RemoteContentExclusion and CloudSessionApiClient onto it. This replaces a third hand-rolled copy of the same backoff logic. - Precompile glob patterns, track the regex rule count directly, and only invalidate memoised results when rules that could change an outcome arrive. Fixes #322275 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32f94728-158b-4639-b789-a5dd483f043f
Contributor
There was a problem hiding this comment.
🟡 Not ready to approve
Cache freshness and in-flight coalescing defects can still bypass exclusions or exhaust the API quota.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Reduces GitHub API usage from content-exclusion refreshes and centralizes rate-limit handling.
Changes:
- Coalesces and concurrently limits content-exclusion requests.
- Adds shared rate-limit backoff middleware.
- Migrates cloud-session requests to the middleware.
File summaries
| File | Description |
|---|---|
rateLimitBackoffMiddleware.ts |
Implements shared rate-limit backoff. |
rateLimitBackoffMiddleware.spec.ts |
Tests backoff behavior. |
remoteContentExclusion.ts |
Adds batching, caching, and compiled rules. |
remoteContentExclusion.spec.ts |
Tests coalescing, recovery, and matching. |
mockCAPIClientService.ts |
Expands content-exclusion test support. |
cloudSessionApiClient.ts |
Centralizes cloud request handling. |
cloudSessionApiClient.spec.ts |
Tests cloud rate-limit behavior. |
Review details
Comments suppressed due to low confidence (1)
extensions/copilot/src/platform/ignore/node/remoteContentExclusion.ts:304
- After an expired entry fails to refresh, the stale cache entry still makes this return
true.isIgnoredthen memoizes a negative verdict as though fresh rules loaded, causing future checks of that URI to skip retries and potentially bypass newly added exclusions. Recheck freshness after the waits, not just key presence.
for (const url of required) {
if (!this._contentExclusionCache.has(url)) {
return false;
}
- Files reviewed: 7/7 changed files
- Comments generated: 5
- Review effort level: Medium
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Five correctness issues raised in review, each with a test that fails against the previous implementation. - rateLimitBackoffMiddleware: a response that was already in flight could clear a block established by a concurrent rate-limited request, letting later calls reach the server during the window the server asked us to wait out. The backoff is now only reset once the active block has elapsed. - isIgnored returned a memoised verdict before reaching the staleness check, so a URI that had been evaluated once never triggered a refresh and could miss newly added exclusions indefinitely. Verdicts are now tagged with a rule generation and are only trusted while the rules behind them are unchanged and unexpired. - applyRules only invalidated verdicts when the incoming rules were non-empty, so a refresh that removed the last rule left files excluded permanently. Incoming rules are now compared against the previous set, which also avoids invalidating on an unchanged refresh. - drainPendingRepos cleared the pending map before its batches completed, so a lookup arriving while a request was slow queued a duplicate fetch every batching window. Entries now stay registered until their request settles, and are removed only if still owned by that attempt. - dispose only settled repos still queued. Limiter.dispose drops queued factories without running them, so with more than five batches the callers awaiting them never resolved. Pending entries are now settled before the limiter is disposed, and enqueues after disposal resolve immediately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32f94728-158b-4639-b789-a5dd483f043f
roblourens
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #322275
The problem
A user with many git repos in one workspace (an AOSP checkout) hit
API rate limit exceededevery few prompts. Their log shows 463 completed content exclusion refreshes and 16,374 failed batch requests in ~30 minutes.RemoteContentExclusion.makeContentExclusionRequestlet every waiting caller start its own refresh:...and each refresh re-fetched every cached repo, because
_contentExclusionRequestsnapshotted the whole cache. Request volume therefore grew quadratically with repo count.Changes
Request coalescing. Per-repo
DeferredPromises, a 50ms batching window (measured from the first enqueue so a steady trickle can't starve it), and aLimiter(5)so batches run concurrently rather than serially. Each caller waits only on its own repos, so a large background load can't stall a single file check.Measured by stashing the new implementation and running the new test against the original code:
That's 26 repos; it's quadratic, so this is the ~16,000 → ~35 difference at the reporter's scale.
Two fail-open bugs. Both caused exclusions to silently stop applying during a rate-limit window:
shouldFetchContentExclusionRulesthen returnedfalse, blocking any retry for 30 minutes. Rules are now cached only on a successful response._ignoreGlobResultCache, so a file checked during an outage stayed allowed permanently.ensureRulesLoadednow reports whether rules actually loaded, and the verdict is only memoised when they did.Shared rate-limit middleware.
shared-fetch-utilsalready hadauthBlockedMiddleware(401/403),serverErrorBackoffMiddleware(5xx) andetagMiddleware, but nothing for 429 /Retry-After— a gap that had been filled by hand in three places. NewrateLimitBackoffMiddlewarecovers 429 and quota-exhausted 403, honoursRetry-Afterandx-ratelimit-remaining/x-ratelimit-reset, and caps any wait (including a server hint) atmaxDelayMsso a bogus header can't stall the client.RemoteContentExclusionnow composes:Order is load-bearing and pinned by a test: rate-limit must sit inside auth-blocked, or
authBlockedMiddlewarethrows on the 403 first and a rate limit gets misread as an auth failure, blocking the token for an hour instead of waiting for the actual reset.CloudSessionApiClientmigrated onto the same middleware. Removes_handleRateLimit, the dead write-only_rateLimitCount, and theisRateLimited()+_buildRequest+ fetch + 429 preamble repeated across six methods, replaced by one_fetchhelper.isRateLimited()andonRateLimitedare preserved — the exporter polls the former synchronously to skip a flush cycle, so it stays as a projection of the middleware's window rather than a second implementation.Deliberately not the full stack there:
403meanspolicy_blockedfor that API (so noauthBlockedMiddleware), and 5xx backoff is already owned by the exporter'sCircuitBreaker(so noserverErrorBackoffMiddleware).Performance. Glob patterns precompile into
Minimatchinstances behind a flat list instead of recompiling every pattern on everyisIgnoredcall;isRegexContextExclusionsEnabledis a counter rather than a full cache scan; memo caches are only invalidated when rules that could change an outcome arrive; per-entry TTL replaces the blanket 30-minute refresh; malformed server patterns are skipped rather than throwing on every lookup.Testing
288 tests pass across the touched areas; typecheck and eslint clean.
RemoteContentExclusiontests: coalescing, incremental fetch, empty-ruleset caching, TTL expiry, failed-fetch retry, backoff, rate-limit reset, glob matching, malformed patterns.CloudSessionApiClienttests. The pre-existing ones (429 -> rate_limited,403 -> policy_blocked,500 -> error) pass unmodified, which is the main evidence that migration is behavior-preserving.Clocks are injected via optional constructor/option params defaulting to
Date.now, so TTL and backoff are tested without stubbing globals.Notes for reviewers
etagMiddlewareis intentionally omitted. GitHub 304s don't count against the rate limit, so it looks like a free win, but it holds a single etag slot per instance while our URL varies per batch (?repos=a,b,c) — it could serve batch A's rules for batch B. It needs to be keyed by request first.FetchedValuewas not used. It models one endpoint → one value; content exclusion is a keyed, batched cache (N repos → ⌈N/10⌉ requests), and oneFetchedValueper repo would make batching impossible.capiClientFetchedValuecomposes etag + authBlocked + serverErrorBackoff but not rate-limit, so everyFetchedValue-backed CAPI endpoint still lacks 429 handling. One-line fix, but it affects every consumer, so I left it out of this PR._ignoreGlobResultCache/_ignoreRegexResultCacheremain unbounded. Real concern on a million-file workspace, but eviction changes hit-rate characteristics and deserves its own change.