From f81c9c792e41390e2a973bc82170c88507c4f299 Mon Sep 17 00:00:00 2001 From: adepat06 Date: Wed, 8 Jul 2026 22:30:45 +0530 Subject: [PATCH] feat(api): add GitHub health check to health endpoint --- app/api/health/route.test.ts | 102 +++++++++++++++++++++++++++++++++++ app/api/health/route.ts | 15 +++++- lib/github.ts | 29 +++++++++- 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 app/api/health/route.test.ts diff --git a/app/api/health/route.test.ts b/app/api/health/route.test.ts new file mode 100644 index 000000000..ef35f972d --- /dev/null +++ b/app/api/health/route.test.ts @@ -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'); + }); +}); diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 0cc057c09..73d8b5a6b 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -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(); @@ -16,6 +28,7 @@ export async function GET() { return NextResponse.json({ status, + githubApi: 'ok', quota: { totalTokens: aggregate.totalTokens, activeTokens: aggregate.activeTokens, diff --git a/lib/github.ts b/lib/github.ts index 230118873..a07b8a9cb 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -791,7 +791,7 @@ export async function handleTokenExpiration(token: string): Promise { } } -const getHeaders = (userToken?: string) => ({ +export const getHeaders = (userToken?: string) => ({ Authorization: `bearer ${userToken || getGitHubToken()}`, 'Content-Type': 'application/json', }); @@ -2884,3 +2884,30 @@ function getMockRepo(repoName: string): GitHubRepo { participation: [], }; } + +export async function checkGitHubHealth(): Promise { + 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)); + } +}