Skip to content

Commit f894346

Browse files
rekram1-nodemrsimpson
authored andcommitted
fix(opencode): clear webfetch timeouts on failed fetches (anomalyco#21378)
1 parent 699e201 commit f894346

File tree

2 files changed

+65
-8
lines changed

2 files changed

+65
-8
lines changed

packages/opencode/src/tool/webfetch.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Tool } from "./tool"
33
import TurndownService from "turndown"
44
import DESCRIPTION from "./webfetch.txt"
55
import { abortAfterAny } from "../util/abort"
6+
import { iife } from "@/util/iife"
67

78
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
89
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -62,15 +63,18 @@ export const WebFetchTool = Tool.define("webfetch", {
6263
"Accept-Language": "en-US,en;q=0.9",
6364
}
6465

65-
const initial = await fetch(params.url, { signal, headers })
66+
const response = await iife(async () => {
67+
try {
68+
const initial = await fetch(params.url, { signal, headers })
6669

67-
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
68-
const response =
69-
initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
70-
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
71-
: initial
72-
73-
clearTimeout()
70+
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
71+
return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
72+
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
73+
: initial
74+
} finally {
75+
clearTimeout()
76+
}
77+
})
7478

7579
if (!response.ok) {
7680
throw new Error(`Request failed with status code: ${response.status}`)

packages/opencode/test/tool/webfetch.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const ctx = {
1717
ask: async () => {},
1818
}
1919

20+
type TimerID = ReturnType<typeof setTimeout>
21+
2022
async function withFetch(
2123
mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
2224
fn: () => Promise<void>,
@@ -30,6 +32,32 @@ async function withFetch(
3032
}
3133
}
3234

35+
async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise<void>) {
36+
const set = globalThis.setTimeout
37+
const clear = globalThis.clearTimeout
38+
const ids: TimerID[] = []
39+
const cleared: TimerID[] = []
40+
41+
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
42+
const id = set(...args)
43+
ids.push(id)
44+
return id
45+
}) as typeof setTimeout
46+
47+
globalThis.clearTimeout = ((id?: TimerID) => {
48+
if (id !== undefined) cleared.push(id)
49+
return clear(id)
50+
}) as typeof clearTimeout
51+
52+
try {
53+
await fn({ ids, cleared })
54+
} finally {
55+
ids.forEach(clear)
56+
globalThis.setTimeout = set
57+
globalThis.clearTimeout = clear
58+
}
59+
}
60+
3361
describe("tool.webfetch", () => {
3462
test("returns image responses as file attachments", async () => {
3563
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
@@ -98,4 +126,29 @@ describe("tool.webfetch", () => {
98126
},
99127
)
100128
})
129+
130+
test("clears timeout when fetch rejects", async () => {
131+
await withTimers(async ({ ids, cleared }) => {
132+
await withFetch(
133+
async () => {
134+
throw new Error("boom")
135+
},
136+
async () => {
137+
await Instance.provide({
138+
directory: projectRoot,
139+
fn: async () => {
140+
const webfetch = await WebFetchTool.init()
141+
await expect(
142+
webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx),
143+
).rejects.toThrow("boom")
144+
},
145+
})
146+
},
147+
)
148+
149+
expect(ids).toHaveLength(1)
150+
expect(cleared).toHaveLength(1)
151+
expect(cleared[0]).toBe(ids[0])
152+
})
153+
})
101154
})

0 commit comments

Comments
 (0)