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
41 changes: 22 additions & 19 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
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.
* Health check, in two flavours.
*
* Reports the database AND the LLM chain. It used to report only the database,
* so on 2026-08-28 it answered "healthy" while every AI feature on the site was
* failing on an invalid Groq key -- the product's core capability was dead and
* nothing that watches this endpoint could tell.
* 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.
*
* LLM state comes from what the chat routes actually observed, so it costs
* nothing here and reflects real traffic rather than a synthetic probe.
* ?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.
*/
export async function GET() {
export async function GET(request: NextRequest) {
const strict = request.nextUrl.searchParams.get('strict') === '1';
const llm = getLLMHealth();

try {
Expand All @@ -28,20 +34,17 @@ export async function GET() {
throw error;
}

// The database being up is not the same as the product working.
if (llm.status === 'down') {
logger.error('Health check: LLM chain is down', { lastError: llm.lastError });
// 'unknown' means nothing has exercised the chain since this process
// started. That is not evidence of a problem, so it is not degraded.
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: llm.status === 'degraded' ? 'degraded' : 'healthy',
database: 'connected',
llm,
},
{ cache: 'PUBLIC_SHORT' },
);
return jsonSuccess({ status, database: 'connected', llm }, { cache: 'PUBLIC_SHORT' });
} catch (error) {
logger.error('Health check failed:', error);
return jsonServiceUnavailable('Database connection failed');
Expand Down
10 changes: 10 additions & 0 deletions tests/__tests__/lib/llm-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,14 @@ describe('chat routes do not dress a failure as success', () => {
expect(source).toContain('getLLMHealth');
expect(source).toMatch(/llm/);
});

// Liveness 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', () => {
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'\)/);
});
});
Loading