{"record":{"id":"24f7e92a1b98b00e","repo":"BloopAI/vibe-kanban","slug":"failed-to-download-attachment-24f7e9","errorCode":null,"errorMessage":"Failed to download attachment","messagePattern":"Failed to download attachment","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/ui/src/components/image-node.tsx","lineNumber":97,"sourceCode":"}\n\nfunction formatFileSize(bytes: bigint | number | null | undefined): string {\n  if (!bytes) return '';\n  const num = Number(bytes);\n  if (num < 1024) return `${num} B`;\n  if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KB`;\n  return `${(num / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nasync function downloadBlobUrl(url: string, filename: string): Promise<void> {\n  const response = await fetch(url, {\n    method: 'GET',\n    mode: 'cors',\n    credentials: 'omit',\n  });\n\n  if (!response.ok) {\n    throw new Error('Failed to download attachment');\n  }\n\n  const blob = await response.blob();\n  const objectUrl = URL.createObjectURL(blob);\n\n  try {\n    const anchor = document.createElement('a');\n    anchor.href = objectUrl;\n    anchor.download = filename;\n    document.body.appendChild(anchor);\n    anchor.click();\n    document.body.removeChild(anchor);\n  } finally {\n    URL.revokeObjectURL(objectUrl);\n  }\n}\n\nfunction toMetadataFromLocalImage(","sourceCodeStart":79,"sourceCodeEnd":115,"githubUrl":"https://github.com/BloopAI/vibe-kanban/blob/4deb7eca8f381f7cbc1f9d15515a9ab8f8009053/packages/ui/src/components/image-node.tsx#L79-L115","documentation":"downloadBlobUrl fetches an attachment image (GET, cors, credentials omitted) to build a downloadable blob URL. Any non-ok fetch response throws the opaque 'Failed to download attachment' — the message intentionally does not include status, so the real cause must come from the network tab. Called from handleDownload in the image node UI.","triggerScenarios":"Non-ok response downloading the attachment blob: 404 (blob expired or deleted), 403 (SAS URL expired between render and download), CORS misconfiguration, or network failure to the storage host.","commonSituations":"User waits on a page long enough for the SAS URL to expire, then clicks download; CDN/storage returning 403 due to missing CORS headers on a custom domain; attachments hosted on a different origin than the app.","solutions":["Check the network tab for the true status code (404 vs 403)","Re-fetch a fresh SAS URL before downloading instead of reusing a cached one","For CORS errors, add the storage/CDN origin's allowed origins (Access-Control-Allow-Origin) on the server","Retry once with a fresh URL on failure; show a user-facing download-failed toast otherwise"],"exampleFix":"// before\nonClick={handleDownload}\n// after\nconst handleDownload = async () => {\n  try { await downloadBlobUrl(url, name); }\n  catch { notify.error('Download failed — link may have expired, retrying…');\n          await downloadBlobUrl(await refreshSasUrl(id), name); }\n};","handlingStrategy":"try-catch","validationCode":"new URL(url); // throws on malformed attachment URL before fetch\nif (!url.startsWith('http')) throw new Error('invalid attachment url');","typeGuard":"function isHttpUrl(s: string): boolean { try { const u = new URL(s); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; } }","tryCatchPattern":"try {\n  await downloadBlobUrl(url, filename);\n} catch {\n  notify.error('Download failed. The link may have expired — refresh and try again.');\n}","preventionTips":["Refresh the SAS URL immediately before download instead of reusing a cached one","Configure CORS on the blob/CDN host for the app's origins","Show disabled download buttons for attachments known to be expired/purged","Never assume statusText is available — check e.message plus the network tab"],"tags":["download","blob","cors","network"],"backgroundTag":"blob-download-failed","analyzedSha":"4deb7eca8f381f7cbc1f9d15515a9ab8f8009053","analyzedAt":"2026-08-29T09:24:13.446Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}