From eab3b2a1293317257fd0405ccd11bd8a56446391 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 04:16:03 +0000 Subject: [PATCH 1/4] Initial plan From 619dab21d54ebbc6be3a57c429cc0042fcf5c41d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 04:18:59 +0000 Subject: [PATCH 2/4] Initial commit - planning fetch timeout implementation Co-authored-by: wangzuo <1039026+wangzuo@users.noreply.github.com> --- bun.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/bun.lock b/bun.lock index 4539f24..9745ed2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "ai", From e753a93a2c1915c84334bc78d17d6cc4b2a7854b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 04:22:14 +0000 Subject: [PATCH 3/4] Add timeout support to HTTP fetch requests Co-authored-by: wangzuo <1039026+wangzuo@users.noreply.github.com> --- packages/fluent-ai/src/job/fal.ts | 4 +- packages/fluent-ai/src/job/http.ts | 46 ++++++++++- packages/fluent-ai/src/job/openai.ts | 4 +- packages/fluent-ai/src/job/openrouter.ts | 2 +- packages/fluent-ai/src/job/schema.ts | 1 + packages/fluent-ai/src/job/voyage.ts | 2 +- packages/fluent-ai/test/timeout.test.ts | 97 ++++++++++++++++++++++++ 7 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 packages/fluent-ai/test/timeout.test.ts diff --git a/packages/fluent-ai/src/job/fal.ts b/packages/fluent-ai/src/job/fal.ts index db5e3b7..46f9cb2 100644 --- a/packages/fluent-ai/src/job/fal.ts +++ b/packages/fluent-ai/src/job/fal.ts @@ -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); }, }; diff --git a/packages/fluent-ai/src/job/http.ts b/packages/fluent-ai/src/job/http.ts index 3f1e8f4..e55ccb2 100644 --- a/packages/fluent-ai/src/job/http.ts +++ b/packages/fluent-ai/src/job/http.ts @@ -5,12 +5,33 @@ import type { ImageJob } from "./schema"; export async function createHTTPJob( request: RequestInfo | URL, handleResponse: (response: Response) => T | Promise, + timeout?: number, ): Promise { 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; @@ -21,6 +42,7 @@ export async function downloadImages( images: Array<{ url: string; [key: string]: any }>, options: ImageJob["input"]["download"], jobId?: string, + timeout?: number, ): Promise> { const localDir = options!.local; @@ -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}`); } @@ -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; } }), diff --git a/packages/fluent-ai/src/job/openai.ts b/packages/fluent-ai/src/job/openai.ts index 32699f0..b77d104 100644 --- a/packages/fluent-ai/src/job/openai.ts +++ b/packages/fluent-ai/src/job/openai.ts @@ -74,7 +74,7 @@ export const runner = { } : undefined, }; - }); + }, options?.timeout); }, models: async ( @@ -100,6 +100,6 @@ export const runner = { name: model.id, })), }; - }); + }, options?.timeout); }, }; diff --git a/packages/fluent-ai/src/job/openrouter.ts b/packages/fluent-ai/src/job/openrouter.ts index 1d03633..0ad6be3 100644 --- a/packages/fluent-ai/src/job/openrouter.ts +++ b/packages/fluent-ai/src/job/openrouter.ts @@ -74,6 +74,6 @@ export const runner = { } : undefined, }; - }); + }, options?.timeout); }, }; diff --git a/packages/fluent-ai/src/job/schema.ts b/packages/fluent-ai/src/job/schema.ts index da4f54d..4f464a0 100644 --- a/packages/fluent-ai/src/job/schema.ts +++ b/packages/fluent-ai/src/job/schema.ts @@ -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({ diff --git a/packages/fluent-ai/src/job/voyage.ts b/packages/fluent-ai/src/job/voyage.ts index 747a782..2a5b62c 100644 --- a/packages/fluent-ai/src/job/voyage.ts +++ b/packages/fluent-ai/src/job/voyage.ts @@ -28,6 +28,6 @@ export const runner = { return { embeddings: data.data.map((item: any) => item.embedding), }; - }); + }, options?.timeout); }, }; diff --git a/packages/fluent-ai/test/timeout.test.ts b/packages/fluent-ai/test/timeout.test.ts new file mode 100644 index 0000000..ae50f86 --- /dev/null +++ b/packages/fluent-ai/test/timeout.test.ts @@ -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" }))); + + 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" }))); + + 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; + }); + + 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"); + }); + + 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; + } +}); + From c68ed44c8f3c3b9b8594eb6b8d68bfc1b8b11f93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 04:25:28 +0000 Subject: [PATCH 4/4] Fix TypeScript type errors in timeout tests and add usage example Co-authored-by: wangzuo <1039026+wangzuo@users.noreply.github.com> --- .../fluent-ai/examples/timeout-example.ts | 62 +++++++++++++++++++ packages/fluent-ai/test/timeout.test.ts | 8 +-- 2 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 packages/fluent-ai/examples/timeout-example.ts diff --git a/packages/fluent-ai/examples/timeout-example.ts b/packages/fluent-ai/examples/timeout-example.ts new file mode 100644 index 0000000..aa5b6de --- /dev/null +++ b/packages/fluent-ai/examples/timeout-example.ts @@ -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(); diff --git a/packages/fluent-ai/test/timeout.test.ts b/packages/fluent-ai/test/timeout.test.ts index ae50f86..c39fc85 100644 --- a/packages/fluent-ai/test/timeout.test.ts +++ b/packages/fluent-ai/test/timeout.test.ts @@ -7,7 +7,7 @@ test("createHTTPJob passes timeout parameter correctly", async () => { // Mock fetch to return a successful response const originalFetch = global.fetch; - global.fetch = mock(async () => new Response(JSON.stringify({ test: "data" }))); + global.fetch = mock(async () => new Response(JSON.stringify({ test: "data" }))) as unknown as typeof fetch; try { const result = await createHTTPJob( @@ -28,7 +28,7 @@ test("createHTTPJob works without timeout parameter", async () => { // Mock fetch to return a successful response const originalFetch = global.fetch; - global.fetch = mock(async () => new Response(JSON.stringify({ test: "data" }))); + global.fetch = mock(async () => new Response(JSON.stringify({ test: "data" }))) as unknown as typeof fetch; try { const result = await createHTTPJob( @@ -53,7 +53,7 @@ test("createHTTPJob throws timeout error on AbortError", async () => { const error = new Error("The operation was aborted"); error.name = "AbortError"; throw error; - }); + }) as unknown as typeof fetch; try { await createHTTPJob( @@ -78,7 +78,7 @@ test("createHTTPJob propagates non-abort errors", async () => { const originalFetch = global.fetch; global.fetch = mock(async () => { throw new Error("Network error"); - }); + }) as unknown as typeof fetch; try { await createHTTPJob(