diff --git a/apps/web/src/app/api/internal/usage/record/route.ts b/apps/web/src/app/api/internal/usage/record/route.ts index 35a2c8f825..d123f4f297 100644 --- a/apps/web/src/app/api/internal/usage/record/route.ts +++ b/apps/web/src/app/api/internal/usage/record/route.ts @@ -11,6 +11,7 @@ import { import { createPhaseTimer, emitUsageRecordTiming, + isPrimaryPoolSaturated, readPoolGauges, shouldEmitUsageRecordTiming, } from '@/lib/ai-gateway/usage-record-diagnostics'; @@ -46,6 +47,14 @@ export async function POST(request: NextRequest): Promise { // the pre-check gets confirmed. const timer = createPhaseTimer(); const poolBefore = readPoolGauges(); + if (isPrimaryPoolSaturated(poolBefore)) { + // Do not join an unbounded pg-pool queue. A 503 proves no write started, so + // the caller can safely retry this delivery without creating a duplicate. + return NextResponse.json( + { error: 'Usage record sink busy' }, + { status: 503, headers: { 'retry-after': '1' } } + ); + } let poolWaitingPeak = poolBefore.waiting; const samplePool = () => { const gauges = readPoolGauges(); diff --git a/apps/web/src/lib/ai-gateway/usage-record-diagnostics.test.ts b/apps/web/src/lib/ai-gateway/usage-record-diagnostics.test.ts index 95a51e5975..018d47356d 100644 --- a/apps/web/src/lib/ai-gateway/usage-record-diagnostics.test.ts +++ b/apps/web/src/lib/ai-gateway/usage-record-diagnostics.test.ts @@ -7,6 +7,7 @@ import { describe, expect, test } from '@jest/globals'; import { createPhaseTimer, describeDatabaseError, + isPrimaryPoolSaturated, isUsageRowConflict, readPoolGauges, shouldEmitUsageRecordTiming, @@ -252,6 +253,20 @@ describe('readPoolGauges', () => { }); }); +describe('isPrimaryPoolSaturated', () => { + test('rejects work when every configured connection is checked out', () => { + expect(isPrimaryPoolSaturated({ total: 10, idle: 0, waiting: 245 })).toBe(true); + }); + + test('accepts work when an idle connection is available', () => { + expect(isPrimaryPoolSaturated({ total: 10, idle: 1, waiting: 0 })).toBe(false); + }); + + test('accepts work when the pool can still open a connection', () => { + expect(isPrimaryPoolSaturated({ total: 9, idle: 0, waiting: 0 })).toBe(false); + }); +}); + describe('shouldEmitUsageRecordTiming', () => { test('always emits at or above the slow threshold', () => { expect(shouldEmitUsageRecordTiming(1_000, () => 1)).toBe(true); diff --git a/apps/web/src/lib/ai-gateway/usage-record-diagnostics.ts b/apps/web/src/lib/ai-gateway/usage-record-diagnostics.ts index d1de25d674..053b99d53f 100644 --- a/apps/web/src/lib/ai-gateway/usage-record-diagnostics.ts +++ b/apps/web/src/lib/ai-gateway/usage-record-diagnostics.ts @@ -78,6 +78,16 @@ export function readPoolGauges(): PoolGauges { }; } +/** + * Whether a new request would have to queue for the primary pool. + * + * Fail open if the pool implementation does not expose its configured maximum. + */ +export function isPrimaryPoolSaturated(gauges: PoolGauges): boolean { + const max = poolMax(); + return max !== null && gauges.idle === 0 && gauges.total >= max; +} + function eventLoopLagMs() { return { mean_ms: Math.round(eventLoopDelay.mean / 1e6),