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
5 changes: 5 additions & 0 deletions .changeset/friendly-chefs-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ai-sdk/gateway': patch
---

fix (provider/gateway): added custom error class and message for client side timeouts
13 changes: 7 additions & 6 deletions examples/ai-functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@ai-sdk/black-forest-labs": "workspace:*",
"@ai-sdk/alibaba": "workspace:*",
"@ai-sdk/amazon-bedrock": "workspace:*",
"@ai-sdk/anthropic": "workspace:*",
"@ai-sdk/assemblyai": "workspace:*",
"@ai-sdk/azure": "workspace:*",
"@ai-sdk/baseten": "workspace:*",
"@ai-sdk/black-forest-labs": "workspace:*",
"@ai-sdk/cerebras": "workspace:*",
"@ai-sdk/cohere": "workspace:*",
"@ai-sdk/deepgram": "workspace:*",
Expand All @@ -23,16 +23,17 @@
"@ai-sdk/google": "workspace:*",
"@ai-sdk/google-vertex": "workspace:*",
"@ai-sdk/groq": "workspace:*",
"@ai-sdk/huggingface": "workspace:*",
"@ai-sdk/hume": "workspace:*",
"@ai-sdk/klingai": "workspace:*",
"@ai-sdk/lmnt": "workspace:*",
"@ai-sdk/luma": "workspace:*",
"@ai-sdk/hume": "workspace:*",
"@ai-sdk/mcp": "workspace:*",
"@ai-sdk/mistral": "workspace:*",
"@ai-sdk/moonshotai": "workspace:*",
"@ai-sdk/open-responses": "workspace:*",
"@ai-sdk/openai": "workspace:*",
"@ai-sdk/openai-compatible": "workspace:*",
"@ai-sdk/open-responses": "workspace:*",
"@ai-sdk/perplexity": "workspace:*",
"@ai-sdk/prodia": "workspace:*",
"@ai-sdk/provider": "workspace:*",
Expand All @@ -42,23 +43,23 @@
"@ai-sdk/valibot": "workspace:*",
"@ai-sdk/vercel": "workspace:*",
"@ai-sdk/xai": "workspace:*",
"@ai-sdk/huggingface": "workspace:*",
"@google/generative-ai": "0.21.0",
"google-auth-library": "^9.15.1",
"@langfuse/otel": "^4.5.0",
"@opentelemetry/auto-instrumentations-node": "0.54.0",
"@opentelemetry/sdk-node": "^0.210.0",
"@opentelemetry/sdk-trace-node": "^2.5.0",
"@standard-schema/spec": "1.1.0",
"@valibot/to-json-schema": "^1.3.0",
"ai": "workspace:*",
"arktype": "2.1.28",
"dotenv": "16.4.5",
"effect": "3.18.4",
"google-auth-library": "^9.15.1",
"image-type": "^5.2.0",
"mathjs": "14.0.0",
"sharp": "^0.33.5",
"@standard-schema/spec": "1.1.0",
"terminal-image": "^2.0.0",
"undici": "^7.21.0",
"valibot": "1.1.0",
"zod": "3.25.76"
},
Expand Down
89 changes: 89 additions & 0 deletions examples/ai-functions/src/generate-text/gateway-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Example demonstrating Gateway timeout error handling
*
* This example uses undici with an extremely short timeout (1ms) to trigger
* a timeout error. The Gateway SDK will catch this and provide a helpful
* error message with troubleshooting guidance.
*
* Prerequisites:
* - Set AI_GATEWAY_API_KEY environment variable
* (See .env.example for setup instructions)
*
* Run: pnpm tsx src/generate-text/gateway-timeout.ts
*/
import { createGateway, generateText } from 'ai';
import { Agent, fetch as undiciFetch } from 'undici';
import { run } from '../lib/run';

run(async () => {
try {
// Create an undici Agent with very short timeouts
// bodyTimeout applies to receiving the entire response body
const agent = new Agent({
headersTimeout: 1, // 1ms - will timeout waiting for headers
bodyTimeout: 1, // 1ms - will timeout reading response body
});

// Create custom fetch using undici with the configured agent
const customFetch = (
url: string | URL | Request,
options?: RequestInit,
): Promise<Response> => {
return undiciFetch(url as Parameters<typeof undiciFetch>[0], {
...(options as any),
dispatcher: agent,
}) as Promise<Response>;
};

// Create gateway provider with custom fetch
const gateway = createGateway({
fetch: customFetch,
});

console.log('Making request with 1ms timeout...');
console.log(
'This should timeout immediately and show the timeout error handling.\n',
);

const { text, usage } = await generateText({
model: gateway('anthropic/claude-3.5-sonnet'),
prompt:
'Write a detailed essay about the history of artificial intelligence, covering major milestones from the 1950s to present day.',
});

console.log('Success! Response received:');
console.log(text);
console.log();
console.log('Usage:', usage);
} catch (error) {
console.error(
'╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ TIMEOUT ERROR CAUGHT ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
console.error('Error Name:', (error as Error).name);
console.error('Error Type:', (error as any).type);
console.error('Status Code:', (error as any).statusCode);
console.error('Error Code:', (error as any).code);
console.error('\nError Message:');
console.error('─'.repeat(70));
console.error((error as Error).message);
console.error('─'.repeat(70));

// Log the cause to see the original undici error
if ((error as any).cause) {
console.error('\n📋 Original Error (cause):');
console.error(' Name:', ((error as any).cause as Error).name);
console.error(' Code:', ((error as any).cause as any).code);
console.error(' Message:', ((error as any).cause as Error).message);
console.error(
' Constructor:',
((error as any).cause as Error).constructor.name,
);
}
}
});
171 changes: 171 additions & 0 deletions packages/gateway/src/errors/as-gateway-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { APICallError } from '@ai-sdk/provider';
import { describe, expect, it } from 'vitest';
import { asGatewayError } from './as-gateway-error';
import {
GatewayError,
GatewayTimeoutError,
GatewayResponseError,
} from './index';

describe('asGatewayError', () => {
describe('timeout error detection', () => {
it('should detect error with UND_ERR_HEADERS_TIMEOUT code', async () => {
const error = Object.assign(new Error('Request timeout'), {
code: 'UND_ERR_HEADERS_TIMEOUT',
});

const result = await asGatewayError(error);

expect(GatewayTimeoutError.isInstance(result)).toBe(true);
expect(result.message).toContain('Request timeout');
});

it('should detect error with UND_ERR_BODY_TIMEOUT code', async () => {
const error = Object.assign(new Error('Body timeout'), {
code: 'UND_ERR_BODY_TIMEOUT',
});

const result = await asGatewayError(error);

expect(GatewayTimeoutError.isInstance(result)).toBe(true);
});

it('should detect error with UND_ERR_CONNECT_TIMEOUT code', async () => {
const error = Object.assign(new Error('Connect timeout'), {
code: 'UND_ERR_CONNECT_TIMEOUT',
});

const result = await asGatewayError(error);

expect(GatewayTimeoutError.isInstance(result)).toBe(true);
});
});

describe('non-timeout errors', () => {
it('should not treat network errors as timeout errors', async () => {
const error = new Error('Network error');

const result = await asGatewayError(error);

expect(GatewayTimeoutError.isInstance(result)).toBe(false);
expect(GatewayResponseError.isInstance(result)).toBe(true);
expect(result.message).toContain('Gateway request failed: Network error');
});

it('should not treat connection errors as timeout errors', async () => {
const error = Object.assign(new Error('Connection refused'), {
code: 'ECONNREFUSED',
});

const result = await asGatewayError(error);

expect(GatewayTimeoutError.isInstance(result)).toBe(false);
expect(GatewayResponseError.isInstance(result)).toBe(true);
});

it('should pass through existing GatewayError instances', async () => {
const existingError = GatewayTimeoutError.createTimeoutError({
originalMessage: 'existing timeout',
});

const result = await asGatewayError(existingError);

expect(result).toBe(existingError);
});

it('should handle non-Error objects', async () => {
const error = { message: 'timeout occurred' };

const result = await asGatewayError(error);

// Non-Error objects won't be detected as timeout errors
expect(GatewayTimeoutError.isInstance(result)).toBe(false);
expect(GatewayResponseError.isInstance(result)).toBe(true);
});

it('should handle null', async () => {
const result = await asGatewayError(null);

expect(GatewayTimeoutError.isInstance(result)).toBe(false);
expect(GatewayResponseError.isInstance(result)).toBe(true);
});

it('should handle undefined', async () => {
const result = await asGatewayError(undefined);

expect(GatewayTimeoutError.isInstance(result)).toBe(false);
expect(GatewayResponseError.isInstance(result)).toBe(true);
});
});

describe('error properties', () => {
it('should preserve the original error as cause', async () => {
const originalError = Object.assign(new Error('timeout error'), {
code: 'UND_ERR_HEADERS_TIMEOUT',
});

const result = await asGatewayError(originalError);

expect(result.cause).toBe(originalError);
});

it('should set correct status code for timeout errors', async () => {
const error = Object.assign(new Error('timeout'), {
code: 'UND_ERR_HEADERS_TIMEOUT',
});

const result = await asGatewayError(error);

expect(result.statusCode).toBe(408);
});

it('should have correct error type', async () => {
const error = Object.assign(new Error('timeout'), {
code: 'UND_ERR_HEADERS_TIMEOUT',
});

const result = await asGatewayError(error);

expect(result.type).toBe('timeout_error');
});
});

describe('APICallError with timeout cause', () => {
it('should detect timeout when APICallError has UND_ERR_HEADERS_TIMEOUT in cause', async () => {
const timeoutError = Object.assign(new Error('Request timeout'), {
code: 'UND_ERR_HEADERS_TIMEOUT',
});

const apiCallError = new APICallError({
message: 'Cannot connect to API: Request timeout',
url: 'https://example.com',
requestBodyValues: {},
cause: timeoutError,
});

const result = await asGatewayError(apiCallError);

expect(GatewayTimeoutError.isInstance(result)).toBe(true);
expect(result.message).toContain('Gateway request timed out');
});

it('should not treat APICallError as timeout if cause is not timeout-related', async () => {
const networkError = new Error('Network connection failed');

const apiCallError = new APICallError({
message: 'Cannot connect to API: Network connection failed',
url: 'https://example.com',
requestBodyValues: {},
cause: networkError,
statusCode: 500,
responseBody: JSON.stringify({
error: { message: 'Internal error', type: 'internal_error' },
}),
});

const result = await asGatewayError(apiCallError);

expect(GatewayTimeoutError.isInstance(result)).toBe(false);
});
});
});
47 changes: 44 additions & 3 deletions packages/gateway/src/errors/as-gateway-error.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,58 @@
import { APICallError } from '@ai-sdk/provider';
import { extractApiCallResponse, GatewayError } from '.';
import { createGatewayErrorFromResponse } from './create-gateway-error';
import { GatewayTimeoutError } from './gateway-timeout-error';

export function asGatewayError(
/**
* Checks if an error is a timeout error from undici.
* Only checks undici-specific error codes to avoid false positives.
*/
function isTimeoutError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}

// Check for undici-specific timeout error codes
const errorCode = (error as any).code;
if (typeof errorCode === 'string') {
const undiciTimeoutCodes = [
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_BODY_TIMEOUT',
'UND_ERR_CONNECT_TIMEOUT',
];
return undiciTimeoutCodes.includes(errorCode);
}

return false;
}

export async function asGatewayError(
error: unknown,
authMethod?: 'api-key' | 'oidc',
) {
if (GatewayError.isInstance(error)) {
return error;
}

// Check if this is a timeout error (or has a timeout error in the cause chain)
if (isTimeoutError(error)) {
return GatewayTimeoutError.createTimeoutError({
originalMessage: error instanceof Error ? error.message : 'Unknown error',
cause: error,
});
}

// Check if this is an APICallError caused by a timeout
if (APICallError.isInstance(error)) {
return createGatewayErrorFromResponse({
// Check if the cause is a timeout error
if (error.cause && isTimeoutError(error.cause)) {
return GatewayTimeoutError.createTimeoutError({
originalMessage: error.message,
cause: error,
});
}

return await createGatewayErrorFromResponse({
response: extractApiCallResponse(error),
statusCode: error.statusCode ?? 500,
defaultMessage: 'Gateway request failed',
Expand All @@ -20,7 +61,7 @@ export function asGatewayError(
});
}

return createGatewayErrorFromResponse({
return await createGatewayErrorFromResponse({
response: {},
statusCode: 500,
defaultMessage:
Expand Down
Loading
Loading