Skip to content

Commit 3d14b95

Browse files
committed
fix(releases): add in-memory TTL cache for GitHub API responses
Cache getLatestRelease and getReleaseAssetUrl responses for 5 minutes to prevent redundant API calls during parallel asset downloads. This avoids hitting GitHub rate limits (60 req/hour unauthenticated) when building multiple assets concurrently or when pre-commit hooks trigger a second build within the same process.
1 parent 4eabd6c commit 3d14b95

File tree

1 file changed

+80
-41
lines changed

1 file changed

+80
-41
lines changed

src/releases/github.ts

Lines changed: 80 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,27 @@ export const SOCKET_BTM_REPO = {
103103

104104
const logger = getDefaultLogger()
105105

106+
// In-memory TTL cache for GitHub API responses.
107+
// Prevents redundant API calls during parallel asset downloads and
108+
// repeated builds (e.g., pre-commit hooks) within the same process.
109+
const API_CACHE_TTL_MS = 5 * 60_000
110+
const _apiCache = new Map<string, { data: unknown; expiresAt: number }>()
111+
112+
function getCachedApiResponse<T>(key: string): T | undefined {
113+
const entry = _apiCache.get(key)
114+
if (entry && entry.expiresAt > Date.now()) {
115+
return entry.data as T
116+
}
117+
if (entry) {
118+
_apiCache.delete(key)
119+
}
120+
return undefined
121+
}
122+
123+
function setCachedApiResponse(key: string, data: unknown): void {
124+
_apiCache.set(key, { data, expiresAt: Date.now() + API_CACHE_TTL_MS })
125+
}
126+
106127
let _fs: typeof import('node:fs') | undefined
107128
let _path: typeof import('node:path') | undefined
108129

@@ -362,32 +383,42 @@ export async function getLatestRelease(
362383
// Create matcher function if pattern provided.
363384
const isMatch = assetPattern ? createAssetMatcher(assetPattern) : undefined
364385

386+
const cacheKey = `releases:${owner}/${repo}`
387+
365388
return await pRetry(
366389
async () => {
367-
// Fetch recent releases (100 should cover all tool releases).
368-
const response = await httpRequest(
369-
`https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`,
370-
{
371-
headers: getAuthHeaders(),
372-
},
373-
)
390+
// Check in-memory cache first to avoid redundant API calls.
391+
let releases = getCachedApiResponse<
392+
Array<{
393+
tag_name: string
394+
published_at: string
395+
assets: Array<{ name: string }>
396+
}>
397+
>(cacheKey)
398+
399+
if (!releases) {
400+
// Fetch recent releases (100 should cover all tool releases).
401+
const response = await httpRequest(
402+
`https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`,
403+
{
404+
headers: getAuthHeaders(),
405+
},
406+
)
374407

375-
if (!response.ok) {
376-
throw new Error(`Failed to fetch releases: ${response.status}`)
377-
}
408+
if (!response.ok) {
409+
throw new Error(`Failed to fetch releases: ${response.status}`)
410+
}
378411

379-
let releases: Array<{
380-
tag_name: string
381-
published_at: string
382-
assets: Array<{ name: string }>
383-
}>
384-
try {
385-
releases = JSON.parse(response.body.toString('utf8'))
386-
} catch (cause) {
387-
throw new Error(
388-
`Failed to parse GitHub releases response from https://api.github.com/repos/${owner}/${repo}/releases`,
389-
{ cause },
390-
)
412+
try {
413+
releases = JSON.parse(response.body.toString('utf8'))
414+
} catch (cause) {
415+
throw new Error(
416+
`Failed to parse GitHub releases response from https://api.github.com/repos/${owner}/${repo}/releases`,
417+
{ cause },
418+
)
419+
}
420+
421+
setCachedApiResponse(cacheKey, releases)
391422
}
392423

393424
// Filter releases matching the tool prefix.
@@ -483,29 +514,37 @@ export async function getReleaseAssetUrl(
483514
? (input: string) => input === assetPattern
484515
: createAssetMatcher(assetPattern as AssetPattern)
485516

517+
const cacheKey = `release:${owner}/${repo}:${tag}`
518+
486519
return await pRetry(
487520
async () => {
488-
const response = await httpRequest(
489-
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}`,
490-
{
491-
headers: getAuthHeaders(),
492-
},
493-
)
494-
495-
if (!response.ok) {
496-
throw new Error(`Failed to fetch release ${tag}: ${response.status}`)
497-
}
498-
499-
let release: {
521+
// Check in-memory cache first to avoid redundant API calls.
522+
let release = getCachedApiResponse<{
500523
assets: Array<{ name: string; browser_download_url: string }>
501-
}
502-
try {
503-
release = JSON.parse(response.body.toString('utf8'))
504-
} catch (cause) {
505-
throw new Error(
506-
`Failed to parse GitHub release response for tag ${tag}`,
507-
{ cause },
524+
}>(cacheKey)
525+
526+
if (!release) {
527+
const response = await httpRequest(
528+
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}`,
529+
{
530+
headers: getAuthHeaders(),
531+
},
508532
)
533+
534+
if (!response.ok) {
535+
throw new Error(`Failed to fetch release ${tag}: ${response.status}`)
536+
}
537+
538+
try {
539+
release = JSON.parse(response.body.toString('utf8'))
540+
} catch (cause) {
541+
throw new Error(
542+
`Failed to parse GitHub release response for tag ${tag}`,
543+
{ cause },
544+
)
545+
}
546+
547+
setCachedApiResponse(cacheKey, release)
509548
}
510549

511550
// Find the matching asset.

0 commit comments

Comments
 (0)