fix(producer): treat 4xx from Google Fonts as deterministic "not served", not as failClosed trigger (#957)

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.
This commit is contained in:
James Russo
2026-05-19 03:09:16 -04:00
committed by GitHub
parent 21f5066832
commit c336508d4e
2 changed files with 79 additions and 16 deletions
@@ -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 <html><head> 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 <html><head> wrap is required because
// injectDeterministicFontFaces injects into <head>.)
const html = `<!doctype html><html><head><style>body { font-family: "Inter", sans-serif; }</style></head><body></body></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");
});
@@ -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;