From 4af251207b14a0ceada687b1c0216a47a6385c17 Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 14 Jun 2026 19:06:16 +0800 Subject: [PATCH] fix(desktop): abort the repos fetch on unmount + retry transient rate limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two robustness fixes for the GitHub dialog's fetches: - The "Your repositories" fetch had no AbortController, so closing the dialog before it resolved updated stale state (and the spinner could flip after unmount). It now aborts on unmount and when superseded, and only the latest request owns the loading spinner. - New ghFetch() wrapper retries once on GitHub's *secondary* (abuse) rate limit — 403/429 with a short Retry-After (≤ 8s) — used by openFile/enterDir/loadRepos. A *primary* rate limit (no/far Retry-After) still surfaces as an error with the sign-in hint rather than hanging. tsc ✓ · vitest (3) ✓ · biome ✓ · build ✓. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/GitHubOpenDialog.tsx | 43 ++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/GitHubOpenDialog.tsx b/src/components/GitHubOpenDialog.tsx index a264ea5..ec52699 100644 --- a/src/components/GitHubOpenDialog.tsx +++ b/src/components/GitHubOpenDialog.tsx @@ -41,6 +41,23 @@ const RAW_HEADERS = { "X-GitHub-Api-Version": "2022-11-28", }; +/** fetch() with one automatic retry on GitHub's *secondary* (abuse) rate limit, + * which returns 403/429 with a short `Retry-After`. We only wait when that + * header is present and small (≤ 8s) — a *primary* rate limit resets far in the + * future, so we surface it as an error (with the sign-in hint) instead of + * hanging. An aborted signal makes the retry reject immediately, as intended. */ +async function ghFetch(input: string, init?: RequestInit): Promise { + const res = await fetch(input, init); + if (res.status === 403 || res.status === 429) { + const ra = Number(res.headers.get("retry-after")); + if (Number.isFinite(ra) && ra > 0 && ra <= 8) { + await new Promise((r) => setTimeout(r, ra * 1000)); + return fetch(input, init); + } + } + return res; +} + /** Map an HTTP status to a message and whether signing in would likely help * (so the dialog can offer a "Sign in" shortcut). `signedIn` tailors the copy: * a signed-out 404 is probably a private repo; a 403 is probably a rate limit. */ @@ -92,25 +109,31 @@ export function GitHubOpenDialog({ onClose, onOpen, onOpenVault }: Props) { const [reposLoading, setReposLoading] = useState(false); const [signInCode, setSignInCode] = useState(null); const signInAbort = useRef(null); + const reposAbort = useRef(null); const browsing = stack.length > 0; const current = stack[stack.length - 1]; const loadRepos = useCallback(async () => { if (!isGitHubSignedIn()) return; + reposAbort.current?.abort(); + const controller = new AbortController(); + reposAbort.current = controller; setReposLoading(true); try { - const res = await fetch( + const res = await ghFetch( "https://api.github.com/user/repos?per_page=100&sort=updated", { headers: { ...JSON_HEADERS, ...githubAuthHeaders() }, + signal: controller.signal, }, ); if (res.ok) setRepos(parseRepos(await res.json())); } catch { - /* leave repos empty; URL entry still works */ + /* aborted, or network error — leave repos empty; URL entry still works */ } finally { - setReposLoading(false); + // Only the latest request owns the spinner (an aborted one already moved on). + if (reposAbort.current === controller) setReposLoading(false); } }, []); @@ -118,8 +141,14 @@ export function GitHubOpenDialog({ onClose, onOpen, onOpenVault }: Props) { if (signedIn) loadRepos(); }, [signedIn, loadRepos]); - // Stop any in-flight poll when the dialog unmounts. - useEffect(() => () => signInAbort.current?.abort(), []); + // Stop any in-flight sign-in poll / repos fetch when the dialog unmounts. + useEffect( + () => () => { + signInAbort.current?.abort(); + reposAbort.current?.abort(); + }, + [], + ); // Hydrate the token from the Keychain on first open (migrating any token a // pre-Keychain build left in localStorage), then refresh the signed-in state. @@ -189,7 +218,7 @@ export function GitHubOpenDialog({ onClose, onOpen, onOpenVault }: Props) { setLoading(true); clearError(); try { - const res = await fetch(contentsApiUrl(link), { + const res = await ghFetch(contentsApiUrl(link), { headers: { ...RAW_HEADERS, ...githubAuthHeaders() }, }); if (!res.ok) { @@ -245,7 +274,7 @@ export function GitHubOpenDialog({ onClose, onOpen, onOpenVault }: Props) { setLoading(true); clearError(); try { - const res = await fetch(contentsApiUrl(link), { + const res = await ghFetch(contentsApiUrl(link), { headers: { ...JSON_HEADERS, ...githubAuthHeaders() }, }); if (!res.ok) {