mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:01:01 +00:00
fix(browser): keep transient fetch errors retryable
Co-authored-by: jriff <jriff@users.noreply.github.com>
This commit is contained in:
@@ -197,11 +197,29 @@ describe("fetchBrowserJson loopback auth", () => {
|
||||
expect(headers.get("authorization")).toBe("Bearer loopback-token");
|
||||
});
|
||||
|
||||
it("preserves dispatcher error context while keeping no-retry hint", async () => {
|
||||
it("preserves dispatcher timeout context without no-retry hint", async () => {
|
||||
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout"));
|
||||
|
||||
await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), {
|
||||
contains: ["Chrome CDP handshake timeout", "Do NOT retry the browser tool"],
|
||||
contains: ["Chrome CDP handshake timeout", "Restart the OpenClaw gateway"],
|
||||
omits: ["Can't reach the OpenClaw browser control service", "Do NOT retry the browser tool"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves dispatcher abort context without no-retry hint", async () => {
|
||||
mocks.dispatch.mockRejectedValueOnce(new DOMException("operation aborted", "AbortError"));
|
||||
|
||||
await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), {
|
||||
contains: ["operation aborted", "Restart the OpenClaw gateway"],
|
||||
omits: ["Do NOT retry the browser tool"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps no-retry hint for persistent dispatcher failures", async () => {
|
||||
mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP connection refused"));
|
||||
|
||||
await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), {
|
||||
contains: ["Chrome CDP connection refused", "Do NOT retry the browser tool"],
|
||||
omits: ["Can't reach the OpenClaw browser control service"],
|
||||
});
|
||||
});
|
||||
@@ -300,4 +318,38 @@ describe("fetchBrowserJson loopback auth", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("omits no-retry hint for absolute HTTP timeout failures", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("timed out");
|
||||
}),
|
||||
);
|
||||
|
||||
await expectThrownBrowserFetchError(
|
||||
() => fetchBrowserJson<{ ok: boolean }>("http://example.com/", { timeoutMs: 1234 }),
|
||||
{
|
||||
contains: ["timed out after 1234ms"],
|
||||
omits: ["Do NOT retry the browser tool"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("omits no-retry hint for absolute HTTP abort failures", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new DOMException("operation aborted", "AbortError");
|
||||
}),
|
||||
);
|
||||
|
||||
await expectThrownBrowserFetchError(
|
||||
() => fetchBrowserJson<{ ok: boolean }>("http://example.com/"),
|
||||
{
|
||||
contains: ["Browser control request was cancelled"],
|
||||
omits: ["Do NOT retry the browser tool"],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,27 @@ function appendBrowserToolModelHint(message: string): string {
|
||||
return `${message} ${BROWSER_TOOL_MODEL_HINT}`;
|
||||
}
|
||||
|
||||
type BrowserFetchFailureKind = "timeout" | "aborted" | "persistent";
|
||||
|
||||
function classifyBrowserFetchFailure(err: unknown): BrowserFetchFailureKind {
|
||||
const msg = normalizeErrorMessage(err);
|
||||
const msgLower = normalizeLowercaseStringOrEmpty(msg);
|
||||
const nameLower = err instanceof Error ? normalizeLowercaseStringOrEmpty(err.name) : "";
|
||||
const looksLikeTimeout =
|
||||
nameLower.includes("timeout") || msgLower.includes("timed out") || msgLower.includes("timeout");
|
||||
if (looksLikeTimeout) {
|
||||
return "timeout";
|
||||
}
|
||||
const looksLikeAbort =
|
||||
nameLower === "aborterror" ||
|
||||
msgLower.includes("aborterror") ||
|
||||
msgLower.includes("aborted") ||
|
||||
msgLower.includes("abort") ||
|
||||
msgLower.includes("cancelled") ||
|
||||
msgLower.includes("canceled");
|
||||
return looksLikeAbort ? "aborted" : "persistent";
|
||||
}
|
||||
|
||||
async function discardResponseBody(res: Response): Promise<void> {
|
||||
try {
|
||||
await res.body?.cancel();
|
||||
@@ -137,32 +158,36 @@ async function discardResponseBody(res: Response): Promise<void> {
|
||||
|
||||
function enhanceDispatcherPathError(url: string, err: unknown): Error {
|
||||
const msg = normalizeErrorMessage(err);
|
||||
const suffix = `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`;
|
||||
const kind = classifyBrowserFetchFailure(err);
|
||||
const suffix =
|
||||
kind === "persistent"
|
||||
? `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}`
|
||||
: resolveBrowserFetchOperatorHint(url);
|
||||
const normalized = msg.endsWith(".") ? msg : `${msg}.`;
|
||||
return new Error(`${normalized} ${suffix}`, err instanceof Error ? { cause: err } : undefined);
|
||||
}
|
||||
|
||||
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
|
||||
const operatorHint = resolveBrowserFetchOperatorHint(url);
|
||||
const msg = String(err);
|
||||
const msgLower = normalizeLowercaseStringOrEmpty(msg);
|
||||
const looksLikeTimeout =
|
||||
msgLower.includes("timed out") ||
|
||||
msgLower.includes("timeout") ||
|
||||
msgLower.includes("aborted") ||
|
||||
msgLower.includes("abort") ||
|
||||
msgLower.includes("aborterror");
|
||||
if (looksLikeTimeout) {
|
||||
const msg = normalizeErrorMessage(err);
|
||||
const kind = classifyBrowserFetchFailure(err);
|
||||
if (kind === "timeout") {
|
||||
return new Error(
|
||||
appendBrowserToolModelHint(
|
||||
`Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`,
|
||||
),
|
||||
`Can't reach the OpenClaw browser control service (timed out after ${timeoutMs}ms). ${operatorHint}`,
|
||||
err instanceof Error ? { cause: err } : undefined,
|
||||
);
|
||||
}
|
||||
if (kind === "aborted") {
|
||||
return new Error(
|
||||
`Browser control request was cancelled. ${operatorHint}`,
|
||||
err instanceof Error ? { cause: err } : undefined,
|
||||
);
|
||||
}
|
||||
return new Error(
|
||||
appendBrowserToolModelHint(
|
||||
`Can't reach the OpenClaw browser control service. ${operatorHint} (${msg})`,
|
||||
),
|
||||
err instanceof Error ? { cause: err } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ describe("browser client", () => {
|
||||
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/sandboxed session/i);
|
||||
});
|
||||
|
||||
it("adds useful timeout messaging for abort-like failures", async () => {
|
||||
it("adds useful cancellation messaging for abort-like failures", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("aborted")));
|
||||
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i);
|
||||
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/cancelled/i);
|
||||
});
|
||||
|
||||
it("surfaces non-2xx responses with body text", async () => {
|
||||
|
||||
Reference in New Issue
Block a user