Skip to content
Draft
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
1 change: 1 addition & 0 deletions bun.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "ai",
Expand Down
62 changes: 62 additions & 0 deletions packages/fluent-ai/examples/timeout-example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Example demonstrating timeout feature
*
* This example shows how to use the timeout option with fluent-ai
*/

import { openai, user } from "../src/index";

// Example 1: Using timeout with OpenAI chat
async function exampleWithTimeout() {
console.log("Example 1: Chat with 10 second timeout");

const job = openai({ timeout: 10000 }) // 10 second timeout
.chat("gpt-4o-mini")
.messages([user("What is AI?")]);

try {
const result = await job.run();
console.log("Success:", result);
} catch (error) {
if (error instanceof Error && error.message.includes("timed out")) {
console.error("Request timed out!");
} else {
console.error("Request failed:", error);
}
}
}

// Example 2: Using timeout with declarative API
async function exampleDeclarative() {
console.log("\nExample 2: Declarative API with timeout");

const job = {
type: "chat" as const,
provider: "openai" as const,
options: {
timeout: 10000, // 10 second timeout
},
input: {
model: "gpt-4o-mini",
messages: [{ role: "user", content: "Hello!" }],
},
};

console.log("Job definition:", JSON.stringify(job, null, 2));
}

// Example 3: No timeout (default behavior)
async function exampleNoTimeout() {
console.log("\nExample 3: Without timeout (default behavior)");

const job = openai()
.chat("gpt-4o-mini")
.messages([user("What is AI?")]);

console.log("Job will use default fetch behavior without timeout");
}

// Run examples (commented out to avoid actual API calls in example)
// exampleWithTimeout();
exampleDeclarative();
exampleNoTimeout();
4 changes: 2 additions & 2 deletions packages/fluent-ai/src/job/fal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ export const runner = {
});

if (input.download) {
images = await downloadImages(images, input.download);
images = await downloadImages(images, input.download, undefined, options?.timeout);
}

return { images };
});
}, options?.timeout);
},
};
46 changes: 43 additions & 3 deletions packages/fluent-ai/src/job/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,33 @@ import type { ImageJob } from "./schema";
export async function createHTTPJob<T>(
request: RequestInfo | URL,
handleResponse: (response: Response) => T | Promise<T>,
timeout?: number,
): Promise<T> {
try {
const response = await fetch(request);
let response: Response;

if (timeout && timeout > 0) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const requestWithSignal = new Request(request, {
signal: controller.signal,
});
response = await fetch(requestWithSignal);
} finally {
clearTimeout(timeoutId);
}
} else {
response = await fetch(request);
}

return await handleResponse(response);
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new Error(`HTTP request timed out after ${timeout}ms`);
}
throw new Error(`HTTP request failed: ${error.message}`);
}
throw error;
Expand All @@ -21,6 +42,7 @@ export async function downloadImages(
images: Array<{ url: string; [key: string]: any }>,
options: ImageJob["input"]["download"],
jobId?: string,
timeout?: number,
): Promise<Array<{ url: string; downloadPath?: string; [key: string]: any }>> {
const localDir = options!.local;

Expand All @@ -31,7 +53,21 @@ export async function downloadImages(
const downloadedImages = await Promise.all(
images.map(async (img, index) => {
try {
const response = await fetch(img.url);
let response: Response;

if (timeout && timeout > 0) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
response = await fetch(img.url, { signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
} else {
response = await fetch(img.url);
}

if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
Expand All @@ -54,7 +90,11 @@ export async function downloadImages(
downloadPath: path.join(path.basename(localDir), filename),
};
} catch (error) {
console.error(`Failed to download image ${index + 1}:`, error);
if (error instanceof Error && error.name === 'AbortError') {
console.error(`Image ${index + 1} download timed out after ${timeout}ms`);
} else {
console.error(`Failed to download image ${index + 1}:`, error);
}
return img;
}
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/fluent-ai/src/job/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const runner = {
}
: undefined,
};
});
}, options?.timeout);
},

models: async (
Expand All @@ -100,6 +100,6 @@ export const runner = {
name: model.id,
})),
};
});
}, options?.timeout);
},
};
2 changes: 1 addition & 1 deletion packages/fluent-ai/src/job/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,6 @@ export const runner = {
}
: undefined,
};
});
}, options?.timeout);
},
};
1 change: 1 addition & 0 deletions packages/fluent-ai/src/job/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const modelsOutputSchema = z.object({
// TODO: options schema per provider/job type
const optionsSchema = z.object({
apiKey: z.string().optional(),
timeout: z.number().optional(),
});

export const chatJobSchema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion packages/fluent-ai/src/job/voyage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ export const runner = {
return {
embeddings: data.data.map((item: any) => item.embedding),
};
});
}, options?.timeout);
},
};
97 changes: 97 additions & 0 deletions packages/fluent-ai/test/timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { test, expect, mock } from "bun:test";
import { createHTTPJob } from "~/src/job/http";

test("createHTTPJob passes timeout parameter correctly", async () => {
// Test that the function signature accepts timeout
const mockHandler = mock(async (response: Response) => ({ success: true }));

// Mock fetch to return a successful response
const originalFetch = global.fetch;
global.fetch = mock(async () => new Response(JSON.stringify({ test: "data" }))) as unknown as typeof fetch;

try {
const result = await createHTTPJob(
"http://example.com",
mockHandler,
5000
);

expect(result).toEqual({ success: true });
expect(mockHandler).toHaveBeenCalled();
} finally {
global.fetch = originalFetch;
}
});

test("createHTTPJob works without timeout parameter", async () => {
const mockHandler = mock(async (response: Response) => ({ success: true }));

// Mock fetch to return a successful response
const originalFetch = global.fetch;
global.fetch = mock(async () => new Response(JSON.stringify({ test: "data" }))) as unknown as typeof fetch;

try {
const result = await createHTTPJob(
"http://example.com",
mockHandler
);

expect(result).toEqual({ success: true });
expect(mockHandler).toHaveBeenCalled();
} finally {
global.fetch = originalFetch;
}
});

test("createHTTPJob throws timeout error on AbortError", async () => {
const mockHandler = mock(async (response: Response) => ({ success: true }));
const timeout = 1000;

// Mock fetch to throw AbortError
const originalFetch = global.fetch;
global.fetch = mock(async () => {
const error = new Error("The operation was aborted");
error.name = "AbortError";
throw error;
}) as unknown as typeof fetch;

try {
await createHTTPJob(
"http://example.com",
mockHandler,
timeout
);
throw new Error("Should have thrown timeout error");
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("timed out");
expect((error as Error).message).toContain("1000ms");
} finally {
global.fetch = originalFetch;
}
});

test("createHTTPJob propagates non-abort errors", async () => {
const mockHandler = mock(async (response: Response) => ({ success: true }));

// Mock fetch to throw a regular error
const originalFetch = global.fetch;
global.fetch = mock(async () => {
throw new Error("Network error");
}) as unknown as typeof fetch;

try {
await createHTTPJob(
"http://example.com",
mockHandler
);
throw new Error("Should have thrown network error");
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("HTTP request failed");
expect((error as Error).message).toContain("Network error");
} finally {
global.fetch = originalFetch;
}
});