fix(opencode): clear webfetch timeouts on failed fetches (#21378)

pull/13502/merge
Aiden Cline 2026-04-07 15:46:02 -05:00 committed by GitHub
parent 095aeba0a7
commit bc1840b196
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 65 additions and 8 deletions

View File

@ -3,6 +3,7 @@ import { Tool } from "./tool"
import TurndownService from "turndown" import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt" import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort" import { abortAfterAny } from "../util/abort"
import { iife } from "@/util/iife"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@ -62,15 +63,18 @@ export const WebFetchTool = Tool.define("webfetch", {
"Accept-Language": "en-US,en;q=0.9", "Accept-Language": "en-US,en;q=0.9",
} }
const initial = await fetch(params.url, { signal, headers }) const response = await iife(async () => {
try {
const initial = await fetch(params.url, { signal, headers })
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
const response = return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" ? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) : initial
: initial } finally {
clearTimeout()
clearTimeout() }
})
if (!response.ok) { if (!response.ok) {
throw new Error(`Request failed with status code: ${response.status}`) throw new Error(`Request failed with status code: ${response.status}`)

View File

@ -17,6 +17,8 @@ const ctx = {
ask: async () => {}, ask: async () => {},
} }
type TimerID = ReturnType<typeof setTimeout>
async function withFetch( async function withFetch(
mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>, mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
fn: () => Promise<void>, fn: () => Promise<void>,
@ -30,6 +32,32 @@ async function withFetch(
} }
} }
async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise<void>) {
const set = globalThis.setTimeout
const clear = globalThis.clearTimeout
const ids: TimerID[] = []
const cleared: TimerID[] = []
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
const id = set(...args)
ids.push(id)
return id
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: TimerID) => {
if (id !== undefined) cleared.push(id)
return clear(id)
}) as typeof clearTimeout
try {
await fn({ ids, cleared })
} finally {
ids.forEach(clear)
globalThis.setTimeout = set
globalThis.clearTimeout = clear
}
}
describe("tool.webfetch", () => { describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => { test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
@ -98,4 +126,29 @@ describe("tool.webfetch", () => {
}, },
) )
}) })
test("clears timeout when fetch rejects", async () => {
await withTimers(async ({ ids, cleared }) => {
await withFetch(
async () => {
throw new Error("boom")
},
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
await expect(
webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx),
).rejects.toThrow("boom")
},
})
},
)
expect(ids).toHaveLength(1)
expect(cleared).toHaveLength(1)
expect(cleared[0]).toBe(ids[0])
})
})
}) })