diff --git a/CHANGELOG.md b/CHANGELOG.md index 622ac2c70c..72ca668717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding) + --- ## [3.8.49] — TBD diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index bcf23669a0..f028dee7cd 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -401,7 +401,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { ): Promise { try { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const ddgTestMs = FETCH_TIMEOUT_MS; + const timeout = setTimeout(() => { + const err = new Error(`duckduckgo-web testConnection timeout after ${ddgTestMs}ms`); + err.name = "TimeoutError"; + controller.abort(err); + }, ddgTestMs); const mergedSignal = signal ? AbortSignal.any([signal, controller.signal]) @@ -518,7 +523,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { try { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const ddgExecMs = FETCH_TIMEOUT_MS; + const timeout = setTimeout(() => { + const err = new Error(`duckduckgo-web execute timeout after ${ddgExecMs}ms`); + err.name = "TimeoutError"; + controller.abort(err); + }, ddgExecMs); const mergedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; diff --git a/open-sse/executors/felo-web.ts b/open-sse/executors/felo-web.ts index 847b8d3f77..537d46afba 100644 --- a/open-sse/executors/felo-web.ts +++ b/open-sse/executors/felo-web.ts @@ -204,7 +204,12 @@ export class FeloWebExecutor extends BaseExecutor { signal?: AbortSignal ): Promise { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.getTimeoutMs()); + const feloTestMs = this.getTimeoutMs(); + const timeout = setTimeout(() => { + const err = new Error(`felo-web testConnection timeout after ${feloTestMs}ms`); + err.name = "TimeoutError"; + controller.abort(err); + }, feloTestMs); try { const mergedSignal = signal ? AbortSignal.any([signal, controller.signal]) @@ -243,7 +248,12 @@ export class FeloWebExecutor extends BaseExecutor { } const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.getTimeoutMs()); + const feloExecMs = this.getTimeoutMs(); + const timeout = setTimeout(() => { + const err = new Error(`felo-web execute timeout after ${feloExecMs}ms`); + err.name = "TimeoutError"; + controller.abort(err); + }, feloExecMs); const mergedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; try { diff --git a/open-sse/executors/firecrawl-fetch.ts b/open-sse/executors/firecrawl-fetch.ts index b2c59386ec..069e8a5b13 100644 --- a/open-sse/executors/firecrawl-fetch.ts +++ b/open-sse/executors/firecrawl-fetch.ts @@ -100,7 +100,12 @@ export async function firecrawlFetch(opts: FirecrawlScrapeOptions): Promise controller.abort(), getFirecrawlTimeoutMs()); + const firecrawlMs = getFirecrawlTimeoutMs(); + const timeoutId = setTimeout(() => { + const err = new Error(`firecrawl-fetch timeout after ${firecrawlMs}ms`); + err.name = "TimeoutError"; + controller.abort(err); + }, firecrawlMs); try { const headers: Record = { "Content-Type": "application/json" }; diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts index 239dfc430d..356963a581 100644 --- a/open-sse/executors/mimocode.ts +++ b/open-sse/executors/mimocode.ts @@ -170,7 +170,11 @@ async function bootstrapJwt( const url = `${baseUrl}${BOOTSTRAP_PATH}`; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), BOOTSTRAP_TIMEOUT_MS); + const timer = setTimeout(() => { + const err = new Error(`mimocode bootstrap timeout after ${BOOTSTRAP_TIMEOUT_MS}ms`); + err.name = "TimeoutError"; + controller.abort(err); + }, BOOTSTRAP_TIMEOUT_MS); const onSignal = signal ? () => controller.abort(signal.reason) : null; if (signal && onSignal) signal.addEventListener("abort", onSignal, { once: true }); diff --git a/open-sse/executors/tavily-fetch.ts b/open-sse/executors/tavily-fetch.ts index 9ca3437fa4..25dbda3c8f 100644 --- a/open-sse/executors/tavily-fetch.ts +++ b/open-sse/executors/tavily-fetch.ts @@ -40,7 +40,11 @@ export async function tavilyFetch(opts: TavilyFetchOptions): Promise controller.abort(), TAVILY_TIMEOUT_MS); + const timeoutId = setTimeout(() => { + const err = new Error(`tavily-fetch timeout after ${TAVILY_TIMEOUT_MS}ms`); + err.name = "TimeoutError"; + controller.abort(err); + }, TAVILY_TIMEOUT_MS); try { const response = await fetch(TAVILY_EXTRACT_URL, { diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts index 8acc471b3a..688e79eb2c 100644 --- a/open-sse/executors/theoldllm.ts +++ b/open-sse/executors/theoldllm.ts @@ -187,7 +187,11 @@ async function directFetch( signal?: AbortSignal | null ): Promise { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 120_000); + const timer = setTimeout(() => { + const err = new Error("theoldllm timeout after 120000ms"); + err.name = "TimeoutError"; + controller.abort(err); + }, 120_000); const onSignal = signal ? () => controller.abort(signal.reason) : undefined; signal?.addEventListener("abort", onSignal!, { once: true }); diff --git a/open-sse/executors/tinyfish-fetch.ts b/open-sse/executors/tinyfish-fetch.ts index 80952461a6..ef97561a6c 100644 --- a/open-sse/executors/tinyfish-fetch.ts +++ b/open-sse/executors/tinyfish-fetch.ts @@ -62,7 +62,11 @@ export async function tinyfishFetch(opts: TinyFishFetchOptions): Promise controller.abort(), TINYFISH_TIMEOUT_MS); + const timeoutId = setTimeout(() => { + const err = new Error(`tinyfish-fetch timeout after ${TINYFISH_TIMEOUT_MS}ms`); + err.name = "TimeoutError"; + controller.abort(err); + }, TINYFISH_TIMEOUT_MS); try { const response = await fetch(TINYFISH_FETCH_URL, { diff --git a/tests/unit/isLocalStreamLifecycleError-abort-shape.test.ts b/tests/unit/isLocalStreamLifecycleError-abort-shape.test.ts new file mode 100644 index 0000000000..dbd75bf9cd --- /dev/null +++ b/tests/unit/isLocalStreamLifecycleError-abort-shape.test.ts @@ -0,0 +1,54 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { isLocalStreamLifecycleError } from "../../src/shared/utils/circuitBreaker.ts"; + +describe("isLocalStreamLifecycleError — AbortError shape discrimination", () => { + // Case 1: bare abort() with no reason → AbortError → should return TRUE + // (This is the current buggy behavior — internal timeouts produce AbortError + // which is indistinguishable from a real client disconnect.) + it("returns TRUE for bare AbortController.abort() with no reason", () => { + const controller = new AbortController(); + controller.abort(); // no reason → DOMException { name: "AbortError" } + // Simulate what Node does: the signal's reason is a DOMException + const reason = controller.signal.reason; + assert.equal(isLocalStreamLifecycleError(reason), true); + }); + + // Case 2: abort(reason) where reason.name = "TimeoutError" → should return FALSE + // (This is the fixed pattern from base.ts — internal timeouts should NOT be + // treated as client disconnects.) + it("returns FALSE for abort(reason) with name 'TimeoutError'", () => { + const controller = new AbortController(); + const err = new Error("Fetch timeout after 120000ms on https://example.com"); + err.name = "TimeoutError"; + controller.abort(err); + assert.equal(isLocalStreamLifecycleError(controller.signal.reason), false); + }); + + // Case 3: abort(reason) where reason.name = "BodyTimeoutError" → should return FALSE + it("returns FALSE for abort(reason) with name 'BodyTimeoutError'", () => { + const controller = new AbortController(); + const err = new Error("Body Timeout Error"); + err.name = "BodyTimeoutError"; + controller.abort(err); + assert.equal(isLocalStreamLifecycleError(controller.signal.reason), false); + }); + + // Case 4: message "controller is already closed" → should return TRUE (existing) + it("returns TRUE for message 'controller is already closed'", () => { + const err = new Error("controller is already closed"); + assert.equal(isLocalStreamLifecycleError(err), true); + }); + + // Case 5: message "request_signal_aborted" → should return TRUE (existing) + it("returns TRUE for message 'request_signal_aborted'", () => { + const err = new Error("request_signal_aborted"); + assert.equal(isLocalStreamLifecycleError(err), true); + }); + + // Case 6: message "client disconnected" → should return TRUE (existing) + it("returns TRUE for message 'client disconnected'", () => { + const err = new Error("client disconnected"); + assert.equal(isLocalStreamLifecycleError(err), true); + }); +});