Skip to content

Coalesce content exclusion fetches to stop exhausting the GitHub API rate limit - #328268

Merged
lramos15 merged 4 commits into
mainfrom
lramos15/working-guineafowl
Jul 31, 2026
Merged

Coalesce content exclusion fetches to stop exhausting the GitHub API rate limit#328268
lramos15 merged 4 commits into
mainfrom
lramos15/working-guineafowl

Conversation

@lramos15

@lramos15 lramos15 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Fixes #322275

The problem

A user with many git repos in one workspace (an AOSP checkout) hit API rate limit exceeded every few prompts. Their log shows 463 completed content exclusion refreshes and 16,374 failed batch requests in ~30 minutes.

RemoteContentExclusion.makeContentExclusionRequest let every waiting caller start its own refresh:

if (this._contentExclusionFetchPromise) {
    await this._contentExclusionFetchPromise;
}
// every waiting caller then continues here and starts another refresh

...and each refresh re-fetched every cached repo, because _contentExclusionRequest snapshotted 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 a Limiter(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:

requests repo-slots sent unique repos
before 75 650 26
after 3 26 26

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:

  • Empty placeholder rules were written on discovery and left in place on failure, so a failed fetch was indistinguishable from "no rules" — and shouldFetchContentExclusionRules then returned false, blocking any retry for 30 minutes. Rules are now cached only on a successful response.
  • Even after that, a negative verdict was memoised into _ignoreGlobResultCache, so a file checked during an outage stayed allowed permanently. ensureRulesLoaded now reports whether rules actually loaded, and the verdict is only memoised when they did.

Shared rate-limit middleware. shared-fetch-utils already had authBlockedMiddleware (401/403), serverErrorBackoffMiddleware (5xx) and etagMiddleware, but nothing for 429 / Retry-After — a gap that had been filled by hand in three places. New rateLimitBackoffMiddleware covers 429 and quota-exhausted 403, honours Retry-After and x-ratelimit-remaining/x-ratelimit-reset, and caps any wait (including a server hint) at maxDelayMs so a bogus header can't stall the client.

RemoteContentExclusion now composes:

composeFetchMiddleware(
    authBlockedMiddleware(),              // bare 401/403 -> block token
    rateLimitBackoffMiddleware({ now }),  // 429 / quota 403 -> server-hinted wait
    serverErrorBackoffMiddleware(),       // 5xx -> exponential
)

Order is load-bearing and pinned by a test: rate-limit must sit inside auth-blocked, or authBlockedMiddleware throws 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.

CloudSessionApiClient migrated onto the same middleware. Removes _handleRateLimit, the dead write-only _rateLimitCount, and the isRateLimited() + _buildRequest + fetch + 429 preamble repeated across six methods, replaced by one _fetch helper. isRateLimited() and onRateLimited are 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: 403 means policy_blocked for that API (so no authBlockedMiddleware), and 5xx backoff is already owned by the exporter's CircuitBreaker (so no serverErrorBackoffMiddleware).

Performance. Glob patterns precompile into Minimatch instances behind a flat list instead of recompiling every pattern on every isIgnored call; isRegexContextExclusionsEnabled is 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.

  • 9 new RemoteContentExclusion tests: coalescing, incremental fetch, empty-ruleset caching, TTL expiry, failed-fetch retry, backoff, rate-limit reset, glob matching, malformed patterns.
  • 7 new middleware tests, including that a plain 403 is not treated as a rate limit.
  • 3 new CloudSessionApiClient tests. 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

  • Fail-open is preserved, not fixed. When rules can't be fetched, files are still treated as not excluded — this change makes that state transient and retryable rather than permanent. Fail-closed would be safer for content exclusion but would break Copilot entirely during a GitHub outage. Worth a separate discussion.
  • etagMiddleware is 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.
  • FetchedValue was not used. It models one endpoint → one value; content exclusion is a keyed, batched cache (N repos → ⌈N/10⌉ requests), and one FetchedValue per repo would make batching impossible.
  • Follow-up: capiClientFetchedValue composes etag + authBlocked + serverErrorBackoff but not rate-limit, so every FetchedValue-backed CAPI endpoint still lacks 429 handling. One-line fix, but it affects every consumer, so I left it out of this PR.
  • _ignoreGlobResultCache / _ignoreRegexResultCache remain unbounded. Real concern on a million-file workspace, but eviction changes hit-rate characteristics and deserves its own change.

…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
Copilot AI review requested due to automatic review settings July 30, 2026 20:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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. isIgnored then 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.

Comment thread extensions/copilot/src/platform/ignore/node/remoteContentExclusion.ts Outdated
Comment thread extensions/copilot/src/platform/ignore/node/remoteContentExclusion.ts Outdated
Comment thread extensions/copilot/src/platform/ignore/node/remoteContentExclusion.ts Outdated
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
@lramos15
lramos15 merged commit 9bbf5e4 into main Jul 31, 2026
29 checks passed
@lramos15
lramos15 deleted the lramos15/working-guineafowl branch July 31, 2026 12:37
@vs-code-engineering vs-code-engineering Bot added this to the 1.132.0 milestone Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitHub API limit frequently exhausted

3 participants