{"record":{"id":"9471f58485add5d0","repo":"danny-avila/LibreChat","slug":"failed-to-fetch-image-from-url-status-response","errorCode":null,"errorMessage":"Failed to fetch image from URL. Status: ${response.status}","messagePattern":"Failed to fetch image from URL\\. Status: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"api/server/services/Files/images/avatar.js","lineNumber":55,"sourceCode":"    throw new Error(`Refusing to fetch avatar over ${parsed.protocol}`);\n  }\n\n  const { httpAgent, httpsAgent } = createSSRFSafeAgents();\n  /**\n   * `node-fetch` v2's `timeout` is the total request budget (request initiation\n   * through full body receipt), not a TCP-connect-only timeout. That is the\n   * stronger of the two for this path — bounds total slow-loris exposure.\n   */\n  const response = await fetch(parsed.href, {\n    headers: fetchOptions.headers,\n    agent: (urlObj) => (urlObj.protocol === 'https:' ? httpsAgent : httpAgent),\n    redirect: 'error',\n    timeout: 5000,\n    size: MAX_AVATAR_BYTES,\n  });\n\n  if (!response.ok) {\n    throw new Error(`Failed to fetch image from URL. Status: ${response.status}`);\n  }\n\n  const contentLength = parseInt(response.headers.get('content-length') ?? '0', 10);\n  if (contentLength > MAX_AVATAR_BYTES) {\n    throw new Error(`Avatar response too large: ${contentLength} bytes`);\n  }\n\n  /**\n   * Re-check after read in case the server lied about Content-Length or\n   * omitted it. `node-fetch` v2 honors the `size` option above and throws on\n   * overflow, but Defense-in-depth: assert on the actual buffer length.\n   */\n  const buffer = await response.buffer();\n  if (buffer.length > MAX_AVATAR_BYTES) {\n    throw new Error(`Avatar response too large: ${buffer.length} bytes`);\n  }\n  return buffer;\n}","sourceCodeStart":37,"sourceCodeEnd":73,"githubUrl":"https://github.com/danny-avila/LibreChat/blob/5ff282f9006c436e561de1afd39a481bea1ef0d8/api/server/services/Files/images/avatar.js#L37-L73","documentation":"Thrown after the fetch completes when `response.ok` is false — i.e. the remote avatar server returned a non-2xx HTTP status. This is a downstream HTTP error: the URL was reachable, the protocol was allowed, SSRF agents connected, but the origin answered with an error code (404, 403, 500, etc.). The status code is interpolated into the message for diagnostics.","triggerScenarios":"An avatar URL that resolves and connects but the origin returns 4xx/5xx: deleted profile photo (404), hotlink-protected image (403), expired signed URL (403/410), or upstream outage (5xx). Also a temporarily rate-limited CDN (429).","commonSituations":"Social provider avatar links that expire or get deleted after the user changed their profile picture; corporate CDN with Referer/Origin hotlink protection; rate-limited Gravatar/CDN endpoints under load; expired S3 presigned URLs stored as the avatar.","solutions":["Verify the URL returns 2xx with a direct `curl -I <url>` from the server host.","If the link is stale, refresh the user's `picture` from the OAuth provider at next login.","For hotlink-protected origins, mirror the avatar to your own storage on first successful fetch instead of hotlinking on every render.","Retry transient 5xx/429 once with backoff in the caller before surfacing failure to the user."],"exampleFix":"// before: no retry, surfaces first 5xx\nconst buf = await fetchAvatarBuffer(url);\n\n// after: one retry on transient failure\nlet buf, lastErr;\nfor (let attempt = 0; attempt < 2; attempt++) {\n  try { buf = await fetchAvatarBuffer(url); break; }\n  catch (e) {\n    lastErr = e;\n    if (!/Status: (5\\d\\d|429)/.test(e.message)) throw e;\n    await new Promise(r => setTimeout(r, 500 * (attempt + 1)));\n  }\n}\nif (!buf) throw lastErr;","handlingStrategy":"retry","validationCode":"// Pre-flight HEAD request (origin must support HEAD)\nasync function checkAvatarUrl(url) {\n  const res = await fetch(url, { method: 'HEAD', timeout: 5000 });\n  if (!res.ok) throw new Error(`Avatar endpoint returned ${res.status}`);\n  return res;\n}","typeGuard":null,"tryCatchPattern":"async function fetchWithRetry(url, attempts = 2) {\n  let last;\n  for (let i = 0; i < attempts; i++) {\n    try { return await fetchAvatarBuffer(url); }\n    catch (e) {\n      last = e;\n      const transient = /Status: (5\\d\\d|429)/.test(e.message);\n      if (!transient || i === attempts - 1) throw e;\n      await new Promise(r => setTimeout(r, 500 * (i + 1)));\n    }\n  }\n  throw last;\n}","preventionTips":["Mirror remote avatars to your own storage on first successful fetch to avoid repeated hotlinking.","Refresh OAuth provider `picture` URLs at login — they expire.","Don't store signed URLs with short TTLs as the user's avatar."],"tags":["network","avatar","http-status","upstream"],"backgroundTag":null,"analyzedSha":"5ff282f9006c436e561de1afd39a481bea1ef0d8","analyzedAt":"2026-08-12T21:38:08.145Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}