diff --git a/core/llm/index.ts b/core/llm/index.ts index dd6dd9c00aa..08d23442e10 100644 --- a/core/llm/index.ts +++ b/core/llm/index.ts @@ -31,6 +31,7 @@ import { ToolOverride, Usage, } from "../index.js"; +import { isAbortError } from "../util/isAbortError.js"; import { isLemonadeInstalled } from "../util/lemonadeHelper.js"; import { Logger } from "../util/Logger.js"; import mergeJson from "../util/merge.js"; @@ -394,7 +395,7 @@ export abstract class BaseLLM implements ILLM { }); return "success"; } else { - if (error === "cancel" || error?.name?.includes("AbortError")) { + if (isAbortError(error)) { interaction?.logItem({ kind: "cancel", promptTokens, @@ -502,7 +503,7 @@ export abstract class BaseLLM implements ILLM { `HTTP ${e.response.status} ${e.response.statusText} from ${e.response.url}\n\n${e.response.body}`, ); } else { - if (e.name !== "AbortError") { + if (!isAbortError(e)) { // Don't pollute console with abort errors. Check on name instead of instanceof, to avoid importing node-fetch here console.debug( `${e.message}\n\nCode: ${e.code}\nError number: ${e.errno}\nSyscall: ${e.erroredSysCall}\nType: ${e.type}\n\n${e.stack}`, diff --git a/core/llm/utils/retry.ts b/core/llm/utils/retry.ts index dce06ef5c24..45ee3dbef88 100644 --- a/core/llm/utils/retry.ts +++ b/core/llm/utils/retry.ts @@ -1,3 +1,5 @@ +import { isAbortError } from "../../util/isAbortError.js"; + /** * Configuration options for the retry decorator */ @@ -98,7 +100,7 @@ function defaultShouldRetry(error: any, attempt: number): boolean { } // Abort signal errors should not be retried - if (error.name === "AbortError" || error.code === "ABORT_ERR") { + if (isAbortError(error)) { return false; } diff --git a/core/util/isAbortError.ts b/core/util/isAbortError.ts new file mode 100644 index 00000000000..ba57fe57735 --- /dev/null +++ b/core/util/isAbortError.ts @@ -0,0 +1,30 @@ +/** + * Unified abort error detection. + * Handles all known abort error patterns across different environments. + */ +export function isAbortError(error: unknown): boolean { + // String-based "cancel" (used in Continue codebase) + if (error === "cancel") return true; + + // Standard Error objects + if (error instanceof Error) { + if (error.name.includes("AbortError")) return true; + if ("code" in error && (error as any).code === "ABORT_ERR") return true; + } + + // DOMException (browser/Node.js 18+) + if ( + typeof DOMException !== "undefined" && + error instanceof DOMException && + error.name === "AbortError" + ) + return true; + + // Handle plain objects with name property + if (typeof error === "object" && error !== null && "name" in error) { + const name = (error as any).name; + if (typeof name === "string" && name.includes("AbortError")) return true; + } + + return false; +} diff --git a/core/util/isAbortError.vitest.ts b/core/util/isAbortError.vitest.ts new file mode 100644 index 00000000000..d53bea56665 --- /dev/null +++ b/core/util/isAbortError.vitest.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { isAbortError } from "./isAbortError"; + +describe("isAbortError", () => { + it('should return true for "cancel" string', () => { + expect(isAbortError("cancel")).toBe(true); + }); + + it("should return true for DOMException with AbortError name", () => { + if (typeof DOMException !== "undefined") { + const error = new DOMException("Operation aborted", "AbortError"); + expect(isAbortError(error)).toBe(true); + } else { + // Skip in environments without DOMException + expect(true).toBe(true); + } + }); + + it("should return true for Error with AbortError name", () => { + const error = Object.assign(new Error("aborted"), { name: "AbortError" }); + expect(isAbortError(error)).toBe(true); + }); + + it("should return true for Error with name containing AbortError (middle match)", () => { + const error = Object.assign(new Error("aborted"), { + name: "NetworkAbortError", + }); + expect(isAbortError(error)).toBe(true); + }); + + it("should return true for Error with name starting with AbortError", () => { + const error = Object.assign(new Error("aborted"), { + name: "AbortErrorSomething", + }); + expect(isAbortError(error)).toBe(true); + }); + + it("should return true for Error with ABORT_ERR code", () => { + const error = Object.assign(new Error("aborted"), { code: "ABORT_ERR" }); + expect(isAbortError(error)).toBe(true); + }); + + it("should return true for plain object with AbortError name", () => { + const error = { name: "AbortError" }; + expect(isAbortError(error)).toBe(true); + }); + + it("should return true for plain object with name containing AbortError (middle match)", () => { + const error = { name: "NetworkAbortError" }; + expect(isAbortError(error)).toBe(true); + }); + + it("should return true for plain object with name starting with AbortError", () => { + const error = { name: "AbortErrorNetwork" }; + expect(isAbortError(error)).toBe(true); + }); + + it("should return false for regular Error", () => { + const error = new Error("network error"); + expect(isAbortError(error)).toBe(false); + }); + + it("should return false for null", () => { + expect(isAbortError(null)).toBe(false); + }); + + it("should return false for undefined", () => { + expect(isAbortError(undefined)).toBe(false); + }); + + it("should return false for number", () => { + expect(isAbortError(42)).toBe(false); + }); + + it("should return false for plain object without name", () => { + expect(isAbortError({ message: "error" })).toBe(false); + }); +});