Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions packages/workshop-backend/src/web-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,16 +283,18 @@ export async function webFetch(
signal: abortController.signal,
});
} catch (err) {
clearTimeout(timeoutId);
if (
err instanceof Error &&
(err.name === "AbortError" || /abort/i.test(err.message))
) {
throw new Error(`Fetch timed out after ${FETCH_TIMEOUT_MS}ms`, { cause: err });
}
throw err;
} finally {
clearTimeout(timeoutId);
}
// NB: the timer deliberately stays armed past this point. `fetch` resolves once the response
// headers arrive, so clearing it here would leave the body read below unbounded, and a server
// that answers promptly and then stalls could hold the agent open indefinitely.

// `response.url` is set by the runtime to the final URL after any redirects. Fall back
// to the original URL if it happens to be empty.
Expand All @@ -302,6 +304,7 @@ export async function webFetch(
// Respect the Content-Signal header (https://contentsignals.org/). If the site
// explicitly sets `ai-input=no`, we must not feed its content to the AI agent.
if (contentSignalDenies(response, "ai-input")) {
clearTimeout(timeoutId);
try {
await response.body?.cancel();
} catch {
Expand All @@ -313,7 +316,14 @@ export async function webFetch(
);
}

const { bytes, truncated } = await readBodyCapped(response, maxBytes);
const { bytes, truncated } = await readBodyCapped(response, maxBytes)
.catch((err: unknown) => {
if (abortController.signal.aborted) {
throw new Error(`Fetch timed out after ${FETCH_TIMEOUT_MS}ms`, { cause: err });
}
throw err;
})
.finally(() => clearTimeout(timeoutId));

let body: string;
if (input.raw) {
Expand Down
Loading