mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(producer): treat 4xx from Google Fonts as deterministic "not served", not as failClosed trigger (#957)
Afterc8e8fdcfadded 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 byc8e8fdcf(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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user