fix(producer): retry transient deterministic font fetches (#2865)

* fix(producer): retry transient deterministic font fetches

* fix(producer): secure Lambda font cache directory
This commit is contained in:
James Russo
2026-07-28 22:49:03 -07:00
committed by GitHub
parent 675afc5654
commit 20f4f8f49c
13 changed files with 802 additions and 92 deletions
@@ -182,6 +182,7 @@ describe("HyperframesRenderStack — snapshot", () => {
present: true,
});
}
expect(collected.has("FONT_FETCH_UNAVAILABLE")).toBe(false);
});
it("classifies plan v2 integrity failures as terminal in every v2 Lambda task", () => {
+2
View File
@@ -249,6 +249,8 @@ describe("handler dispatch", () => {
"PLAN_TOO_LARGE",
"PLAN_PROTOCOL_UNSUPPORTED",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"FONT_FETCH_FAILED",
"FONT_FETCH_UNAVAILABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
"INVALID_VIDEO_METADATA",
+2
View File
@@ -150,6 +150,8 @@ function normalizeTerminalErrorName(error: unknown): void {
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
candidate.code === "PLAN_TOO_LARGE" ||
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
candidate.code === "FONT_FETCH_FAILED" ||
candidate.code === "FONT_FETCH_UNAVAILABLE" ||
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
candidate.code === "INVALID_VIDEO_METADATA"
+3 -1
View File
@@ -578,7 +578,9 @@ describe("createApp HTTP mapping", () => {
it.each([
["VIDEO_SOURCE_UNRENDERABLE", 400],
["VIDEO_EXTRACTION_FAILED", 500],
] as const)("routes producer video code %s with HTTP %s", async (code, expectedStatus) => {
["FONT_FETCH_FAILED", 400],
["FONT_FETCH_UNAVAILABLE", 500],
] as const)("routes producer code %s with HTTP %s", async (code, expectedStatus) => {
const gcs = new FakeGcs();
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
const app = createApp(
+2
View File
@@ -183,6 +183,8 @@ function normalizeTerminalErrorName(error: unknown): void {
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
candidate.code === "PLAN_TOO_LARGE" ||
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
candidate.code === "FONT_FETCH_FAILED" ||
candidate.code === "FONT_FETCH_UNAVAILABLE" ||
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
candidate.code === "INVALID_VIDEO_METADATA"
+6
View File
@@ -114,7 +114,13 @@ export { normalizeErrorMessage } from "./utils/errorMessage.js";
// timing. The render pipeline runs this in its compile stage; the CLI audit
// paths (snapshot/check) reuse it so their captures match the render.
export {
FONT_FETCH_FAILED,
FONT_FETCH_UNAVAILABLE,
FontFetchError,
FontFetchUnavailableError,
injectDeterministicFontFaces,
type FontFetchErrorCode,
type FontFetchRetryPolicy,
type InjectDeterministicFontFacesOptions,
} from "./services/deterministicFonts.js";
export { quantizeTimeToFrame } from "./utils/parityContract.js";
@@ -17,7 +17,9 @@
import { describe, expect, it } from "bun:test";
import {
FONT_FETCH_FAILED,
FONT_FETCH_UNAVAILABLE,
FontFetchError,
FontFetchUnavailableError,
injectDeterministicFontFaces,
} from "./deterministicFonts.js";
@@ -75,6 +77,15 @@ function makeGoogleFontFetch(cssRequests: string[]): typeof fetch {
}) as unknown as typeof fetch;
}
async function rejectedError(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (error) {
return error;
}
throw new Error("Expected promise to reject");
}
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, {
@@ -141,19 +152,18 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
});
}
it("throws FontFetchError on a network failure", async () => {
let caught: unknown;
try {
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
it("throws FontFetchUnavailableError after retrying a network failure", async () => {
const caught = await rejectedError(
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
failClosedFontFetch: true,
fetchImpl: makeFailingFetch(),
});
} catch (err) {
caught = err;
}
fontFetchRetryPolicy: { baseDelayMs: 0 },
}),
);
expect(caught).toBeInstanceOf(FontFetchError);
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
expect((caught as FontFetchError).code).toBe("FONT_FETCH_FAILED");
expect(caught).toBeInstanceOf(FontFetchUnavailableError);
expect((caught as FontFetchError).code).toBe(FONT_FETCH_UNAVAILABLE);
expect((caught as FontFetchError).code).toBe("FONT_FETCH_UNAVAILABLE");
expect((caught as FontFetchError).familyName).toBe("NotARealFontFamilyForTest");
expect((caught as Error).message).toContain("simulated network failure");
});
@@ -164,62 +174,53 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
// unresolvable (no alias, no Google Fonts, no system capture) which IS
// a fail-closed error — the render would use a fallback font, producing
// non-deterministic output across machines.
let caught: unknown;
try {
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
const caught = await rejectedError(
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeHttp400Fetch(),
});
} catch (err) {
caught = err;
}
}),
);
expect(caught).toBeInstanceOf(FontFetchError);
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
expect((caught as FontFetchError).familyName).toBe("NotARealFontFamilyForTest");
});
it("throws on a 404 response when font is completely unresolvable", async () => {
let caught: unknown;
try {
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
const caught = await rejectedError(
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeHttp404Fetch(),
});
} catch (err) {
caught = err;
}
}),
);
expect(caught).toBeInstanceOf(FontFetchError);
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
});
it("throws FontFetchError on a 5xx response — non-deterministic, could differ on retry", async () => {
let caught: unknown;
try {
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
it("throws FontFetchUnavailableError on an exhausted 5xx response", async () => {
const caught = await rejectedError(
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
failClosedFontFetch: true,
fetchImpl: makeHttp503Fetch(),
});
} catch (err) {
caught = err;
}
fontFetchRetryPolicy: { baseDelayMs: 0 },
}),
);
expect(caught).toBeInstanceOf(FontFetchError);
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
expect(caught).toBeInstanceOf(FontFetchUnavailableError);
expect((caught as FontFetchError).code).toBe(FONT_FETCH_UNAVAILABLE);
expect((caught as Error).message).toContain("HTTP 503");
expect((caught as Error).message).toContain("NotARealFontFamilyForTest");
});
it("includes the requested URL in 5xx errors", async () => {
let caught: unknown;
try {
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
const caught = await rejectedError(
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
failClosedFontFetch: true,
fetchImpl: makeHttp503Fetch(),
});
} catch (err) {
caught = err;
}
fontFetchRetryPolicy: { baseDelayMs: 0 },
}),
);
expect((caught as FontFetchError).url).toContain("fonts.googleapis.com");
expect((caught as FontFetchError).url).toContain("NotARealFontFamilyForTest");
});
@@ -0,0 +1,417 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
FONT_FETCH_FAILED,
FONT_FETCH_UNAVAILABLE,
FontFetchError,
FontFetchUnavailableError,
injectDeterministicFontFaces,
type FontFetchRetryPolicy,
} from "./deterministicFonts.js";
let previousCacheDir: string | undefined;
let testCacheDir: string;
beforeAll(() => {
previousCacheDir = process.env.HYPERFRAMES_FONT_CACHE_DIR;
testCacheDir = mkdtempSync(join(tmpdir(), "hf-font-retries-"));
process.env.HYPERFRAMES_FONT_CACHE_DIR = testCacheDir;
});
afterAll(() => {
if (previousCacheDir === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR;
else process.env.HYPERFRAMES_FONT_CACHE_DIR = previousCacheDir;
rmSync(testCacheDir, { recursive: true, force: true });
});
const FAST_RETRY = {
maxAttempts: 2,
attemptTimeoutMs: 20,
maxElapsedMs: 100,
baseDelayMs: 0,
} as const;
let familySequence = 0;
function unresolvedHtml(): { family: string; html: string } {
familySequence += 1;
const family = `RetryTestFont${process.pid}${familySequence}`;
return {
family,
html: `<!doctype html><html><head><style>
body { font-family: "${family}", sans-serif; }
</style></head><body>retry</body></html>`,
};
}
function googleCss(woff2Url: string): Response {
return new Response(
`@font-face {
font-style: normal;
font-weight: 400;
src: url(${woff2Url}) format('woff2');
}`,
{ status: 200 },
);
}
function requestUrl(input: string | URL | Request): string {
return input instanceof Request ? input.url : String(input);
}
function injectWithRetries(
html: string,
fetchImpl: typeof fetch,
fontFetchRetryPolicy: Partial<FontFetchRetryPolicy> = FAST_RETRY,
): Promise<string> {
return injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy,
});
}
describe("deterministic Google Fonts retries", () => {
for (const status of [408, 425, 429, 500, 502, 503]) {
it(`retries HTTP ${status} exactly once, then reports temporary unavailability`, async () => {
const { html } = unresolvedHtml();
let calls = 0;
const fetchImpl = (async () => {
calls += 1;
return new Response("", {
status,
headers: status === 429 ? { "Retry-After": "0" } : undefined,
});
}) as typeof fetch;
const result = injectWithRetries(html, fetchImpl);
await expect(result).rejects.toMatchObject({
code: FONT_FETCH_UNAVAILABLE,
name: "FontFetchUnavailableError",
});
expect(calls).toBe(2);
});
}
it("recovers when the Google Fonts CSS retry succeeds", async () => {
const { html } = unresolvedHtml();
const woff2Url = `https://fonts.gstatic.com/s/retry/${process.pid}-css.woff2`;
let cssCalls = 0;
let woff2Calls = 0;
const fetchImpl = (async (input: string | URL | Request) => {
if (requestUrl(input).startsWith("https://fonts.googleapis.com/")) {
cssCalls += 1;
return cssCalls === 1 ? new Response("", { status: 502 }) : googleCss(woff2Url);
}
woff2Calls += 1;
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
}) as typeof fetch;
const result = await injectWithRetries(html, fetchImpl);
expect(result).toContain("data-hyperframes-deterministic-fonts");
expect(cssCalls).toBe(2);
expect(woff2Calls).toBe(1);
});
it("does not wait for a stalled retryable response-body cancellation", async () => {
const { html } = unresolvedHtml();
const woff2Url = `https://fonts.gstatic.com/s/retry/${process.pid}-cancel.woff2`;
let cssCalls = 0;
let cancelledBodies = 0;
const fetchImpl = (async (input: string | URL | Request) => {
if (!requestUrl(input).startsWith("https://fonts.googleapis.com/")) {
return new Response(new Uint8Array([10, 11, 12]), { status: 200 });
}
cssCalls += 1;
if (cssCalls === 1) {
return new Response(
new ReadableStream({
cancel() {
cancelledBodies += 1;
return new Promise<void>(() => {});
},
}),
{ status: 502 },
);
}
return googleCss(woff2Url);
}) as typeof fetch;
const result = await injectWithRetries(html, fetchImpl);
expect(result).toContain("data-hyperframes-deterministic-fonts");
expect(cssCalls).toBe(2);
expect(cancelledBodies).toBe(1);
});
it("recovers when a woff2 retry succeeds", async () => {
const { html } = unresolvedHtml();
const woff2Url = `https://fonts.gstatic.com/s/retry/${process.pid}-woff2.woff2`;
let cssCalls = 0;
let woff2Calls = 0;
const fetchImpl = (async (input: string | URL | Request) => {
if (requestUrl(input).startsWith("https://fonts.googleapis.com/")) {
cssCalls += 1;
return googleCss(woff2Url);
}
woff2Calls += 1;
return woff2Calls === 1
? new Response("", { status: 503 })
: new Response(new Uint8Array([4, 5, 6]), { status: 200 });
}) as typeof fetch;
const result = await injectWithRetries(html, fetchImpl);
expect(result).toContain("data-hyperframes-deterministic-fonts");
expect(cssCalls).toBe(1);
expect(woff2Calls).toBe(2);
});
it("reports temporary unavailability when woff2 retries are exhausted", async () => {
const { html } = unresolvedHtml();
const woff2Url = `https://fonts.gstatic.com/s/retry/${process.pid}-woff2-exhausted.woff2`;
let cssCalls = 0;
let woff2Calls = 0;
const fetchImpl = (async (input: string | URL | Request) => {
if (requestUrl(input).startsWith("https://fonts.googleapis.com/")) {
cssCalls += 1;
return googleCss(woff2Url);
}
woff2Calls += 1;
return new Response("", { status: 502 });
}) as typeof fetch;
const result = injectWithRetries(html, fetchImpl);
await expect(result).rejects.toMatchObject({
code: FONT_FETCH_UNAVAILABLE,
url: woff2Url,
});
expect(cssCalls).toBe(1);
expect(woff2Calls).toBe(2);
});
it("retries transport exceptions and preserves the final cause", async () => {
const { html } = unresolvedHtml();
let calls = 0;
const fetchImpl = (async () => {
calls += 1;
throw new TypeError(`network failure ${calls}`);
}) as typeof fetch;
let caught: unknown;
try {
await injectWithRetries(html, fetchImpl);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(FontFetchUnavailableError);
expect(caught).toBeInstanceOf(FontFetchError);
expect((caught as FontFetchUnavailableError).code).toBe(FONT_FETCH_UNAVAILABLE);
expect((caught as Error).message).toContain("network failure 2");
expect(calls).toBe(2);
});
it("retries a response-body transport failure", async () => {
const { html } = unresolvedHtml();
const woff2Url = `https://fonts.gstatic.com/s/retry/${process.pid}-body.woff2`;
let cssCalls = 0;
const fetchImpl = (async (input: string | URL | Request) => {
if (!requestUrl(input).startsWith("https://fonts.googleapis.com/")) {
return new Response(new Uint8Array([7, 8, 9]), { status: 200 });
}
cssCalls += 1;
if (cssCalls === 1) {
return new Response(
new ReadableStream({
start(controller) {
controller.error(new TypeError("CSS body connection reset"));
},
}),
{ status: 200 },
);
}
return googleCss(woff2Url);
}) as typeof fetch;
const result = await injectWithRetries(html, fetchImpl);
expect(result).toContain("data-hyperframes-deterministic-fonts");
expect(cssCalls).toBe(2);
});
it("times out each hung attempt and reports temporary unavailability", async () => {
const { html } = unresolvedHtml();
let calls = 0;
const fetchImpl = ((_input: string | URL | Request, init?: RequestInit) => {
calls += 1;
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
reject(new Error("missing timeout signal"));
return;
}
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
}) as typeof fetch;
const result = injectWithRetries(html, fetchImpl, {
...FAST_RETRY,
attemptTimeoutMs: 5,
maxElapsedMs: 50,
});
await expect(result).rejects.toMatchObject({ code: FONT_FETCH_UNAVAILABLE });
expect(calls).toBe(2);
});
it("does not exceed the shared budget to honor a long Retry-After", async () => {
const { html } = unresolvedHtml();
let calls = 0;
const fetchImpl = (async () => {
calls += 1;
return new Response("", {
status: 429,
headers: { "Retry-After": "1" },
});
}) as typeof fetch;
const result = injectWithRetries(html, fetchImpl, {
...FAST_RETRY,
maxElapsedMs: 100,
});
await expect(result).rejects.toMatchObject({ code: FONT_FETCH_UNAVAILABLE });
expect(calls).toBe(1);
});
it("shares one elapsed-time budget across multiple font resources", async () => {
const { html } = unresolvedHtml();
const firstUrl = `https://fonts.gstatic.com/s/retry/${process.pid}-budget-first.woff2`;
const secondUrl = `https://fonts.gstatic.com/s/retry/${process.pid}-budget-second.woff2`;
let firstCalls = 0;
let secondCalls = 0;
const fetchImpl = ((input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = requestUrl(input);
if (url.startsWith("https://fonts.googleapis.com/")) {
return Promise.resolve(
new Response(
`@font-face {
font-style: normal;
font-weight: 400;
src: url(${firstUrl}) format('woff2');
}
@font-face {
font-style: normal;
font-weight: 700;
src: url(${secondUrl}) format('woff2');
}`,
{ status: 200 },
),
);
}
if (url === firstUrl) {
firstCalls += 1;
return new Promise<Response>((resolve, reject) => {
const timeout = setTimeout(
() => resolve(new Response(new Uint8Array([13, 14, 15]), { status: 200 })),
30,
);
init?.signal?.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(init.signal?.reason);
},
{ once: true },
);
});
}
secondCalls += 1;
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
once: true,
});
});
}) as typeof fetch;
const result = injectWithRetries(html, fetchImpl, {
maxAttempts: 2,
attemptTimeoutMs: 70,
maxElapsedMs: 100,
baseDelayMs: 0,
});
await expect(result).rejects.toMatchObject({ code: FONT_FETCH_UNAVAILABLE });
expect(firstCalls).toBe(1);
expect(secondCalls).toBe(1);
});
it("does not retry an ordinary 4xx and retains deterministic failure classification", async () => {
const { html } = unresolvedHtml();
let calls = 0;
const fetchImpl = (async () => {
calls += 1;
return new Response("", { status: 400 });
}) as typeof fetch;
const result = injectWithRetries(html, fetchImpl);
await expect(result).rejects.toMatchObject({
code: FONT_FETCH_FAILED,
name: "FontFetchError",
});
expect(calls).toBe(1);
});
it("propagates caller cancellation without wrapping or retrying it", async () => {
const { html } = unresolvedHtml();
const controller = new AbortController();
const cancellation = new Error("render cancelled by caller");
let calls = 0;
const fetchImpl = ((_input: string | URL | Request, init?: RequestInit) => {
calls += 1;
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
});
}) as typeof fetch;
const result = injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
abortSignal: controller.signal,
fontFetchRetryPolicy: FAST_RETRY,
});
controller.abort(cancellation);
await expect(result).rejects.toBe(cancellation);
expect(calls).toBe(1);
});
it("keeps fail-open mode at one attempt with no typed failure", async () => {
const { html } = unresolvedHtml();
let calls = 0;
const fetchImpl = (async () => {
calls += 1;
return new Response("", { status: 503 });
}) as typeof fetch;
const result = await injectDeterministicFontFaces(html, {
failClosedFontFetch: false,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy: FAST_RETRY,
});
expect(result).not.toContain("data-hyperframes-deterministic-fonts");
expect(calls).toBe(1);
});
});
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { defaultLogger } from "../logger.js";
@@ -581,19 +581,22 @@ function warnUnresolvedFonts(unresolved: string[]): void {
// Google Fonts on-demand fetch + local cache
// ---------------------------------------------------------------------------
// On AWS Lambda `$HOME` resolves to a `/home/sbx_*` tree that's
// read-only; only `/tmp` is writable. Route the cache there when
// running inside Lambda, and honor `HYPERFRAMES_FONT_CACHE_DIR` as
// an explicit override for any environment.
let lambdaFontCacheRoot: string | undefined;
// On AWS Lambda `$HOME` resolves to a `/home/sbx_*` tree that's read-only;
// only `/tmp` is writable. Create one private, unguessable cache directory per
// warm process and reuse it across invocations. Honor HYPERFRAMES_FONT_CACHE_DIR
// as an explicit override for any environment.
function resolveFontCacheRoot(): string {
return (
process.env.HYPERFRAMES_FONT_CACHE_DIR ??
(process.env.AWS_LAMBDA_FUNCTION_NAME
? join(tmpdir(), "hyperframes", "fonts")
: join(homedir(), ".cache", "hyperframes", "fonts"))
);
if (process.env.HYPERFRAMES_FONT_CACHE_DIR) {
return process.env.HYPERFRAMES_FONT_CACHE_DIR;
}
if (process.env.AWS_LAMBDA_FUNCTION_NAME) {
lambdaFontCacheRoot ??= mkdtempSync(join(tmpdir(), "hyperframes-fonts-"));
return lambdaFontCacheRoot;
}
return join(homedir(), ".cache", "hyperframes", "fonts");
}
const GOOGLE_FONTS_CACHE_DIR = resolveFontCacheRoot();
// Chrome UA triggers woff2 responses from Google Fonts CSS API
const WOFF2_USER_AGENT =
@@ -607,7 +610,7 @@ function fontSlug(familyName: string): string {
}
function fontCacheDir(slug: string): string {
const dir = join(GOOGLE_FONTS_CACHE_DIR, slug);
const dir = join(resolveFontCacheRoot(), slug);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
@@ -637,37 +640,77 @@ type GoogleFontFace = {
};
/**
* Typed code classifying a font-fetch failure as non-retryable for
* distributed workflow adapters a missing Google Fonts entry will not heal
* on retry.
* Typed codes let distributed workflow adapters distinguish deterministic
* resolution failures from temporary upstream unavailability.
*/
export const FONT_FETCH_FAILED = "FONT_FETCH_FAILED";
export const FONT_FETCH_UNAVAILABLE = "FONT_FETCH_UNAVAILABLE";
export type FontFetchErrorCode = typeof FONT_FETCH_FAILED | typeof FONT_FETCH_UNAVAILABLE;
/**
* Typed error thrown by {@link injectDeterministicFontFaces} when
* `failClosedFontFetch === true` and an external font fetch fails. The
* default (swallow + warn) preserves the in-process behavior.
* `failClosedFontFetch === true` and deterministic font resolution fails.
* The default (swallow + warn) preserves the in-process behavior.
*/
export class FontFetchError extends Error {
readonly code: typeof FONT_FETCH_FAILED = FONT_FETCH_FAILED;
readonly code: FontFetchErrorCode;
readonly familyName: string;
readonly url: string;
readonly cause?: unknown;
constructor(familyName: string, url: string, message: string, cause?: unknown) {
constructor(
familyName: string,
url: string,
message: string,
cause?: unknown,
code: FontFetchErrorCode = FONT_FETCH_FAILED,
) {
super(message);
this.name = "FontFetchError";
this.code = code;
this.familyName = familyName;
this.url = url;
this.cause = cause;
}
}
/**
* Retryable font-fetch failure. Distributed adapters map this code to an
* unavailable response so the workflow can retry the plan activity.
*/
export class FontFetchUnavailableError extends FontFetchError {
constructor(familyName: string, url: string, message: string, cause?: unknown) {
super(familyName, url, message, cause, FONT_FETCH_UNAVAILABLE);
this.name = "FontFetchUnavailableError";
}
}
export interface FontFetchRetryPolicy {
/** Total fetch attempts for one CSS or woff2 URL. Default: 2. */
maxAttempts: number;
/** Timeout for each individual fetch attempt. Default: 8 seconds. */
attemptTimeoutMs: number;
/** Shared wall-clock budget for all Google Fonts requests in one compile. Default: 20 seconds. */
maxElapsedMs: number;
/** Initial full-jitter backoff ceiling. Default: 250 ms. */
baseDelayMs: number;
}
const DEFAULT_FONT_FETCH_RETRY_POLICY: FontFetchRetryPolicy = {
maxAttempts: 2,
attemptTimeoutMs: 8_000,
maxElapsedMs: 20_000,
baseDelayMs: 250,
};
/** Internal threading of the failClosed flag + fetch override through callers. */
interface InternalFontFetchOptions {
failClosedFontFetch: boolean;
fetchImpl: typeof fetch;
allowSystemFontCapture: boolean;
abortSignal?: AbortSignal;
retryPolicy: FontFetchRetryPolicy;
retryDeadlineMs: number;
}
/**
@@ -680,16 +723,158 @@ function fontFetchError(
url: string,
what: "Google Fonts CSS" | `Google Fonts woff2 (${string}/${string})`,
cause: { status: number } | { error: unknown },
unavailable = false,
): FontFetchError {
const reason =
"status" in cause
? `returned HTTP ${cause.status}`
: `failed: ${(cause.error as Error).message}`;
: `failed: ${cause.error instanceof Error ? cause.error.message : String(cause.error)}`;
const message =
`[deterministicFonts] ${what} fetch for ${JSON.stringify(familyName)} ${reason}. ` +
`Distributed renders require deterministic fonts; system-font fallback would produce ` +
`non-byte-identical output.`;
return new FontFetchError(familyName, url, message, "error" in cause ? cause.error : undefined);
const errorCause = "error" in cause ? cause.error : undefined;
return unavailable
? new FontFetchUnavailableError(familyName, url, message, errorCause)
: new FontFetchError(familyName, url, message, errorCause);
}
function isRetryableFontFetchStatus(status: number): boolean {
return status === 408 || status === 425 || status === 429 || status >= 500;
}
function retryAfterMs(response: Response): number | null {
const value = response.headers.get("retry-after");
if (value === null) return null;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const date = Date.parse(value);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
function callerAbortReason(signal: AbortSignal): unknown {
return signal.reason ?? new DOMException("Font fetch cancelled", "AbortError");
}
function throwIfCallerAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw callerAbortReason(signal);
}
async function waitForFontFetchRetry(
delayMs: number,
signal: AbortSignal | undefined,
): Promise<void> {
throwIfCallerAborted(signal);
if (delayMs <= 0) return;
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, delayMs);
const onAbort = (): void => {
clearTimeout(timeout);
reject(signal ? callerAbortReason(signal) : new DOMException("Cancelled", "AbortError"));
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
});
}
function retryDelayMs(
response: Response | undefined,
attempt: number,
baseDelayMs: number,
): number {
const requestedDelay = response ? retryAfterMs(response) : null;
if (requestedDelay !== null) return requestedDelay;
const ceiling = baseDelayMs * 2 ** attempt;
return Math.floor(Math.random() * (ceiling + 1));
}
function cancelResponseBody(response: Response): void {
try {
void response.body?.cancel().catch(() => {
// Best-effort connection cleanup must not replace the original status.
});
} catch {
// Best-effort connection cleanup must not replace the original status.
}
}
type FontFetchResult<T> =
| { ok: true; response: Response; body: T }
| { ok: false; response: Response };
type FontFetchAttemptResult<T> =
| { completed: true; result: FontFetchResult<T> }
| {
completed: false;
response?: Response;
cause: { status: number } | { error: unknown };
};
async function runFontFetchAttempt<T>(
url: string,
init: RequestInit | undefined,
readBody: (response: Response) => Promise<T>,
options: InternalFontFetchOptions,
remainingMs: number,
): Promise<FontFetchAttemptResult<T>> {
const timeoutSignal = AbortSignal.timeout(
Math.max(1, Math.min(options.retryPolicy.attemptTimeoutMs, remainingMs)),
);
const signal = options.abortSignal
? AbortSignal.any([options.abortSignal, timeoutSignal])
: timeoutSignal;
let response: Response | undefined;
try {
response = await options.fetchImpl(url, { ...init, signal });
if (!isRetryableFontFetchStatus(response.status)) {
if (!response.ok) return { completed: true, result: { ok: false, response } };
const body = await readBody(response);
return { completed: true, result: { ok: true, response, body } };
}
cancelResponseBody(response);
return { completed: false, response, cause: { status: response.status } };
} catch (error) {
throwIfCallerAborted(options.abortSignal);
return { completed: false, response, cause: { error } };
}
}
async function fetchFontResource<T>(
url: string,
init: RequestInit | undefined,
readBody: (response: Response) => Promise<T>,
familyName: string,
what: "Google Fonts CSS" | `Google Fonts woff2 (${string}/${string})`,
options: InternalFontFetchOptions,
): Promise<FontFetchResult<T>> {
if (!options.failClosedFontFetch) {
const response = await options.fetchImpl(url, { ...init, signal: options.abortSignal });
if (!response.ok) return { ok: false, response };
return { ok: true, response, body: await readBody(response) };
}
let lastCause: { status: number } | { error: unknown } = {
error: new DOMException("Font fetch budget exhausted", "TimeoutError"),
};
for (let attempt = 0; attempt < options.retryPolicy.maxAttempts; attempt += 1) {
throwIfCallerAborted(options.abortSignal);
const remainingMs = options.retryDeadlineMs - Date.now();
if (remainingMs <= 0) break;
const attemptResult = await runFontFetchAttempt(url, init, readBody, options, remainingMs);
if (attemptResult.completed) return attemptResult.result;
lastCause = attemptResult.cause;
if (attempt + 1 >= options.retryPolicy.maxAttempts) break;
const delayMs = retryDelayMs(attemptResult.response, attempt, options.retryPolicy.baseDelayMs);
if (delayMs >= options.retryDeadlineMs - Date.now()) break;
await waitForFontFetchRetry(delayMs, options.abortSignal);
}
throw fontFetchError(familyName, url, what, lastCause, true);
}
/**
@@ -714,17 +899,20 @@ async function ensureWoff2DataUri(
const woff2What = `Google Fonts woff2 (${weight}/${style})` as const;
try {
const fontRes = await options.fetchImpl(woff2Url);
if (!fontRes.ok) {
if (fontRes.status >= 500 && options.failClosedFontFetch) {
throw fontFetchError(familyName, woff2Url, woff2What, { status: fontRes.status });
}
return null;
}
const fontResult = await fetchFontResource(
woff2Url,
undefined,
(response) => response.arrayBuffer(),
familyName,
woff2What,
options,
);
if (!fontResult.ok) return null;
// wx = O_CREAT|O_EXCL: atomic create, rejects symlinks, fails with
// EEXIST if a concurrent call cached it between our read and write.
writeFileSync(cachePath, Buffer.from(await fontRes.arrayBuffer()), { flag: "wx", mode: 0o644 });
writeFileSync(cachePath, Buffer.from(fontResult.body), { flag: "wx", mode: 0o644 });
} catch (err) {
throwIfCallerAborted(options.abortSignal);
if (err instanceof FontFetchError) throw err;
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
// Concurrent call wrote it — read their result below.
@@ -755,10 +943,15 @@ async function fetchGoogleFont(
let cssText: string;
try {
const res = await options.fetchImpl(url, {
headers: { "User-Agent": WOFF2_USER_AGENT },
});
if (!res.ok) {
const cssResult = await fetchFontResource(
url,
{ headers: { "User-Agent": WOFF2_USER_AGENT } },
(response) => response.text(),
familyName,
"Google Fonts CSS",
options,
);
if (!cssResult.ok) {
// 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.
@@ -767,16 +960,14 @@ async function fetchGoogleFont(
// 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();
cssText = cssResult.body;
} catch (err) {
// Rethrow typed error untouched. Network / DNS / fetch-throws are
// non-deterministic infrastructure failures — wrapped when failClosed
// is on, swallowed otherwise.
throwIfCallerAborted(options.abortSignal);
if (err instanceof FontFetchError) throw err;
if (options.failClosedFontFetch) {
throw fontFetchError(familyName, url, "Google Fonts CSS", { error: err });
@@ -829,16 +1020,16 @@ async function fetchGoogleFont(
*/
export interface InjectDeterministicFontFacesOptions {
/**
* When `true`, any external font fetch failure (Google Fonts CSS or
* woff2) throws {@link FontFetchError} with code `FONT_FETCH_FAILED`.
* When `true`, exhausted transient fetch failures throw
* {@link FontFetchUnavailableError} with code `FONT_FETCH_UNAVAILABLE`;
* deterministic resolution failures retain `FONT_FETCH_FAILED`.
*
* Default `false`: failed fetches are silently swallowed; the composition
* falls back to system fonts via `warnUnresolvedFonts`. This preserves the
* in-process behavior.
*
* Distributed callers pass `true` so font availability is part of the
* planDir's content-addressed hash and fetch failures surface as typed
* non-retryable errors.
* planDir's content-addressed hash and failures surface as typed errors.
*/
failClosedFontFetch?: boolean;
/**
@@ -847,6 +1038,13 @@ export interface InjectDeterministicFontFacesOptions {
* network.
*/
fetchImpl?: typeof fetch;
/** Caller cancellation propagated through fetch attempts and retry waits. */
abortSignal?: AbortSignal;
/**
* Optional retry tuning. Defaults are deliberately bounded for composition
* planning; tests may lower delays and timeouts without replacing timers.
*/
fontFetchRetryPolicy?: Partial<FontFetchRetryPolicy>;
/**
* When `true` (default for local renders), fonts that aren't resolved by
* the bundled alias map or Google Fonts are located on the local filesystem,
@@ -873,6 +1071,29 @@ function extractGoogleFontsText(html: string): string | undefined {
: undefined;
}
function resolveFontFetchRetryPolicy(
configured: Partial<FontFetchRetryPolicy> | undefined,
): FontFetchRetryPolicy {
return {
maxAttempts: Math.max(
1,
Math.floor(configured?.maxAttempts ?? DEFAULT_FONT_FETCH_RETRY_POLICY.maxAttempts),
),
attemptTimeoutMs: Math.max(
1,
configured?.attemptTimeoutMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.attemptTimeoutMs,
),
maxElapsedMs: Math.max(
1,
configured?.maxElapsedMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.maxElapsedMs,
),
baseDelayMs: Math.max(
0,
configured?.baseDelayMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.baseDelayMs,
),
};
}
export async function injectDeterministicFontFaces(
html: string,
options: InjectDeterministicFontFacesOptions = {},
@@ -880,10 +1101,14 @@ export async function injectDeterministicFontFaces(
const failClosedFontFetch = options.failClosedFontFetch === true;
const fetchImpl = options.fetchImpl ?? fetch;
const allowSystemFontCapture = options.allowSystemFontCapture !== false;
const retryPolicy = resolveFontFetchRetryPolicy(options.fontFetchRetryPolicy);
const fetchOptions: InternalFontFetchOptions = {
failClosedFontFetch,
fetchImpl,
allowSystemFontCapture,
abortSignal: options.abortSignal,
retryPolicy,
retryDeadlineMs: Date.now() + retryPolicy.maxElapsedMs,
};
const existingFaces = extractExistingFontFaces(html);
@@ -911,6 +911,7 @@ export async function plan(
needsAlpha,
log,
assertNotAborted,
abortSignal,
// Distributed renders fail closed on font-fetch errors so the planDir
// is content-addressed against deterministic fonts only.
failClosedFontFetch: config.failClosedFontFetch !== false,
@@ -1714,11 +1714,11 @@ export interface CompileForRenderOptions {
log?: ProducerLogger;
/**
* Threaded through to {@link injectDeterministicFontFaces}. When `true`,
* any external font fetch failure throws `FontFetchError` instead of
* silently falling back to system fonts. Distributed `plan()` sets this
* to `true` so font availability is part of the planDir's content-addressed
* hash and fetch failures surface as typed non-retryable errors. Default
* `false` preserves the in-process behavior.
* deterministic font resolution and exhausted transient fetch failures
* surface as typed errors instead of silently falling back to system fonts.
* Distributed `plan()` sets this to `true` so font availability is part of
* the planDir's content-addressed hash. Default `false` preserves the
* in-process behavior.
*/
failClosedFontFetch?: boolean;
/**
@@ -1728,6 +1728,8 @@ export interface CompileForRenderOptions {
* prevent host-specific font capture from leaking into the planDir.
*/
allowSystemFontCapture?: boolean;
/** Caller cancellation propagated through compile-time font fetches. */
abortSignal?: AbortSignal;
/**
* Optional persistent cache directory for prep-time animated GIF WebM
* transcodes. When omitted, the render's downloadDir is used.
@@ -1839,6 +1841,7 @@ export async function compileForRender(
const coalescedHtml = await injectDeterministicFontFaces(normalizedFontHtml, {
failClosedFontFetch: options.failClosedFontFetch === true,
allowSystemFontCapture: options.allowSystemFontCapture,
abortSignal: options.abortSignal,
});
// Download CDN scripts and inline them AFTER coalescing. This order matters:
@@ -112,6 +112,16 @@ const IFRAME_HTML = `<!doctype html>
</body>
</html>`;
const UNRESOLVED_FONT_HTML = `<!doctype html>
<html>
<head><style>body { font-family: "CompileAbortFont", sans-serif; }</style></head>
<body>
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="1">
<p>cancelled font compile</p>
</div>
</body>
</html>`;
// Portrait/square/landscape fixture template — same shape as PLAIN_HTML but
// with caller-supplied composition dimensions. Consumed by the aspect-agnostic
// re-map tests below (and reachable from any sibling describe block, unlike a
@@ -232,6 +242,40 @@ describe("runCompileStage — forceScreenshot snapshot", () => {
});
});
describe("runCompileStage — font fetch cancellation", () => {
let fixture: CompileFixture | null = null;
afterEach(() => {
fixture?.cleanup();
fixture = null;
});
it("propagates the caller abort signal through compileForRender to font fetching", async () => {
fixture = setupFixture(UNRESOLVED_FONT_HTML);
const projectDir = join(fixture.workDir, "project");
const controller = new AbortController();
const cancellation = new Error("cancel distributed compile");
controller.abort(cancellation);
const result = runCompileStage({
projectDir,
workDir: fixture.workDir,
htmlPath: fixture.htmlPath,
entryFile: "index.html",
job: createJob(),
cfg: createCfg(),
needsAlpha: false,
log: noopLog,
assertNotAborted: () => {},
abortSignal: controller.signal,
failClosedFontFetch: true,
allowSystemFontCapture: false,
});
await expect(result).rejects.toBe(cancellation);
});
});
/**
* Tests for the aspect-agnostic --resolution re-map in `runCompileStage`.
*
@@ -71,12 +71,14 @@ export interface CompileStageInput {
log: ProducerLogger;
/** Cooperative-cancellation probe; throws `RenderCancelledError` when aborted. */
assertNotAborted: () => void;
/** Caller cancellation propagated through compile-time network requests. */
abortSignal?: AbortSignal;
/**
* When `true`, `compileForRender` threads through to
* `injectDeterministicFontFaces` and any external font fetch failure
* throws `FontFetchError` instead of silently falling back to system
* fonts. Distributed `plan()` passes `true`; the in-process renderer
* leaves it `undefined` to preserve current behavior.
* `injectDeterministicFontFaces` so deterministic resolution and exhausted
* transient fetch failures surface as typed errors instead of silently
* falling back to system fonts. Distributed `plan()` passes `true`; the
* in-process renderer leaves it `undefined` to preserve current behavior.
*/
failClosedFontFetch?: boolean;
/**
@@ -158,6 +160,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
assertNotAborted,
failClosedFontFetch,
allowSystemFontCapture,
abortSignal,
} = input;
const compileStart = Date.now();
@@ -165,6 +168,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
log,
failClosedFontFetch: failClosedFontFetch === true,
allowSystemFontCapture,
abortSignal,
variables: input.variables,
animatedGifCacheDir: cfg.extractCacheDir
? join(cfg.extractCacheDir, "animated-gif")