Files
orca/src/main/github/rate-limit.ts
T
c34b944136 feat(github): bind projects to a specific gh account (#13664)
* feat(github): bind projects to a specific gh account

Adds per-project `Repo.ghAccount` so repo-scoped gh calls (create-worktree
issue/PR search, work items, hosted-review reads and mutations) run as the bound
account via ephemeral child-env token injection instead of the globally active
gh login. Multi-account resolution is capability-gated (gh >= 2.40) and fails
closed when the bound account or host is unavailable; Project View stays
ambient by design.

Repository settings gains a section for selecting or clearing a keyring-backed
account (shadcn `Select`), with mixed-version "not enforced" handling for older
remote runtimes. Attached `-Rhost/owner/repo` forms are covered by the host-drift
guard and its tests; es/ja/ko/zh catalogs carry the section's strings.

`getLocalProjectGhExecOptions` centralizes the binding lookup so every gh
execution path picks it up, including the Electron `hostedReview:*` handlers
that previously stayed on the ambient login. `gh auth token` (a keyring read)
is exempt from the rate-limit breaker gate so a tripped bucket cannot turn a
bound-token resolve into a false "unavailable".

The `ghAccount` update field and the two binding RPC methods live in the shared
RPC params contract; the generated catalog is regenerated.

Fixes #13612

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012B3QEP5iP4WGGEpPLtkHqA

* fix(settings): make GitHub account refresh secondary

* fix(github): satisfy strict casting quality checks

* test(rpc): use runtime fixture for repo binding

* fix(github): preserve project account for PR worktree lookups

* test(rpc): avoid incomplete runtime settings fixture

* fix(i18n): add GitHub account refresh label

* fix(i18n): refresh runtime required catalog

* fix(windows): preserve mobile patch bytes

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 20:39:52 -07:00

329 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GitHub API rate-limit probe.
*
* Why: heavy fan-out (listWorkItems × repos, org-walks) can drain the core/search buckets; surfacing remaining budget lets users self-regulate rather than throttle.
* The probe itself is exempt from rate-limit accounting per GitHub docs.
*
* Part A: breaker stays host/runtime-scoped (not account-scoped). Bound-account
* calls can trip a host breaker that ambient reset probes later clear — known
* cross-account bleed, accepted for Part A.
*/
import type {
GetRateLimitResult,
GitHubRateLimitBucket,
GitHubRateLimitSnapshot
} from '../../shared/github/rate-limit-types'
import { isDefaultGitHubHost } from '../../shared/github/repository-identity-key'
import { isWslUncPath } from '../../shared/wsl-paths'
import { acquire, release } from './gh-utils'
import { ghExecFileAsync } from '../git/runner'
import {
clearGhRateLimitBlock,
getGhRateLimitBlockedUntilMs,
ghRateLimitScopeKey,
parseGhRateLimitScopeKey,
recordGhPrimaryRateLimit,
registerGhRateLimitResetProbe,
type GhRateLimitBucket
} from '../git/gh-rate-limit-breaker'
// Why: GET /rate_limit is exempt from limits, so caching only avoids a gh subprocess per render; 30s stays live while absorbing 1/s polling.
const RATE_LIMIT_CACHE_TTL_MS = 30_000
let cached: GitHubRateLimitSnapshot | null = null
// Why: cache failures too — a host that 404s every probe (GHES with rate limiting off) would otherwise spawn a gh subprocess per refresh.
let probeFailure: { at: number; error: string } | null = null
type GhRateLimitPayload = {
resources?: {
core?: { limit?: number; remaining?: number; reset?: number }
search?: { limit?: number; remaining?: number; reset?: number }
graphql?: { limit?: number; remaining?: number; reset?: number }
}
}
function parseBucket(
raw:
| {
limit?: number
remaining?: number
reset?: number
}
| undefined
): GitHubRateLimitBucket {
// Why: absent bucket → 0/0/now so the UI reads "unknown" rather than a misleading "plenty left".
return {
limit: typeof raw?.limit === 'number' ? raw.limit : 0,
remaining: typeof raw?.remaining === 'number' ? raw.remaining : 0,
resetAt: typeof raw?.reset === 'number' ? raw.reset : Math.floor(Date.now() / 1000)
}
}
/** @internal — test-only */
export function _resetRateLimitCache(): void {
cached = null
probeFailure = null
scopeProbeFailureAtMs.clear()
scopeRefinementInFlight.clear()
}
// Circuit-breaker floors: enough budget for one user flow; search paginates by 1, so 2 leaves a safety click under the 30/min cap.
const MIN_REMAINING_CORE = 50
const MIN_REMAINING_GRAPHQL = 50
const MIN_REMAINING_SEARCH = 2
export type RateLimitBucketKind = 'core' | 'graphql' | 'search'
/**
* Return a "soft" stop reason if we should refuse a new gh request for the bucket; `{ blocked: false }` when there's budget or no snapshot yet (fail open).
* Why: advisory (returns a reason, doesn't throw) so callers format the error envelope in their own shape.
*/
export function rateLimitGuard(bucket: RateLimitBucketKind):
| { blocked: false }
| {
blocked: true
remaining: number
limit: number
resetAt: number
} {
// Why: the breaker learns exhaustion from real 403s, which can precede a probe (e.g. quota burned by another tool on the account).
const breakerBlockedUntilMs = getGhRateLimitBlockedUntilMs(bucket)
if (breakerBlockedUntilMs !== null) {
return {
blocked: true,
remaining: 0,
limit: cached?.[bucket].limit ?? 0,
resetAt: Math.ceil(breakerBlockedUntilMs / 1000)
}
}
if (!cached) {
return { blocked: false }
}
const b = cached[bucket]
const floor =
bucket === 'core'
? MIN_REMAINING_CORE
: bucket === 'graphql'
? MIN_REMAINING_GRAPHQL
: MIN_REMAINING_SEARCH
// Why: a snapshot from before reset describes an ended window — fail open rather than block on stale data.
if (b.resetAt * 1000 <= Date.now()) {
return { blocked: false }
}
// Why: limit:0 means "unknown" (parseBucket fallback) — don't block on missing data or a single bad response bricks the app.
if (b.limit > 0 && b.remaining < floor) {
return { blocked: true, remaining: b.remaining, limit: b.limit, resetAt: b.resetAt }
}
return { blocked: false }
}
/**
* Decrement the cached `remaining` for a bucket after a successful spawn.
* Why: between probes the snapshot would over-report budget, so this keeps the breaker honest during a burst instead of waiting for the TTL.
*/
export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void {
if (!cached) {
return
}
const b = cached[bucket]
if (b.remaining > 0) {
cached = { ...cached, [bucket]: { ...b, remaining: Math.max(0, b.remaining - cost) } }
}
}
// Why: this snapshot covers native github.com only; GHES and WSL use quotas protected by the runner's scoped breaker.
export function spendsSharedGitHubComQuota(
repository: { host?: string } | null | undefined,
executionOptions?: { wslDistro?: string; cwd?: string }
): boolean {
if (!isDefaultGitHubHost(repository?.host) || executionOptions?.wslDistro) {
return false
}
// Why: a \\wsl.localhost\... (or \\wsl$\...) cwd makes the runner spawn gh
// inside WSL, so it spends that distro's quota. The UNC shape is
// platform-unambiguous, so no win32 gate is needed.
return !(executionOptions?.cwd && isWslUncPath(executionOptions.cwd))
}
export function repositoryRateLimitGuard(
repository: { host?: string } | null | undefined,
bucket: RateLimitBucketKind,
executionOptions?: { wslDistro?: string; cwd?: string }
): ReturnType<typeof rateLimitGuard> {
return spendsSharedGitHubComQuota(repository, executionOptions)
? rateLimitGuard(bucket)
: { blocked: false }
}
export function noteRepositoryRateLimitSpend(
repository: { host?: string } | null | undefined,
bucket: RateLimitBucketKind,
cost = 1,
executionOptions?: { wslDistro?: string; cwd?: string }
): void {
if (spendsSharedGitHubComQuota(repository, executionOptions)) {
noteRateLimitSpend(bucket, cost)
}
}
// Why: the breaker only knows "blocked", not the reset time; one exempt probe refines it to the real reset or clears a stale block (single-flight so a 403 burst probes once).
let resetRefinementInFlight: Promise<void> | null = null
function refineBreakerFromSnapshot(): void {
if (resetRefinementInFlight) {
return
}
resetRefinementInFlight = (async () => {
try {
const result = await getRateLimit({ force: true })
if (!result.ok) {
return
}
for (const bucket of ['core', 'search', 'graphql'] as GhRateLimitBucket[]) {
const b = result.snapshot[bucket]
if (b.limit > 0 && b.remaining <= 0) {
recordGhPrimaryRateLimit(bucket, b.resetAt * 1000)
} else if (b.limit > 0) {
clearGhRateLimitBlock(bucket)
}
}
} finally {
resetRefinementInFlight = null
}
})()
}
const DEFAULT_BREAKER_SCOPE = ghRateLimitScopeKey('native', 'github.com')
// Why: non-default scopes (GHES hosts, WSL runtimes) have their own quota and
// their own probe lifecycle — single-flight and failure negative-cache are
// keyed per scope so a GHES 403 storm can't starve a WSL refinement (or vice
// versa).
const scopeRefinementInFlight = new Map<string, Promise<void>>()
const scopeProbeFailureAtMs = new Map<string, number>()
const SCOPE_PROBE_FAILURE_MAX_ENTRIES = 512
function rememberScopeProbeFailure(scope: string, failedAt: number): void {
for (const [key, at] of scopeProbeFailureAtMs) {
if (failedAt - at >= RATE_LIMIT_CACHE_TTL_MS) {
scopeProbeFailureAtMs.delete(key)
}
}
scopeProbeFailureAtMs.delete(scope)
scopeProbeFailureAtMs.set(scope, failedAt)
while (scopeProbeFailureAtMs.size > SCOPE_PROBE_FAILURE_MAX_ENTRIES) {
const oldestKey = scopeProbeFailureAtMs.keys().next().value
if (oldestKey === undefined) {
break
}
scopeProbeFailureAtMs.delete(oldestKey)
}
}
function refineBreakerForScope(scope: string): void {
// Default scope keeps the existing shared-snapshot refinement path.
if (scope === DEFAULT_BREAKER_SCOPE) {
refineBreakerFromSnapshot()
return
}
const parts = parseGhRateLimitScopeKey(scope)
if (!parts || scopeRefinementInFlight.has(scope)) {
return
}
// Why: GHES with rate limiting disabled 404s every probe. Fail open (the
// fallback block stands) and don't re-probe in a tight loop while the
// breaker keeps tripping.
const failedAt = scopeProbeFailureAtMs.get(scope)
if (failedAt !== undefined && Date.now() - failedAt < RATE_LIMIT_CACHE_TTL_MS) {
return
}
const probe = (async () => {
try {
await acquire()
try {
// Run the probe on the tripping scope's runtime/host so it describes
// that account's buckets, not the native github.com ones.
const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], {
encoding: 'utf-8',
host: parts.host,
...(parts.runtime === 'wsl' ? { wslDistro: parts.wslDistro } : {})
})
const parsed = JSON.parse(stdout) as GhRateLimitPayload
scopeProbeFailureAtMs.delete(scope)
// Why: mirrors the default-scope refinement, but records into the
// per-scope breaker only — the shared snapshot must keep describing
// native github.com exclusively.
for (const bucket of ['core', 'search', 'graphql'] as GhRateLimitBucket[]) {
const b = parseBucket(parsed.resources?.[bucket])
if (b.limit > 0 && b.remaining <= 0) {
recordGhPrimaryRateLimit(bucket, b.resetAt * 1000, scope)
} else if (b.limit > 0) {
clearGhRateLimitBlock(bucket, scope)
}
}
} finally {
release()
}
} catch {
// Why: GHES hosts can be supplied through pasted project URLs. Bound the
// negative cache so repeated failing hosts cannot accumulate forever.
rememberScopeProbeFailure(scope, Date.now())
} finally {
scopeRefinementInFlight.delete(scope)
}
})()
scopeRefinementInFlight.set(scope, probe)
}
registerGhRateLimitResetProbe((_bucket, scope) => refineBreakerForScope(scope))
// Why: single-flight so a concurrent fan-out resolves to one probe — the TTL cache can't dedupe calls that start before the first lands.
let probeInFlight: Promise<GetRateLimitResult> | null = null
export async function getRateLimit(options?: { force?: boolean }): Promise<GetRateLimitResult> {
if (!options?.force && cached && Date.now() - cached.fetchedAt < RATE_LIMIT_CACHE_TTL_MS) {
return { ok: true, snapshot: cached }
}
if (!options?.force && probeFailure && Date.now() - probeFailure.at < RATE_LIMIT_CACHE_TTL_MS) {
return { ok: false, error: probeFailure.error }
}
if (!options?.force && probeInFlight) {
return probeInFlight
}
const probe = fetchRateLimitSnapshot()
probeInFlight = probe
try {
return await probe
} finally {
if (probeInFlight === probe) {
probeInFlight = null
}
}
}
async function fetchRateLimitSnapshot(): Promise<GetRateLimitResult> {
await acquire()
try {
// Why: this singleton snapshot guards native github.com traffic. Pin the
// host so a process-level GH_HOST cannot make it describe a GHES account.
const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], {
encoding: 'utf-8',
host: 'github.com'
})
const parsed = JSON.parse(stdout) as GhRateLimitPayload
const snapshot: GitHubRateLimitSnapshot = {
core: parseBucket(parsed.resources?.core),
search: parseBucket(parsed.resources?.search),
graphql: parseBucket(parsed.resources?.graphql),
fetchedAt: Date.now()
}
cached = snapshot
probeFailure = null
return { ok: true, snapshot }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
probeFailure = { at: Date.now(), error: message }
return { ok: false, error: message }
} finally {
release()
}
}