From 151a4d91373a0c7b2335fcf22add3f30a02b9531 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 17 May 2026 21:10:20 +0200 Subject: [PATCH] fix(github): auto-retry GitHub calls on 401 and expose unauthorized state fetchLatestSha and queryFileContent were silently catching every error (including expired-token 401s) and returning null, so the freshness badge could stay on "offline" with no UI hint that re-auth was needed. Wrap both calls in runWithAuthRetry to recover from refreshable tokens transparently, and switch fetchLatestSha to a tagged result so the freshness hook can distinguish auth failure from network failure. --- src/hooks/useGitHubContent.hook.ts | 28 +++++++++++++++++----------- src/hooks/useNoteFreshness.hook.ts | 29 +++++++++++++++++++++++------ src/modules/repo/services/repo.ts | 10 ++++------ 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/hooks/useGitHubContent.hook.ts b/src/hooks/useGitHubContent.hook.ts index 927a3fc..a0e415d 100644 --- a/src/hooks/useGitHubContent.hook.ts +++ b/src/hooks/useGitHubContent.hook.ts @@ -1,8 +1,14 @@ -import { getOctokit } from "@/modules/repo/services/octo" +import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo" import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8" import { confirmMessage, errorMessage } from "@/utils/notif" const isConflictStatus = (status: number) => status === 409 || status === 422 +const isUnauthorizedStatus = (status: number | undefined) => status === 401 + +export type FetchShaResult = + | { kind: "ok"; sha: string | null } + | { kind: "unauthorized" } + | { kind: "offline" } export const useGitHubContent = ({ user, @@ -11,23 +17,23 @@ export const useGitHubContent = ({ user: string repo: string }) => { - const fetchLatestSha = async (path: string): Promise => { + const fetchLatestSha = async (path: string): Promise => { try { - const octokit = await getOctokit() - const response = await octokit.request( - "GET /repos/{owner}/{repo}/contents/{+path}", - { + const response = await runWithAuthRetry((octokit) => + octokit.request("GET /repos/{owner}/{repo}/contents/{+path}", { owner: user, repo, path, headers: { "X-GitHub-Api-Version": "2026-03-10" } - } + }) ) const data = response?.data - if (Array.isArray(data) || !data) return null - return "sha" in data ? data.sha : null - } catch { - return null + if (Array.isArray(data) || !data) return { kind: "ok", sha: null } + return { kind: "ok", sha: "sha" in data ? data.sha : null } + } catch (error) { + const status = (error as { status?: number })?.status + if (isUnauthorizedStatus(status)) return { kind: "unauthorized" } + return { kind: "offline" } } } diff --git a/src/hooks/useNoteFreshness.hook.ts b/src/hooks/useNoteFreshness.hook.ts index 92f4d86..81a955b 100644 --- a/src/hooks/useNoteFreshness.hook.ts +++ b/src/hooks/useNoteFreshness.hook.ts @@ -12,6 +12,7 @@ export type FreshnessStatus = | "verified" | "outdated" | "offline" + | "unauthorized" const MIN_SPINNER_MS = 400 @@ -43,14 +44,16 @@ export const useNoteFreshness = ({ const startedAt = performance.now() let next: FreshnessStatus - const remoteSha = await fetchLatestSha(path.value) - if (remoteSha === null) { + const result = await fetchLatestSha(path.value) + if (result.kind === "unauthorized") { + next = "unauthorized" + } else if (result.kind === "offline" || result.sha === null) { next = "offline" } else { - latestSha.value = remoteSha + latestSha.value = result.sha lastCheckedAt.value = new Date() const local = await expectedSha() - next = remoteSha === local ? "verified" : "outdated" + next = result.sha === local ? "verified" : "outdated" } const elapsed = performance.now() - startedAt @@ -60,13 +63,27 @@ export const useNoteFreshness = ({ status.value = next } + const resolveRemoteSha = async ( + path: string + ): Promise<{ sha: string | null; failureStatus: FreshnessStatus | null }> => { + if (latestSha.value) return { sha: latestSha.value, failureStatus: null } + const result = await fetchLatestSha(path) + if (result.kind === "unauthorized") { + return { sha: null, failureStatus: "unauthorized" } + } + if (result.kind === "offline" || result.sha === null) { + return { sha: null, failureStatus: "offline" } + } + return { sha: result.sha, failureStatus: null } + } + const pullLatest = async (): Promise => { if (!path.value) return null const usedCachedSha = latestSha.value !== null - const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value)) + const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value) if (!remoteSha) { console.warn("pullLatest: could not resolve remote sha", { path: path.value }) - status.value = "offline" + if (failureStatus) status.value = failureStatus return null } const fileContent = await queryFileContent(user, repo, remoteSha) diff --git a/src/modules/repo/services/repo.ts b/src/modules/repo/services/repo.ts index d95e068..c4f2c8c 100644 --- a/src/modules/repo/services/repo.ts +++ b/src/modules/repo/services/repo.ts @@ -2,7 +2,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook" import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache" import { RepoFile } from "@/modules/repo/interfaces/RepoFile" import { UserSettings } from "@/modules/repo/interfaces/UserSettings" -import { getOctokit } from "@/modules/repo/services/octo" +import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo" export const getFiles = async ( owner: string, @@ -131,14 +131,12 @@ export const queryFileContent = async ( } try { - const octokit = await getOctokit() - const file = await octokit.request( - "GET /repos/{owner}/{repo}/git/blobs/{file_sha}", - { + const file = await runWithAuthRetry((octokit) => + octokit.request("GET /repos/{owner}/{repo}/git/blobs/{file_sha}", { owner: user, repo: repo, file_sha: sha - } + }) ) return file?.data.content ?? null } catch (error) {