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
20 changes: 14 additions & 6 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,20 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
const machineIdHeader = extractHeaderAndLimitLength(request, 'x-kilocode-machineid');

const logClientDisconnect = () => {
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();
Expand Down
58 changes: 51 additions & 7 deletions apps/web/src/lib/ai-gateway/providers/upstream-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand All @@ -202,14 +232,20 @@ 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);
const errorMessage = getErrorMessage(error);
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),
Expand All @@ -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',
Expand All @@ -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'),
};
}
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/proxy-error-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const proxyErrorTypeSchema = z.enum([
'abuse_blocked',
'organization_auto_configuration',
'upstream_disconnect',
'client_disconnect',
]);

export type ProxyErrorType = z.infer<typeof proxyErrorTypeSchema>;
Expand Down
68 changes: 67 additions & 1 deletion apps/web/src/tests/openrouterApi.timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 () => {
Expand Down