CherryHQ/cherry-studio · error · Error
Failed to fetch ${url}: ${e.message}
Error message
Failed to fetch ${url}: ${e.message} What it means
Thrown by Fetcher._fetchText when fetchRemoteText rejects with a standard Error. It re-wraps the underlying cause (network failure, DNS binding mismatch, redirect loop beyond maxRedirects=5, non-2xx HTTP status, or invalid URL) into a uniform 'Failed to fetch <url>: <reason>' message. The wrapper preserves the original message so the caller can diagnose the specific transport failure. This error is caught one frame up by Fetcher.html/json/txt/markdown and returned to the MCP client as a tool result with isError:true rather than propagating as an uncaught exception.
Source
Thrown at src/main/ai/mcp/servers/fetch.ts:37
function buildHeaders(headers: RequestPayload['headers']): Headers {
const resolvedHeaders = new Headers(headers)
if (!resolvedHeaders.has('User-Agent')) {
resolvedHeaders.set('User-Agent', DEFAULT_USER_AGENT)
}
return resolvedHeaders
}
export class Fetcher {
private static async _fetchText({ url, headers }: RequestPayload): Promise<string> {
try {
// The URL is model-supplied and this tool is auto-callable, so direct
// main-process fetches must bind the connection to validated DNS results.
return await fetchRemoteText(url, { headers: buildHeaders(headers), maxRedirects: 5 })
} catch (e: unknown) {
if (e instanceof Error) {
throw new Error(`Failed to fetch ${url}: ${e.message}`)
} else {
throw new Error(`Failed to fetch ${url}: Unknown error`)
}
}
}
static async html(requestPayload: RequestPayload) {
try {
const html = await this._fetchText(requestPayload)
return { content: [{ type: 'text', text: html }], isError: false }
} catch (error) {
return {
content: [{ type: 'text', text: (error as Error).message }],
isError: true
}
}
}
View on GitHub (pinned to 726446b54c)
Solutions
- Verify the URL is reachable from the main process's network context using a direct curl/fetch outside the tool, since fetchRemoteText binds to validated DNS results.
- Check whether the host is in any allowlist or DNS-resolution path the tool requires; model-supplied URLs are only honored when DNS validates.
- If the endpoint redirects, confirm the chain length is <= 5 or raise maxRedirects in the fetchRemoteText call.
- Inspect the trailing message after the colon — it is the underlying cause (e.g. 'ENOTFOUND', 'ETIMEDOUT', 'max redirect') and points at the layer to fix.
Example fix
// before
return await fetchRemoteText(url, { headers: buildHeaders(headers), maxRedirects: 5 })
// after — surface the specific failure class for the caller
try {
return await fetchRemoteText(url, { headers: buildHeaders(headers), maxRedirects: 5 })
} catch (e) {
const reason = e instanceof Error ? e.message : 'Unknown error'
if (reason.includes('ENOTFOUND')) throw new Error(`DNS resolution failed for ${url}: ${reason}`)
if (reason.includes('redirect')) throw new Error(`Redirect limit exceeded for ${url}`)
throw new Error(`Failed to fetch ${url}: ${reason}`)
} Defensive patterns
Strategy: retry
Validate before calling
// Validate reachability + redirect budget before dispatching the fetch tool.
async function preflightUrl(url: string, maxRedirects = 5): Promise<void> {
const parsed = z.url().safeParse(url)
if (!parsed.success) throw new Error(`Bad URL: ${url}`)
// Optional: HEAD request to confirm host resolves and responds
const res = await fetch(url, { method: 'HEAD', redirect: 'manual' })
if (res.status >= 400) throw new Error(`Preflight status ${res.status}`)
} Type guard
// Narrow an unknown fetch failure to a network/DNS error worth retrying.
function isTransientNetworkError(e: unknown): boolean {
if (!(e instanceof Error)) return false
return /ENOTFOUND|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|redirect/i.test(e.message)
} Try / catch
// Retry transient fetch failures with backoff; surface the final error.
async function fetchWithRetry(url: string, attempts = 3): Promise<string> {
let lastErr: unknown
for (let i = 0; i < attempts; i++) {
try {
return await fetchRemoteText(url, { maxRedirects: 5 })
} catch (e) {
lastErr = e
if (!isTransientNetworkError(e) || i === attempts - 1) break
await new Promise(r => setTimeout(r, 2 ** i * 200))
}
}
throw lastErr
} Prevention
- Validate the URL with z.url() on the client before dispatching the fetch tool.
- Confirm the host resolves in the main process's DNS context, not just the browser's.
- Keep redirect chains under 5 hops or raise maxRedirects explicitly.
- Log the underlying error code (ENOTFOUND, ETIMEDOUT) so transient vs permanent failures are distinguishable.
When it happens
Trigger: An MCP client invokes fetch_html/fetch_json/fetch_txt/fetch_markdown with a URL that fetchRemoteText cannot honor: the host does not resolve through the validated DNS path, the server returns 4xx/5xx, the redirect chain exceeds 5 hops, the connection times out, or the URL fails z.url() parsing in RequestPayloadSchema before reaching _fetchText (in which case parse throws first). The catch only fires for Error instances; the non-Error branch is error 301.
Common situations: Corporate or sandboxed environments where DNS does not resolve public hosts; endpoints behind auth that return 401/403; redirect loops on URL shorteners; TLS certificate problems on self-signed servers; offline development machines; mis-typed URLs that pass z.url() but point at nonexistent hosts.
Related errors
- DashScope API request timeout after ${timeout / 1000}s
- Rerank response results must contain numeric index and relev
- Failed to get gateway URL: HTTP ${response.status} - ${error
- Discord API error ${url}: HTTP ${response.status} - ${errorT
- Feishu WebSocket connection failed: ${error instanceof Error
AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12).
Data as JSON: /api/errors/a1601475361f285e.
Report an issue: GitHub.