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
102 changes: 102 additions & 0 deletions app/api/health/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GET } from './route';

vi.mock('@/lib/github', () => ({
checkGitHubHealth: vi.fn(),
getCircuitTelemetry: vi.fn(),
}));

vi.mock('@/services/github/quota-monitor', () => ({
quotaMonitor: {
getAggregateQuota: vi.fn(),
},
}));

import { checkGitHubHealth, getCircuitTelemetry } from '@/lib/github';
import { quotaMonitor } from '@/services/github/quota-monitor';

describe('GET /api/health', () => {
beforeEach(() => {
vi.clearAllMocks();

vi.mocked(checkGitHubHealth).mockResolvedValue(undefined);

vi.mocked(getCircuitTelemetry).mockReturnValue({
isOpen: false,
resetInMs: 0,
});

vi.mocked(quotaMonitor.getAggregateQuota).mockReturnValue({
totalTokens: 1,
activeTokens: 1,
aggregateLimit: 5000,
aggregateRemaining: 4500,
lowestRemainingPercent: 90,
totalRefreshes: 0,
});
});

it('returns 200 when GitHub health check succeeds', async () => {
const response = await GET();

expect(response.status).toBe(200);

const body = await response.json();

expect(body.status).toBe('healthy');
expect(body.githubApi).toBe('ok');
expect(body.quota).toBeDefined();
expect(body.circuitBreaker).toBeDefined();
expect(body.timestamp).toBeDefined();
});

it('returns 503 when GitHub health check fails', async () => {
vi.mocked(checkGitHubHealth).mockRejectedValue(new Error('Bad credentials'));

const response = await GET();

expect(response.status).toBe(503);

const body = await response.json();

expect(body.status).toBe('degraded');
expect(body.githubApi).toBe('error');
expect(body.message).toContain('Bad credentials');
});

it('returns exhausted when the circuit breaker is open', async () => {
vi.mocked(getCircuitTelemetry).mockReturnValue({
isOpen: true,
resetInMs: 1000,
});

const response = await GET();

expect(response.status).toBe(200);

const body = await response.json();

expect(body.status).toBe('exhausted');
expect(body.githubApi).toBe('ok');
});

it('returns degraded when quota is low', async () => {
vi.mocked(quotaMonitor.getAggregateQuota).mockReturnValue({
totalTokens: 1,
activeTokens: 1,
aggregateLimit: 5000,
aggregateRemaining: 200,
lowestRemainingPercent: 5,
totalRefreshes: 0,
});

const response = await GET();

expect(response.status).toBe(200);

const body = await response.json();

expect(body.status).toBe('degraded');
expect(body.githubApi).toBe('ok');
});
});
15 changes: 14 additions & 1 deletion app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { NextResponse } from 'next/server';
import { getCircuitTelemetry } from '@/lib/github';
import { checkGitHubHealth, getCircuitTelemetry } from '@/lib/github';
import { quotaMonitor } from '@/services/github/quota-monitor';

export const dynamic = 'force-dynamic';

export async function GET() {
try {
await checkGitHubHealth();
} catch (error) {
return NextResponse.json(
{
status: 'degraded',
githubApi: 'error',
message: error instanceof Error ? error.message : 'Unknown GitHub API error',
},
{ status: 503 }
);
}
const aggregate = quotaMonitor.getAggregateQuota();
const circuit = getCircuitTelemetry();

Expand All @@ -16,6 +28,7 @@ export async function GET() {

return NextResponse.json({
status,
githubApi: 'ok',
quota: {
totalTokens: aggregate.totalTokens,
activeTokens: aggregate.activeTokens,
Expand Down
29 changes: 28 additions & 1 deletion lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ export async function handleTokenExpiration(token: string): Promise<void> {
}
}

const getHeaders = (userToken?: string) => ({
export const getHeaders = (userToken?: string) => ({
Authorization: `bearer ${userToken || getGitHubToken()}`,
'Content-Type': 'application/json',
});
Expand Down Expand Up @@ -2884,3 +2884,30 @@ function getMockRepo(repoName: string): GitHubRepo {
participation: [],
};
}

export async function checkGitHubHealth(): Promise<void> {
const query = `
query {
viewer {
login
}
}
`;

const response = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ query }),
cache: 'no-store',
});

if (!response.ok) {
throw new Error(`GitHub API returned ${response.status}`);
}

const data = await response.json();

if (data.errors) {
throw new Error(getGraphQLErrorMessage(data.errors));
}
}
Loading