Skip to content
Merged
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
31 changes: 11 additions & 20 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
import { type NextRequest } from 'next/server';
import { supabase, isSupabaseConfigured } from '@/lib/supabase';
import { jsonSuccess, jsonServiceUnavailable } from '@/lib/api';
import { logger } from '@/lib/logger';
import { getLLMHealth } from '@/lib/llm-health';

/**
* Health check, in two flavours.
* Health check (liveness): is this process serving and can it reach its
* database? Always 200 once the database answers, even when the LLM chain is
* down, because restarting the app does not fix an expired API key --
* failing liveness on it would just get a healthy process killed, and would
* fail deploy gates on a problem no deploy caused.
*
* Default (liveness): is this process serving and can it reach its database?
* Answers 200 even when the LLM chain is down, because restarting the app does
* not fix an expired API key -- failing liveness on it would just get a healthy
* process killed, and would fail deploy gates on a problem no deploy caused.
*
* ?strict=1 (readiness): is the PRODUCT working? 503 once the LLM chain is
* consistently failing. Point alerting here.
*
* Either way the body carries the real state. This endpoint used to report
* only the database, so on 2026-08-28 it said "healthy" while every AI feature
* on the site was failing on an invalid Groq key.
* The body still carries the real LLM state as an informational field, so
* alerting/dashboards can see it without the process being torn down for it.
* This endpoint used to report only the database, so on 2026-08-28 it said
* "healthy" while every AI feature on the site was failing on an invalid Groq
* key.
*/
export async function GET(request: NextRequest) {
const strict = request.nextUrl.searchParams.get('strict') === '1';
export async function GET() {
const llm = getLLMHealth();

try {
Expand All @@ -39,11 +35,6 @@ export async function GET(request: NextRequest) {
const status =
llm.status === 'down' ? 'down' : llm.status === 'degraded' ? 'degraded' : 'healthy';

if (strict && llm.status === 'down') {
logger.error('Health check (strict): LLM chain is down', { lastError: llm.lastError });
return jsonServiceUnavailable('AI provider unavailable');
}

return jsonSuccess({ status, database: 'connected', llm }, { cache: 'PUBLIC_SHORT' });
} catch (error) {
logger.error('Health check failed:', error);
Expand Down
14 changes: 8 additions & 6 deletions tests/__tests__/lib/llm-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,15 @@ describe('chat routes do not dress a failure as success', () => {
expect(source).toMatch(/llm/);
});

// Liveness must not fail on a dependency a restart cannot fix -- otherwise a
// Health must not fail on a dependency a restart cannot fix -- otherwise a
// stale API key gets a perfectly healthy process killed, and fails deploy
// gates on a problem no deploy caused. Readiness is where that belongs.
it('health separates liveness from readiness', () => {
// gates on a problem no deploy caused. That holds for every caller, so
// there must be no query param that flips LLM status into an HTTP failure.
it('health never gates its HTTP status on LLM state', () => {
const source = readFileSync(join(process.cwd(), 'app/api/health/route.ts'), 'utf-8');
expect(source).toContain('strict');
// the 503-on-LLM path must be gated behind strict, never unconditional
expect(source).toMatch(/if \(strict && llm\.status === 'down'\)/);
expect(source).not.toMatch(
/llm\.status === 'down'\)\s*{?\s*[\s\S]{0,80}jsonServiceUnavailable/,
);
expect(source).not.toContain('strict');
});
});