diff --git a/apps/web/src/app/api/openrouter/[...path]/route.ts b/apps/web/src/app/api/openrouter/[...path]/route.ts index ce1391116b..769e77e6ae 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.ts @@ -257,12 +257,20 @@ export async function POST(request: NextRequest): Promise { - console.log('AI gateway client disconnected, requested model: %s', requestedModelLowerCased, { - path, - elapsed_ms: Math.round(performance.now() - requestStartedAt), - client_request_id: clientRequestId, - session_id: taskId ?? sessionHeader, - }); + // The request signal is forwarded to the upstream fetch and to the response + // stream reader, so this disconnect also aborts them. Any abort/cancellation + // logged for this request after this line is a consequence of the client + // going away, not an upstream provider failure. + console.log( + 'AI gateway client disconnected (aborting in-flight upstream work for this request), requested model: %s', + requestedModelLowerCased, + { + path, + elapsed_ms: Math.round(performance.now() - requestStartedAt), + client_request_id: clientRequestId, + session_id: taskId ?? sessionHeader, + } + ); }; if (request.signal.aborted) { logClientDisconnect(); diff --git a/apps/web/src/lib/ai-gateway/providers/upstream-request.ts b/apps/web/src/lib/ai-gateway/providers/upstream-request.ts index 05cefe2979..598650bd24 100644 --- a/apps/web/src/lib/ai-gateway/providers/upstream-request.ts +++ b/apps/web/src/lib/ai-gateway/providers/upstream-request.ts @@ -22,6 +22,9 @@ type UpstreamFetchFailureFamily = | 'abort' | 'unknown'; +// Longer than Vercel AI Gateway's 13min timeout, shorter than Vercel Function's 30min timeout. +const TIMEOUT_MS = 15 * 60 * 1000; + function getProviderTargetHost(apiUrl: string): string { try { return new URL(apiUrl).host; @@ -136,8 +139,34 @@ function classifyUpstreamFetchFailure({ } } -function upstreamDisconnectResponse() { - const error = 'The upstream provider disconnected before sending a response.'; +/** + * The client going away also aborts our upstream fetch, so the abort has to be + * attributed to the client rather than reported as an upstream fault. The body + * is mostly for logs and observability: the client that would read it is gone. + * 499 mirrors the nginx convention so these cancellations do not show up as + * upstream 5xx failures. + */ +function clientDisconnectResponse() { + const error = + 'The client disconnected before the upstream provider responded, so the request was cancelled. The upstream provider did not fail.'; + return NextResponse.json( + { + error, + error_type: ProxyErrorType.client_disconnect, + message: error, + }, + { status: 499 } + ); +} + +function upstreamFetchFailureResponse(failureFamily: UpstreamFetchFailureFamily) { + const error = + failureFamily === 'request_timeout' || + failureFamily === 'headers_timeout' || + failureFamily === 'connect_timeout' || + failureFamily === 'read_timeout' + ? 'The upstream provider did not send response headers before the gateway timeout.' + : 'The upstream provider closed the connection before sending a response.'; return NextResponse.json( { error, @@ -178,10 +207,11 @@ export async function upstreamRequest({ const targetUrl = `${provider.apiUrl}${path}${search}`; - const TIMEOUT_MS = 15 * 60 * 1000; // longer than Vercel AI Gateway's 13min timeout, shorter than Vercel Function's 30min timeout const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS); const onTimeoutAbort = () => { - errorExceptInTest('[upstreamRequest] timeout'); + errorExceptInTest( + `[upstreamRequest] gateway timeout after ${TIMEOUT_MS}ms waiting for upstream response headers` + ); }; timeoutSignal.addEventListener('abort', onTimeoutAbort); after(() => { @@ -202,6 +232,12 @@ export async function upstreamRequest({ }), }; } catch (error) { + // The caller passes the incoming request signal, so a client that goes away + // aborts this fetch as well. Those aborts are client-side cancellations and + // must not be reported (or alerted on) as upstream failures. + const clientDisconnected = signal?.aborted === true; + // Stays `undefined` when diagnostic enrichment below throws before classifying. + let failureFamily: UpstreamFetchFailureFamily | undefined; try { const cause = error instanceof Error ? error.cause : undefined; const errorName = getErrorName(error); @@ -209,7 +245,7 @@ export async function upstreamRequest({ const causeCode = getCauseCode(cause); const causeName = getCauseName(cause); const causeMessage = getCauseMessage(cause); - const failureFamily = classifyUpstreamFetchFailure({ errorName, causeCode, causeName }); + failureFamily = classifyUpstreamFetchFailure({ errorName, causeCode, causeName }); const failureMetadata = { providerId: provider.id, targetHost: getProviderTargetHost(provider.apiUrl), @@ -222,7 +258,7 @@ export async function upstreamRequest({ ...(causeMessage && { causeMessage }), }; - if (!(failureFamily === 'abort' && signal?.aborted)) { + if (!(failureFamily === 'abort' && clientDisconnected)) { errorExceptInTest('AI gateway upstream fetch failed', failureMetadata); captureException(createLoggedFetchFailure(errorName, errorMessage), { level: 'error', @@ -238,7 +274,15 @@ export async function upstreamRequest({ // Fetch failure must remain caller-visible even when diagnostic enrichment fails. } - return { type: 'error', response: upstreamDisconnectResponse() }; + const causedByClientDisconnect = + clientDisconnected && (failureFamily === 'abort' || failureFamily === undefined); + + return { + type: 'error', + response: causedByClientDisconnect + ? clientDisconnectResponse() + : upstreamFetchFailureResponse(failureFamily ?? 'unknown'), + }; } } diff --git a/apps/web/src/lib/proxy-error-types.ts b/apps/web/src/lib/proxy-error-types.ts index eb85dc26c6..4cec452b5a 100644 --- a/apps/web/src/lib/proxy-error-types.ts +++ b/apps/web/src/lib/proxy-error-types.ts @@ -29,6 +29,7 @@ export const proxyErrorTypeSchema = z.enum([ 'abuse_blocked', 'organization_auto_configuration', 'upstream_disconnect', + 'client_disconnect', ]); export type ProxyErrorType = z.infer; diff --git a/apps/web/src/tests/openrouterApi.timeout.test.ts b/apps/web/src/tests/openrouterApi.timeout.test.ts index 522d727df1..0c5eaf187d 100644 --- a/apps/web/src/tests/openrouterApi.timeout.test.ts +++ b/apps/web/src/tests/openrouterApi.timeout.test.ts @@ -30,7 +30,7 @@ describe('upstreamRequest timeout', () => { global.fetch = originalFetch; }); - it('should abort after timeout', async () => { + it('reports a client disconnect instead of an upstream disconnect when the caller aborts', async () => { const controller = new AbortController(); controller.abort(); @@ -49,6 +49,72 @@ describe('upstreamRequest timeout', () => { expect(result.type).toBe('error'); expect(mockCaptureException).not.toHaveBeenCalled(); + if (result.type !== 'error') throw new Error('expected an error result'); + expect(result.response.status).toBe(499); + await expect(result.response.json()).resolves.toEqual({ + error: + 'The client disconnected before the upstream provider responded, so the request was cancelled. The upstream provider did not fail.', + error_type: 'client_disconnect', + message: + 'The client disconnected before the upstream provider responded, so the request was cancelled. The upstream provider did not fail.', + }); + }); + + it('reports a gateway timeout message when the upstream sends no response headers', async () => { + const timeoutError = new DOMException( + 'The operation was aborted due to timeout', + 'TimeoutError' + ); + global.fetch = jest.fn().mockRejectedValue(timeoutError); + + const result = await upstreamRequest({ + path: '/chat/completions', + search: '', + method: 'POST', + body: { + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + }, + extraHeaders: {}, + provider: PROVIDERS.OPENROUTER, + }); + + expect(result.type).toBe('error'); + if (result.type !== 'error') throw new Error('expected an error result'); + expect(result.response.status).toBe(503); + await expect(result.response.json()).resolves.toEqual({ + error: 'The upstream provider did not send response headers before the gateway timeout.', + error_type: 'upstream_disconnect', + message: 'The upstream provider did not send response headers before the gateway timeout.', + }); + }); + + it('reports an upstream disconnect when the upstream connection fails', async () => { + const resetCause = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }); + global.fetch = jest + .fn() + .mockRejectedValue(new TypeError('fetch failed', { cause: resetCause })); + + const result = await upstreamRequest({ + path: '/chat/completions', + search: '', + method: 'POST', + body: { + model: 'test-model', + messages: [{ role: 'user', content: 'test' }], + }, + extraHeaders: {}, + provider: PROVIDERS.OPENROUTER, + }); + + expect(result.type).toBe('error'); + if (result.type !== 'error') throw new Error('expected an error result'); + expect(result.response.status).toBe(503); + await expect(result.response.json()).resolves.toEqual({ + error: 'The upstream provider closed the connection before sending a response.', + error_type: 'upstream_disconnect', + message: 'The upstream provider closed the connection before sending a response.', + }); }); it('classifies request timeout aborts separately', async () => {