diff --git a/lib/github/client.ts b/lib/github/client.ts index f061a38..39c8652 100644 --- a/lib/github/client.ts +++ b/lib/github/client.ts @@ -598,9 +598,10 @@ export async function fetchProfile( try { user = await fetchUser(tok); } catch (e) { - // Only a rate limit is cured by another token (a timeout or 5xx would just - // fail again) — retry once on the healthiest token, if the pool has one. - if ((e as GithubError).type !== "ratelimit" || pool.length < 2) throw e; + // A rate-limited or invalid token can be cured by another token. Other + // failures (timeouts, 5xx, etc.) would just fail again on the fallback. + const type = (e as GithubError).type; + if ((type !== "ratelimit" && type !== "config") || pool.length < 2) throw e; const fallback = await pickFailover(tok.idx, pool); if (!fallback) throw e; // every other token is benched too tok = fallback; diff --git a/tests/client-token-failover.test.ts b/tests/client-token-failover.test.ts new file mode 100644 index 0000000..6b6bd5a --- /dev/null +++ b/tests/client-token-failover.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/redis", () => ({ redis: null })); + +import { fetchProfile } from "@/lib/github/client"; +import { hashLogin } from "@/lib/github/tokens"; + +const POOL = ["expired-token", "healthy-token"]; +const LOGIN = "someuser"; +const NOW = new Date("2026-07-03T12:00:00Z"); + +const USER = { + login: LOGIN, + name: null, + avatarUrl: "https://example.com/a.png", + location: null, + createdAt: "2026-01-01T00:00:00Z", + followers: { totalCount: 1 }, + repositories: { totalCount: 0, nodes: [] }, + recent: { + totalCommitContributions: 1, + totalPullRequestContributions: 0, + totalPullRequestReviewContributions: 0, + totalIssueContributions: 0, + restrictedContributionsCount: 0, + commitContributionsByRepository: [], + contributionCalendar: { weeks: [] }, + }, +}; + +const ok = (body: unknown) => + new Response(JSON.stringify(body), { status: 200 }); + +beforeEach(() => { + vi.stubEnv("GITHUB_TOKENS", POOL.join(",")); + vi.stubEnv("GITHUB_TOKEN", ""); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +it("fails over when the hash-assigned GitHub token is invalid", async () => { + const primary = POOL[hashLogin(LOGIN) % POOL.length]; + const fallback = POOL.find((token) => token !== primary)!; + const calls: string[] = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (_url: unknown, init?: RequestInit) => { + const token = String( + (init?.headers as Record).Authorization, + ).replace("Bearer ", ""); + const body = String(init?.body); + calls.push(token); + + if (token === primary) { + return new Response(JSON.stringify({ message: "Bad credentials" }), { + status: 401, + }); + } + + return body.includes("query Profile") + ? ok({ data: { user: USER } }) + : ok({ data: { user: {} } }); + }), + ); + + const payload = await fetchProfile(LOGIN, NOW); + + expect(payload.login).toBe(LOGIN); + expect(calls).toEqual([primary, fallback, fallback]); +});