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
24 changes: 15 additions & 9 deletions lib/github.rotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
getGlobalCircuitBreakerOpenUntilForTests,
} from './github';

const MOCK_TOKEN_1 = 'ghp_token1AAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const MOCK_TOKEN_2 = 'ghp_token2AAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const MOCK_TOKEN_3 = 'ghp_token3AAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const MOCK_BAD_TOKEN = 'ghp_badtokenAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const MOCK_GOOD_TOKEN = 'ghp_goodtokenAAAAAAAAAAAAAAAAAAAAAAAAAAA';

describe('GitHub Multi-Token Rotation & Fallback', () => {
const originalGitHubPat = process.env.GITHUB_PAT;
const originalGitHubToken = process.env.GITHUB_TOKEN;
Expand All @@ -25,15 +31,15 @@
});

it('correctly parses multiple comma-separated tokens', () => {
process.env.GITHUB_PAT = ' token1, token2, token3 ';
process.env.GITHUB_PAT = ` ${MOCK_TOKEN_1}, ${MOCK_TOKEN_2}, ${MOCK_TOKEN_3} `;
delete process.env.GITHUB_TOKEN;

const tokens = getGitHubTokens();
expect(tokens).toEqual(['token1', 'token2', 'token3']);
expect(tokens).toEqual([MOCK_TOKEN_1, MOCK_TOKEN_2, MOCK_TOKEN_3]);
});

it('rotates to the next token on HTTP 429 rate limiting', async () => {
process.env.GITHUB_PAT = 'token1,token2';
process.env.GITHUB_PAT = `${MOCK_TOKEN_1},${MOCK_TOKEN_2}`;
delete process.env.GITHUB_TOKEN;

fetchMock.mockResolvedValueOnce({
Expand All @@ -60,14 +66,14 @@
expect(fetchMock).toHaveBeenCalledTimes(2);

const firstCallHeaders = fetchMock.mock.calls[0][1].headers;
expect(firstCallHeaders.Authorization).toBe('bearer token1');
expect(firstCallHeaders.Authorization).toBe(`bearer ${MOCK_TOKEN_1}`);

const secondCallHeaders = fetchMock.mock.calls[1][1].headers;
expect(secondCallHeaders.Authorization).toBe('bearer token2');
expect(secondCallHeaders.Authorization).toBe(`bearer ${MOCK_TOKEN_2}`);
});

it('rotates to the next token on HTTP 401 unauthorized and excludes the bad token for 24h', async () => {
process.env.GITHUB_PAT = 'bad_token,good_token';
process.env.GITHUB_PAT = `${MOCK_BAD_TOKEN},${MOCK_GOOD_TOKEN}`;
delete process.env.GITHUB_TOKEN;

fetchMock.mockResolvedValueOnce({
Expand All @@ -89,8 +95,8 @@

expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('bearer bad_token');
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('bearer good_token');
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(`bearer ${MOCK_BAD_TOKEN}`);
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(`bearer ${MOCK_GOOD_TOKEN}`);

fetchMock.mockResolvedValueOnce({
status: 200,
Expand All @@ -104,7 +110,7 @@
});
expect(res2.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe('bearer good_token');
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe(`bearer ${MOCK_GOOD_TOKEN}`);
});

it('prioritizes token with highest remaining quota', async () => {
Expand Down Expand Up @@ -162,7 +168,7 @@
tokenStats.set('token1', { remaining: 0, resetTime: resetTime1 });
tokenStats.set('token2', { remaining: 0, resetTime: resetTime2 });

await expect(fetchWithRetry('https://api.github.com/graphql', { headers: {} })).rejects.toThrow(

Check failure on line 171 in lib/github.rotation.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

lib/github.rotation.test.ts > GitHub Multi-Token Rotation & Fallback > correctly sets global circuit breaker to the earliest reset time when all tokens are rate-limited

AssertionError: expected [Function] to throw error including 'API Rate Limit Exceeded' but got 'GitHub token is missing. Set GITHUB_P…' Expected: "API Rate Limit Exceeded" Received: "GitHub token is missing. Set GITHUB_PAT or GITHUB_TOKEN." ❯ lib/github.rotation.test.ts:171:83
'API Rate Limit Exceeded'
);

Expand Down
12 changes: 6 additions & 6 deletions lib/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function mockResponse(body: unknown, status = 200): Response {

beforeEach(() => {
clearGitHubApiCacheForTests();
process.env.GITHUB_PAT = 'test-token';
process.env.GITHUB_PAT = 'ghp_testtokenAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
delete process.env.GITHUB_TOKEN;
});

Expand Down Expand Up @@ -177,7 +177,7 @@ describe('fetchGitHubContributions', () => {
expect(url).toBe('https://api.github.com/graphql');
expect(options?.method).toBe('POST');
expect(options?.headers).toMatchObject({
Authorization: 'bearer test-token',
Authorization: 'bearer ghp_testtokenAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
'Content-Type': 'application/json',
});

Expand All @@ -188,7 +188,7 @@ describe('fetchGitHubContributions', () => {

it('uses GITHUB_TOKEN when GITHUB_PAT is not configured', async () => {
delete process.env.GITHUB_PAT;
process.env.GITHUB_TOKEN = 'actions-token';
process.env.GITHUB_TOKEN = 'ghp_actionstokenAAAAAAAAAAAAAAAAAAAAAAAA';
vi.mocked(fetch).mockResolvedValue(
mockResponse({
data: {
Expand All @@ -206,13 +206,13 @@ describe('fetchGitHubContributions', () => {

const [, options] = vi.mocked(fetch).mock.calls[0];
expect(options?.headers).toMatchObject({
Authorization: 'bearer actions-token',
Authorization: 'bearer ghp_actionstokenAAAAAAAAAAAAAAAAAAAAAAAA',
});
});

it('verifies Authorization header uses GITHUB_TOKEN value in fallback path', async () => {
delete process.env.GITHUB_PAT;
process.env.GITHUB_TOKEN = 'my-actions-token';
process.env.GITHUB_TOKEN = 'ghp_fallbacktokenAAAAAAAAAAAAAAAAAAAAAAAA';
vi.mocked(fetch).mockResolvedValue(
mockResponse({
data: {
Expand All @@ -230,7 +230,7 @@ describe('fetchGitHubContributions', () => {

const [, options] = vi.mocked(fetch).mock.calls[0];
expect(options?.headers).toMatchObject({
Authorization: 'bearer my-actions-token',
Authorization: 'bearer ghp_fallbacktokenAAAAAAAAAAAAAAAAAAAAAAAA',
});
});

Expand Down
14 changes: 11 additions & 3 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@
return globalCircuitBreakerOpenUntil;
}

//Explicit, strongly-typed Error subclass
export class RateLimitError extends Error {
constructor(
message: string,
Expand All @@ -56,15 +55,24 @@
}
}

// Global circuit state tracking
let globalCircuitBreakerOpenUntil = 0;

function isValidGitHubTokenFormat(token: string): boolean {
return (
token.length >= 36 &&
(token.startsWith('ghp_') ||
token.startsWith('ghu_') ||
token.startsWith('ghs_') ||
token.startsWith('ghr_') ||
token.startsWith('github_pat_'))
);
}
export function getGitHubTokens(): string[] {
const envToken = process.env.GITHUB_PAT || process.env.GITHUB_TOKEN || '';
return envToken
.split(',')
.map((t) => t.trim())
.filter((t) => t !== '');
.filter((t) => t !== '' && isValidGitHubTokenFormat(t));
}

function isAbortError(error: unknown): boolean {
Expand Down Expand Up @@ -512,7 +520,7 @@
const tokens = getGitHubTokens();
const MISSING_GITHUB_TOKEN_MESSAGE = 'GitHub token is missing. Set GITHUB_PAT or GITHUB_TOKEN.';
if (tokens.length === 0) {
throw new Error(MISSING_GITHUB_TOKEN_MESSAGE);

Check failure on line 523 in lib/github.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

lib/github.rotation.test.ts > GitHub Multi-Token Rotation & Fallback > prioritizes token with highest remaining quota

Error: GitHub token is missing. Set GITHUB_PAT or GITHUB_TOKEN. ❯ getGitHubToken lib/github.ts:523:11 ❯ fetchWithRetry lib/github.ts:114:22 ❯ lib/github.rotation.test.ts:150:11
}

const now = Date.now();
Expand Down
Loading