Skip to content

Commit 72bc82e

Browse files
chore(p1): harden GitHub API requests (#37)
1 parent d5c7c69 commit 72bc82e

File tree

1 file changed

+59
-13
lines changed

1 file changed

+59
-13
lines changed

src/core/selfUpdate.ts

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const GITHUB_RELEASES_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_N
1414
const GITHUB_RELEASE_TAG_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags`;
1515
const TIMEOUT_MS = 60000;
1616
const MAX_RETRIES = 2;
17+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
1718

1819
export interface ReleaseInfo {
1920
tag_name: string;
@@ -31,12 +32,62 @@ export interface UpdateStatus {
3132
releaseInfo?: ReleaseInfo;
3233
}
3334

35+
function getGithubHeaders(): Record<string, string> {
36+
const headers: Record<string, string> = { 'User-Agent': 'cloudsqlctl/upgrade' };
37+
const token = process.env.CLOUDSQLCTL_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
38+
if (token) {
39+
headers.Authorization = `Bearer ${token}`;
40+
}
41+
return headers;
42+
}
43+
44+
function getRateLimitMessage(error: unknown): string | null {
45+
if (axios.isAxiosError(error) && error.response) {
46+
const remaining = error.response.headers['x-ratelimit-remaining'];
47+
const reset = error.response.headers['x-ratelimit-reset'];
48+
if (remaining === '0' && reset) {
49+
const resetTime = new Date(Number(reset) * 1000).toISOString();
50+
return `GitHub API rate limit exceeded. Resets at ${resetTime}.`;
51+
}
52+
}
53+
return null;
54+
}
55+
56+
function shouldRetry(error: unknown): boolean {
57+
if (axios.isAxiosError(error)) {
58+
if (!error.response) return true;
59+
return RETRYABLE_STATUS.has(error.response.status);
60+
}
61+
return false;
62+
}
63+
64+
async function githubGet<T>(url: string) {
65+
let attempt = 0;
66+
while (attempt <= MAX_RETRIES) {
67+
try {
68+
return await axios.get<T>(url, {
69+
timeout: TIMEOUT_MS,
70+
headers: getGithubHeaders()
71+
});
72+
} catch (error) {
73+
attempt++;
74+
const rateLimit = getRateLimitMessage(error);
75+
if (rateLimit) {
76+
logger.warn(rateLimit);
77+
}
78+
if (attempt > MAX_RETRIES || !shouldRetry(error)) {
79+
throw error;
80+
}
81+
const delayMs = 1000 * attempt;
82+
await new Promise(resolve => setTimeout(resolve, delayMs));
83+
}
84+
}
85+
throw new Error('GitHub API request failed after retries');
86+
}
87+
3488
export async function getLatestRelease(): Promise<ReleaseInfo> {
3589
try {
36-
const response = await axios.get(GITHUB_API_URL, {
37-
timeout: TIMEOUT_MS,
38-
headers: { 'User-Agent': 'cloudsqlctl/upgrade' }
39-
});
90+
const response = await githubGet<ReleaseInfo>(GITHUB_API_URL);
4091
return response.data;
4192
} catch (error) {
4293
logger.error('Failed to fetch latest release info', error);
@@ -50,10 +101,7 @@ function normalizeTag(tag: string): string {
50101

51102
export async function getReleaseByTag(tag: string): Promise<ReleaseInfo> {
52103
try {
53-
const response = await axios.get(`${GITHUB_RELEASE_TAG_URL}/${normalizeTag(tag)}`, {
54-
timeout: TIMEOUT_MS,
55-
headers: { 'User-Agent': 'cloudsqlctl/upgrade' }
56-
});
104+
const response = await githubGet<ReleaseInfo>(`${GITHUB_RELEASE_TAG_URL}/${normalizeTag(tag)}`);
57105
return response.data;
58106
} catch (error) {
59107
logger.error('Failed to fetch release by tag', error);
@@ -63,10 +111,7 @@ export async function getReleaseByTag(tag: string): Promise<ReleaseInfo> {
63111

64112
export async function getLatestPrerelease(): Promise<ReleaseInfo> {
65113
try {
66-
const response = await axios.get(GITHUB_RELEASES_URL, {
67-
timeout: TIMEOUT_MS,
68-
headers: { 'User-Agent': 'cloudsqlctl/upgrade' }
69-
});
114+
const response = await githubGet<ReleaseInfo[]>(GITHUB_RELEASES_URL);
70115
const releases = Array.isArray(response.data) ? response.data : [];
71116
const prerelease = releases.find((r: { prerelease?: boolean; draft?: boolean }) => r.prerelease && !r.draft);
72117
if (!prerelease) {
@@ -123,7 +168,8 @@ export async function fetchSha256Sums(release: ReleaseInfo): Promise<Map<string,
123168

124169
const response = await axios.get(checksumAsset.browser_download_url, {
125170
responseType: 'text',
126-
timeout: TIMEOUT_MS
171+
timeout: TIMEOUT_MS,
172+
headers: getGithubHeaders()
127173
});
128174

129175
const sums = new Map<string, string>();

0 commit comments

Comments
 (0)