diff --git a/packages/producer/src/services/deterministicFonts-failClosed.test.ts b/packages/producer/src/services/deterministicFonts-failClosed.test.ts index 2b1080044..65ffb9935 100644 --- a/packages/producer/src/services/deterministicFonts-failClosed.test.ts +++ b/packages/producer/src/services/deterministicFonts-failClosed.test.ts @@ -4,8 +4,12 @@ * Production callers (the in-process `htmlCompiler`) call the function * without options and get the legacy behavior: external font fetch failures * are swallowed and a warning is logged. Distributed callers pass - * `failClosedFontFetch: true` so missing fonts surface as typed - * non-retryable failures before any chunk is rendered. + * `failClosedFontFetch: true` so non-deterministic infrastructure failures + * (5xx, network errors, DNS) surface as typed non-retryable failures before + * any chunk is rendered. 4xx responses are treated as a *deterministic* + * "Google Fonts does not serve this family" answer — same outcome on every + * retry — and pass through to the embedded-face fallback without + * tripping `failClosedFontFetch` in either mode. * * The tests inject `fetchImpl` so no real network call happens. */ @@ -38,6 +42,19 @@ function makeHttp404Fetch(): typeof fetch { new Response("", { status: 404, statusText: "Not Found" })) as unknown as typeof fetch; } +function makeHttp400Fetch(): typeof fetch { + return (async () => + new Response("", { status: 400, statusText: "Bad Request" })) as unknown as typeof fetch; +} + +function makeHttp503Fetch(): typeof fetch { + return (async () => + new Response("", { + status: 503, + statusText: "Service Unavailable", + })) as unknown as typeof fetch; +} + describe("injectDeterministicFontFaces — failClosedFontFetch: false (default)", () => { it("swallows a network failure and returns the original HTML (no throw)", async () => { const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, { @@ -57,6 +74,14 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: false (default)" expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false); }); + it("swallows a 5xx response and returns the original HTML (no throw)", async () => { + const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, { + failClosedFontFetch: false, + fetchImpl: makeHttp503Fetch(), + }); + expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false); + }); + it("preserves legacy behavior when no options object is supplied at all", async () => { // injectDeterministicFontFaces(html) — no second arg. // We can't easily mock fetch globally here, so just assert the call @@ -84,28 +109,50 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => { expect((caught as Error).message).toContain("simulated network failure"); }); - it("throws FontFetchError on a 404 response", async () => { + it("does NOT throw on a 4xx response — 4xx means 'Google Fonts does not serve this family', a deterministic answer", async () => { + // Google Fonts returns HTTP 400 for non-Google families like + // "Segoe UI", "Arial", "Futura". Same response on every retry, so it + // doesn't violate the byte-identical-retry contract — the render + // falls back to embedded faces / the composition's font-family chain. + // No FONT_FETCH_FAILED. + const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, { + failClosedFontFetch: true, + fetchImpl: makeHttp400Fetch(), + }); + // No @font-face was injected (no faces returned), but the call resolves. + expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false); + }); + + it("does NOT throw on a 404 response either — same reasoning as 4xx generally", async () => { + const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, { + failClosedFontFetch: true, + fetchImpl: makeHttp404Fetch(), + }); + expect(result.includes("data-hyperframes-deterministic-fonts")).toBe(false); + }); + + it("throws FontFetchError on a 5xx response — non-deterministic, could differ on retry", async () => { let caught: unknown; try { await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, { failClosedFontFetch: true, - fetchImpl: makeHttp404Fetch(), + fetchImpl: makeHttp503Fetch(), }); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(FontFetchError); expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED); - expect((caught as Error).message).toContain("HTTP 404"); + expect((caught as Error).message).toContain("HTTP 503"); expect((caught as Error).message).toContain("NotARealFontFamilyForTest"); }); - it("includes the requested URL in the error", async () => { + it("includes the requested URL in 5xx errors", async () => { let caught: unknown; try { await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, { failClosedFontFetch: true, - fetchImpl: makeHttp404Fetch(), + fetchImpl: makeHttp503Fetch(), }); } catch (err) { caught = err; @@ -114,15 +161,19 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => { expect((caught as FontFetchError).url).toContain("NotARealFontFamilyForTest"); }); - it("does NOT throw when the HTML uses a pre-bundled font (no fetch happens)", async () => { - // "Inter" is in FONT_ALIASES → uses bundled font data, never reaches - // the Google Fonts path → failClosedFontFetch=true is irrelevant here - // and shouldn't trip. (The full
wrap is required because + it("does NOT throw when bundled-font Google Fonts supplement returns no extra faces", async () => { + // "Inter" is in FONT_ALIASES → uses the embedded font bundle. Since + // c8e8fdcf, the resolver also queries Google Fonts to supplement any + // weights not in the embedded bundle. A successful empty CSS response + // means the bundle was sufficient — `failClosedFontFetch` doesn't + // trip. (The full wrap is required because // injectDeterministicFontFaces injects into .) const html = ``; + const fetchImpl = (async () => + new Response("/* no faces */", { status: 200 })) as unknown as typeof fetch; const result = await injectDeterministicFontFaces(html, { failClosedFontFetch: true, - fetchImpl: makeFailingFetch(), + fetchImpl, }); expect(result).toContain("data-hyperframes-deterministic-fonts"); }); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index e1b5cd27f..d96c7060e 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -430,15 +430,24 @@ async function fetchGoogleFont( headers: { "User-Agent": WOFF2_USER_AGENT }, }); if (!res.ok) { - if (options.failClosedFontFetch) { + // 4xx is a *deterministic* answer from Google Fonts that this + // family is not served (e.g. HTTP 400 for "Segoe UI", "Arial", + // "Futura" — names absent from Google's catalog) or is misnamed. + // The render falls back to embedded faces / the composition's + // font-family chain; we return [] in both modes. 5xx (and other + // transient upstream failures) could return faces on retry, which + // would break the byte-identical-retry contract distributed + // renders rely on — those still fail closed when requested. + if (res.status >= 500 && options.failClosedFontFetch) { throw fontFetchError(familyName, url, "Google Fonts CSS", { status: res.status }); } return []; } cssText = await res.text(); } catch (err) { - // Rethrow typed error untouched. Other errors (network, DNS) get wrapped - // when failClosed is on, swallowed otherwise. + // Rethrow typed error untouched. Network / DNS / fetch-throws are + // non-deterministic infrastructure failures — wrapped when failClosed + // is on, swallowed otherwise. if (err instanceof FontFetchError) throw err; if (options.failClosedFontFetch) { throw fontFetchError(familyName, url, "Google Fonts CSS", { error: err }); @@ -467,7 +476,10 @@ async function fetchGoogleFont( try { const fontRes = await options.fetchImpl(woff2Url); if (!fontRes.ok) { - if (options.failClosedFontFetch) { + // Same 4xx vs 5xx split as the CSS fetch above: 4xx = the + // font's woff2 isn't served, skip silently; 5xx = transient + // upstream failure, may fail closed. + if (fontRes.status >= 500 && options.failClosedFontFetch) { throw fontFetchError(familyName, woff2Url, woff2What, { status: fontRes.status }); } continue;