fix(menubar): retry transient release-asset download failures

`codeburn menubar` aborted the install on the first bad response from
GitHub release-asset delivery. A transient HTTP 500 on the checksum
fetch (issue #876) killed an otherwise healthy install even though the
asset was published correctly and the next request succeeded.

Retry the zip and checksum downloads up to 3 times with a short
exponential backoff (0.5s, 1s) on 5xx responses and network-level
errors. 4xx is never retried: 404/410 still falls through to the
release-API discovery path unchanged, and a 403/429 rate limit cannot
clear inside the backoff window so it surfaces immediately with its
retry-after hint. The checksum comparison stays outside the retry loop
so a genuine digest mismatch still aborts on the first look.

Thrown errors now name the requested URL so a failure is actionable.
Retry parameters and the fetch/sleep/log seams are injectable, matching
the options pattern in src/sync/push.ts and src/cache-refresh-lock.ts.
This commit is contained in:
ozymandiashh 2026-08-03 23:08:59 +03:00
parent b8c92bfba7
commit eb90b29422
2 changed files with 290 additions and 16 deletions

View file

@ -37,6 +37,32 @@ type ProxyEnv = Partial<Record<'HTTPS_PROXY' | 'https_proxy' | 'HTTP_PROXY' | 'h
type FetchOptions = Parameters<typeof undiciFetch>[1]
type HeaderGetter = { get(name: string): string | null }
/// Only the response surface the asset downloads actually touch, so tests can inject a
/// plain object instead of constructing a full undici Response.
type FetchLikeResponse = {
ok: boolean
status: number
headers: HeaderGetter
body: unknown
text(): Promise<string>
}
type FetchImpl = (url: string, options?: FetchOptions) => Promise<FetchLikeResponse>
/// Release-asset delivery (github.com -> Azure blob) occasionally returns a transient 5xx or
/// drops the socket. Three attempts with a short exponential backoff (0.5s, then 1s) rides out
/// that class of blip while adding at most ~1.5s before a genuinely broken download reports
/// back — `codeburn menubar` is interactive, so failing fast still matters.
const ASSET_MAX_ATTEMPTS = 3
const ASSET_BASE_DELAY_MS = 500
export type AssetFetchOptions = {
fetchImpl?: FetchImpl
sleep?: (ms: number) => Promise<void>
log?: (message: string) => void
maxAttempts?: number
baseDelayMs?: number
}
class HttpStatusError extends Error {
constructor(message: string, readonly status: number) {
super(message)
@ -135,7 +161,7 @@ export function formatGitHubReleaseLookupError(status: number, headers?: HeaderG
return `${base}. ${details.join(' ')}`
}
function isMissingDirectAssetError(err: unknown): boolean {
export function isMissingDirectAssetError(err: unknown): boolean {
return err instanceof HttpStatusError && shouldFallbackToReleaseApi(err.status)
}
@ -195,19 +221,75 @@ async function fetchLatestReleaseAssets(): Promise<ResolvedAssets> {
return resolveLatestMenubarReleaseAssets(body)
}
async function verifyChecksum(archivePath: string, checksumUrl: string): Promise<void> {
const response = await fetchWithProxy(checksumUrl, {
headers: { 'User-Agent': 'codeburn-menubar-installer' },
redirect: 'follow',
})
if (!response.ok) {
throw new HttpStatusError(`Checksum download failed: HTTP ${response.status}`, response.status)
/// 5xx means "GitHub/the CDN is unhappy right now" and is worth another attempt. 4xx is not:
/// 404/410 must keep falling through to the release-API path untouched, and a 403/429 rate limit
/// cannot clear inside a 1.5s backoff window — hammering it would only spend more of the budget,
/// so those surface immediately with the retry-after hint instead.
function isTransientStatus(status: number): boolean {
return status >= 500 && status <= 599
}
function formatAssetHttpError(label: string, url: string, response: FetchLikeResponse): string {
const base = `${label} failed: HTTP ${response.status} (${url})`
if (response.status !== 403 && response.status !== 429) return base
const retryAfter = response.headers.get('retry-after')
const hint = retryAfter
? `GitHub may be rate limiting this download; retry-after=${retryAfter}.`
: 'GitHub may be rate limiting this download.'
return `${base}. ${hint}`
}
/// Fetch a release asset, retrying only transient failures. Returns the successful response;
/// the caller consumes the body. Every thrown message carries the requested URL so the user can
/// retry it by hand.
async function fetchReleaseAsset(url: string, label: string, options: AssetFetchOptions): Promise<FetchLikeResponse> {
const doFetch = options.fetchImpl ?? fetchWithProxy
const sleep = options.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
const log = options.log ?? console.log
const maxAttempts = options.maxAttempts ?? ASSET_MAX_ATTEMPTS
const baseDelayMs = options.baseDelayMs ?? ASSET_BASE_DELAY_MS
for (let attempt = 1; ; attempt++) {
const isLastAttempt = attempt >= maxAttempts
const delayMs = baseDelayMs * 2 ** (attempt - 1)
let response: FetchLikeResponse
try {
response = await doFetch(url, {
headers: { 'User-Agent': 'codeburn-menubar-installer' },
redirect: 'follow',
})
} catch (err) {
// Network-level failure (ECONNRESET / ETIMEDOUT / socket hang up): no status to inspect,
// and always transient enough to be worth one more try.
const reason = err instanceof Error ? err.message : String(err)
if (isLastAttempt) throw new Error(`${label} failed after ${maxAttempts} attempts: ${reason} (${url})`)
log(`${label} hit a network error (${reason}), retrying in ${delayMs}ms (attempt ${attempt + 1} of ${maxAttempts})...`)
await sleep(delayMs)
continue
}
if (response.ok) return response
if (!isTransientStatus(response.status) || isLastAttempt) {
throw new HttpStatusError(formatAssetHttpError(label, url, response), response.status)
}
log(`${label} failed with HTTP ${response.status}, retrying in ${delayMs}ms (attempt ${attempt + 1} of ${maxAttempts})...`)
await sleep(delayMs)
}
}
export async function verifyChecksum(
archivePath: string,
checksumUrl: string,
options: AssetFetchOptions = {},
): Promise<void> {
const response = await fetchReleaseAsset(checksumUrl, 'Checksum download', options)
const text = await response.text()
const expected = text.trim().split(/\s+/)[0]!.toLowerCase()
const fileBytes = await readFile(archivePath)
const actual = createHash('sha256').update(fileBytes).digest('hex')
if (actual !== expected) {
// Deliberately outside the retry loop: retries cover transport failures only. A digest
// mismatch is an integrity failure and must abort immediately, never re-download.
throw new Error(
`Checksum mismatch for ${archivePath}.\n` +
` Expected: ${expected}\n` +
@ -217,13 +299,14 @@ async function verifyChecksum(archivePath: string, checksumUrl: string): Promise
}
}
async function downloadToFile(url: string, destPath: string): Promise<void> {
const response = await fetchWithProxy(url, {
headers: { 'User-Agent': 'codeburn-menubar-installer' },
redirect: 'follow',
})
if (!response.ok || response.body === null) {
throw new HttpStatusError(`Download failed: HTTP ${response.status}`, response.status)
export async function downloadToFile(
url: string,
destPath: string,
options: AssetFetchOptions = {},
): Promise<void> {
const response = await fetchReleaseAsset(url, 'Download', options)
if (response.body === null) {
throw new HttpStatusError(`Download failed: HTTP ${response.status} with an empty body (${url})`, response.status)
}
// fetch's ReadableStream needs to be wrapped for Node streams.
const nodeStream = Readable.fromWeb(response.body as never)

View file

@ -1,13 +1,20 @@
import { describe, expect, it } from 'vitest'
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
buildPersistentCodeburnLookupPath,
downloadToFile,
formatGitHubReleaseLookupError,
isMissingDirectAssetError,
resolveLatestMenubarReleaseAssets,
resolveMenubarReleaseAssets,
resolvePersistentCodeburnPathFromWhichOutput,
resolveProxyUrlForUrl,
resolveVersionedMenubarReleaseAssets,
shouldFallbackToReleaseApi,
verifyChecksum,
type ReleaseResponse,
} from '../src/menubar-installer.js'
@ -161,3 +168,187 @@ describe('resolveMenubarReleaseAssets', () => {
expect(proxyUrl).toBeUndefined()
})
})
const ZIP_URL = 'https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.19/CodeBurnMenubar-v0.9.19.zip'
const CHECKSUM_URL = `${ZIP_URL}.sha256`
/** Minimal stand-in for the fetch response surface the asset downloads touch. */
function httpResponse(status: number, body?: string, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
body: body === undefined ? null : new Response(body).body,
text: async () => body ?? '',
}
}
function sha256(text: string): string {
return createHash('sha256').update(Buffer.from(text)).digest('hex')
}
describe('release asset download retry', () => {
let sandbox: string
let sleeps: number[]
let logs: string[]
let recorder: { sleep: (ms: number) => Promise<void>; log: (message: string) => void }
beforeEach(async () => {
sandbox = await mkdtemp(join(tmpdir(), 'menubar-installer-'))
sleeps = []
logs = []
recorder = {
sleep: async (ms: number) => { sleeps.push(ms) },
log: (message: string) => { logs.push(message) },
}
})
afterEach(async () => {
await rm(sandbox, { recursive: true, force: true })
})
it('retries a transient 500 zip download and completes on the next attempt', async () => {
const dest = join(sandbox, 'CodeBurnMenubar-v0.9.19.zip')
const statuses = [500, 200]
let calls = 0
await downloadToFile(ZIP_URL, dest, {
...recorder,
fetchImpl: async () => {
const status = statuses[calls++]!
return httpResponse(status, status === 200 ? 'zip-bytes' : 'upstream error')
},
})
expect(calls).toBe(2)
expect(await readFile(dest, 'utf8')).toBe('zip-bytes')
expect(sleeps).toEqual([500])
expect(logs).toHaveLength(1)
expect(logs[0]).toContain('HTTP 500')
expect(logs[0]).toContain('attempt 2 of 3')
})
it('retries a transient 500 checksum download, the failure reported in the issue', async () => {
const archive = join(sandbox, 'CodeBurnMenubar-v0.9.19.zip')
await writeFile(archive, 'zip-bytes')
const statuses = [500, 200]
let calls = 0
await verifyChecksum(archive, CHECKSUM_URL, {
...recorder,
fetchImpl: async () => {
const status = statuses[calls++]!
return httpResponse(status, status === 200 ? `${sha256('zip-bytes')} CodeBurnMenubar-v0.9.19.zip` : 'boom')
},
})
expect(calls).toBe(2)
expect(sleeps).toEqual([500])
expect(logs[0]).toContain('Checksum download failed with HTTP 500')
})
it('gives up on a persistent 500 and names the requested URL in the error', async () => {
let calls = 0
await expect(verifyChecksum(join(sandbox, 'unused.zip'), CHECKSUM_URL, {
...recorder,
fetchImpl: async () => { calls++; return httpResponse(500) },
})).rejects.toThrow(CHECKSUM_URL)
expect(calls).toBe(3)
expect(sleeps).toEqual([500, 1000])
})
it('does not retry a 404 and still routes to the missing-asset fallback', async () => {
let calls = 0
let captured: unknown
await downloadToFile(ZIP_URL, join(sandbox, 'out.zip'), {
...recorder,
fetchImpl: async () => { calls++; return httpResponse(404) },
}).catch((err: unknown) => { captured = err })
expect(calls).toBe(1)
expect(sleeps).toEqual([])
expect(isMissingDirectAssetError(captured)).toBe(true)
expect(captured).toBeInstanceOf(Error)
expect((captured as Error).message).toContain(ZIP_URL)
})
it('does not retry a 429 and surfaces the retry-after hint instead', async () => {
let calls = 0
await expect(downloadToFile(ZIP_URL, join(sandbox, 'out.zip'), {
...recorder,
fetchImpl: async () => { calls++; return httpResponse(429, undefined, { 'retry-after': '120' }) },
})).rejects.toThrow(/retry-after=120/)
expect(calls).toBe(1)
expect(sleeps).toEqual([])
})
it('retries a network-level failure and reports it with the URL when it persists', async () => {
let calls = 0
await expect(downloadToFile(ZIP_URL, join(sandbox, 'out.zip'), {
...recorder,
fetchImpl: async () => {
calls++
throw Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' })
},
})).rejects.toThrow(/socket hang up/)
expect(calls).toBe(3)
expect(sleeps).toEqual([500, 1000])
expect(logs).toHaveLength(2)
})
it('recovers when a network-level failure clears on the next attempt', async () => {
const dest = join(sandbox, 'CodeBurnMenubar-v0.9.19.zip')
let calls = 0
await downloadToFile(ZIP_URL, dest, {
...recorder,
fetchImpl: async () => {
calls++
if (calls === 1) throw Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' })
return httpResponse(200, 'zip-bytes')
},
})
expect(calls).toBe(2)
expect(await readFile(dest, 'utf8')).toBe('zip-bytes')
})
it('fails a genuine checksum mismatch immediately instead of re-downloading', async () => {
const archive = join(sandbox, 'CodeBurnMenubar-v0.9.19.zip')
await writeFile(archive, 'tampered-bytes')
let calls = 0
await expect(verifyChecksum(archive, CHECKSUM_URL, {
...recorder,
fetchImpl: async () => {
calls++
return httpResponse(200, `${sha256('zip-bytes')} CodeBurnMenubar-v0.9.19.zip`)
},
})).rejects.toThrow(/Checksum mismatch/)
// The retry budget covers transport only. A digest mismatch must abort on the first look.
expect(calls).toBe(1)
expect(sleeps).toEqual([])
})
it('honors an overridden attempt budget', async () => {
let calls = 0
await expect(downloadToFile(ZIP_URL, join(sandbox, 'out.zip'), {
...recorder,
maxAttempts: 2,
baseDelayMs: 10,
fetchImpl: async () => { calls++; return httpResponse(503) },
})).rejects.toThrow(/HTTP 503/)
expect(calls).toBe(2)
expect(sleeps).toEqual([10])
})
})