mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(updater): send the gh token and cache the release picker's build list
The dev build picker listed releases through api.github.com with no Authorization header, so it spent GitHub's 60/hour per-IP bucket that every unauthenticated caller on the same network shares, and it refetched on every settings mount and channel click. When that bucket ran dry the picker showed "No builds found" with a rate-limit line even though GitHub was healthy and the user's own token had its full quota. Attach the local `gh auth token` when there is one so the request draws from the user's 5000/hour bucket, fall back to unauthenticated on a rejected token or a spent token bucket, cache the list per channel for five minutes in the main process (the refresh button forces a reload), classify 403 by the rate-limit headers, and say when the limit resets. Fixes #21898
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const ghExecMock = vi.fn()
|
||||
vi.mock('./git/runner', () => ({
|
||||
ghExecFileAsync: (...args: unknown[]) => ghExecMock(...args)
|
||||
}))
|
||||
|
||||
const { _resetReleaseApiTokenCache, rejectReleaseApiToken, resolveReleaseApiToken } =
|
||||
await import('./updater-release-api-token')
|
||||
|
||||
const T0 = 1_000_000
|
||||
const TTL_MS = 5 * 60_000
|
||||
|
||||
describe('resolveReleaseApiToken', () => {
|
||||
beforeEach(() => {
|
||||
ghExecMock.mockReset()
|
||||
_resetReleaseApiTokenCache()
|
||||
})
|
||||
|
||||
it('reads the github.com token through gh and trims it', async () => {
|
||||
ghExecMock.mockResolvedValue({ stdout: 'gho_abc\n', stderr: '' })
|
||||
|
||||
await expect(resolveReleaseApiToken(T0)).resolves.toBe('gho_abc')
|
||||
|
||||
expect(ghExecMock.mock.calls[0][0]).toEqual(['auth', 'token', '--hostname', 'github.com'])
|
||||
expect(ghExecMock.mock.calls[0][1]).toMatchObject({ idempotent: false })
|
||||
})
|
||||
|
||||
it('serves the cached token within its TTL and re-reads after it', async () => {
|
||||
ghExecMock.mockResolvedValue({ stdout: 'gho_abc', stderr: '' })
|
||||
|
||||
await resolveReleaseApiToken(T0)
|
||||
await resolveReleaseApiToken(T0 + TTL_MS - 1)
|
||||
expect(ghExecMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await resolveReleaseApiToken(T0 + TTL_MS + 1)
|
||||
expect(ghExecMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// Why: gh missing or logged out is the unauthenticated path the picker always
|
||||
// had; it must not fail the list, and it must not spawn gh on every click.
|
||||
it('returns null when gh is missing or logged out and does not re-spawn within the TTL', async () => {
|
||||
ghExecMock.mockRejectedValueOnce(new Error('gh: not found'))
|
||||
|
||||
await expect(resolveReleaseApiToken(T0)).resolves.toBeNull()
|
||||
await expect(resolveReleaseApiToken(T0 + TTL_MS - 1)).resolves.toBeNull()
|
||||
expect(ghExecMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
ghExecMock.mockResolvedValueOnce({ stdout: 'gho_new', stderr: '' })
|
||||
await expect(resolveReleaseApiToken(T0 + TTL_MS + 1)).resolves.toBe('gho_new')
|
||||
})
|
||||
|
||||
it('treats empty output as no token', async () => {
|
||||
ghExecMock.mockResolvedValue({ stdout: '\n', stderr: '' })
|
||||
|
||||
await expect(resolveReleaseApiToken(T0)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('shares one in-flight read between concurrent callers', async () => {
|
||||
let finish: (value: { stdout: string; stderr: string }) => void = () => {}
|
||||
ghExecMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = resolveReleaseApiToken(T0)
|
||||
const second = resolveReleaseApiToken(T0)
|
||||
finish({ stdout: 'gho_abc', stderr: '' })
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(['gho_abc', 'gho_abc'])
|
||||
expect(ghExecMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why: a token GitHub rejected would otherwise be re-read from the keyring
|
||||
// and re-sent on every load — one gh spawn and one wasted request each time.
|
||||
it('goes unauthenticated for a TTL after the token is rejected', async () => {
|
||||
ghExecMock.mockResolvedValue({ stdout: 'gho_stale', stderr: '' })
|
||||
await expect(resolveReleaseApiToken(T0)).resolves.toBe('gho_stale')
|
||||
|
||||
rejectReleaseApiToken(T0)
|
||||
|
||||
await expect(resolveReleaseApiToken(T0 + TTL_MS - 1)).resolves.toBeNull()
|
||||
expect(ghExecMock).toHaveBeenCalledTimes(1)
|
||||
await expect(resolveReleaseApiToken(T0 + TTL_MS + 1)).resolves.toBe('gho_stale')
|
||||
expect(ghExecMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ghExecFileAsync } from './git/runner'
|
||||
|
||||
/**
|
||||
* The github.com token the release picker attaches to its api.github.com calls.
|
||||
*
|
||||
* Why: an unauthenticated request draws from a 60/hour bucket shared by every
|
||||
* caller behind the same public IP — Homebrew, other apps, agents running curl
|
||||
* — so the picker can find it empty without ever having spent it. The user's
|
||||
* own token has its own 5000/hour bucket. `gh auth token` is a local keyring
|
||||
* read that never touches the API; a missing or logged-out gh just means the
|
||||
* request goes out unauthenticated as before. The token is never logged.
|
||||
*
|
||||
* Why not resolveGhAccountToken: that resolves a per-project bound account and
|
||||
* needs a binding plus a `gh auth token --user` capability probe. This is the
|
||||
* ambient login, and "no token" is an ordinary outcome here, not an error.
|
||||
*/
|
||||
|
||||
const TOKEN_RESOLVE_TIMEOUT_MS = 5_000
|
||||
// Why one TTL for hits and misses: a miss re-spawns gh — through wsl.exe on a
|
||||
// Windows box without a native gh — so a short miss TTL would make the picker
|
||||
// slower for exactly the users who can never get a token.
|
||||
const TOKEN_TTL_MS = 5 * 60_000
|
||||
|
||||
type TokenCacheEntry = { token: string | null; expiresAt: number }
|
||||
|
||||
let cached: TokenCacheEntry | null = null
|
||||
let inFlight: Promise<string | null> | null = null
|
||||
|
||||
async function readGhToken(): Promise<string | null> {
|
||||
try {
|
||||
// Why no retry: a hung keyring would otherwise hold the picker through the
|
||||
// runner's backoff, and unauthenticated is an acceptable fallback anyway.
|
||||
const { stdout } = await ghExecFileAsync(['auth', 'token', '--hostname', 'github.com'], {
|
||||
timeout: TOKEN_RESOLVE_TIMEOUT_MS,
|
||||
idempotent: false
|
||||
})
|
||||
const token = stdout.replace(/\r?\n/g, '').trim()
|
||||
return token || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveReleaseApiToken(now: number = Date.now()): Promise<string | null> {
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.token
|
||||
}
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
inFlight = readGhToken()
|
||||
.then((token) => {
|
||||
cached = { token, expiresAt: now + TOKEN_TTL_MS }
|
||||
return token
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null
|
||||
})
|
||||
return inFlight
|
||||
}
|
||||
|
||||
/** GitHub rejected the token: go unauthenticated for a TTL instead of re-reading the same stale keyring entry on every load. */
|
||||
export function rejectReleaseApiToken(now: number = Date.now()): void {
|
||||
cached = { token: null, expiresAt: now + TOKEN_TTL_MS }
|
||||
}
|
||||
|
||||
/** @internal — test-only */
|
||||
export function _resetReleaseApiTokenCache(): void {
|
||||
cached = null
|
||||
inFlight = null
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ReleaseBuild } from '../shared/release-channel'
|
||||
import { ReleaseBuildListCache } from './updater-release-build-cache'
|
||||
|
||||
const build = (tag: string): ReleaseBuild => ({
|
||||
tag,
|
||||
version: tag.slice(1),
|
||||
channel: 'hourly',
|
||||
name: null,
|
||||
publishedAt: null,
|
||||
releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/${tag}`,
|
||||
installerUrl: null
|
||||
})
|
||||
|
||||
const TTL_MS = 5 * 60_000
|
||||
|
||||
function createCache(load = vi.fn().mockResolvedValue([build('v1.4.160-hourly.202607281400')])) {
|
||||
let now = 1_000_000
|
||||
const cache = new ReleaseBuildListCache(load, TTL_MS, () => now)
|
||||
return { cache, load, advance: (ms: number) => (now += ms) }
|
||||
}
|
||||
|
||||
describe('ReleaseBuildListCache', () => {
|
||||
it('serves a repeat request within the TTL without loading again', async () => {
|
||||
const { cache, load } = createCache()
|
||||
|
||||
await cache.list('hourly')
|
||||
await cache.list('hourly')
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reloads once the TTL has passed', async () => {
|
||||
const { cache, load, advance } = createCache()
|
||||
|
||||
await cache.list('hourly')
|
||||
advance(TTL_MS + 1)
|
||||
await cache.list('hourly')
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reloads on force even inside the TTL', async () => {
|
||||
const { cache, load } = createCache()
|
||||
|
||||
await cache.list('hourly')
|
||||
await cache.list('hourly', { force: true })
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shares one in-flight load between concurrent callers', async () => {
|
||||
const { cache, load } = createCache()
|
||||
|
||||
const [first, second] = await Promise.all([cache.list('hourly'), cache.list('hourly')])
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('keys by channel', async () => {
|
||||
const { cache, load } = createCache()
|
||||
|
||||
await cache.list('hourly')
|
||||
await cache.list('daily')
|
||||
await cache.list('hourly')
|
||||
|
||||
expect(load.mock.calls).toEqual([['hourly'], ['daily']])
|
||||
})
|
||||
|
||||
it('does not cache a failed load', async () => {
|
||||
const load = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('GitHub rate limit reached.'))
|
||||
.mockResolvedValueOnce([build('v1.4.160-hourly.202607281400')])
|
||||
const { cache } = createCache(load)
|
||||
|
||||
await expect(cache.list('hourly')).rejects.toThrow(/rate limit/)
|
||||
await expect(cache.list('hourly')).resolves.toHaveLength(1)
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps a forced reload that replaced a failing entry', async () => {
|
||||
let failFirst: (error: Error) => void = () => {}
|
||||
const load = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failFirst = reject
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce([build('v1.4.160-hourly.202607281400')])
|
||||
const { cache } = createCache(load)
|
||||
|
||||
const failing = cache.list('hourly')
|
||||
const forced = cache.list('hourly', { force: true })
|
||||
failFirst(new Error('timed out'))
|
||||
await expect(failing).rejects.toThrow(/timed out/)
|
||||
await forced
|
||||
|
||||
await cache.list('hourly')
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel'
|
||||
|
||||
const DEFAULT_TTL_MS = 5 * 60_000
|
||||
|
||||
type LoadBuilds = (channel: ReleaseChannel) => Promise<ReleaseBuild[]>
|
||||
|
||||
type CacheEntry = { builds: Promise<ReleaseBuild[]>; expiresAt: number }
|
||||
|
||||
export type ReleaseBuildListOptions = {
|
||||
/** Bypass the cache — the refresh button, so a build published a minute ago shows up on demand. */
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-channel cache of listed release builds.
|
||||
*
|
||||
* Why: the picker reloads on every settings mount and every channel click, and
|
||||
* each load was one GitHub API request. Serving repeats from here keeps a few
|
||||
* minutes of browsing at one request per channel, and sharing the in-flight
|
||||
* promise collapses two concurrent loads of one channel into a single request.
|
||||
*/
|
||||
export class ReleaseBuildListCache {
|
||||
private readonly entries = new Map<ReleaseChannel, CacheEntry>()
|
||||
|
||||
constructor(
|
||||
private readonly load: LoadBuilds,
|
||||
private readonly ttlMs: number = DEFAULT_TTL_MS,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
list(channel: ReleaseChannel, options: ReleaseBuildListOptions = {}): Promise<ReleaseBuild[]> {
|
||||
const existing = this.entries.get(channel)
|
||||
if (!options.force && existing && existing.expiresAt > this.now()) {
|
||||
return existing.builds
|
||||
}
|
||||
const builds = this.load(channel)
|
||||
const entry: CacheEntry = { builds, expiresAt: this.now() + this.ttlMs }
|
||||
this.entries.set(channel, entry)
|
||||
// Why: a failed load must not be served for the next five minutes; drop it so
|
||||
// the next call retries. Only evict our own entry — a forced reload may have replaced it.
|
||||
builds.catch(() => {
|
||||
if (this.entries.get(channel) === entry) {
|
||||
this.entries.delete(channel)
|
||||
}
|
||||
})
|
||||
return builds
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
vi.mock('electron', () => ({ net: { fetch: (...args: unknown[]) => fetchMock(...args) } }))
|
||||
|
||||
const { listReleaseBuilds, resolveTargetBuild } = await import('./updater-release-builds')
|
||||
const tokenMock = vi.fn<() => Promise<string | null>>()
|
||||
const rejectTokenMock = vi.fn()
|
||||
vi.mock('./updater-release-api-token', () => ({
|
||||
resolveReleaseApiToken: () => tokenMock(),
|
||||
rejectReleaseApiToken: () => rejectTokenMock()
|
||||
}))
|
||||
|
||||
function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}) {
|
||||
const blockedUntilMock = vi.fn<() => number | null>()
|
||||
const recordRateLimitMock = vi.fn()
|
||||
vi.mock('./git/gh-rate-limit-breaker', () => ({
|
||||
getGhRateLimitBlockedUntilMs: () => blockedUntilMock(),
|
||||
recordGhPrimaryRateLimit: (...args: unknown[]) => recordRateLimitMock(...args)
|
||||
}))
|
||||
|
||||
const { describeRateLimitReset, listReleaseBuilds, rateLimitResetAtMs, resolveTargetBuild } =
|
||||
await import('./updater-release-builds')
|
||||
|
||||
function jsonResponse(
|
||||
body: unknown,
|
||||
init: { ok?: boolean; status?: number; headers?: Record<string, string> } = {}
|
||||
) {
|
||||
return {
|
||||
ok: init.ok ?? true,
|
||||
status: init.status ?? 200,
|
||||
headers: new Headers(init.headers ?? {}),
|
||||
json: () => Promise.resolve(body)
|
||||
}
|
||||
}
|
||||
|
||||
function requestHeaders(call = 0): Record<string, string> {
|
||||
return fetchMock.mock.calls[call][1].headers
|
||||
}
|
||||
|
||||
/** Every platform's manifest by default, so a case that is not about asset
|
||||
* filtering stays readable and stays green whatever platform is passed. */
|
||||
const allPlatformAssets = [
|
||||
@@ -36,6 +59,16 @@ const release = (tag: string, extra: Record<string, unknown> = {}) => ({
|
||||
describe('listReleaseBuilds', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
rejectTokenMock.mockReset()
|
||||
recordRateLimitMock.mockReset()
|
||||
tokenMock.mockReset()
|
||||
tokenMock.mockResolvedValue(null)
|
||||
blockedUntilMock.mockReset()
|
||||
blockedUntilMock.mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('lists hourly builds from the dedicated repo, newest first', async () => {
|
||||
@@ -209,9 +242,140 @@ describe('listReleaseBuilds', () => {
|
||||
await expect(listReleaseBuilds('hourly', 'win32')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a rate limit as an actionable message', async () => {
|
||||
// Why: unauthenticated requests draw from a 60/hour bucket shared by every
|
||||
// caller behind the same IP; the user's own token has a 5000/hour bucket.
|
||||
it('sends the local gh token as a bearer header when one is available', async () => {
|
||||
tokenMock.mockResolvedValue('gho_abc')
|
||||
fetchMock.mockResolvedValue(jsonResponse([release('v1.4.159')]))
|
||||
|
||||
await listReleaseBuilds('stable', 'darwin')
|
||||
|
||||
expect(requestHeaders()).toEqual({
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: 'Bearer gho_abc'
|
||||
})
|
||||
})
|
||||
|
||||
it('sends no authorization header when gh has no token', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse([release('v1.4.159')]))
|
||||
|
||||
await listReleaseBuilds('stable', 'darwin')
|
||||
|
||||
expect(requestHeaders()).toEqual({ Accept: 'application/vnd.github+json' })
|
||||
})
|
||||
|
||||
// Why: a revoked keyring token must not take the picker down when the
|
||||
// unauthenticated request still lists the public repo.
|
||||
it('retries unauthenticated once when GitHub rejects the token', async () => {
|
||||
tokenMock.mockResolvedValue('gho_stale')
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse(null, { ok: false, status: 401 }))
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.159')]))
|
||||
|
||||
await expect(
|
||||
listReleaseBuilds('stable', 'darwin').then((builds) => builds.map((build) => build.version))
|
||||
).resolves.toEqual(['1.4.159'])
|
||||
|
||||
expect(rejectTokenMock).toHaveBeenCalledTimes(1)
|
||||
expect(requestHeaders(1)).toEqual({ Accept: 'application/vnd.github+json' })
|
||||
})
|
||||
|
||||
it('does not retry a 401 that was already unauthenticated', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 401 }))
|
||||
|
||||
await expect(listReleaseBuilds('stable', 'darwin')).rejects.toThrow(/HTTP 401/)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(rejectTokenMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: the token's 5000/hour bucket and the per-IP bucket are independent, so
|
||||
// a spent token — an agent running gh in a loop — must not take the picker down
|
||||
// while the unauthenticated request would still succeed.
|
||||
it('falls back to the per-IP bucket when the token is rate limited and tells the gh breaker', async () => {
|
||||
tokenMock.mockResolvedValue('gho_abc')
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(null, {
|
||||
ok: false,
|
||||
status: 403,
|
||||
headers: { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1800000600' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.159')]))
|
||||
|
||||
await expect(
|
||||
listReleaseBuilds('stable', 'darwin').then((builds) => builds.map((build) => build.version))
|
||||
).resolves.toEqual(['1.4.159'])
|
||||
|
||||
expect(recordRateLimitMock).toHaveBeenCalledWith('core', 1_800_000_600_000)
|
||||
expect(rejectTokenMock).not.toHaveBeenCalled()
|
||||
expect(requestHeaders(1)).toEqual({ Accept: 'application/vnd.github+json' })
|
||||
})
|
||||
|
||||
it('skips the token while the gh breaker has the core bucket blocked', async () => {
|
||||
blockedUntilMock.mockReturnValue(Date.now() + 60_000)
|
||||
tokenMock.mockResolvedValue('gho_abc')
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(null, { ok: false, status: 403, headers: { 'x-ratelimit-remaining': '0' } })
|
||||
)
|
||||
|
||||
const failure = listReleaseBuilds('stable', 'darwin')
|
||||
await expect(failure).rejects.toThrow(/rate limit reached/)
|
||||
// Why: the user is signed in; the breaker, not a missing login, kept the token home.
|
||||
await expect(failure).rejects.not.toThrow(/gh auth login/)
|
||||
|
||||
expect(tokenMock).not.toHaveBeenCalled()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(requestHeaders()).toEqual({ Accept: 'application/vnd.github+json' })
|
||||
})
|
||||
|
||||
it('surfaces a rate limit with its reset time and a sign-in hint when unauthenticated', async () => {
|
||||
const nowMs = 1_800_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(nowMs)
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(null, {
|
||||
ok: false,
|
||||
status: 403,
|
||||
headers: {
|
||||
'x-ratelimit-remaining': '0',
|
||||
'x-ratelimit-reset': String(nowMs / 1000 + 28 * 60)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await expect(listReleaseBuilds('hourly', 'darwin')).rejects.toThrow(
|
||||
"GitHub rate limit reached. Try again in about 28 minutes, or run `gh auth login` so Orca can use your account's higher limit."
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the sign-in hint when the rate-limited request was authenticated', async () => {
|
||||
tokenMock.mockResolvedValue('gho_abc')
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(null, { ok: false, status: 403, headers: { 'x-ratelimit-remaining': '0' } })
|
||||
)
|
||||
|
||||
const failure = listReleaseBuilds('hourly', 'darwin')
|
||||
await expect(failure).rejects.toThrow(/rate limit reached/)
|
||||
await expect(failure).rejects.not.toThrow(/gh auth login/)
|
||||
})
|
||||
|
||||
it('treats 429 as a rate limit', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(null, { ok: false, status: 429, headers: { 'retry-after': '90' } })
|
||||
)
|
||||
|
||||
await expect(listReleaseBuilds('hourly', 'darwin')).rejects.toThrow(/in about 2 minutes/)
|
||||
})
|
||||
|
||||
// Why: a 403 without rate-limit headers is a permission or access problem, and
|
||||
// telling the user to wait would send them waiting for a reset that never comes.
|
||||
it('reports a 403 without rate-limit headers as a plain HTTP error', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 403 }))
|
||||
await expect(listReleaseBuilds('hourly', 'darwin')).rejects.toThrow(/rate limit/i)
|
||||
|
||||
const failure = listReleaseBuilds('hourly', 'darwin')
|
||||
await expect(failure).rejects.toThrow(/HTTP 403/)
|
||||
await expect(failure).rejects.not.toThrow(/rate limit/)
|
||||
})
|
||||
|
||||
it('reports a missing hourly repo distinctly', async () => {
|
||||
@@ -220,6 +384,48 @@ describe('listReleaseBuilds', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('rateLimitResetAtMs', () => {
|
||||
const nowMs = 1_800_000_000_000
|
||||
|
||||
it('is null when GitHub sent no reset', () => {
|
||||
expect(rateLimitResetAtMs(new Headers(), nowMs)).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the primary reset epoch over retry-after', () => {
|
||||
const headers = new Headers({
|
||||
'x-ratelimit-reset': String(nowMs / 1000 + 10 * 60),
|
||||
'retry-after': '30'
|
||||
})
|
||||
expect(rateLimitResetAtMs(headers, nowMs)).toBe(nowMs + 10 * 60_000)
|
||||
})
|
||||
|
||||
it('reads retry-after as seconds', () => {
|
||||
expect(rateLimitResetAtMs(new Headers({ 'retry-after': '90' }), nowMs)).toBe(nowMs + 90_000)
|
||||
})
|
||||
|
||||
// Why: secondary limits may send Retry-After as an HTTP date (RFC 9110).
|
||||
it('reads retry-after as an HTTP date', () => {
|
||||
const headers = new Headers({ 'retry-after': new Date(nowMs + 5 * 60_000).toUTCString() })
|
||||
expect(rateLimitResetAtMs(headers, nowMs)).toBe(nowMs + 5 * 60_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeRateLimitReset', () => {
|
||||
const nowMs = 1_800_000_000_000
|
||||
|
||||
it('falls back to a vague wait when the reset is unknown', () => {
|
||||
expect(describeRateLimitReset(null, nowMs)).toBe('in a few minutes')
|
||||
})
|
||||
|
||||
it('rounds a sub-minute reset up to a minute', () => {
|
||||
expect(describeRateLimitReset(nowMs + 20_000, nowMs)).toBe('in about a minute')
|
||||
})
|
||||
|
||||
it('rounds a partial minute up', () => {
|
||||
expect(describeRateLimitReset(nowMs + 9 * 60_000 + 1, nowMs)).toBe('in about 10 minutes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTargetBuild', () => {
|
||||
it('pins an hourly tag at the hourly repo download path', () => {
|
||||
expect(resolveTargetBuild('hourly', 'v1.4.160-hourly.202607281400')).toEqual({
|
||||
|
||||
@@ -9,15 +9,78 @@ import {
|
||||
type ReleaseBuild,
|
||||
type ReleaseChannel
|
||||
} from '../shared/release-channel'
|
||||
import { parseRelayRetryAfterMs } from '../shared/relay-retry-after-header'
|
||||
import { getGhRateLimitBlockedUntilMs, recordGhPrimaryRateLimit } from './git/gh-rate-limit-breaker'
|
||||
import { isValidVersion } from './updater-fallback'
|
||||
import { rejectReleaseApiToken, resolveReleaseApiToken } from './updater-release-api-token'
|
||||
|
||||
const FETCH_TIMEOUT_MS = 8000
|
||||
const MAX_LISTED_BUILDS = 100
|
||||
const RETRY_AFTER_MAX_MS = 60 * 60_000
|
||||
|
||||
function getReleasesApiUrl(repo: string): string {
|
||||
return `https://api.github.com/repos/${repo}/releases?per_page=${MAX_LISTED_BUILDS}`
|
||||
}
|
||||
|
||||
function fetchReleases(repo: string, token: string | null): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/vnd.github+json' }
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return net.fetch(getReleasesApiUrl(repo), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
/** GitHub answers a spent primary bucket with 403 + `x-ratelimit-remaining: 0`; secondary limits carry Retry-After. */
|
||||
function isRateLimited(res: Response): boolean {
|
||||
return (
|
||||
res.status === 429 ||
|
||||
(res.status === 403 &&
|
||||
(res.headers.get('x-ratelimit-remaining') === '0' || res.headers.has('retry-after')))
|
||||
)
|
||||
}
|
||||
|
||||
/** Primary limits carry the reset epoch; secondary limits carry Retry-After as seconds or an HTTP date. */
|
||||
export function rateLimitResetAtMs(headers: Headers, nowMs: number): number | null {
|
||||
const resetEpochSeconds = Number(headers.get('x-ratelimit-reset'))
|
||||
if (resetEpochSeconds > 0) {
|
||||
return resetEpochSeconds * 1000
|
||||
}
|
||||
const retryAfterMs = parseRelayRetryAfterMs(headers.get('retry-after'), RETRY_AFTER_MAX_MS, nowMs)
|
||||
return retryAfterMs === null ? null : nowMs + retryAfterMs
|
||||
}
|
||||
|
||||
export function describeRateLimitReset(resetAtMs: number | null, nowMs: number): string {
|
||||
if (resetAtMs === null) {
|
||||
return 'in a few minutes'
|
||||
}
|
||||
const minutes = Math.ceil((resetAtMs - nowMs) / 60_000)
|
||||
return minutes <= 1 ? 'in about a minute' : `in about ${minutes} minutes`
|
||||
}
|
||||
|
||||
function releaseListError(
|
||||
res: Response,
|
||||
repo: string,
|
||||
channel: ReleaseChannel,
|
||||
signedIn: boolean
|
||||
): Error {
|
||||
if (res.status === 404) {
|
||||
return new Error(`No releases repository found at ${repo}.`)
|
||||
}
|
||||
if (isRateLimited(res)) {
|
||||
const nowMs = Date.now()
|
||||
const retry = describeRateLimitReset(rateLimitResetAtMs(res.headers, nowMs), nowMs)
|
||||
return new Error(
|
||||
signedIn
|
||||
? `GitHub rate limit reached. Try again ${retry}.`
|
||||
: `GitHub rate limit reached. Try again ${retry}, or run \`gh auth login\` so Orca can use your account's higher limit.`
|
||||
)
|
||||
}
|
||||
return new Error(`Could not list ${channel} builds (HTTP ${res.status}).`)
|
||||
}
|
||||
|
||||
export function getReleaseDownloadUrlForRepo(repo: string, tag: string): string {
|
||||
return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}`
|
||||
}
|
||||
@@ -89,26 +152,39 @@ function parseReleaseEntry(
|
||||
*
|
||||
* Why the REST API rather than the atom feed the routine update path uses: the
|
||||
* feed caps at the 10 newest entries, which cannot express "jump back to
|
||||
* yesterday's hourly". This runs only on explicit dev interaction, so its
|
||||
* unauthenticated rate limit never touches background checks.
|
||||
* yesterday's hourly". This runs only on explicit dev interaction, so it never
|
||||
* touches background checks; it sends the local gh token when there is one so
|
||||
* the request spends the user's own quota, not the per-IP bucket every
|
||||
* unauthenticated caller on the network shares.
|
||||
*/
|
||||
export async function listReleaseBuilds(
|
||||
channel: ReleaseChannel,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<ReleaseBuild[]> {
|
||||
const repo = getReleaseRepoForChannel(channel)
|
||||
const res = await net.fetch(getReleasesApiUrl(repo), {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
})
|
||||
// Why: while the gh breaker has the token's core bucket marked spent, an
|
||||
// authenticated request is a guaranteed 403 — go straight to the per-IP bucket.
|
||||
const tokenBucketBlocked = getGhRateLimitBlockedUntilMs('core') !== null
|
||||
const token = tokenBucketBlocked ? null : await resolveReleaseApiToken()
|
||||
let signedIn = tokenBucketBlocked || token !== null
|
||||
let res = await fetchReleases(repo, token)
|
||||
if (token && res.status === 401) {
|
||||
// Why: a revoked or expired keyring token answers 401, and the unauthenticated
|
||||
// request still lists a public repo — fall back instead of failing the picker.
|
||||
rejectReleaseApiToken()
|
||||
signedIn = false
|
||||
res = await fetchReleases(repo, null)
|
||||
} else if (token && isRateLimited(res)) {
|
||||
// Why: the token's bucket and the per-IP bucket are separate, so the other one
|
||||
// may still have quota. Tell the breaker first so gh calls fail fast until the reset.
|
||||
const resetAtMs = rateLimitResetAtMs(res.headers, Date.now())
|
||||
if (resetAtMs !== null) {
|
||||
recordGhPrimaryRateLimit('core', resetAtMs)
|
||||
}
|
||||
res = await fetchReleases(repo, null)
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Error(`No releases repository found at ${repo}.`)
|
||||
}
|
||||
if (res.status === 403 || res.status === 429) {
|
||||
throw new Error('GitHub rate limit reached. Try again in a few minutes.')
|
||||
}
|
||||
throw new Error(`Could not list ${channel} builds (HTTP ${res.status}).`)
|
||||
throw releaseListError(res, repo, channel, signedIn)
|
||||
}
|
||||
const payload: unknown = await res.json()
|
||||
if (!Array.isArray(payload)) {
|
||||
|
||||
+6
-2
@@ -10,6 +10,7 @@ import type {
|
||||
RemoteServerUpdateSupport
|
||||
} from '../shared/remote-server-update'
|
||||
import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel'
|
||||
import type { ReleaseBuildListOptions } from './updater-release-build-cache'
|
||||
import { UpdaterSetup, type UpdaterSetupOptions } from './updater/updater-setup'
|
||||
import type { UpdateInstallMode } from './updater/updater-state'
|
||||
|
||||
@@ -77,8 +78,11 @@ export async function showLinuxPackage(): Promise<void> {
|
||||
return updater.showLinuxPackage()
|
||||
}
|
||||
|
||||
export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise<ReleaseBuild[]> {
|
||||
return updater.listAvailableReleaseBuilds(channel)
|
||||
export async function listAvailableReleaseBuilds(
|
||||
channel: ReleaseChannel,
|
||||
options?: ReleaseBuildListOptions
|
||||
): Promise<ReleaseBuild[]> {
|
||||
return updater.listAvailableReleaseBuilds(channel, options)
|
||||
}
|
||||
|
||||
export function dismissNudge(): void {
|
||||
|
||||
@@ -12,10 +12,15 @@ import {
|
||||
} from '../../shared/release-channel'
|
||||
import { compareVersions } from '../updater-fallback'
|
||||
import { listReleaseBuilds, resolveTargetBuild } from '../updater-release-builds'
|
||||
import { ReleaseBuildListCache, type ReleaseBuildListOptions } from '../updater-release-build-cache'
|
||||
import { UpdaterMenuChecks } from './updater-menu-checks'
|
||||
|
||||
/** Handles local-build selection and exact release-channel/tag jumps. */
|
||||
export abstract class UpdaterBuildSelection extends UpdaterMenuChecks {
|
||||
private readonly releaseBuildCache = new ReleaseBuildListCache((channel) =>
|
||||
listReleaseBuilds(channel)
|
||||
)
|
||||
|
||||
protected async checkForLocalBuildFromMenu(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
this.sendLocalBuildErrorAndRestore(
|
||||
@@ -67,8 +72,11 @@ export abstract class UpdaterBuildSelection extends UpdaterMenuChecks {
|
||||
}
|
||||
}
|
||||
|
||||
protected async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise<ReleaseBuild[]> {
|
||||
return listReleaseBuilds(channel)
|
||||
protected async listAvailableReleaseBuilds(
|
||||
channel: ReleaseChannel,
|
||||
options?: ReleaseBuildListOptions
|
||||
): Promise<ReleaseBuild[]> {
|
||||
return this.releaseBuildCache.list(channel, options)
|
||||
}
|
||||
|
||||
/** Pins the updater at one exact release tag and checks it, so a dev can move to any published build on any channel — including an older one. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { app, powerMonitor } from 'electron'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import type { ReleaseBuild, ReleaseChannel } from '../../shared/release-channel'
|
||||
import type { ReleaseBuildListOptions } from '../updater-release-build-cache'
|
||||
import type {
|
||||
LinuxPackageInstallInstructions,
|
||||
UpdateCheckOptions,
|
||||
@@ -94,8 +95,11 @@ export class UpdaterSetup extends UpdaterDownloadInstall {
|
||||
return super.showLinuxPackage()
|
||||
}
|
||||
|
||||
async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise<ReleaseBuild[]> {
|
||||
return super.listAvailableReleaseBuilds(channel)
|
||||
async listAvailableReleaseBuilds(
|
||||
channel: ReleaseChannel,
|
||||
options?: ReleaseBuildListOptions
|
||||
): Promise<ReleaseBuild[]> {
|
||||
return super.listAvailableReleaseBuilds(channel, options)
|
||||
}
|
||||
|
||||
dismissNudge(): void {
|
||||
|
||||
@@ -113,12 +113,20 @@ export function registerUpdaterHandlers(_store: Store): void {
|
||||
})
|
||||
ipcMain.handle(
|
||||
'updater:listBuilds',
|
||||
async (_event, channel: ReleaseChannel): Promise<ReleaseBuildListResult> => {
|
||||
async (
|
||||
_event,
|
||||
channel: ReleaseChannel,
|
||||
options?: { force?: boolean }
|
||||
): Promise<ReleaseBuildListResult> => {
|
||||
if (!RELEASE_CHANNELS.includes(channel)) {
|
||||
return { ok: false, channel, message: `Unknown release channel "${channel}".` }
|
||||
}
|
||||
try {
|
||||
return { ok: true, channel, builds: await listAvailableReleaseBuilds(channel) }
|
||||
return {
|
||||
ok: true,
|
||||
channel,
|
||||
builds: await listAvailableReleaseBuilds(channel, { force: options?.force === true })
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: a network/rate-limit failure is expected here; return it as data so
|
||||
// the picker can render the reason instead of rejecting the invoke.
|
||||
|
||||
@@ -18,7 +18,11 @@ export type UpdaterApi = {
|
||||
getLinuxPackageInstallInstructions: () => Promise<LinuxPackageInstallInstructions>
|
||||
/** Desktop-only. Reveals the revalidated cached package in the native file manager. */
|
||||
showLinuxPackage: () => Promise<void>
|
||||
listBuilds: (channel: ReleaseChannel) => Promise<ReleaseBuildListResult>
|
||||
/** `force` bypasses the main-process list cache — the refresh button, not mount or channel switches. */
|
||||
listBuilds: (
|
||||
channel: ReleaseChannel,
|
||||
options?: { force?: boolean }
|
||||
) => Promise<ReleaseBuildListResult>
|
||||
|
||||
onStatus: (callback: (status: UpdateStatus) => void) => () => void
|
||||
onClearDismissal: (callback: () => void) => () => void
|
||||
|
||||
@@ -14,7 +14,7 @@ export const updaterApi = {
|
||||
getLinuxPackageInstallInstructions: () =>
|
||||
ipcRenderer.invoke('updater:getLinuxPackageInstallInstructions'),
|
||||
showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'),
|
||||
listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel),
|
||||
listBuilds: (channel, options) => ipcRenderer.invoke('updater:listBuilds', channel, options),
|
||||
quitAndInstall: (): Promise<void> =>
|
||||
prepareAndInvokeUpdaterInstall(
|
||||
window,
|
||||
|
||||
@@ -116,46 +116,54 @@ export function ReleaseChannelSection(): React.JSX.Element {
|
||||
// from a channel the picker is no longer showing.
|
||||
const latestRequestRef = useRef(0)
|
||||
|
||||
const loadBuilds = useCallback(async (channel: ReleaseChannel): Promise<void> => {
|
||||
const requestId = latestRequestRef.current + 1
|
||||
latestRequestRef.current = requestId
|
||||
const isStale = (): boolean => latestRequestRef.current !== requestId
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const result = await window.api.updater.listBuilds(channel)
|
||||
if (isStale()) {
|
||||
return
|
||||
}
|
||||
if (result.ok) {
|
||||
setBuilds(result.builds)
|
||||
setSelectedTag(result.builds[0]?.tag ?? null)
|
||||
} else {
|
||||
const loadBuilds = useCallback(
|
||||
async (channel: ReleaseChannel, options?: { force?: boolean }): Promise<void> => {
|
||||
const requestId = latestRequestRef.current + 1
|
||||
latestRequestRef.current = requestId
|
||||
const isStale = (): boolean => latestRequestRef.current !== requestId
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const result = await window.api.updater.listBuilds(channel, options)
|
||||
if (isStale()) {
|
||||
return
|
||||
}
|
||||
if (result.ok) {
|
||||
setBuilds(result.builds)
|
||||
setSelectedTag(result.builds[0]?.tag ?? null)
|
||||
} else {
|
||||
setBuilds(null)
|
||||
setLoadError(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isStale()) {
|
||||
return
|
||||
}
|
||||
setBuilds(null)
|
||||
setLoadError(result.message)
|
||||
setLoadError(String((error as Error)?.message ?? error))
|
||||
} finally {
|
||||
// Why: only the newest request owns the spinner; a superseded one clearing
|
||||
// it would show "no builds" while the current load is still running.
|
||||
if (!isStale()) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isStale()) {
|
||||
return
|
||||
}
|
||||
setBuilds(null)
|
||||
setLoadError(String((error as Error)?.message ?? error))
|
||||
} finally {
|
||||
// Why: only the newest request owns the spinner; a superseded one clearing
|
||||
// it would show "no builds" while the current load is still running.
|
||||
if (!isStale()) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Why: reload whenever the channel changes so the picker never offers tags
|
||||
// from the channel the user just switched away from.
|
||||
// from the channel the user just switched away from. Not before the version
|
||||
// resolves: the running channel is unknown until then, and a load for the
|
||||
// 'stable' placeholder would be a GitHub request whose result is thrown away.
|
||||
useEffect(() => {
|
||||
if (appVersion === null) {
|
||||
return
|
||||
}
|
||||
setBuilds(null)
|
||||
setSelectedTag(null)
|
||||
void loadBuilds(activeChannel)
|
||||
}, [activeChannel, loadBuilds])
|
||||
}, [activeChannel, appVersion, loadBuilds])
|
||||
|
||||
const selectedBuild = useMemo(
|
||||
() => builds?.find((build) => build.tag === selectedTag) ?? null,
|
||||
@@ -276,7 +284,7 @@ export function ReleaseChannelSection(): React.JSX.Element {
|
||||
<SelectTrigger size="sm" className="min-w-64 flex-1">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loading
|
||||
loading || (builds === null && loadError === null)
|
||||
? translate(
|
||||
'auto.components.settings.ReleaseChannelSection.loadingBuilds',
|
||||
'Loading builds…'
|
||||
@@ -306,7 +314,7 @@ export function ReleaseChannelSection(): React.JSX.Element {
|
||||
'Refresh build list'
|
||||
)}
|
||||
disabled={loading}
|
||||
onClick={() => void loadBuilds(activeChannel)}
|
||||
onClick={() => void loadBuilds(activeChannel, { force: true })}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
|
||||
Reference in New Issue
Block a user