Tier GitHub PR lookup polling to prevent quota exhaustion (#12013)

* Tier GitHub PR lookup polling to prevent quota exhaustion

The selected worktree (O(1)) checks per-minute; card list (O(N)) per-15-minutes.
Introduce process-wide cache to collapse concurrent polling and gate lookups on
available rate-limit budget with exponential backoff on failure.

- Preserve last-known review during backoff
- Invalidate cache when Orca opens a PR
- Stop coordinator from double-charging

* Tier GitHub PR lookup polling to prevent quota exhaustion

- Return the latest reset time when both GitHub API buckets are rate-limited, preventing premature retries against still-blocked buckets.
- Serve the last known review on transient lookup failures, preventing reviews from blinking out on temporary errors.
- Discard in-flight lookups that predate an invalidation so stale answers cannot overwrite newly opened reviews.

* fix: give rate-limit reset tests unique titles

oxlint vitest/no-identical-title was failing static analysis because two
cases shared the same describe title.
This commit is contained in:
Jinjing
2026-08-01 15:50:54 -07:00
committed by GitHub
parent a7d769e13d
commit 2f104d8713
24 changed files with 1070 additions and 53 deletions
+68
View File
@@ -170,6 +170,7 @@ import {
getPRComments,
getPRForBranch,
getPRForBranchOutcome,
getGitHubPRLookupRateLimitBlock,
getRepoSlug,
getRepoUpstream,
getWorkItem,
@@ -4609,3 +4610,70 @@ describe('GitHub GraphQL rate-limit guard', () => {
expect(noteRateLimitSpendMock).not.toHaveBeenCalled()
})
})
describe('getGitHubPRLookupRateLimitBlock', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
getRemoteUrlForRepoMock.mockReset()
gitExecFileAsyncMock.mockReset()
getRateLimitMock.mockReset()
getRateLimitMock.mockResolvedValue(undefined)
rateLimitGuardMock.mockReset()
rateLimitGuardMock.mockReturnValue({ blocked: false })
noteRateLimitSpendMock.mockReset()
_resetOwnerRepoCache()
})
it('reports no block while every lookup bucket has budget', async () => {
await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toBeNull()
expect(getRateLimitMock).toHaveBeenCalled()
})
it('reports a block when either lookup bucket is exhausted', async () => {
rateLimitGuardMock.mockImplementation(((bucket: string) =>
bucket === 'graphql'
? { blocked: true, remaining: 4, limit: 5000, resetAt: 1_800_000_000 }
: { blocked: false }) as () => RateLimitGuardResult)
await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({
resetAt: 1_800_000_000
})
})
it('reports the latest reset when both lookup buckets are exhausted', async () => {
rateLimitGuardMock.mockImplementation(((bucket: string) => ({
blocked: true,
remaining: 4,
limit: 5000,
// Why: core resets first, so returning it would retry into graphql's block.
resetAt: bucket === 'core' ? 1_800_000_000 : 1_800_003_600
})) as () => RateLimitGuardResult)
await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({
resetAt: 1_800_003_600
})
})
it('reports the later reset when graphql outlasts core', async () => {
// Retrying at the earlier reset would fail again on the bucket still blocked.
rateLimitGuardMock.mockImplementation(((bucket: string) => ({
blocked: true,
remaining: 0,
limit: 5000,
resetAt: bucket === 'graphql' ? 1_800_000_600 : 1_800_000_000
})) as () => RateLimitGuardResult)
await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({
resetAt: 1_800_000_600
})
})
it('fails open when the exempt rate-limit probe itself fails', async () => {
getRateLimitMock.mockRejectedValue(new Error('probe offline'))
await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toBeNull()
})
})