diff --git a/e2e/imaging/avatar-endpoint.spec.ts b/e2e/imaging/avatar-endpoint.spec.ts index 16a8e875..17ca9d9a 100644 --- a/e2e/imaging/avatar-endpoint.spec.ts +++ b/e2e/imaging/avatar-endpoint.spec.ts @@ -1,5 +1,11 @@ import { expect, test } from "@playwright/test"; -import { avatarPath, countBytes, looksLikeImage } from "./imaging-target"; +import { + avatarPath, + countBytes, + fetchThroughEdge, + getThroughEdge, + looksLikeImage, +} from "./imaging-target"; // Endpoints exercised here are identical whether the CMS is reached directly // or through the edge proxy, so this spec runs unchanged in the deploy gate and @@ -17,7 +23,8 @@ const IMAGE_SOURCES = new Set([ test("renders an animated APNG for a figure with an effect", async ({ request, }) => { - const response = await request.get( + const response = await getThroughEdge( + request, avatarPath({ img_format: "apng", effect: "14" }), ); expect(response.status()).toBe(200); @@ -35,7 +42,10 @@ test("renders an animated APNG for a figure with an effect", async ({ test("serves a static PNG when no animation is requested", async ({ request, }) => { - const response = await request.get(avatarPath({ img_format: "png" })); + const response = await getThroughEdge( + request, + avatarPath({ img_format: "png" }), + ); expect(response.status()).toBe(200); expect(response.headers()["content-type"]).toContain("image/png"); const body = await response.body(); @@ -45,7 +55,10 @@ test("serves a static PNG when no animation is requested", async ({ test("advertises a cacheable response and a known render source", async ({ request, }) => { - const response = await request.get(avatarPath({ img_format: "png" })); + const response = await getThroughEdge( + request, + avatarPath({ img_format: "png" }), + ); expect(response.status()).toBe(200); const source = response.headers()["x-imager-source"]; @@ -63,14 +76,21 @@ test("advertises a cacheable response and a known render source", async ({ }); test("answers conditional requests with 304", async ({ request }) => { - const first = await request.get(avatarPath({ img_format: "png" })); + const first = await getThroughEdge( + request, + avatarPath({ img_format: "png" }), + ); expect(first.status()).toBe(200); const etag = first.headers()["etag"]; expect(etag).toBeTruthy(); - const second = await request.get(avatarPath({ img_format: "png" }), { - headers: { "if-none-match": etag as string }, - }); + const second = await getThroughEdge( + request, + avatarPath({ img_format: "png" }), + { + headers: { "if-none-match": etag as string }, + }, + ); expect(second.status()).toBe(304); expect(second.headers()["etag"]).toBe(etag); }); @@ -78,7 +98,10 @@ test("answers conditional requests with 304", async ({ request }) => { test("serves the rendered avatar behind permissive CORS", async ({ request, }) => { - const response = await request.get(avatarPath({ img_format: "png" })); + const response = await getThroughEdge( + request, + avatarPath({ img_format: "png" }), + ); expect(response.status()).toBe(200); expect(response.headers()["access-control-allow-origin"]).toBe("*"); }); @@ -86,7 +109,9 @@ test("serves the rendered avatar behind permissive CORS", async ({ test("preflight requests advertise GET and revalidation", async ({ request, }) => { - const response = await request.fetch(avatarPath(), { method: "OPTIONS" }); + const response = await fetchThroughEdge(request, avatarPath(), { + method: "OPTIONS", + }); expect(response.status()).toBe(204); expect(response.headers()["access-control-allow-origin"]).toBe("*"); expect(response.headers()["access-control-allow-methods"]).toContain("GET"); @@ -113,7 +138,10 @@ test.describe("rejects malformed figures", () => { for (const { name, query } of cases) { test(name, async ({ request }) => { - const response = await request.get(`/api/imaging/avatar${query}`); + const response = await getThroughEdge( + request, + `/api/imaging/avatar${query}`, + ); expect(response.status()).toBe(400); }); } @@ -131,7 +159,7 @@ test("collapses a burst of concurrent requests for one figure", async ({ head_direction: "1", }); const responses = await Promise.all( - Array.from({ length: 6 }, () => request.get(path)), + Array.from({ length: 6 }, () => getThroughEdge(request, path)), ); for (const response of responses) { expect(response.status()).toBe(200); @@ -143,14 +171,14 @@ test("collapses a burst of concurrent requests for one figure", async ({ test("the badge endpoint serves an image and revalidates", async ({ request, }) => { - const first = await request.get("/api/imaging/badge?code=ADM"); + const first = await getThroughEdge(request, "/api/imaging/badge?code=ADM"); expect(first.status()).toBe(200); expect(first.headers()["content-type"]).toContain("image/"); expect(first.headers()["access-control-allow-origin"]).toBe("*"); const etag = first.headers()["etag"]; expect(etag).toBeTruthy(); - const second = await request.get("/api/imaging/badge?code=ADM", { + const second = await getThroughEdge(request, "/api/imaging/badge?code=ADM", { headers: { "if-none-match": etag as string }, }); expect(second.status()).toBe(304); @@ -159,7 +187,8 @@ test("the badge endpoint serves an image and revalidates", async ({ test("the badge endpoint can resolve from a local source", async ({ request, }) => { - const response = await request.get( + const response = await getThroughEdge( + request, "/api/imaging/badge?code=ADM&source=local", ); expect(response.status()).toBe(200); @@ -177,16 +206,23 @@ test.describe("rejects malformed badge codes", () => { for (const { name, query } of cases) { test(name, async ({ request }) => { - const response = await request.get(`/api/imaging/badge${query}`); + const response = await getThroughEdge( + request, + `/api/imaging/badge${query}`, + ); expect(response.status()).toBe(400); }); } }); test("the badge preflight advertises GET", async ({ request }) => { - const response = await request.fetch("/api/imaging/badge?code=ADM", { - method: "OPTIONS", - }); + const response = await fetchThroughEdge( + request, + "/api/imaging/badge?code=ADM", + { + method: "OPTIONS", + }, + ); expect(response.status()).toBe(204); expect(response.headers()["access-control-allow-methods"]).toContain("GET"); }); diff --git a/e2e/imaging/imaging-target.ts b/e2e/imaging/imaging-target.ts index afe04a5a..82443c81 100644 --- a/e2e/imaging/imaging-target.ts +++ b/e2e/imaging/imaging-target.ts @@ -1,3 +1,5 @@ +import type { APIRequestContext, APIResponse } from "@playwright/test"; + /** * Where the imaging specs are pointed. * @@ -12,6 +14,56 @@ */ export const IS_EDGE = process.env.IMAGING_EDGE === "1"; +/** + * Failures that mean "the request never reached the app". + * + * Cloudflare answers the 520-527 family when its own handshake to the origin + * fails, and gateways add 502/503/504. Retrying those is not papering over a + * bug: the assertion under test is about the CMS, and a 525 says nothing about + * it. A genuinely broken edge still exhausts the retries and fails the test. + */ +const TRANSIENT_EDGE_STATUSES = new Set([ + 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, +]); +const EDGE_RETRY_ATTEMPTS = 3; +const EDGE_RETRY_DELAY_MS = 500; + +async function withEdgeRetry( + send: () => Promise, +): Promise { + let response = await send(); + for ( + let attempt = 1; + attempt < EDGE_RETRY_ATTEMPTS && + TRANSIENT_EDGE_STATUSES.has(response.status()); + attempt += 1 + ) { + await new Promise((resolve) => + setTimeout(resolve, EDGE_RETRY_DELAY_MS * attempt), + ); + response = await send(); + } + return response; +} + +/** GET that survives a transient edge/transport failure. */ +export function getThroughEdge( + request: APIRequestContext, + url: string, + options: Parameters[1] = {}, +): Promise { + return withEdgeRetry(() => request.get(url, options)); +} + +/** POST/OPTIONS/… counterpart of `getThroughEdge`. */ +export function fetchThroughEdge( + request: APIRequestContext, + url: string, + options: Parameters[1] = {}, +): Promise { + return withEdgeRetry(() => request.fetch(url, options)); +} + // The exact figure that regressed in production: the game client appends a // trailing `.` segment that the renderer ignores, but the previous // validation regex rejected with a 400. diff --git a/e2e/imaging/public-routing.spec.ts b/e2e/imaging/public-routing.spec.ts index cc28e379..ddaf1a55 100644 --- a/e2e/imaging/public-routing.spec.ts +++ b/e2e/imaging/public-routing.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { avatarPath, + getThroughEdge, IMAGER_PATH, IS_EDGE, looksLikeImage, @@ -17,7 +18,10 @@ test.skip( ); test("the edge serves the imager endpoint as an image", async ({ request }) => { - const response = await request.get(IMAGER_PATH({ img_format: "png" })); + const response = await getThroughEdge( + request, + IMAGER_PATH({ img_format: "png" }), + ); expect(response.status()).toBe(200); expect(response.headers()["content-type"]).toContain("image/"); expect(looksLikeImage(await response.body())).toBe(true); @@ -26,14 +30,17 @@ test("the edge serves the imager endpoint as an image", async ({ request }) => { test("the CMS avatar endpoint is reachable through the edge", async ({ request, }) => { - const response = await request.get(avatarPath({ img_format: "png" })); + const response = await getThroughEdge( + request, + avatarPath({ img_format: "png" }), + ); expect(response.status()).toBe(200); expect(response.headers()["content-type"]).toContain("image/"); expect(looksLikeImage(await response.body())).toBe(true); }); test("the edge does not shadow the CMS badge endpoint", async ({ request }) => { - const response = await request.get("/api/imaging/badge?code=ADM"); + const response = await getThroughEdge(request, "/api/imaging/badge?code=ADM"); expect(response.status()).toBe(200); expect(response.headers()["content-type"]).toContain("image/"); });