diff --git a/docs/site/content/docs/github-errors.mdx b/docs/site/content/docs/github-errors.mdx index b6b758f102d..90a6990ecec 100644 --- a/docs/site/content/docs/github-errors.mdx +++ b/docs/site/content/docs/github-errors.mdx @@ -9,14 +9,14 @@ This page covers the errors you’ll see most often and how to fix them. ## Quick triage -| What you see | Likely cause | First thing to try | -| ----------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| “GitHub is rate-limiting requests” / “rate limit exceeded (core)” | GitHub REST (core) quota exhausted for your user | Wait for reset; stop extra `gh` / agent / Orca usage; check [Settings → Git → GitHub API Budget](/docs/settings) | -| “GitHub authentication is unavailable” / `gh auth` prompts | `gh` not logged in, expired token, or bad `GITHUB_TOKEN` | `gh auth status`, then `gh auth login` | -| “GitHub did not allow access” / HTTP 403 (not rate limit) | Missing scopes or no access to the repo | Re-auth with `repo` (and needed org SSO); confirm you can open the PR in the browser | -| “repository is unavailable” / HTTP 404 | Wrong remote, private repo without access, or renamed repo | Check `git remote -v` and browser access | -| “GitHub is unreachable” / timeouts | Network, proxy, VPN, or GitHub outage | Check [githubstatus.com](https://www.githubstatus.com/); retry off VPN | -| “GitHub CLI is unavailable” | `gh` missing from PATH Orca uses | Install `gh` and restart Orca | +| What you see | Likely cause | First thing to try | +| -------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| “GitHub is rate-limiting requests” / “rate limit exceeded (core)” or “(graphql)” | GitHub REST (core) or GraphQL quota exhausted for your user | Wait for reset; stop extra `gh` / agent / Orca usage; check [Settings → Git → GitHub API Budget](/docs/settings) | +| “GitHub authentication is unavailable” / `gh auth` prompts | `gh` not logged in, expired token, or bad `GITHUB_TOKEN` | `gh auth status`, then `gh auth login` | +| “GitHub did not allow access” / HTTP 403 (not rate limit) | Missing scopes or no access to the repo | Re-auth with `repo` (and needed org SSO); confirm you can open the PR in the browser | +| “repository is unavailable” / HTTP 404 | Wrong remote, private repo without access, or renamed repo | Check `git remote -v` and browser access | +| “GitHub is unreachable” / timeouts | Network, proxy, VPN, or GitHub outage | Check [githubstatus.com](https://www.githubstatus.com/); retry off VPN | +| “GitHub CLI is unavailable” | `gh` missing from PATH Orca uses | Install `gh` and restart Orca | ## Rate limits (most common) @@ -24,13 +24,13 @@ GitHub gives each **authenticated user** a shared hourly budget. **Every tool on ### Buckets Orca cares about -| Bucket | What it covers | Typical limit (authenticated) | -| --------------- | ---------------------------------------------------------------------------- | ----------------------------- | -| **REST (core)** | Most PR/issue/API calls (`gh pr view`, checks metadata, many REST endpoints) | 5,000 / hour | -| **GraphQL** | Project/Tasks and some richer PR queries | 5,000 points / hour | -| **Search** | Search-driven lists | 30 / minute | +| Bucket | What it covers | Typical limit (authenticated) | +| --------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| **REST (core)** | `gh api` REST endpoints: PR lookup for a branch, check runs, PR files, issue comments, labels | 5,000 / hour | +| **GraphQL** | `gh pr view` / `list` / `checks` / `merge` / `edit`, `gh issue view` / `close` / `reopen` / `edit`, Project/Tasks queries | 5,000 points / hour | +| **Search** | Search-driven lists | 30 / minute | -When a primary bucket is exhausted, GitHub returns HTTP **403** with a message like `API rate limit exceeded`. Orca classifies that as rate-limited, keeps the last known PR status when it can, and **stops spawning more `gh` calls** for a short window so a single limit doesn’t turn into a storm of failures. +When a primary bucket is exhausted, REST calls fail with HTTP **403** and `API rate limit exceeded`, and GraphQL calls fail with `API rate limit already exceeded`. Orca classifies either as rate-limited, keeps the last known PR status when it can, and **stops spawning more `gh` calls** for a short window so a single limit doesn’t turn into a storm of failures. ### Why Settings can look “OK” while the PR panel is blocked @@ -50,11 +50,14 @@ Also don’t confuse **GitHub API Budget** with **Claude / Codex / Grok usage** # Probe (does not consume quota; can look healthier than reality) gh api rate_limit --jq '.resources | {core, graphql, search}' -# Real REST call — this is what PR refresh depends on +# Real REST call — PR refresh, check runs, PR files, and comments depend on this gh api user -i 2>&1 | head -40 + +# Real GraphQL call — PR details, PR lists, merges, and issue close/reopen depend on this +gh api graphql -f query='{ viewer { login } }' ``` -If `gh api user` returns **403** with `API rate limit exceeded` and `X-Ratelimit-Remaining: 0`, the account is blocked for REST until `X-Ratelimit-Reset` (Unix epoch seconds). +If `gh api user` returns **403** with `API rate limit exceeded` and `X-Ratelimit-Remaining: 0`, the account is blocked for REST until `X-Ratelimit-Reset` (Unix epoch seconds). If the GraphQL call fails with `API rate limit already exceeded`, the GraphQL bucket is the one exhausted. ### What usually burns the quota diff --git a/src/main/git/gh-rate-limit-breaker.test.ts b/src/main/git/gh-rate-limit-breaker.test.ts index 1a1352f04ed..071015d3903 100644 --- a/src/main/git/gh-rate-limit-breaker.test.ts +++ b/src/main/git/gh-rate-limit-breaker.test.ts @@ -33,10 +33,14 @@ describe('classifyGhRateLimitBucket', () => { expect(classifyGhRateLimitBucket(['search', 'prs', '--author', 'x'])).toBe('search') }) - it('classifies graphql and defaults everything else to core', () => { + it('classifies graphql endpoints and GraphQL-backed subcommands, defaulting the rest to core', () => { expect(classifyGhRateLimitBucket(['api', 'graphql', '-f', 'query=…'])).toBe('graphql') + expect(classifyGhRateLimitBucket(['pr', 'list', '--limit', '36'])).toBe('graphql') + expect(classifyGhRateLimitBucket(['issue', 'close', '5', '--repo', 'a/b'])).toBe('graphql') + expect(classifyGhRateLimitBucket(['repo', 'view', 'a/b', '--json', 'isFork'])).toBe('graphql') expect(classifyGhRateLimitBucket(['api', 'repos/a/b/pulls?per_page=36'])).toBe('core') - expect(classifyGhRateLimitBucket(['pr', 'list', '--limit', '36'])).toBe('core') + expect(classifyGhRateLimitBucket(['run', 'list', '--json', 'status'])).toBe('core') + expect(classifyGhRateLimitBucket(['pr'])).toBe('core') }) }) @@ -69,6 +73,9 @@ describe('isGhPrimaryRateLimitStderr', () => { 'gh: API rate limit exceeded for user ID 1775218. If you reach out to GitHub Support… (HTTP 403)' ) ).toBe(true) + expect( + isGhPrimaryRateLimitStderr('GraphQL: API rate limit already exceeded for user ID 1775218.') + ).toBe(true) expect(isGhPrimaryRateLimitStderr('You have exceeded a secondary rate limit.')).toBe(false) expect(isGhPrimaryRateLimitStderr('gh: Not Found (HTTP 404)')).toBe(false) }) diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts index f5c8648de67..7539914c69e 100644 --- a/src/main/git/gh-rate-limit-breaker.ts +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -78,13 +78,23 @@ function findGhApiEndpoint(args: readonly string[]): string | null { return null } +// gh serves these over GraphQL; a mixed REST trip (e.g. GHES `pr edit` reviewers) is corrected by the reset probe. +const GH_GRAPHQL_SUBCOMMANDS = new Map>([ + ['issue', new Set(['close', 'edit', 'list', 'reopen', 'view'])], + [ + 'pr', + new Set(['checks', 'close', 'create', 'edit', 'list', 'merge', 'ready', 'reopen', 'view']) + ], + ['repo', new Set(['view'])] +]) + export function classifyGhRateLimitBucket(args: readonly string[]): GhRateLimitBucket { const command = args[0] if (command === 'search') { return 'search' } if (command !== 'api') { - return 'core' + return GH_GRAPHQL_SUBCOMMANDS.get(command)?.has(args[1]) ? 'graphql' : 'core' } const endpoint = findGhApiEndpoint(args) if (!endpoint) { @@ -114,10 +124,10 @@ export function isGhLocalOnlyCommand(args: readonly string[]): boolean { return args[0] === 'auth' && args[1] === 'token' } -/** Recognize primary limits; secondary limits use Retry-After handling. */ +/** Recognize primary limits (GraphQL says "already exceeded"); secondary limits use Retry-After handling. */ export function isGhPrimaryRateLimitStderr(stderr: string): boolean { const s = stderr.toLowerCase() - return s.includes('api rate limit exceeded') && !s.includes('secondary rate limit') + return /api rate limit (?:already )?exceeded/.test(s) && !s.includes('secondary rate limit') } export function recordGhPrimaryRateLimit( diff --git a/src/main/git/runner-gh-rate-limit-breaker.test.ts b/src/main/git/runner-gh-rate-limit-breaker.test.ts index 31882977695..a3f7f4add50 100644 --- a/src/main/git/runner-gh-rate-limit-breaker.test.ts +++ b/src/main/git/runner-gh-rate-limit-breaker.test.ts @@ -139,9 +139,29 @@ describe('ghExecFileAsync rate-limit breaker', () => { ).rejects.toThrow('rate limit') expect( - getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com') + getGhRateLimitBlockedUntilMs('graphql', Date.now(), 'native:github.acme-corp.com') ).not.toBeNull() - expect(getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.com')).toBeNull() + expect(getGhRateLimitBlockedUntilMs('graphql', Date.now(), 'native:github.com')).toBeNull() + }) + + it('trips the graphql bucket on a GraphQL rate limit and leaves REST calls running', async () => { + mockGhFailure('GraphQL: API rate limit already exceeded for user ID 1775218.') + await expect( + ghExecFileAsync(['pr', 'view', '12', '--repo', 'a/b', '--json', 'number']) + ).rejects.toThrow('rate limit') + expect(getGhRateLimitBlockedUntilMs('graphql')).not.toBeNull() + expect(getGhRateLimitBlockedUntilMs('core')).toBeNull() + + await expect( + ghExecFileAsync(['issue', 'close', '5', '--repo', 'a/b'], { idempotent: false }) + ).rejects.toMatchObject({ ghRateLimitBlocked: true }) + expect(spawnMock).toHaveBeenCalledTimes(1) + + mockGhSuccess('[]') + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toMatchObject({ + stdout: '[]' + }) + expect(spawnMock).toHaveBeenCalledTimes(2) }) it('scopes a WSL UNC-cwd 403 to the distro runtime and probes that scope', async () => { diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts index 4876f6da14b..1409000d118 100644 --- a/src/main/github/project-view/mutations.test.ts +++ b/src/main/github/project-view/mutations.test.ts @@ -111,7 +111,7 @@ describe('updateIssueBySlug', () => { expect(runRestMock).not.toHaveBeenCalled() }) - it('guards and accounts state mutations in the selected host quota scope', async () => { + it('guards and accounts state mutations in the selected host GraphQL quota', async () => { const args = { owner: 'acme', repo: 'widgets', @@ -122,8 +122,8 @@ describe('updateIssueBySlug', () => { await expect(updateIssueBySlug(args)).resolves.toEqual({ ok: true }) - expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(args, 'core') - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith(args, 'core') + expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(args, 'graphql') + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith(args, 'graphql') }) it('does not spawn a state mutation while that host quota is blocked', async () => { diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index ea890d2b155..7388f7a3f99 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -107,7 +107,7 @@ async function updateIssueState(args: UpdateIssueBySlugArgs): Promise