fix(executors): pass TimeoutError reason to controller.abort() in 7 niche executors (#8699)

Co-authored-by: ikelvingo <im.kelvinwong@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-27 17:24:39 -03:00
committed by GitHub
parent 7d67912f5b
commit f2d7e9a783
9 changed files with 108 additions and 9 deletions

View File

@@ -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

View File

@@ -401,7 +401,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
): Promise<boolean> {
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;

View File

@@ -204,7 +204,12 @@ export class FeloWebExecutor extends BaseExecutor {
signal?: AbortSignal
): Promise<boolean> {
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 {

View File

@@ -100,7 +100,12 @@ export async function firecrawlFetch(opts: FirecrawlScrapeOptions): Promise<WebF
}
const controller = new AbortController();
const timeoutId = setTimeout(() => 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<string, string> = { "Content-Type": "application/json" };

View File

@@ -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 });

View File

@@ -40,7 +40,11 @@ export async function tavilyFetch(opts: TavilyFetchOptions): Promise<WebFetchRes
};
const controller = new AbortController();
const timeoutId = setTimeout(() => 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, {

View File

@@ -187,7 +187,11 @@ async function directFetch(
signal?: AbortSignal | null
): Promise<Response> {
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 });

View File

@@ -62,7 +62,11 @@ export async function tinyfishFetch(opts: TinyFishFetchOptions): Promise<WebFetc
};
const controller = new AbortController();
const timeoutId = setTimeout(() => 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, {

View File

@@ -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);
});
});