{"record":{"id":"bbcce70bfe44e95e","repo":"stablyai/orca","slug":"github-bucket-rate-limit-is-low-retry-after","errorCode":null,"errorMessage":"GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}","messagePattern":"GitHub (.+?) rate limit is low; retry after (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"src/main/github/client.ts","lineNumber":249,"sourceCode":"    const oldestKey = repositoryMergeMetadataCache.keys().next().value\n    if (oldestKey === undefined) {\n      break\n    }\n    repositoryMergeMetadataCache.delete(oldestKey)\n  }\n}\n\nasync function assertRateLimitBudget(\n  bucket: RateLimitBucketKind,\n  repository?: GitHubApiRepository | null,\n  executionOptions?: Pick<GhExecOptions, 'cwd' | 'wslDistro'>\n): Promise<void> {\n  if (spendsSharedGitHubComQuota(repository, executionOptions)) {\n    await getRateLimit()\n  }\n  const guard = repositoryRateLimitGuard(repository, bucket, executionOptions)\n  if (guard.blocked) {\n    throw new Error(\n      `GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}`\n    )\n  }\n}\n\n// Why: a branch lookup prefers REST but can fall back to `gh pr list` and\n// `gh pr view`, so both buckets are guarded and charged. Mirrors the PR refresh\n// coordinator's own estimate.\nconst PR_BRANCH_LOOKUP_BUCKETS = ['core', 'graphql'] as const\n\n/**\n * Rate-limit floor for GitHub PR lookups that do not run through the PR refresh\n * coordinator's queue (#11532).\n *\n * The coordinator guards and paces its own background refreshes, but\n * `hostedReview:forBranch` polls the same lookup straight from the renderer.\n * Ungated, the two paths together could spend the user's entire hourly quota —\n * which is per user and shared with their own `gh` and CLI agents.","sourceCodeStart":231,"sourceCodeEnd":267,"githubUrl":"https://github.com/stablyai/orca/blob/1136503c6a231a16dce8f921f6fadb63d181e8db/src/main/github/client.ts#L231-L267","documentation":"Thrown by assertRateLimitBudget as a client-side pre-flight guard before spending GitHub API quota. When spendsSharedGitHubComQuota is true it refreshes the rate-limit snapshot, then repositoryRateLimitGuard reports blocked=true for the named bucket (core or graphql). The error is proactive — no request has been made yet — and includes the reset time so callers can schedule a retry. It protects a per-user, per-hour budget shared between Orca, the user's own gh CLI, and any other agents.","triggerScenarios":"Heavy PR refresh polling combined with renderer-driven branch lookups exhausting the hourly core/graphql budget; a large stack of PRs being refreshed at once; running gh CLI in parallel terminals against the same account; secondary rate limits kicking in near the floor.","commonSituations":"User runs many parallel gh operations; a monorepo with dozens of open PRs all refreshing; CI or other tooling consuming the same token; an org with strict per-user limits.","solutions":["Wait until the reset time shown in the message, then retry — the guard will re-evaluate.","Reduce background refresh frequency in Orca settings to lower steady-state spend.","If you are running other gh CLI tooling, stagger or pause it.","For automated jobs, switch to a GitHub App token (higher limits) or use conditional requests / caching.","Confirm the bucket that is low (core vs graphql) — graphql drains faster for stack-aware PR lookups."],"exampleFix":"// before: caller retries immediately on any error\nawait lookupPR(number).catch(() => lookupPR(number))\n\n// after: honor the rate-limit reset hint\ntry {\n  await lookupPR(number)\n} catch (err) {\n  if (/rate limit is low; retry after/.test(String(err.message))) {\n    scheduleRetryAfterMessage(err.message)\n    return\n  }\n  throw err\n}","handlingStrategy":"retry","validationCode":"import { getRateLimit } from './client'\nasync function budgetAllows(bucket: 'core' | 'graphql'): Promise<boolean> {\n  await getRateLimit() // warm snapshot\n  return !repositoryRateLimitGuard(undefined, bucket, undefined).blocked\n}","typeGuard":"function isRateLimitError(err: unknown): boolean {\n  return err instanceof Error && /rate limit is low; retry after/.test(err.message)\n}","tryCatchPattern":"try {\n  await lookupPR(number)\n} catch (err) {\n  if (isRateLimitError(err)) {\n    const resetAt = parseRetryAfterTime(err.message)\n    scheduleRetry(resetAt) // do NOT retry immediately\n    return\n  }\n  throw err\n}","preventionTips":["Parse the reset time from the message and schedule, never busy-retry.","Lower background refresh frequency to reduce steady-state quota spend.","Stagger other gh CLI tooling against the same token."],"tags":["github","rate-limit","api","retry","quota"],"backgroundTag":null,"analyzedSha":"1136503c6a231a16dce8f921f6fadb63d181e8db","analyzedAt":"2026-08-12T23:15:58.167Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}