/** * 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. */ 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 { 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 | 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>() const scopeProbeFailureAtMs = new Map() 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 | null = null export async function getRateLimit(options?: { force?: boolean }): Promise { 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 { 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() } }