Skip to content

Commit 20f4f8f

Browse files
authored
fix(producer): retry transient deterministic font fetches (#2865)
* fix(producer): retry transient deterministic font fetches * fix(producer): secure Lambda font cache directory
1 parent 675afc5 commit 20f4f8f

13 files changed

Lines changed: 802 additions & 92 deletions

File tree

packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ describe("HyperframesRenderStack — snapshot", () => {
182182
present: true,
183183
});
184184
}
185+
expect(collected.has("FONT_FETCH_UNAVAILABLE")).toBe(false);
185186
});
186187

187188
it("classifies plan v2 integrity failures as terminal in every v2 Lambda task", () => {

packages/aws-lambda/src/handler.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@ describe("handler dispatch", () => {
249249
"PLAN_TOO_LARGE",
250250
"PLAN_PROTOCOL_UNSUPPORTED",
251251
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
252+
"FONT_FETCH_FAILED",
253+
"FONT_FETCH_UNAVAILABLE",
252254
"VIDEO_SOURCE_UNRENDERABLE",
253255
"VIDEO_EXTRACTION_FAILED",
254256
"INVALID_VIDEO_METADATA",

packages/aws-lambda/src/handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ function normalizeTerminalErrorName(error: unknown): void {
150150
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
151151
candidate.code === "PLAN_TOO_LARGE" ||
152152
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
153+
candidate.code === "FONT_FETCH_FAILED" ||
154+
candidate.code === "FONT_FETCH_UNAVAILABLE" ||
153155
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
154156
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
155157
candidate.code === "INVALID_VIDEO_METADATA"

packages/gcp-cloud-run/src/server.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,9 @@ describe("createApp HTTP mapping", () => {
578578
it.each([
579579
["VIDEO_SOURCE_UNRENDERABLE", 400],
580580
["VIDEO_EXTRACTION_FAILED", 500],
581-
] as const)("routes producer video code %s with HTTP %s", async (code, expectedStatus) => {
581+
["FONT_FETCH_FAILED", 400],
582+
["FONT_FETCH_UNAVAILABLE", 500],
583+
] as const)("routes producer code %s with HTTP %s", async (code, expectedStatus) => {
582584
const gcs = new FakeGcs();
583585
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
584586
const app = createApp(

packages/gcp-cloud-run/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ function normalizeTerminalErrorName(error: unknown): void {
183183
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
184184
candidate.code === "PLAN_TOO_LARGE" ||
185185
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
186+
candidate.code === "FONT_FETCH_FAILED" ||
187+
candidate.code === "FONT_FETCH_UNAVAILABLE" ||
186188
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
187189
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
188190
candidate.code === "INVALID_VIDEO_METADATA"

packages/producer/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,13 @@ export { normalizeErrorMessage } from "./utils/errorMessage.js";
114114
// timing. The render pipeline runs this in its compile stage; the CLI audit
115115
// paths (snapshot/check) reuse it so their captures match the render.
116116
export {
117+
FONT_FETCH_FAILED,
118+
FONT_FETCH_UNAVAILABLE,
119+
FontFetchError,
120+
FontFetchUnavailableError,
117121
injectDeterministicFontFaces,
122+
type FontFetchErrorCode,
123+
type FontFetchRetryPolicy,
118124
type InjectDeterministicFontFacesOptions,
119125
} from "./services/deterministicFonts.js";
120126
export { quantizeTimeToFrame } from "./utils/parityContract.js";

packages/producer/src/services/deterministicFonts-failClosed.test.ts

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import { describe, expect, it } from "bun:test";
1818
import {
1919
FONT_FETCH_FAILED,
20+
FONT_FETCH_UNAVAILABLE,
2021
FontFetchError,
22+
FontFetchUnavailableError,
2123
injectDeterministicFontFaces,
2224
} from "./deterministicFonts.js";
2325

@@ -75,6 +77,15 @@ function makeGoogleFontFetch(cssRequests: string[]): typeof fetch {
7577
}) as unknown as typeof fetch;
7678
}
7779

80+
async function rejectedError(promise: Promise<unknown>): Promise<unknown> {
81+
try {
82+
await promise;
83+
} catch (error) {
84+
return error;
85+
}
86+
throw new Error("Expected promise to reject");
87+
}
88+
7889
describe("injectDeterministicFontFaces — failClosedFontFetch: false (default)", () => {
7990
it("swallows a network failure and returns the original HTML (no throw)", async () => {
8091
const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
@@ -141,19 +152,18 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
141152
});
142153
}
143154

144-
it("throws FontFetchError on a network failure", async () => {
145-
let caught: unknown;
146-
try {
147-
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
155+
it("throws FontFetchUnavailableError after retrying a network failure", async () => {
156+
const caught = await rejectedError(
157+
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
148158
failClosedFontFetch: true,
149159
fetchImpl: makeFailingFetch(),
150-
});
151-
} catch (err) {
152-
caught = err;
153-
}
160+
fontFetchRetryPolicy: { baseDelayMs: 0 },
161+
}),
162+
);
154163
expect(caught).toBeInstanceOf(FontFetchError);
155-
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
156-
expect((caught as FontFetchError).code).toBe("FONT_FETCH_FAILED");
164+
expect(caught).toBeInstanceOf(FontFetchUnavailableError);
165+
expect((caught as FontFetchError).code).toBe(FONT_FETCH_UNAVAILABLE);
166+
expect((caught as FontFetchError).code).toBe("FONT_FETCH_UNAVAILABLE");
157167
expect((caught as FontFetchError).familyName).toBe("NotARealFontFamilyForTest");
158168
expect((caught as Error).message).toContain("simulated network failure");
159169
});
@@ -164,62 +174,53 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
164174
// unresolvable (no alias, no Google Fonts, no system capture) which IS
165175
// a fail-closed error — the render would use a fallback font, producing
166176
// non-deterministic output across machines.
167-
let caught: unknown;
168-
try {
169-
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
177+
const caught = await rejectedError(
178+
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
170179
failClosedFontFetch: true,
171180
allowSystemFontCapture: false,
172181
fetchImpl: makeHttp400Fetch(),
173-
});
174-
} catch (err) {
175-
caught = err;
176-
}
182+
}),
183+
);
177184
expect(caught).toBeInstanceOf(FontFetchError);
178185
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
179186
expect((caught as FontFetchError).familyName).toBe("NotARealFontFamilyForTest");
180187
});
181188

182189
it("throws on a 404 response when font is completely unresolvable", async () => {
183-
let caught: unknown;
184-
try {
185-
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
190+
const caught = await rejectedError(
191+
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
186192
failClosedFontFetch: true,
187193
allowSystemFontCapture: false,
188194
fetchImpl: makeHttp404Fetch(),
189-
});
190-
} catch (err) {
191-
caught = err;
192-
}
195+
}),
196+
);
193197
expect(caught).toBeInstanceOf(FontFetchError);
194198
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
195199
});
196200

197-
it("throws FontFetchError on a 5xx response — non-deterministic, could differ on retry", async () => {
198-
let caught: unknown;
199-
try {
200-
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
201+
it("throws FontFetchUnavailableError on an exhausted 5xx response", async () => {
202+
const caught = await rejectedError(
203+
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
201204
failClosedFontFetch: true,
202205
fetchImpl: makeHttp503Fetch(),
203-
});
204-
} catch (err) {
205-
caught = err;
206-
}
206+
fontFetchRetryPolicy: { baseDelayMs: 0 },
207+
}),
208+
);
207209
expect(caught).toBeInstanceOf(FontFetchError);
208-
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
210+
expect(caught).toBeInstanceOf(FontFetchUnavailableError);
211+
expect((caught as FontFetchError).code).toBe(FONT_FETCH_UNAVAILABLE);
209212
expect((caught as Error).message).toContain("HTTP 503");
210213
expect((caught as Error).message).toContain("NotARealFontFamilyForTest");
211214
});
212215

213216
it("includes the requested URL in 5xx errors", async () => {
214-
let caught: unknown;
215-
try {
216-
await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
217+
const caught = await rejectedError(
218+
injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
217219
failClosedFontFetch: true,
218220
fetchImpl: makeHttp503Fetch(),
219-
});
220-
} catch (err) {
221-
caught = err;
222-
}
221+
fontFetchRetryPolicy: { baseDelayMs: 0 },
222+
}),
223+
);
223224
expect((caught as FontFetchError).url).toContain("fonts.googleapis.com");
224225
expect((caught as FontFetchError).url).toContain("NotARealFontFamilyForTest");
225226
});

0 commit comments

Comments
 (0)