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.
This commit is contained in:
Julien Calixte
2026-05-17 21:10:20 +02:00
parent c412c75cfd
commit 151a4d9137
3 changed files with 44 additions and 23 deletions

View File

@@ -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 { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { confirmMessage, errorMessage } from "@/utils/notif" import { confirmMessage, errorMessage } from "@/utils/notif"
const isConflictStatus = (status: number) => status === 409 || status === 422 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 = ({ export const useGitHubContent = ({
user, user,
@@ -11,23 +17,23 @@ export const useGitHubContent = ({
user: string user: string
repo: string repo: string
}) => { }) => {
const fetchLatestSha = async (path: string): Promise<string | null> => { const fetchLatestSha = async (path: string): Promise<FetchShaResult> => {
try { try {
const octokit = await getOctokit() const response = await runWithAuthRetry((octokit) =>
const response = await octokit.request( octokit.request("GET /repos/{owner}/{repo}/contents/{+path}", {
"GET /repos/{owner}/{repo}/contents/{+path}",
{
owner: user, owner: user,
repo, repo,
path, path,
headers: { "X-GitHub-Api-Version": "2026-03-10" } headers: { "X-GitHub-Api-Version": "2026-03-10" }
} })
) )
const data = response?.data const data = response?.data
if (Array.isArray(data) || !data) return null if (Array.isArray(data) || !data) return { kind: "ok", sha: null }
return "sha" in data ? data.sha : null return { kind: "ok", sha: "sha" in data ? data.sha : null }
} catch { } catch (error) {
return null const status = (error as { status?: number })?.status
if (isUnauthorizedStatus(status)) return { kind: "unauthorized" }
return { kind: "offline" }
} }
} }

View File

@@ -12,6 +12,7 @@ export type FreshnessStatus =
| "verified" | "verified"
| "outdated" | "outdated"
| "offline" | "offline"
| "unauthorized"
const MIN_SPINNER_MS = 400 const MIN_SPINNER_MS = 400
@@ -43,14 +44,16 @@ export const useNoteFreshness = ({
const startedAt = performance.now() const startedAt = performance.now()
let next: FreshnessStatus let next: FreshnessStatus
const remoteSha = await fetchLatestSha(path.value) const result = await fetchLatestSha(path.value)
if (remoteSha === null) { if (result.kind === "unauthorized") {
next = "unauthorized"
} else if (result.kind === "offline" || result.sha === null) {
next = "offline" next = "offline"
} else { } else {
latestSha.value = remoteSha latestSha.value = result.sha
lastCheckedAt.value = new Date() lastCheckedAt.value = new Date()
const local = await expectedSha() const local = await expectedSha()
next = remoteSha === local ? "verified" : "outdated" next = result.sha === local ? "verified" : "outdated"
} }
const elapsed = performance.now() - startedAt const elapsed = performance.now() - startedAt
@@ -60,13 +63,27 @@ export const useNoteFreshness = ({
status.value = next 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<string | null> => { const pullLatest = async (): Promise<string | null> => {
if (!path.value) return null if (!path.value) return null
const usedCachedSha = latestSha.value !== null const usedCachedSha = latestSha.value !== null
const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value)) const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value)
if (!remoteSha) { if (!remoteSha) {
console.warn("pullLatest: could not resolve remote sha", { path: path.value }) console.warn("pullLatest: could not resolve remote sha", { path: path.value })
status.value = "offline" if (failureStatus) status.value = failureStatus
return null return null
} }
const fileContent = await queryFileContent(user, repo, remoteSha) const fileContent = await queryFileContent(user, repo, remoteSha)

View File

@@ -2,7 +2,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache" import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { RepoFile } from "@/modules/repo/interfaces/RepoFile" import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
import { UserSettings } from "@/modules/repo/interfaces/UserSettings" 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 ( export const getFiles = async (
owner: string, owner: string,
@@ -131,14 +131,12 @@ export const queryFileContent = async (
} }
try { try {
const octokit = await getOctokit() const file = await runWithAuthRetry((octokit) =>
const file = await octokit.request( octokit.request("GET /repos/{owner}/{repo}/git/blobs/{file_sha}", {
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
{
owner: user, owner: user,
repo: repo, repo: repo,
file_sha: sha file_sha: sha
} })
) )
return file?.data.content ?? null return file?.data.content ?? null
} catch (error) { } catch (error) {