diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 02377a46b..171734028 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -76,6 +76,26 @@ describe("web media loading", () => { fetchMock.mockRestore(); }); + it("includes URL + status in fetch errors", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + body: true, + text: async () => "Not Found", + headers: { get: () => null }, + status: 404, + statusText: "Not Found", + url: "https://example.com/missing.jpg", + } as Response); + + await expect( + loadWebMedia("https://example.com/missing.jpg", 1024 * 1024), + ).rejects.toThrow( + /Failed to fetch media from https:\/\/example\.com\/missing\.jpg.*HTTP 404/i, + ); + + fetchMock.mockRestore(); + }); + it("uses content-disposition filename when available", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, diff --git a/src/web/media.ts b/src/web/media.ts index e1ba089e6..c0f9de41a 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -45,6 +45,22 @@ function parseContentDispositionFileName( return undefined; } +async function readErrorBodySnippet( + res: Response, + maxChars = 200, +): Promise { + try { + const text = await res.text(); + if (!text) return undefined; + const collapsed = text.replace(/\s+/g, " ").trim(); + if (!collapsed) return undefined; + if (collapsed.length <= maxChars) return collapsed; + return `${collapsed.slice(0, maxChars)}…`; + } catch { + return undefined; + } +} + async function loadWebMediaInternal( mediaUrl: string, options: WebMediaOptions = {}, @@ -85,9 +101,26 @@ async function loadWebMediaInternal( } catch { // ignore parse errors; leave undefined } - const res = await fetch(mediaUrl); + let res: Response; + try { + res = await fetch(mediaUrl); + } catch (err) { + throw new Error(`Failed to fetch media from ${mediaUrl}: ${String(err)}`); + } if (!res.ok || !res.body) { - throw new Error(`Failed to fetch media: HTTP ${res.status}`); + const statusText = res.statusText ? ` ${res.statusText}` : ""; + const redirected = + res.url && res.url !== mediaUrl ? ` (redirected to ${res.url})` : ""; + let detail = `HTTP ${res.status}${statusText}`; + if (!res.body) { + detail = `HTTP ${res.status}${statusText}; empty response body`; + } else if (!res.ok) { + const snippet = await readErrorBodySnippet(res); + if (snippet) detail += `; body: ${snippet}`; + } + throw new Error( + `Failed to fetch media from ${mediaUrl}${redirected}: ${detail}`, + ); } const array = Buffer.from(await res.arrayBuffer()); const headerFileName = parseContentDispositionFileName(