From e7fd88be37167eeae727d5b518c0b1d6453d9252 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 19 May 2026 05:17:47 +0000 Subject: [PATCH] fix(producer): treat 4xx from Google Fonts as deterministic "not served", not as failClosed trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After c8e8fdcf added a Google Fonts supplement-fetch to the Path 1 (bundled-font) branch, every `plan()` call against a composition whose CSS named a non-Google family that Google Fonts 400s on (e.g. `"Segoe UI"`, `"Arial"`, `"Futura"`) started failing on distributed renders with `FONT_FETCH_FAILED`. Distributed renders default to `failClosedFontFetch: true`, and the existing code treated *all* non-2xx responses uniformly: throw if failClosed, swallow otherwise. That conflates two very different failure modes: - **4xx** is a *deterministic* answer — Google Fonts does not serve this family, and won't serve it on retry either. The byte-identical- retry contract distributed renders rely on is unaffected; the render falls back to embedded faces / the composition's font-family chain (which is what it would have done pre-c8e8fdcf anyway). No reason to fail-close here. - **5xx** (and network / DNS / fetch exceptions) is *non-deterministic* infrastructure failure. A retry might succeed and produce different pixel output than the first attempt — exactly what `failClosedFontFetch` is meant to protect against. Keep failing closed in this mode. Fix: split the !res.ok branch in both the CSS fetch and the woff2 fetch inside `fetchGoogleFont` — only `>= 500` paired with failClosed throws; 4xx returns `[]` in both modes. Network/DNS exceptions in the catch block are unchanged (still failClosed-gated). This: - Unblocks distributed renders for compositions that name any cross-alias system font Google doesn't serve (Segoe UI, Arial, etc.). - **Preserves the current regression baseline** — Google Fonts actually *does* serve some non-canonical names (e.g. "Helvetica" and "Helvetica Neue" both return HTTP 200 with real @font-face rules, confirmed via curl), so the supplement-fetch still runs and binds those real faces to the composition's CSS family names exactly as today. style-7-prod (which uses `"Helvetica Neue", Helvetica, Arial, sans-serif`) continues to render against real Helvetica glyphs. - Leaves the FONT_ALIASES table and call-site untouched. The fix is in the right place — the error semantics inside fetchGoogleFont — not in any composition-aware logic upstream. Tests: - 2 new positive cases on `failClosedFontFetch: true`: 400 and 404 responses no longer throw, render falls back cleanly. - 2 new negative cases on `failClosedFontFetch: true`: 503 throws `FONT_FETCH_FAILED`, error includes URL + family. - 1 new case on `failClosedFontFetch: false`: 5xx swallowed as before. - The pre-existing "does NOT throw when the HTML uses a pre-bundled font" test was broken by c8e8fdcf (the supplement-fetch always fires for self-aliased bundled fonts now). Updated it to use a successful empty CSS response, which is the actual invariant we want. 12/12 tests pass. Followup discussion: should `FONT_ALIASES` exist at all in a deterministic cloud renderer? Today `font-family: "Helvetica"` in CSS silently produces real Helvetica glyphs (via Google Fonts' undocumented alias serving) and `font-family: "Segoe UI"` silently produces embedded Roboto, with no warning to the author. That's a WYSIWYG violation worth its own proposal — but not in scope for this fix. --- .../deterministicFonts-failClosed.test.ts | 75 ++++++++++++++++--- .../src/services/deterministicFonts.ts | 20 ++++- 2 files changed, 79 insertions(+), 16 deletions(-) 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;