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

  1. 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.
  2. Check whether the host is in any allowlist or DNS-resolution path the tool requires; model-supplied URLs are only honored when DNS validates.
  3. If the endpoint redirects, confirm the chain length is <= 5 or raise maxRedirects in the fetchRemoteText call.
  4. 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

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


AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12). Data as JSON: /api/errors/a1601475361f285e. Report an issue: GitHub.