Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions lib/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
75 changes: 75 additions & 0 deletions tests/client-token-failover.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>).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]);
});